📘 mcpgw 图解教程 · 目录 第二部分 · 检索深入 · 向量检索 04 / 15
第二部分 · 检索深入 · 向量检索

向量检索总览

上一部分我们见过 BM25:它是字面匹配——查询和工具描述共享词才会命中。 但人说话千变万化:「联系我的团队」和工具描述里的 post message 一个共享词都没有,BM25 就召回为空。 向量检索解决的正是这件事:把文本变成向量,意思相近就命中,哪怕一个字都不重合。 mcpgw 把检索做成可插拔策略,而向量策略内置 BM25 作透明降级——嵌入服务坏了也不会硬失败。

🔌 生活类比
BM25 像「按书名里的关键词找书」:你说的词必须正好印在书名上,才找得到。 向量检索像「告诉图书管理员你想干什么,他凭理解给你推荐」—— 你说「我想给团队发个通知」,他自然递来《如何用 Slack 发消息》,哪怕书名里根本没有「通知」二字。

两种检索机制的对比

BM25(字面 / 稀疏)

  • 命中机制:查询词与文档共享 token 才有分。
  • 同义/改写鲁棒性:弱——换个说法就可能完全召回不到
  • 外部服务:不需要,纯本地算词频。
  • 离线可用:✅ 永远可用,无网络依赖。

向量(语义 / 稠密)

  • 命中机制:靠余弦相似度,意思接近就高分。
  • 同义/改写鲁棒性:强——换说法、近义词依然能命中。
  • 外部服务:需要一个 embedding 服务把文本转成向量。
  • 离线可用:⚠️ 取决于服务可达;不可达时就要降级。

关键设计:透明降级

🔬 细节 / 代码对应

VectorStrategy 并不是「纯向量」——它同时持有一个 embedder、 一个内置的 Bm25Strategy,以及一个 degraded 标志:

crates/retrieval/src/vector.rs · VectorStrategy::{index,search}
async fn index(&mut self, catalog: &Catalog) {
    // 无论如何先把 BM25 兜底索引建好
    self.bm25 = Bm25Strategy::new();
    self.bm25.index(catalog).await;

    match self.embedder.embed(&texts).await {
        Ok(vecs) if vecs.len() == tools.len() => { /* 存归一化向量 */ self.degraded = false; }
        _ => { self.vectors.clear(); self.degraded = true; } // 失败/数量不符 → 降级
    }
}

async fn search(&self, query: &str, top_k: usize) -> Vec<ScoredTool> {
    if self.degraded || self.vectors.is_empty() {
        return self.bm25.search(query, top_k).await; // 透明回落 BM25
    }
    let qv = match self.embedder.embed(&[query.to_string()]).await {
        Ok(mut v) => normalize(v.remove(0)),
        Err(_) => return self.bm25.search(query, top_k).await, // 单次查询嵌入失败也回落
    };
    // …对每个工具向量算 dot(qv, v) 余弦打分、排序、截断 top_k…
}

语义增益:一个 BM25 救不了的查询

这是真实的门控冒烟用例(crates/mcpgw/tests/smoke_vector_real.rs)。查询 "communicate with my team"任何工具描述都没有共享词

查询 "communicate with my team"
和所有工具描述零共享词
BM25
召回为空(没有命中词)
向量检索
把 slack__post_message 排第一
检索策略对 "communicate with my team" 的结果
BM25(字面)空——没有任何共享词可命中
Vector(语义)slack__post_message 排在第一
✅ 关键要点
💡 设计亮点
降级是透明的——调用方拿到的永远是「尽力而为的最佳排序」: 能嵌入就给语义结果,不能嵌入就给 BM25 结果,无需自己处理 embedder 故障,也无需关心当前到底走了哪条路。