修 5 坏 2,还是修 3 坏 0?——一次 55 题的多路召回实验,与两个能亲手操作的复盘页
给检索链路补一条字面路(BM25)去修专名盲区:55 题实测,两条路全程都开能多答对 5 题、但也弄坏 2 题;改成「查询点了名、且语义路确实没找到」才补路,则多答对 3 题、零回退。本文记录四配置对比、λ 的两次扫描(含一个被数据否决的方向)、以及一条必须诚实标注的「不算数的修复」,文末附两个无需后端、离线的交互式复盘页。
多一条搜索路,不一定是好事——但也不一定就是坏事,取决于你什么时候让它上场。
这是上一篇《把 143 块拆到 45 块:一次 RAG 分块重构,和它揭出来的评测陷阱》的下游续集。那篇的结尾留了个尾巴:分块改造之后,「多路召回的增益」从 +11.1% 缩水到 +2.2pp——说明原先那部分收益,有相当一块其实是在给脏文本打补丁。
于是问题变得具体了:字面搜索(BM25)这条路,到底还要不要?如果要,什么时候让它上场?
同一个 55 题问答集、同一套本地库(1120 块),四种用法各跑一遍:
注意第 2、4 两栏:同样是 38,拿到它的方式完全不同。这就是全文的主角。
一、病灶:一个模型名,难倒了一整条链路
起点是一个非常朴素的问题:
「SenseVoice 是什么模型?」
库里有资料——本地 1120 块语料里,含「SenseVoice」的块确实存在。但语义检索(dense)给出的前 32 条候选里,只有 2 条包含它,分别排在第 25、27 位。而真正喂给生成模型的上下文是前 8 条。
模型不是不知道,是根本没看到。
同类问题还有一题:「Pyannote 是用来做什么的?」——含关键词的资料排在第 9 名,恰好够不着前 8。
两题都以「参考资料中未提及」式拒答收场,而它们的内容明明就在库里。这类失败很有意思:问题不在内容层(写没写过),而在召回层(有没有被找回来)。向量检索把查询和文档都压成语义向量,低频的专名(产品名、型号名、人名)容易被「平均」掉;而字面检索(BM25)不认意思、只认词——这类题恰恰是它的主场。
这就是「结构性盲区」:不改检索结构,光加内容、换模型都救不回来。
二、第一轮:全开——修 5 坏 2
最直觉的做法:既然字面路能补盲区,那就每道题都两路融合(RRF 倒数排名融合),一次到位。
55 题跑完,账是这样的:
| 方向 | 数量 | 典型案例 |
|---|---|---|
| 修好 | 5 | A03 / A12:短拒答 → 实质回答(专名盲区修复);A07 等:长拒答 → 实质回答 |
| 弄坏 | 2 | B04:701 字带引用的答复 → 18 字「参考资料中未提及」;B22:845 字 → 拒答 |
两道坏掉的题机制一样:字面路把「字面像、语义不像」的文章拉进了候选池,在等权融合下挤掉了原本语义匹配的位置。B04 问「大模型量化有哪些新方法」,字面路听到「量化」两个字就拉了一堆同词不同义的块;B22 问「FunASR 和 sherpa-onnx 的对比」,字面路把单讲 FunASR 的文章超量放大。
净账「多答对 3 题」之下,藏着修 5 坏 2:有 5 道题被救活,同时有 2 道题被弄死。如果只看总分,这个配置看起来和最终方案一样好;把回退拆出来看,它们完全不是一回事。
三、被否决的一步:全局加权(负结果也是结果)
第一反应是「那就压一压字面路的权重」——工业界也常见这种默认(偏语义的加权融合),看起来是个便宜的修正。
这一步没有花生成层的钱。写了一个检索层扫描脚本:对关键题在若干权重下跑融合,只看两个零成本信号——
- 专名命中:融合结果前 8 条里,字面含查询专名的块数(越大越好,这是修盲区的底线指标);
- 与纯语义名单的重合率:融合结果和「只用语义路」的结果重合多少(字面噪声的代理指标,越高越安全)。
扫描结果(dense 权重 1.0 → 1.5 → 2 → 3,λ = 1 / dense 权重):
| 题 | w=1.0 | w=1.5 | w=2.0 | w=3.0 |
|---|---|---|---|---|
| A03 专名命中 | 1 | 0 | 0 | 0 |
| A12 专名命中 | 1 | 1 | 1 | 1 |
| B04 与语义名单重合 | 4/8 | 4/8 | 4/8 | 4/8 |
结论干脆:加权是全局折中,救不了这个病。把权重往语义侧压,A03 的救命候选立刻被压出前 8(专名命中 1 → 0);而 B04 的噪声该在还是在(重合率纹丝不动)。原因也清楚——「该救的字面候选」(A03 的专名块)和「该压的字面噪声」(B04 的同词块)在字面路里排得一样靠前,全局旋钮区分不了它们。
这个方向被秒级、零 LLM 成本的检索层扫描直接否决,没有浪费一轮 55 题的生成层实验(每轮约 25 分钟)。先用便宜信号筛掉方向,再花预算做贵的验证——这是这次实验里最省时间的一条纪律。
四、方案:条件触发——两个问题,都答「是」才补路
既然「全开」会引噪声、「不开」会留盲区,那就别在权重上折中,而是在时机上做选择:
平时保持纯语义路;只有当这道题明确需要字面路时,才请它帮忙。
「明确需要」被定义成两个问题,都答「是」才补路:
- 查询里点名了吗?——扫描查询中的英文/型号 token(首字母 + 后续字符,总长 ≥ 3,如
SenseVoice、sherpa-onnx、gpt-6;去重后最多取 4 个)。 - 语义路真的没找到它吗?——看语义路召回的前 8 条(就是最终喂给模型的上下文):只要其中任何一条的「标题 + 正文前 600 字」字面出现了这个词,就算「已找到」,不补。
两条都成立时才接上字面路,并且让它的权重略高(λ = 1.5,即 dense 权重 0.67)——因为此时它带来的几乎都是「唯一解药」。
用代码说就是:
tokens = entity_tokens(query) # ① 查询里的英文/型号 token
if tokens and not covered_by_tokens(tokens, docs): # ② 语义路前 8 条未字面覆盖
docs = hybrid_search(query, dense_weight=0.67) # 才补路,且偏字面
为什么两类题不会互相干扰?「不能碰」的题,天然不满足触发条件:
- B04(量化那道)是开放式泛化问题——没点名,条件 ① 就不成立;
- B22(FunASR 对比)点名了,但语义路第 1 条就命中——条件 ② 不成立。
那 2 条回退就这么消失了,而专名盲区(A03/A12 型)照救不误。整个 55 题里,触发面只有 3/55——绝大多数问题连判定都没通过、保持原样,风险面被压到极小。
五、λ=1.5 的由来:先扫描、后验证,再补扫一次
「补路时用多大权重」是同一个问题的小号版本。这次学乖了,还是先用检索层扫描(零成本)把候选区间画出来:
| λ(=1÷dense权重) | A03 专名命中 | A12 专名命中 | B04 与语义名单重合 |
|---|---|---|---|
| 1.5 | 3 | 3 | 3/8 |
| 1.0 | 1 | 1 | 4/8 |
| 0.7 | 0 | 1 | 4/8 |
| 0.5 | 0 | 1 | 4/8 |
| 0.2 | 0 | 1 | 5/8 |
三个观察:
- 专名场景要偏字面:λ 从 1.0 提到 1.5,命中数从 1 条涨到 3 条;
- 低于 0.9 是数学死区:字面路第 1 名的融合得分是 λ ÷ 61,语义路第 8 名是 1 ÷ 68——λ 低于 0.9 时,字面候选连前 8 都挤不进去,「温柔加权」是一种幻觉;
- 有效区间是断裂的:要么约 1.0(等权),要么 ≥ 1.5(有效偏置),中间档位没有意义。
那 1.5 往上呢?一开始没扫。文章写到复盘时补了一轮(dense 权重 0.6 → 0.33):
全部饱和——A03/A12 命中停在 3 条,B04 重合停在 3/8;继续压到 λ=3(dense 权重 0.33)时,另一道题的专名命中反而从 8 掉到 7(语义侧被压得太狠,开始丢分)。也就是说:1.5 是「够用的最小偏置」,再高无收益、只有风险,扫描到这里可以收工。
最后用生成层 55 题做真实作答验证:修 3 题、零回退。
λ=1.5 不是「试出来手感不错」的一个数,而是「两侧扫描 + 边界补扫 + 生成层验证」三步夹出来的一个区间端点。结论也不是一根旋钮,而是两档开关:平时 1.0(其实是「不参与」),触发时才切 1.5。
六、诚实标注:一个不算数的「修复」,与判据的两侧边界
这一段是写这篇文章最想保留的部分——有些好消息经不起细看。
一个不算数的修复。 生成层实测里,「按需补路 + 偏字面」这档多答对的 3 题中,有一题(B27:问同时提到 Agent 和知识检索的论文)其实是生成随机性:它的触发判定是「未触发」——因为查询词「Agent」已被语义路第 1 条覆盖。也就是说它在两档配置下走的是同一条纯语义路径,前后结果的差异只是 LLM 采样的波动。报告里把它标注为「含随机性成分」,不算机制性修复。对外的成绩如果按「修 3」讲,这个细节必须能接得住追问。
判据的两侧边界。
- 中文专名不触发:判定条件是「英文/型号 token」,像 A07(端侧部署)这类中文问句无论如何都不会触发——这部分盲区并没有被这个方案解决,只是没有被恶化。
- 多专名混搭只认「任一覆盖」:一条查询点名了两个专名,只要语义路捞到了其中任何一个,判定就是「已覆盖」、不补路——另一个更稀有的专名可能就此漏掉。这是有意的保守取舍(防止触发面变大、噪声回来)。
字面规则的两个误判方向:文档里顺带提一句专名 → 判「已覆盖」、漏救;专名写成中文译名 → 判「未覆盖」、白补一次。两个方向的代价都小——这正是这个判据不上模型、只用字面规则的理由:零成本、确定性、错了也错得起。
七、两个能亲手操作的复盘页
这个实验后来做成了两个单文件、无后端、离线可跑的交互页,把这个页面上所有结论都变成可以手动拨弄的东西。下面这个是通俗版(给不熟悉检索的同学看,把语义/字面两条路拟人化成两位「搜索员」):
工程师版是另一个页面,建议新标签全屏打开——里面有逐步执行的触发判定流水线(扫到第几条命中、是否短路)、金标准在两条路与融合结果中的位置轨道、以及 λ 的一整套扫描对照:
https://joker1point.github.io/techlog/static/lab/vdb-news-retrieval-lab.html
深色界面;含判定流水线动画、RRF 等权/加权的方案对比轨道、55 题四配置实测卡与术语速查。
八、这次的实验留下的三句话
- 收益和回退要一起看。「净 +3」和「修 5 坏 2」是同一件事的两面;一个「同样好」的配置,拆开看可能完全不是一回事。
- 参数是「扫出来 + 验证出来」的,不是调出来的。 先用零成本的检索层扫描否定方向、圈定区间,再用生成层预算做验证;最后补一次边界扫描,让结论闭合。
- 不可靠的修复要标注成不可靠。 把随机波动当成机制改善讲,早晚会在某个追问里翻车——不如自己先说出来。