发现的问题
这套问答要先去文章库找资料、再让模型照着资料回答。找资料本来有一条按「意思像」找的路(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 型盲区仍未解决);一条查询点名多个专名时,只要有一个被语义路捞到就不补——更稀有的那个可能漏救;
· 维护面:多一套判定逻辑(正则 + 覆盖检查)与两个配置项;判据是字面规则,存在两方向误判(文档里顺带一提 → 判「已覆盖」漏救;专名写成译名 → 判「未覆盖」白补一次)——两个方向的代价都小,这是不上模型的理由。
λ 是怎么试出来的:先检索层扫描(零 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.5 | 3 | 3 | 3/8 | 专名场景最优 → 最终选择(仅在 auto 触发时生效) |
| 1.0 | 1 | 1 | 4/8 | 只救回 1 条,不够 |
| 0.7 | 0 | 1 | 4/8 | A03 开始失守 |
| 0.5 | 0 | 1 | 4/8 | 同上 |
| 0.2 | 0 | 1 | 5/8 | 字面路已无贡献(灰行 = 数学死区,反证用) |
① 没有全场景通吃的旋钮——专名要偏字面、泛化怕字面,全局加权两头不讨好;
② λ<0.9 是死区(推导见上方滑杆提示:λ÷61 < 1÷68,BM25 头名进不了 top-K),表里灰行即为反证;
③ 有效区间只剩两档:1.0(等权)与 1.5(偏字面)——所以最终形态是 开关而不是旋钮:平时 λ 不参与(纯 dense),auto 触发才切到 1.5。