你给知识库接上第二条召回路径,再加一个重排器。配置更像正式产品了,答案却未必更可靠。原先能找到的错误码说明,为什么反而被两条都觉得“差不多相关”的介绍页挤下去了?
第二篇已经把尺子固定在原文证据范围上。这一篇固定检索单元,换排序链路:证据在哪一步丢了,是召回没给机会、融合改坏了顺序,还是最终预算容不下?
先把实验身份说清楚:本文实际运行的是两路词法检索:BM25 原查询,以及使用固定全局词表扩展后的 BM25;之后做 RRF 与可解释规则重排。人工合成语料和问题只用于机制实验,没有调用 Embedding、向量数据库、神经重排器或生成模型,不是生产 RAG 或大模型 benchmark。 规则与数据都经过教学设计,不提供泛化性能证明。资料核对日期:2026 年 10 月 3 日。
生产中常见的词法与向量混合检索,是这套实验可迁移的应用场景;本文没有把查询扩展假装成语义模型。先把候选、融合、重排和预算这几层接对,再谈替换成什么模型。
1. 第二条路带来的,是新证据还是第二张赞成票?#
两条路径若总给出相同列表,融合只是在重复同一种偏好。真正值得测的是互补性:第一路漏掉哪些证据,第二路是否补上;第一路已经找对时,第二路会不会把它拖下去。
词法检索擅长处理精确名称、错误码和有共同词项的表达;换了说法,相关文本可能得不到分数。本文用固定扩展词表制造一个可审计的第二入口:例如把业务中确实可能互换的词项加入查询。但词表没有上下文理解能力,一个多义词也可能把另一主题召回来。
这里有两个必须分开的行为:
- 扩展改变了发给检索器的查询,因此可能带来新候选,也可能带来新干扰
- 融合只利用已经返回的列表,不能搜索列表外的材料
所以比较时不能只写“打开混合检索后召回升了”。至少应保留原查询路、扩展路、融合、融合后重排这几个对照,分别检查候选与最终上下文。若只有融合结果,不知道另一条路究竟补了什么,也不知道代价落在哪道题上。
2. 这次实现 BM25,不再用词项交集冒名顶替#
上一篇刻意采用不同词项的交集数量,它没有逆文档频率、词频饱和或长度归一化。这次三部分都实现,才能明确叫作 BM25。
对于查询中一个词,本文使用:
idf = log(1 + (N - df + 0.5) / (df + 0.5))
termScore = idf × tf × (k1 + 1)
/ (tf + k1 × (1 - b + b × length / averageLength))
N 是索引中的检索单元数,df 是包含该词的单元数,tf 是该词在单元中的频次,length 是单元的分词后长度。把查询各个不同词的贡献相加;本例不因为用户重复输入同一个词就重复累加查询权重。
直觉上,idf 让很少见的词更有区分力;词频饱和让第五次出现的边际贡献小于第一次;长度项避免长文本仅靠容纳更多词项占便宜。k1 和 b 控制后两者,不是回答正确性的概率。
本文参数与 IDF 形式参照 Lucene 9.12.1 BM25Similarity 文档,但这是独立教学实现,没有复现 Lucene 的完整分析器、字段统计和底层编码。因此不可把本例分数与 Elasticsearch 查询分数逐位对账。
原文使用 ASCII 英文与固定分词规则,是为了让词项、字符范围和每个贡献可以逐项查账。真实中文知识库必须另行固定分词器、停用词、规范化规则及版本;把一段中文直接塞进这个英文分词器,不能得到有意义的中文检索评测。
3. RRF 不用统一分数量纲,但仍然有参数#
BM25 的 8 分与另一条路径的 0.8 分,不能靠肉眼认为前者强十倍。RRF 绕开原始分数的量纲,使用各列表中的名次:
RRF(d) = sum(1 / (rankConstant + rankInEachList(d)))
名次从 1 开始;某条路径没有返回 d,就不为该路径加分。相同身份的条目合并成一项,保留各路贡献供解释,而不是把重复返回当成多个最终片段。
例如常数为 60 时,一项在两路都排第 2,得到 2/62,约 0.032258;另一项只在一路排第 1,得到 1/61,约 0.016393。前者会领先。这是多路一致性产生的排序偏好,并不代表第一项具有 3.2% 的正确概率。
2009 年 RRF 原论文在先导实验中选定 60,再用于后续验证。这个历史设置不是“所有知识库最优常数”的证明。常数增大时,靠前与靠后的名次贡献更接近;参与融合的路径数量、每路深度、路径相关性,也都会影响结果。
Elasticsearch RRF 文档把 rank_constant 与 rank_window_size 分开配置;后者决定进入融合的各路窗口。不能把“无须直接校准原始分数”误读成“没有任何参数可影响结果”。2023 年融合函数分析也在其研究设置中发现 RRF 对参数敏感;本文引用这一点提醒读者验证配置,不移植论文的性能数字。
还有一个身份陷阱:同一 chunk 通过两路返回,应获得两路的贡献,但最终只占一个上下文位置。相同列表内重复出现同一身份,不能无上限刷票。不同身份却含有同样文字,是另一种近重复问题;只按 ID 去重不会解决它,不能在报告里把两者混为一谈。
4. 把候选深度、重排工作量和最终输入分开#
候选深度决定每条路径先保留多少项。重排数量决定有多少查询—文本对被进一步检查。最终输入预算决定哪些文本真正送到后续答案环节。它们不是一个参数的三种名字。
本文固定原文短段作为检索单元,不再改切块。最终按排序扫描,整段放不下就跳过,继续尝试后面的较短段;不截断,不偷偷合并,也不做最优背包选择。即使最终字符上限相同,不同方案实际使用的字符仍可能不同,结果必须把实际体积一起留下。
资格过滤也不是相关性重排。本例只演示 fixture 声明的 public 可见性标记与当前有效版本;旧版材料即使得分很高,也不能充当当前问题的证据。它不实现 ACL,不提供真实租户权限隔离。生产系统的权限约束必须在所有召回路径与缓存边界一致生效,不能靠平均召回提高来抵消越权。
评分继续沿用前两篇的基本约定:证据绑定原文文档、版本与半开区间;同一事实允许备选支持,但每个事实最多得一分。本文报告的是“上下文是否包含必需证据”,不是生成答案是否真的正确使用证据。无答案题没有必需事实分母,覆盖记为 null,不把它当成满分,也不把没有候选等同于正确拒答。
5. 下载后先跑,再检查逐题变化#
五个附件放到同一目录即可运行,不需要 API key、数据库、模型下载或第三方依赖:
- evaluate.mjs:BM25、扩展、RRF、重排与计分
- fixture.json:原文、查询与证据标注
- test.mjs:可独立运行的边界测试
- results.json:本次运行的完整逐题记录
- README.md:参数、命令与边界
node evaluate.mjs fixture.json > actual-results.json
node test.mjs
本次固定 19 份文档版本记录,其中 17 份符合 public + currentVersion 条件;8 道题里 7 道可回答、1 道无答案,可回答题一共需要 8 条事实。BM25 使用 k1=1.2、b=0.75,RRF 常数为 60。主对照每路深度分别取 2、4,最终预算均为 140 个 ASCII 字符;另加关闭资格过滤与 80 字符小预算两个探针。总共 10 组,完整保留,不挑最好的一组展示。
下表覆盖率先逐题计算,再对 7 道可回答题宏平均,保留三位小数。平均上下文字符以全部 8 题为分母,包含无答案题实际返回的文字;它与覆盖率的分母不同。
| 配置 | 候选证据覆盖 | 最终证据覆盖 | 平均上下文字符 |
|---|---|---|---|
| bm25-d2 | 0.643 | 0.643 | 89.875 |
| expanded-d2 | 0.786 | 0.643 | 96.250 |
| rrf-d2 | 0.786 | 0.643 | 96.250 |
| rrf-rule-d2 | 0.786 | 0.786 | 95.375 |
| bm25-d4 | 0.857 | 0.643 | 93.250 |
| expanded-d4 | 0.857 | 0.643 | 103.375 |
| rrf-d4 | 1.000 | 0.643 | 103.375 |
| rrf-rule-d4 | 1.000 | 1.000 | 100.125 |
| rrf-d4-unfiltered | 1.000 | 0.500 | 103.000 |
| rrf-rule-d4-small-budget | 1.000 | 0.714 | 53.750 |
不要先被那一行 1.000 吸走注意力。fixture 专门包含关键词堆叠的介绍页、短事实段与固定约束词,规则也针对这类可解释特征设计,这是教学同分布上的链路演示,规则与 fixture 共同设计,没有独立留出集。满分既不说明复杂问题能答对,也不说明这套手工规则优于真实重排模型。
先对照深度为 4、预算为 140 的四条链路:
| 问题 ID | BM25 | 扩展 BM25 | RRF | RRF + 规则 |
|---|---|---|---|---|
| exact-identifier | 1 | 1 | 1 | 1 |
| synonym-only | 0 | 1 | 1 | 1 |
| two-facts | 0.5 | 0.5 | 0.5 | 1 |
| negation-distractor | 1 | 1 | 1 | 1 |
| fusion-regression | 1 | 0 | 0 | 1 |
| depth-omission | 0 | 0 | 0 | 1 |
| eligibility | 1 | 1 | 1 | 1 |
| no-answer | null | null | null | null |
得到一题,也可能丢掉另一题#
synonym-only 查询是 terminate credential。原文证据用 Cancel the key to revoke access immediately.,原查询与证据没有共同词,BM25 没把它召回。固定词组把 terminate 连到 cancel/revoke/disable,把 credential 连到 key/token;第二路补进这段,最终覆盖从 0 变成 1。
同一份词表却把 vault deletion undo 扩展到 erase、remove、delete、restore、rollback 等词。vault-faq 主题索引堆了这些相关词,却没有回答撤销窗口是 24 小时。原查询把 vault-policy 排第 1、FAQ 排第 2,扩展路恰好反过来;两者 RRF 分数相同,稳定的身份并列规则让 FAQ 先入选。
FAQ 占了 93 字符,剩下 47,装不进 86 字符的政策证据;后面反而装入 30 字符的 Atlas 介绍。该题从 BM25 的 1 降到 RRF 的 0。整个宏平均恰好被同义题的改善抵消,所以两者最终都是 0.643。总分没有变,不等于逐题风险没有变。 此处还提醒我们:确定性的并列规则便于复现,却不是相关性证据;改变身份命名或并列规则也可能改变结果,应纳入配置。
候选齐了,不代表预算会把它留下#
two-facts 需要“保留 7 天”和“导出 JSON”。RRF d4 候选包含两个事实,最终却先塞进介绍页,只留下导出事实,覆盖 0.5。规则按查询词匹配、精确标识、数字、约束词和导航词特征重排,把两个事实段排到前面,合计 52 字符,覆盖才变成 1。
规则的具体分数是:原查询匹配词数,加上每个匹配标识符 4 分,再给含数字、含 only/never/unless 各 1 分,出现 overview/guide/glossary/index 减 4 分。它不读取金标;同分保留融合顺序。这种惩罚会误伤真正以 guide 为正文一部分的好证据,所以不能直接搬成通用线上规则。
depth-omission 的 Atlas 证据在原查询路径排第 4。每路只取 2 时,规则根本见不到它,最终仍为 0;每路取 4 后,它进入融合池,再重排才变成 1。RRF d4 本身仍先装入三段介绍共 84 字符,103 字符的证据放不下,因此也为 0。这里同时存在候选入口与预算排序两种瓶颈。
把同样的规则链路最终预算压到 80 字符,86 字符的 Vault 政策与 103 字符的 Atlas 规则都整段放不下,宏覆盖降到 0.714。重排器没有变,候选也没有变;证据是在最终选择阶段被丢掉的。
“看起来当前”不能替代资格判断#
eligibility 问当前服务超时。关闭资格过滤后,最终选中 76 字符的 internal staging 说明;它含有 current 等匹配词,但不是问题需要的公开当前版本。候选池里仍有正确证据,最终该题却为 0。启用过滤时,保留的是 timeout/v2 的 7 秒说明。
本例资格过滤还会改变参与 BM25 的索引集合与词项统计;关闭开关时并非只在排序末尾多放两段。因此这一行用于发现资格边界,不把它解释成重排或 RRF 单组件的效果。
无答案题也很重要:140 字符下,各主配置都把 87 字符的 archive 介绍塞进上下文,仍然没有给出最大期限。80 字符预算下它恰好装不下,于是上下文为空。两种状态都不能代替生成侧拒答评测,null 必须保持 null。
最后看查账量。深度 4 时,8 题合计的 BM25 词项—文档检查是 408 次,扩展路单独为 680 次,两路 RRF 为 1088 次;RRF 访问 39 个路径排名条目,跨路合并掉 17 个重复身份,留下 22 个查询—候选对。规则版本再对这 22 对逐个检查特征。这里的合并是按身份,未声称做文本近重复消除。相关计数、每路名次与贡献、最终选择和跳过原因均在 results.json 中,可以从逐题记录重新相加。
6. 重排不能拿着答案把自己评成满分#
一个容易写出“显著提升”的办法,是让规则知道正确文档 ID,或者针对每道题写一条包含答案的条件。程序确实能运行,分数也真实变高了,但这不是检索改进,只是答案泄漏。
本例把职责分开:检索与重排只接收查询、文档及公开配置;必需事实与原文 span 交给后面的评分函数。独立测试更改或移除金标后,要求排序结果不变。这个测试能发现运行时读取标签,却不能证明人类设计者没有通过反复看结果调规则。
因此还要承认更深一层限制:fixture 和规则是同一教学任务中编写的,没有独立留出集。即便代码完全 gold-blind,也不能把这里的改进写成真实业务泛化能力。读者可以用它检查链路,再用没参与规则设计的真实问题评估收益。
规则重排只是一个机制探针。它可把“某个特征为什么改变顺序”暴露出来,但不理解复杂语义。出现数字不保证数字回答了问题,完整匹配错误码不保证后面的步骤适用,文档中的否定也不能用一个统一惩罚项粗暴处理。规则把证据提上来时,应记录原因;把证据压下去时,也必须保留这个反例。
同样,重排无法救回候选中完全不存在的材料。若增加候选深度后才改善,首先应把这次变化归给候选集合,而不是宣布重排器更聪明。对比重排前后时应使用同一候选列表,否则一次实验混了两个变量。
7. 成本可查账,延迟先别凑小数点#
本文运行环境为 Linux x86_64、Node.js v24.19.0。确定性结果保存词项检查、融合贡献、重排工作量与输入字符等操作计数;这些计数对应随附实现,不代表优化后的倒排索引或硬件上的真实复杂度,更不是 API 费用。
本例语料很小,单次墙钟时间容易被进程启动、JIT、垃圾回收、系统调度以及 I/O 淹没。把这样的 0.x 毫秒差距画成性能胜负,没有解释力。可重复的计数与逐题变化是这篇的主要结果;计时只能作为本机诊断,不能外推生产 p95。
换成真实服务后,分别测建索引、查询两路召回、融合、重排和生成,并记录冷暖缓存、并发、超时、候选数、请求大小与失败率。两路并行时,端到端等待通常由较慢路径及协调开销决定,不能把两路时间随意相加或取平均就声称是用户延迟。若做降级,超时缺一路时的质量也需要单独回归。
费用则要用实际收费模型计算:重排按文档对、token 或请求收费,具体取决于服务;生成输入应包含引用标签、分隔符、系统指令与真正送入的上下文。本文只数正文字符,ASCII 字符数也不是通用 token 数。
8. 接入真正的混合检索时,保留这几个接口#
第一步,替换第二路。把固定扩展 BM25 换成真实向量召回,冻结 embedding 模型、维度、索引快照和近邻参数,仍输出稳定身份、原文版本、坐标、排名与来源。向量得分保留用于诊断,不能当作答案置信度。
第二步,替换重排。把规则探针换成指定版本的 cross-encoder 或其他模型,使用同一候选池,固定模型输入格式、截断策略、批量大小与最大长度。截断可能恰好剪掉关键证据,因此应记录模型实际看到的文本,不能只保存未截断的原始候选。
第三步,设独立的留出问题和失败门槛。按精确标识、同义表达、多事实、版本冲突、权限边界、无答案分组,报告逐题升降和失败数量。只看宏平均,容易用几个简单题的上升掩盖关键题退化。若调了候选深度、RRF 常数或重排阈值,最终报告应使用没参与这些选择的数据。
最后再接回第一篇的答案侧评测:给定实际最终上下文,分别检查答案事实覆盖、引用支持与拒答。候选有证据、重排靠前、上下文装得下,只是通向正确答案的条件,不是答案已经正确的替代证明。
这一篇真正想留下的不是一套“最优 RRF 参数”,而是一条排错顺序:先问证据有没有进入候选,再问融合与重排怎样改变顺序,最后看预算是否保住了它。链路每一层都有可查记录时,分数变化才有明确归属。
系列导航:
- 第一篇:检索命中了,为什么答案还是错的?
- 第二篇:切块、稳定证据映射与预算对照
- 本篇:两路词法、RRF 与重排机制实验
- 下一篇计划:从评测到发布,检查标注质量、评审校准与失败回归;验证后发布
- RAG 主题文章
想讨论自己的融合退化,可以先看 AI 实践路线,了解知识星球的内容方向;公众号入口在关于页。带上脱敏后的各路排名、稳定证据位置和实际上下文,通常比只说“重排没效果”更容易定位问题。

