VDB-News · Multi-Route Retrieval Lab

检索实验室:dense · BM25 · RRF · 条件触发

同一批文档()被两条不同的主通路检索——dense(语义)与 BM25(字面)——再看融合排序。 亲手复现 55 题实验的每一个结论 —— 「某篇文档只被某一路捞回」决定了最终能不能答对: 切换查询、开关通路、拖动权重,看金标准(★)的排名如何被改变。 术语不熟?见页脚「术语速查」。

发现的问题 这套问答要先去文章库找资料、再让模型照着资料回答。找资料本来有一条按「意思像」找的路(dense);生僻专名(如 SenseVoice)它会漏,于是加了第二条按「词一样」找的路(BM25)兜底。 结果 55 题实测发现:第二条路全程开着,修好 5 题、却也弄坏 2 题——它会把「字面像、其实无关」的文章拉进来,挤掉原本对的文章。
方案(最短版) 不用在「全开」和「不开」里二选一——只在必要的时候开:查询里点名了英文专名(如 SenseVoice)、且语义路前 8 条确实没找到它,才补字面路;其余时候保持纯语义路。 55 题结论:修 3 题、零回退(比全开少修 2 题,但把 2 条回退消掉了)。
展开:这个判定具体怎么做、为什么成立

判定其实只问两个问题,都答「是」才补路:

① 查询里有没有点名?扫描查询里的英文/型号词(≥3 字符,如 SenseVoice、sherpa-onnx),去重后最多取 4 个。

② 语义路是不是真的漏了它?看语义路召回的前 8 条——这就是最终喂给模型的上下文。只要其中任何一条的「标题 + 正文前 600 字」字面出现了这个词,就说明语义路已经找到了,不补。

两条都成立时才接上字面路,并让它权重略高(λ=1.5)——因为此时它带来的几乎都是「唯一解药」:补路的题上,目标文档从榜外进到前 8,模型从「拒答」变成「答对」。

为什么两类题不会互相干扰?「不能碰」的题天然不满足触发条件:B04 是开放式泛化问题(没点名专名),B22 语义路已经拿到专名(第 1 条就命中)。所以那 2 条回退自动消失,而需要救的题(A03/A12 型)照救不误。55 题里触发面只有 3/55——绝大多数问题连判定都没通过、保持原样。

下方控制台的「触发判定流水线」就是这段逻辑的实时演示:切换问题看 ①② 的判定过程,把 dense 权重拖到 0.67 即与生产默认一致。

展开:方案的成本与收益

收益

· 专名盲区(A03/A12 型)从「结构性拒答」到「答对」——这类题上,字面路是唯一解药;

· 55 题实质回答 35 → 38:与「全开」同为 38,但零回退(全开会弄坏 B04/B22);

· 触发面 3/55(约 5%):绝大多数问题完全不受影响,风险面压到最小。

成本

· 触发时多一次更宽的召回 + 字面检索 + 融合——全是本地操作,无额外 LLM 调用;

· 判据边界:中文专名 / 同义改写型不触发(A07、B15 型盲区仍未解决);一条查询点名多个专名时,只要有一个被语义路捞到就不补——更稀有的那个可能漏救;

· 维护面:多一套判定逻辑(正则 + 覆盖检查)与两个配置项;判据是字面规则,存在两方向误判(文档里顺带一提 → 判「已覆盖」漏救;专名写成译名 → 判「未覆盖」白补一次)——两个方向的代价都小,这是不上模型的理由。

触发判定流水线 · 对应 rag/retriever.py 的 auto 分支
0.67 初值 0.67(λ≈1.5)= 生产默认 | 同一个滑杆的两种读法:λ = BM25 相对权重 = 1 ÷ dense 权重 = 1.49 | λ>1 偏字面路;λ<0.9 时 BM25 头名的融合得分低于 dense 第 8 名,字面路实际进不了 top-K
8 RRF k=60(与生产一致)

dense 召回

BM25 召回

融合结果 top-8

金标准最佳排名 · dense 单路
金标准最佳排名 · BM25 单路
金标准最佳排名 · 当前融合

这道题的真实实测(四配置,来自 55 题全量)

结果不是模拟 —— 是四种配置下真实跑出的答案状态,分两类:实质回答(真的在作答)与拒答(答"参考资料中未提及";不足 40 字的拒答记「短拒答」,否则「长拒答」)。 判定口径 v3:答案开头 60 字内同时出现「资料 / 上下文」类引用词与「没有 / 未」类否定词即判拒答。
绿色卡片 = 当前交互最接近的配置,按「勾选项 + 权重」对照:BM25 未生效 → 纯 dense;生效且 auto 关 → always 全开;auto 开且 dense 权重 ≤ 0.8(λ ≥ 1.25)→ auto λ=1.5;否则 → auto λ=1。

λ 是怎么试出来的:先检索层扫描(零 LLM 成本),再生成层验证

探索路径:最早试的是全局加权(无差别调高 dense 权重)→ 失败,但暴露了反方向信号——把权重压向字面一侧(λ=1.5 / dense 权重 0.67)时专名命中 1→3; 于是围绕这个点做两侧扫描(下表为 λ ≤ 1.5 段,λ > 1.5 段的补扫见表格下方),再用生成层 55 题实测确认(修 3 零回退,见页顶记分板)。 扫描用 evals/scan_rrf_weights.py 的两个零成本判据(秒级、不花 LLM 调用):专名命中(top-8 里字面含查询专名的 chunk 数,越大越好)与 与 dense 的重合率(字面噪声的代理指标,越高越安全)。
λA03 专名命中A12 专名命中B04 与 dense 重合判读
1.5333/8专名场景最优 → 最终选择(仅在 auto 触发时生效)
1.0114/8只救回 1 条,不够
0.7014/8A03 开始失守
0.5014/8同上
0.2015/8字面路已无贡献(灰行 = 数学死区,反证用)

补扫 λ>1.5 方向(dense 权重 0.6→0.33,日志 evals/_scan_lambda_gt15.log):专名命中全部饱和——A03/A12 停在 3、B04 重合停在 3/8、B22 停在 6;到 λ=3 时 B27 命中 8→7 出现回落。即 λ=1.5 是"够用的最小偏置",再高无收益、只有风险 → 扫描边界闭合。

① 没有全场景通吃的旋钮——专名要偏字面、泛化怕字面,全局加权两头不讨好; ② λ<0.9 是死区(推导见上方滑杆提示:λ÷61 < 1÷68,BM25 头名进不了 top-K),表里灰行即为反证; ③ 有效区间只剩两档:1.0(等权)与 1.5(偏字面)——所以最终形态是 开关而不是旋钮:平时 λ 不参与(纯 dense),auto 触发才切到 1.5。

术语速查

55 题
全套实验的问题集口径(evals/qa_set.json v2)——A 类 20 / B 类 27 / C 类 8。A、B 类应能在库内找到答案;C 类为库内确实没有答案的题(考察"该拒答时是否拒答",不计入主指标)。题号前缀即类别(如 A03 属 A 类)。2026-09-28 由 25 题扩至 55 题,旧题原样保留以对齐旧基线。
dense(语义检索)
查询与文档都编码成向量、按语义相似度召回——没写相同词、意思相近的文档也能捞到。
BM25(字面检索)
按关键词出现频率打分——查询里的词要能在文档里找到;擅长抓专有名词,也可能把「字面像、语义不像」的文档拉进来。
RRF(倒数排名融合)
不看原始分数、只看排名:某路第 n 名的得分 = 权重 ÷ (60 + n),两路得分相加后重新排序。k=60 与生产一致。
top-K / dense top-8
召回结果取前 K 条。生产默认 K=8(retrieve(top_k=8),即最终喂给生成模型的上下文条数);auto 的覆盖检查也只看这前 8 条(与线上同口径),"dense top-8" 即此。
专名
专有名词 / 型号词:长度 ≥3 的英文 token(如 SenseVoice、sherpa-onnx、gpt-6)——auto 的触发信号。
字面覆盖
dense top-8 的结果里(标题 + 正文前 600 字)字面上出现了该专名——代表 dense 已经把它捞回来了,不需要字面路再救(B22 型)。
短路
覆盖检查扫到第 1 条命中就停止、不再检查后面条目——对应生产里"命中即返回"的提前退出逻辑。
D / B / D+B 徽章
融合列表里每条结果的来源:D = 只被 dense 召回、B = 只被 BM25 召回、D+B = 两路都召回。
λ / dense 权重
同一个滑杆的两种读法:λ = BM25 相对权重 = 1 ÷ dense 权重。λ>1 偏字面路,要偏语义就往右拖。
条件触发(auto)
BM25 的启用开关(不是第三条通路):仅当「查询含专名」且「dense top-8 未字面覆盖」时才补字面路,其余时候保持纯 dense。
检索层 / 生成层
检索层 = 只看召回与排序(秒级、零 LLM 成本);生成层 = 真跑 LLM 生成答案、按答案质量判定(如 55 题实测,约 25 分钟/轮)。
回退
开启某配置后,原本能答的题目变成拒答(或明显变差)的副作用。例:always 全开时 B04 从 701 字答复退化为 18 字拒答。
金标准(★)
标题或正文含该题关键词的 chunk,即答对这道题所需的文档。