MCP项目笔记十二(RAG-MCP)

C++ RAG 工具检索系统:

目录

  1. 系统整体架构
  2. tool_retriever:系统总调度器
  3. embedding_service:文本向量化
  4. [embedding_cache:LRU 向量缓存](#embedding_cache:LRU 向量缓存)
  5. vector_index:向量检索核心
  6. 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 驱动测试
相关推荐
talen_hx2962 小时前
emqx的Keep alive
java·笔记·学习
xiaoye-duck2 小时前
【C++:C++11】核心进阶:C++11引用折叠、完美转发与可变参数模板实战详解
开发语言·c++·c++11
树獭非懒2 小时前
Harness Engineering:为什么你的 AI 不好用,其实不是模型的问题
人工智能·程序员·llm
晨欣2 小时前
LLM 推理性能指标全解:TTFT、TBT、Output Speed、Throughput、SLO 怎么用(GPT-5.4-high生成)
人工智能·gpt·llm
雾岛听蓝3 小时前
Qt开发核心笔记:从HelloWorld到对象树内存管理与坐标体系详解
开发语言·经验分享·笔记·qt
chaors7 小时前
LangGraph 入门到精通0x02:基础 API (二)
langchain·llm·agent
花酒锄作田8 小时前
企业微信机器人与 DeepAgents 集成实践
python·mcp·deepagents
And_Ii12 小时前
LCR 168. 丑数
c++
CoderMeijun12 小时前
C++ 时间处理与格式化输出:从 Linux 时间函数到 Timestamp 封装
c++·printf·stringstream·时间处理·clock_gettime