📘 mcpgw 图解教程 · 目录 第四部分 · 后续 15 / 15
第四部分 · 后续

Hybrid 检索(RRF)

前面我们见过两路检索:BM25(字面——查询和工具描述共享词才命中)与 向量(语义——意思相近就命中)。它们各有盲区:BM25 漏掉「换了说法」的工具,向量则可能 被字面精确的查询带偏。HybridRRF(Reciprocal Rank Fusion,倒数排名融合) 把两路的排名合并到一起,取长补短。它是可选策略, 默认仍是 bm25

🔌 生活类比
你同时问两位图书管理员:一位按书名关键词找(BM25),一位凭理解推荐(向量)。 两人各给你一份排名。问题是——他们打的「分」根本不可比(一个数词频、一个算余弦)。 聪明的做法不是比分数,而是看名次谁在两份榜单里都靠前,谁就最该被信任。 这正是 RRF 干的事。

为什么按「名次」融合,而不是按「分数」

🔬 细节 / 代码对应

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(两路都靠前)121/61 + 1/62 ≈ 0.0325🥇 1
B(两路都有,略低)231/62 + 1/63 ≈ 0.0320🥈 2
C(仅语义命中)11/61 ≈ 0.0164🥉 3
✅ 这张表说明了两件事

两路的「不对称」是有意的

BM25 一路

  • 只返回命中词的工具(分数 > 0 才入榜)。
  • 没有共享词 → 直接不在榜上。

向量一路

  • 给全部工具按余弦排名(即使相似度很低)。
  • 所以「仅语义相关」的工具也能通过这一路进入融合。

正因为向量这一路覆盖全部工具,hybrid 才能把 BM25 漏掉的语义命中捞回来——这就是上表里 C 的来历。

关键实现:全深度融合 + 复用现成两路

🔬 细节 / 代码对应

HybridStrategy 不重新发明轮子——它内部组合一个现成的 Bm25Strategy 和一个 VectorStrategy,检索时各取一份排名再融合。 注意 子检索按 doc_count(全目录深度)而非 top_k: 若先各自截到 top_k 再融合,会丢掉「一边名次低、另一边名次高」的单边命中,破坏 RRF 的正确性。

crates/retrieval/src/hybrid.rs · HybridStrategy::search + rrf_fuse
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
}

嵌入坏了怎么办?自动退化≈纯 BM25

🛟 降级自愈(无需额外代码)

还记得上一部分:VectorStrategy 在嵌入失败时会透明回落到它内置的 BM25。 放进 hybrid 里,这条性质免费复用了:

怎么开启(以及为什么默认还是 bm25)

🔬 装配 / 代码对应

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"   # 只引用环境变量名,绝不写明文密钥
⚠️ 易错点
hybrid 离不开联网 embedder。离线的 mcpgw search CLI 不注入 embedder, 所以对 strategy="vector"/"hybrid" 会直接报 EmbedderRequired—— 它们只在 serve(在线网关,启动期建好 embedder)下才真正生效。
💡 为什么默认不是 hybrid
路线图原本设想「默认 BM25+向量混合」。但 hybrid 离不开云端 embedder(要 API Key、要联网), 做默认就意味着开箱即跑会失败。所以最终决定:默认仍是零依赖、离线可用的 bm25, hybrid 作为 opt-in——配了 [retrieval.vector] 才启用。零配置体验与语义能力,二者兼得。
✅ 关键要点
🗺️ 回到全局
到这里,检索这条线就完整了:BM25(字面)向量(语义)、以及把两者 RRF 融合Hybrid。它们都藏在网关的三个元工具之后,对客户端透明—— 客户端永远只看到 search_tools / get_tool_details / call_tool, 而用哪种检索策略,只是一行配置的事