/3,031 字/阅读约 11 分钟

修 5 坏 2,还是修 3 坏 0?——一次 55 题的多路召回实验,与两个能亲手操作的复盘页

给检索链路补一条字面路(BM25)去修专名盲区:55 题实测,两条路全程都开能多答对 5 题、但也弄坏 2 题;改成「查询点了名、且语义路确实没找到」才补路,则多答对 3 题、零回退。本文记录四配置对比、λ 的两次扫描(含一个被数据否决的方向)、以及一条必须诚实标注的「不算数的修复」,文末附两个无需后端、离线的交互式复盘页。

眠月眠月
joker1point

多一条搜索路,不一定是好事——但也不一定就是坏事,取决于你什么时候让它上场。

这是上一篇《把 143 块拆到 45 块:一次 RAG 分块重构,和它揭出来的评测陷阱》的下游续集。那篇的结尾留了个尾巴:分块改造之后,「多路召回的增益」从 +11.1% 缩水到 +2.2pp——说明原先那部分收益,有相当一块其实是在给脏文本打补丁。

于是问题变得具体了:字面搜索(BM25)这条路,到底还要不要?如果要,什么时候让它上场?

同一个 55 题问答集、同一套本地库(1120 块),四种用法各跑一遍:

35
题答对 · 只用语义路
基线
38
题答对 · 两条路全程都开
修 5 坏 2
37
题答对 · 按需补路(等权)
修 2,零回退
38
题答对 · 按需补路 + 偏字面
修 3,零回退(当前默认)

注意第 2、4 两栏:同样是 38,拿到它的方式完全不同。这就是全文的主角。


一、病灶:一个模型名,难倒了一整条链路

起点是一个非常朴素的问题:

「SenseVoice 是什么模型?」

库里有资料——本地 1120 块语料里,含「SenseVoice」的块确实存在。但语义检索(dense)给出的前 32 条候选里,只有 2 条包含它,分别排在第 25、27 位。而真正喂给生成模型的上下文是前 8 条。

模型不是不知道,是根本没看到。

同类问题还有一题:「Pyannote 是用来做什么的?」——含关键词的资料排在第 9 名,恰好够不着前 8。

两题都以「参考资料中未提及」式拒答收场,而它们的内容明明就在库里。这类失败很有意思:问题不在内容层(写没写过),而在召回层(有没有被找回来)。向量检索把查询和文档都压成语义向量,低频的专名(产品名、型号名、人名)容易被「平均」掉;而字面检索(BM25)不认意思、只认词——这类题恰恰是它的主场。

这就是「结构性盲区」:不改检索结构,光加内容、换模型都救不回来。

二、第一轮:全开——修 5 坏 2

最直觉的做法:既然字面路能补盲区,那就每道题都两路融合(RRF 倒数排名融合),一次到位。

55 题跑完,账是这样的:

方向数量典型案例
修好5A03 / A12:短拒答 → 实质回答(专名盲区修复);A07 等:长拒答 → 实质回答
弄坏2B04: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.0w=1.5w=2.0w=3.0
A03 专名命中1000
A12 专名命中1111
B04 与语义名单重合4/84/84/84/8

结论干脆:加权是全局折中,救不了这个病。把权重往语义侧压,A03 的救命候选立刻被压出前 8(专名命中 1 → 0);而 B04 的噪声该在还是在(重合率纹丝不动)。原因也清楚——「该救的字面候选」(A03 的专名块)和「该压的字面噪声」(B04 的同词块)在字面路里排得一样靠前,全局旋钮区分不了它们。

方法论收益

这个方向被秒级、零 LLM 成本的检索层扫描直接否决,没有浪费一轮 55 题的生成层实验(每轮约 25 分钟)。先用便宜信号筛掉方向,再花预算做贵的验证——这是这次实验里最省时间的一条纪律。

四、方案:条件触发——两个问题,都答「是」才补路

既然「全开」会引噪声、「不开」会留盲区,那就别在权重上折中,而是在时机上做选择:

平时保持纯语义路;只有当这道题明确需要字面路时,才请它帮忙。

「明确需要」被定义成两个问题,都答「是」才补路:

  1. 查询里点名了吗?——扫描查询中的英文/型号 token(首字母 + 后续字符,总长 ≥ 3,如 SenseVoice、sherpa-onnx、gpt-6;去重后最多取 4 个)。
  2. 语义路真的没找到它吗?——看语义路召回的前 8 条(就是最终喂给模型的上下文):只要其中任何一条的「标题 + 正文前 600 字」字面出现了这个词,就算「已找到」,不补。

两条都成立时才接上字面路,并且让它的权重略高(λ = 1.5,即 dense 权重 0.67)——因为此时它带来的几乎都是「唯一解药」。

用代码说就是:

python
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.5333/8
1.0114/8
0.7014/8
0.5014/8
0.2015/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 题四配置实测卡与术语速查。

八、这次的实验留下的三句话

  1. 收益和回退要一起看。「净 +3」和「修 5 坏 2」是同一件事的两面;一个「同样好」的配置,拆开看可能完全不是一回事。
  2. 参数是「扫出来 + 验证出来」的,不是调出来的。 先用零成本的检索层扫描否定方向、圈定区间,再用生成层预算做验证;最后补一次边界扫描,让结论闭合。
  3. 不可靠的修复要标注成不可靠。 把随机波动当成机制改善讲,早晚会在某个追问里翻车——不如自己先说出来。