前面我们见过两路检索:BM25(字面——查询和工具描述共享词才命中)与 向量(语义——意思相近就命中)。它们各有盲区:BM25 漏掉「换了说法」的工具,向量则可能 被字面精确的查询带偏。Hybrid 用 RRF(Reciprocal Rank Fusion,倒数排名融合) 把两路的排名合并到一起,取长补短。它是可选策略, 默认仍是 bm25。
BM25 的分(词频 × idf)和向量的分(余弦相似度)量纲完全不同,直接相加毫无意义。 RRF 绕开这个问题:只看每个工具在各路排名里的名次 rank(最好为 1), 名次越靠前贡献越大:
# 融合分 = 把该工具在每一路里的「名次贡献」相加,k = 60 固定
fused(doc) = Σ(路 L ∈ {BM25, 向量}) 1 / (60 + rank_L(doc))
常数 k=60 是业界默认,用来压平头部名次的统治力(让第 1 名不至于碾压一切)。 mcpgw 把它定死,不开放成配置——少一个旋钮,少一份漂移。
设查询同时被两路检索。BM25 命中了 A、B(A 更靠前);向量则把语义最近的 C 排第一,A、B 随后。 按 1/(60+rank) 累加:
| 工具 | BM25 名次 | 向量名次 | RRF 融合分 | 最终名次 |
|---|---|---|---|---|
| A(两路都靠前) | 1 | 2 | 1/61 + 1/62 ≈ 0.0325 | 🥇 1 |
| B(两路都有,略低) | 2 | 3 | 1/62 + 1/63 ≈ 0.0320 | 🥈 2 |
| C(仅语义命中) | — | 1 | 1/61 ≈ 0.0164 | 🥉 3 |
正因为向量这一路覆盖全部工具,hybrid 才能把 BM25 漏掉的语义命中捞回来——这就是上表里 C 的来历。
HybridStrategy 不重新发明轮子——它内部组合一个现成的 Bm25Strategy 和一个 VectorStrategy,检索时各取一份排名再融合。 注意 子检索按 doc_count(全目录深度)而非 top_k: 若先各自截到 top_k 再融合,会丢掉「一边名次低、另一边名次高」的单边命中,破坏 RRF 的正确性。
async fn search(&self, query: &str, top_k: usize) -> Vec<ScoredTool> { if self.doc_count == 0 { return Vec::new(); } // 关键:按 doc_count(全目录深度)跑两路,而非 top_k—— // RRF 必须看到每个工具在各自排名里的「真实名次」 let lb = self.bm25.search(query, self.doc_count).await; // 字面一路 let lv = self.vector.search(query, self.doc_count).await; // 语义一路 rrf_fuse(&[lb, lv], top_k) } fn rrf_fuse(lists: &[Vec<ScoredTool>], top_k: usize) -> Vec<ScoredTool> { let mut fused: HashMap<String, (f32, String)> = HashMap::new(); for list in lists { for (i, hit) in list.iter().enumerate() { let rank = (i + 1) as f32; // RRF_K = 60,按名次把贡献累加到该工具名下 fused.entry(hit.qualified_name.clone()) .or_insert_with(|| (0.0, hit.description.clone())) .0 += 1.0 / (RRF_K + rank); } } // 按融合分降序、qualified_name 升序排序(确定性 tie-break),截断 top_k }
还记得上一部分:VectorStrategy 在嵌入失败时会透明回落到它内置的 BM25。 放进 hybrid 里,这条性质免费复用了:
hybrid 和 vector 一样需要一个 embedder(云端嵌入 + API Key),因此三处校验保持一致:
# config.toml —— 开启 hybrid [retrieval] strategy = "hybrid" # 默认是 "bm25";这里显式切到 hybrid [retrieval.vector] model = "text-embedding-3-small" api_key_env = "OPENAI_API_KEY" # 只引用环境变量名,绝不写明文密钥