上一部分我们见过 BM25:它是字面匹配——查询和工具描述共享词才会命中。 但人说话千变万化:「联系我的团队」和工具描述里的 post message 一个共享词都没有,BM25 就召回为空。 向量检索解决的正是这件事:把文本变成向量,意思相近就命中,哪怕一个字都不重合。 mcpgw 把检索做成可插拔策略,而向量策略内置 BM25 作透明降级——嵌入服务坏了也不会硬失败。
VectorStrategy 并不是「纯向量」——它同时持有一个 embedder、 一个内置的 Bm25Strategy,以及一个 degraded 标志:
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… }
这是真实的门控冒烟用例(crates/mcpgw/tests/smoke_vector_real.rs)。查询 "communicate with my team" 与任何工具描述都没有共享词:
| 检索策略 | 对 "communicate with my team" 的结果 |
|---|---|
| BM25(字面) | 空——没有任何共享词可命中 |
| Vector(语义) | slack__post_message 排在第一 |