C++ RAG 工具检索系统:
目录
- 系统整体架构
- tool_retriever:系统总调度器
- embedding_service:文本向量化
- [embedding_cache:LRU 向量缓存](#embedding_cache:LRU 向量缓存)
- vector_index:向量检索核心
- tool_validator:可用性校验层
一、系统整体架构
这套代码实现了一个完整的 RAG(检索增强生成)工具检索系统,核心职责是:给定用户的自然语言 Query,从一批已注册的工具中找出语义最相关的若干个,最终以 LLM Function Calling 格式返回。
整个数据流是一条单向管道:
用户 Query
↓
EmbeddingService (文本 → 向量)
↓
EmbeddingCache (缓存向量,避免重复调用 API)
↓
VectorIndex (余弦相似度检索 Top-K 工具)
↓
ToolRetriever (总调度,串联上述所有模块)
↓
ToolValidator (可选:验证工具真实可用性)
↓
最终工具列表(Function Calling 格式)
五个核心模块各司其职,没有相互耦合,每一层都可以独立测试、替换或扩展。
核心文件一览
| 文件 | 职责 | 类型 |
|---|---|---|
tool_retriever.cpp |
总调度器,串联完整检索流程,是系统入口 | 核心入口 |
embedding_service.cpp |
调用 DashScope API,支持批量处理与指数退避重试 | 基础服务 |
embedding_cache.cpp |
LRU + TTL 双策略缓存,降低延迟与 API 成本 | 性能优化 |
vector_index.cpp |
余弦相似度暴力检索 + Top-K 过滤,支持 JSON 持久化 | 检索核心 |
tool_validator.cpp |
根据 Schema 自动生成测试参数,带超时异步验证工具可用性 | 增强模块 |
二、tool_retriever.cpp:系统总调度器
tool_retriever.cpp 是整套系统的调度中心。它本身不做任何向量计算或 HTTP 请求,只负责把所有底层组件串联起来。
六个核心函数
cpp
// 1. initialize() ------ 把系统搭起来
bool initialize(); // 创建 EmbeddingService / Cache / VectorIndex
// 2. shutdown() ------ 按需保存索引,释放所有资源
void shutdown();
// 3. addTool() ------ 把一个工具文本化 → 向量化 → 写入索引
void addTool(const ToolInfo& tool);
// 4. retrieve() ------ 核心:根据 query 检索最相关工具
std::vector<RetrievedTool> retrieve(const std::string& query);
// 5. getEmbedding() ------ 带缓存地获取向量(Cache Aside 模式)
std::vector<float> getEmbedding(const std::string& text);
// 6. buildToolText() ------ 把工具信息拼成可向量化的文本
std::string buildToolText(const ToolInfo& tool);
Cache Aside:
cpp
// ① 先查缓存 ------ 命中则直接返回,完全不走网络
if (cache_) {
auto cached = cache_->get(text);
if (cached.has_value()) {
return cached.value(); // 命中 → 不调 API
}
}
// ② 缓存未命中 → 调用真实 API
std::vector<float> embedding = embedding_service_->embed(text);
// ③ 写回缓存 ------ 供下次复用,避免重复付出 API 代价
if (cache_) {
cache_->put(text, embedding);
}
return embedding;
buildToolText():为什么不直接把 JSON 扔给 Embedding
把整个 input_schema 原样投喂给向量模型,会引入大量无意义的 JSON 语法符号,干扰语义向量质量。buildToolText() 只提取「参数名 + 参数描述」,构造成自然语言片段:
cpp
// 不把整个 schema 扔给 embedding,只提取语义部分
oss << "Tool: " << tool.name << "\n";
oss << "Description: " << tool.description << "\n";
if (schema.contains("properties")) {
oss << "Parameters: ";
for (auto& [key, value] : schema["properties"].items()) {
oss << key; // 参数名
if (value.contains("description"))
oss << " (" << value["description"] << ")"; // 参数语义描述
oss << ", ";
}
}
// 结果:可读性强的自然语言 → 向量质量远胜原始 JSON
主线逻辑总结
| 阶段 | 核心函数 | 作用 |
|---|---|---|
| 初始化 | initialize() |
创建三大子组件,尝试加载历史索引 |
| 建索引 | addTool() / buildToolText() |
工具文本化 → 向量化 → 写入索引 |
| 检索 | retrieve() |
Query 向量化 → 相似度搜索 → 结果转换 |
| 性能 | getEmbedding() |
Cache Aside 减少重复 API 调用 |
| 适配 | toFunctionCallingFormat() |
检索结果转为 LLM 可用的 JSON 格式 |
三、embedding_service.cpp:文本向量化
这个文件的核心职责只有一件事:把字符串转换成浮点向量。围绕这件事,它处理了配置验证、批量化、HTTP 重试、响应解析四个问题。
完整调用链
embed(text)
↓ 单文本包装成批量
embedBatch({text})
↓ 构造 DashScope JSON 请求体
callApiWithRetry(request)
↓ 指数退避重试
sendPostRequest(data)
↓ libcurl 实际发 HTTP POST
parseEmbeddingResponse(json)
↓ JSON → vector<float>
返回向量
指数退避:三行代码里的工程哲学
直接立即重试会触发 API 限流;固定间隔重试在高并发时形成「惊群效应」。指数退避 + 随机抖动是业界标准解法:
cpp
// 指数退避:delay = initial_delay × 2^(attempt-1)
// attempt=1 → initial_delay
// attempt=2 → 2 × initial_delay
// attempt=3 → 4 × initial_delay
int base_delay = config_.initial_retry_delay_ms * (1 << (attempt - 1));
// 加入 0~25% 的随机抖动,避免多个客户端同时重试
// 否则所有请求会在同一时刻打过去,形成"惊群"
int jitter = (std::rand() % (base_delay / 4 + 1));
return base_delay + jitter;
余弦相似度:为什么不用欧氏距离
cpp
float dot_product = 0.0f, norm_a = 0.0f, norm_b = 0.0f;
for (size_t i = 0; i < a.size(); ++i) {
dot_product += a[i] * b[i]; // 点积分量
norm_a += a[i] * a[i]; // |a|² 分量
norm_b += b[i] * b[i]; // |b|² 分量
}
// 余弦 = a·b / (|a| × |b|) ------ 只看方向,不看长度
// 语义向量的模长受文本长度影响而不稳定,但方向稳定代表语义
return dot_product / (std::sqrt(norm_a) * std::sqrt(norm_b));
为什么 embed() 内部调 embedBatch()? 复用批量接口,避免代码重复。单文本只是长度为 1 的 batch,逻辑完全一致。
四、embedding_cache.cpp:LRU 向量缓存
这是整套系统里数据结构最精妙的模块。表面上只是个缓存,背后是「哈希表 + 双向链表」的经典组合,再加上 TTL 过期和线程安全。
为什么是「哈希表 + 双向链表」
- 只用哈希表:查找 O(1),但无法维护访问顺序,不知道谁最久没被用。
- 只用链表:能维护顺序,但查找 O(n),每次 get 都要从头遍历。
- 哈希表 + 双向链表 :两者各司其职,缺一不可。
cache_map_(哈希表):根据文本键快速找到缓存条目,查找复杂度接近 O(1)cache_list_(双向链表):维护访问顺序。头部 = 最近使用,尾部 = 最久未使用,驱逐时直接删尾部
get() 的四步语义
cpp
if (!config_.enabled) { stats_.misses++; return std::nullopt; }
// ① 如果缓存被禁用,直接返回空,计 miss
std::lock_guard<std::mutex> lock(mutex_);
// ② 加互斥锁 ------ 防止并发读写导致数据竞争
auto it = cache_map_.find(text);
if (it == cache_map_.end()) { stats_.misses++; return std::nullopt; }
// ③ key 不存在 → cache miss
if (isExpired(it->second->second)) {
cache_list_.erase(it->second);
cache_map_.erase(it);
stats_.misses++; return std::nullopt;
}
// ④ 惰性删除:访问时才检查 TTL,过期就清除 ------ 无需后台线程
moveToFront(it); // 命中 → 提升到链表头部,刷新 LRU 顺序
stats_.hits++;
return it->second->second.embedding;
splice:O(1) 移节点的魔法
cpp
// splice 的魔法:不复制数据,只改链表指针
// 把 it->second 指向的节点从原位置摘下,插到链表头部
// 时间复杂度 O(1),完美契合 LRU 的"最近使用提升"语义
cache_list_.splice(cache_list_.begin(), cache_list_, it->second);
惰性删除策略
过期条目不会被后台线程主动清除 ,而是在 get() 访问时才判断并删除。好处是:实现简单,无需额外的定时器或后台线程,适合轻量级缓存场景。
EmbeddingCache就是在用「哈希表 + 双向链表」实现一个支持过期时间和线程安全的 LRU embedding 缓存。
五、vector_index.cpp:向量检索核心
vector_index.cpp 是检索能力的核心载体。它在内存中维护一个以工具名为键的哈希表,检索时通过暴力全量扫描 + 余弦相似度排序找出 Top-K 结果。
search() 的五步流程
① 遍历全量工具 → 计算每个工具与 query_embedding 的余弦相似度
② 降序排序 → 最相关的排最前
③ 阈值过滤 → 相似度低于 threshold 的直接丢弃
④ 兜底保留 → 若全被过滤,强制保留相似度最高的一个
⑤ Top-K 截断 → 返回最多 top_k 条结果
阈值兜底策略
cpp
// 阈值过滤后如果全空 ------ 保底返回最相似的那一个
// 工程策略:宁可给一个次优结果,也不让调用方拿到空列表
// 防止因阈值设置过高导致系统完全不可用
if (filtered_results.empty() && !all_results.empty()) {
filtered_results.push_back(all_results[0]);
}
持久化:索引文件存什么
索引支持 JSON 格式的序列化与反序列化,保存文件包含以下元信息:
json
{
"version": "1.0", // 索引格式版本,便于后期升级迁移
"model": "text-embedding-v2", // 生成向量时用的模型
"dimension": 1536, // 向量维度,加载时做完整性校验
"tools": [ ... ] // 工具列表:name / embedding / schema
}
这样下次启动时可以直接加载历史索引,无需重新对所有工具做 embedding。
为什么用暴力全量扫描,不用 FAISS? 工具数量通常是几十到几百个,全量扫描毫秒内完成,复杂度低,实现简单,可读性好。FAISS 是工具数量达到百万级时的方案。
持久化为什么要写 dimension? 下次加载时可验证当前模型与索引维度是否一致,防止换模型后加载旧索引导致维度不匹配。
VectorIndex就是在做「工具 embedding 的内存存储、相似度检索、JSON 持久化」。
六、tool_validator.cpp:可用性校验层
语义上「相关」不等于技术上「可用」。ToolValidator 在检索完成后,通过真实调用判断工具是否真的能被正常执行。
四个核心设计决策
① 为什么没有 tool_call_func_ 时默认有效?
验证器是可选模块。没注入调用函数时无法验证,只能宽松策略------不阻塞主链路,直接通过。
② 为什么用 async + wait_for 而不是 sleep?
工具调用可能卡死。async 启动异步任务,wait_for 设超时上限,超时按配置决定是否视为有效。轻量无需线程池。
③ 为什么参数错误也算「有效」?
自动生成的测试参数不完整是预期内的。「参数错误」说明工具本身能被调起来,只是参数不对------工具本身没问题。
④ 为什么测试参数最多只生成 3 个?
目标是探测可用性,不是完整业务测试。3 个参数足以激活工具调用路径,更多参数只增加噪声。
自动生成测试参数:Schema 驱动
cpp
// 按参数类型自动填入最小合法值 ------ 无需人工编写测试用例
if (type == "string") test_params[key] = "test";
else if (type == "number" || type == "integer") test_params[key] = 1;
else if (type == "boolean") test_params[key] = true;
else if (type == "array") test_params[key] = json::array();
else if (type == "object") test_params[key] = json::object();
if (test_params.size() >= 3) break; // 3 个足够探测,再多只是噪声
宽容的有效性判定
cpp
// 三种情况视为「有效」:
return result.success // 1. 真正调用成功
|| result.error.find("parameter") != std::string::npos // 2. 参数不对(工具本身正常)
|| result.error.find("argument") != std::string::npos; // 3. 参数类型不符(同上)
// 目标不是验证参数是否完美,而是「工具本身可工作吗」
带超时的异步验证
cpp
// 启动异步任务,带超时控制
auto future = std::async(std::launch::async, [this, &tool, &query]() {
return executeTestQuery(tool.name, query);
});
auto status = future.wait_for(std::chrono::milliseconds(config_.timeout_ms));
if (status == std::future_status::timeout) {
// 超时 → 按配置决定算有效还是无效
}
ToolValidator就是在候选工具检索完成后,自动造测试参数、带超时地试调工具,并过滤掉明显不可用的工具。
总结
整套系统的本质就是:把语义检索的每个环节解耦------向量化、缓存、索引、调度、校验,各自独立、层层配合,最终实现「用户说一句话,系统找到最合适的工具」。
| 文件 | 总结 | 核心 |
|---|---|---|
tool_retriever.cpp |
总调度器,把其他模块串在一起 | Cache Aside |
embedding_service.cpp |
文本 → 向量,带指数退避重试 | 退避算法 + 余弦相似度语义 |
embedding_cache.cpp |
LRU + TTL 双策略向量缓存 | 数据结构选型 + 惰性删除设计 |
vector_index.cpp |
余弦相似度暴力检索 + 持久化 | 暴力检索的合理性 + 持久化元信息意义 |
tool_validator.cpp |
可用性探测,过滤不可用工具 | 宽容判定策略 + Schema 驱动测试 |