一个检索系统先只有"向量召回",后来加了"关键词召回"做补充。上线后"命中率"升了 3 个点,而"答案质量"降了——因为两路结果的"合并方式"是"简单拼接",于是"关键词召回里那些'字面命中但语义无关'"的结果"排到了前面".
多路召回的价值在"互补":向量擅长"同义/近义",关键词擅长"精确/生僻"。而"合并"才是决定成败的一步——这个"吃掉或放大"取决于"两路的分数量级":"向量相似度是 0–1 的小数,BM25 是 0–30 的实数"——**于是"直接相加"等于"关键词一路说了算".
加一路的收益,会被"合并方式"吃掉或放大.
1. 两路召回的三种"互补关系"
| 关系 | 例子 |
|---|---|
| 互补(各覆盖一部分) | 语义问法 vs 型号编码 |
| 重叠(都命中) | 常见问题的热门文档 |
| 互斥(一路总为空) | 生僻词/纯口语 |
第三项是"该警惕的":"关键词一路几乎不命中"第三项的"识别"很便宜:"抽样 50 条查询,统计"仅关键词命中"的比例"——**低于 5%,那一路基本"只贡献噪声".
——**那它带来的只有"排序噪声",没有"召回增益".
"简单拼接"为什么会"更差",用一条查询就能看清:
// 三步"复现问题"
1 查询:"怎么改发票抬头"(语义型)
2 向量路:命中"抬头变更流程"(对)
3 关键词路:命中"发票抬头字段说明"(字面命中、语义不对)→ 拼接后被排到前面
// 判据:"拼接口"吃掉的是"跨路的相对质量"
2. "合并"的三种做法
| 做法 | 特点 |
|---|---|
| 简单拼接(先 A 后 B) | 最差(B 全在后) |
| 交错(交替取) | 简单(位置不公平) |
| 融合打分(RRF/加权) | 最好(可比) |
第三项的关键是"把两路分数换成"可比的排名":RRF(Reciprocal Rank Fusion)只用"名次",不用"原始分"——于是"两路分数尺度不同"这件事不再重要.
"RRF"的实现只有五行,而它"把两路的结果"变成"一张分数表":
def rrf(rank_lists, k=60):
score = {}
for rl in rank_lists: # 每一路是一个"按名次排好的 id 列表"
for r, doc in enumerate(rl, start=1):
score[doc] = score.get(doc, 0) + 1.0 / (k + r)
return sorted(score.items(), key=lambda x: -x[1])
// 判据:合并后"分数与'出现在几路、名次多前'成正比"
3. "RRF"为什么"几乎不用调参"
| 优点 | 说明 |
|---|---|
| 只用名次 | 不依赖"分数归一化" |
| 常数 k 平滑 | 抑制"第一名独大" |
| 可加任意多路 | 每路贡献一份"1/(k+rank)" |
第二项是"稳"的来源:"k 取 60"是常见默认第二项的"数学"很直观:"k=0"时第一名拿 1.0、第二名 0.5、第三名 0.33——**"名次差一点、分数差很多";而"k=60"时"前三名是 0.016、0.016、0.015",于是"头部不再压倒性".
——**于是"某一路的第一名"也只是"多一票",而不是"压倒其它".
"权重"要"能回滚",否则"调参"会变成"玄学":
// 三次"权重"实验(同一回归集)
1 等权:命中率 81.2,答案质量 3.9/5
2 关键词 1.5:命中率 83.0,质量 4.1/5 ← 采纳
3 关键词 2.5:命中率 83.4,质量 3.8/5 ← 过头
// 判据:每次只改一个参数,且"两组指标"一起看
4. "权重"的三种给法
| 给法 | 说明 |
|---|---|
| 等权 | 最简单(RRF 的默认) |
| 手工权重 | 靠"经验"(要"能解释") |
| 学习权重 | 靠"标注数据"(要"回归集") |
第二项的"证据"要"来自数据":"抽样里'含编号'的查询占 30%,而这类查询"关键词命中率 95%、向量 40%"——于是"给关键词 1.5 倍"是"有据的",而不是"拍脑袋".
第二项要"写清理由":"关键词权重 1.5,因为'型号/编号'类查询占 30%"**——**否则"权重"会变成"谁都说不清的神秘参数".
"去重"是"三个检查"的前置,而它常被"漏在合并之前":
// 三步"合并前处理"
1 每路"先过滤"(阈值/黑名单),再进融合
2 按"文档 id"去重(同一文档只留"最高名次")
3 记录"来源位图"(哪些路命中了它)——用于"来源分布"统计
// 判据:融合的输入"干净且可追溯"
5. "合并后"的三个必做检查
| 检查 | 目的 |
|---|---|
| 两路"各覆盖多少"(去重前后) | 判断"是否真互补" |
| "仅 A 命中"与"仅 B 命中"的质量 | 判断"谁在拉低" |
| 最终 Top10 的"来源分布" | 判断"是否被一路垄断" |
第三项最直观:"Top10 里 9 条来自向量"第三项的"修法"很具体:"若 9/1",先"查'仅关键词命中'的质量"——**"质量好 → 提高其权重;质量差 → 那一路该'降权或去掉'".
——说明"关键词那一路"在"合并后"没起作用.
"阈值"是"最便宜的护栏",而它要"按路"分别设:
// 三种"过滤"(按路)
1 向量路:相似度 < 0.35 → 丢弃(防"勉强相似")
2 关键词路:BM25 < 3 → 丢弃(防"只匹配一个常用词")
3 融合后:只保留"来源 ≥ 2 路"的(保守,召回会降)
// 判据:先"保召回",再用"来源数"做"重排特征"
6. 排错:五种"多路召回问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 加一路后"更差" | 拼接顺序 | 改 RRF |
| Top10 被一路垄断 | 分数尺度差 | 用名次融合 |
| 命中率升、质量降 | 噪声进前排 | 加"过滤/阈值" |
| 两路"几乎重合" | 不互补 | 换一路/去掉 |
| "生僻词"仍差 | 关键词路"没生效" | 查分词/字段 |
7. 边界:什么情况"一路就够"
| 场景 | 为什么 | 做法 |
|---|---|---|
| 语料小、问法集中 | 单路召回率已高 | 单路 |
| 只需"精确匹配"(编号/错误码) | 关键词更准 | 单路 |
| 只需"语义相似"(问答) | 向量更合适 | 单路 |
| 延迟极敏感(< 20ms) | 两路=两倍耗时 | 单路 + 缓存 |
最后一条与"延迟"有关的边界:"多路召回"通常是"并行发两条查询",于是"延迟 ≈ max(两路)"而不是"和"——但"融合 + 重排"要"额外几十毫秒";而"重排模型"通常比"多一路召回"更"吃延迟".
判断口径一句话:"两路的结果,会不会"覆盖不同的用户意图""。会(既有"型号编码"又有"口语描述"),多路值得做,且必须"用名次融合";不会(只有一类问法),"加一路"只是"加一份噪声"——而"加了一路更差"这件事,通常不是"那一路不好",而是"合并方式"把它们"放在同一条起跑线上".
8. 自测
- 三种"互补关系"里,哪一种"该警惕"?它带来的只有什么?
- 三种"合并"做法里,为什么"简单拼接"最差?
- RRF 为什么"几乎不用调参"?它只用"什么信息"?
- "权重"为什么必须"写清理由"?不写会怎样?
- "Top10 来源分布"能看出什么?9/1 说明什么?
- 判断"该不该加一路"的那个问题是什么?
补一句"顺序"建议:"加一路之前,先把'单路'调到最好"——因为"多路"会"掩盖单路的问题"(如"分词错"被"向量那路补上"),而"掩盖"会让"下次排查更难";而"单路先做好、再多路"的顺序,能让每次改动"归因清楚".
而"归因清楚"这件事,是多路系统能被长期维护的前提。
否则"多路"迟早变成"没人敢动的一团参数"。
9. 一句话总结
多路召回的关键在"合并",不在"多加":两路分数"尺度不同",所以"不能用原始分相加",而要用"名次融合(RRF)"——它让每路"只贡献一票",于是"哪一路更该在前"由"排名"决定,而不是由"分数的大小"决定。而"加了一路更差"的诊断,第一步永远是"看 Top10 的来源分布"。