# RAG 评测实战（三）：两路词法检索，怎样测 RRF 与重排？

> 用真正 BM25、固定查询扩展、RRF 和规则重排运行零依赖实验，区分候选遗漏、融合退化与上下文预算损失。附完整数据、逐题结果、操作计数和独立测试，明确不冒充向量检索或模型 benchmark。

- 作者：芝士AI吃鱼
- 发布日期：2026-10-03
- 主题：RAG、AI工程、检索评测、混合检索、重排
- HTML 正文：[https://ai-knowledgepoints.cn/blog/rag-evaluation-03-rrf-candidate-reranking](https://ai-knowledgepoints.cn/blog/rag-evaluation-03-rrf-candidate-reranking)
- Markdown 永久链接：[https://ai-knowledgepoints.cn/blog/rag-evaluation-03-rrf-candidate-reranking/index.html.md](https://ai-knowledgepoints.cn/blog/rag-evaluation-03-rrf-candidate-reranking/index.html.md)

引用本文时，请注明作者与 HTML 正文链接。Markdown 版本用于机器阅读，与 HTML 正文共享同一内容来源。

## 正文

你给知识库接上第二条召回路径，再加一个重排器。配置更像正式产品了，答案却未必更可靠。原先能找到的错误码说明，为什么反而被两条都觉得“差不多相关”的介绍页挤下去了？

[第二篇](https://ai-knowledgepoints.cn/blog/rag-evaluation-02-chunking-evidence-mapping)已经把尺子固定在原文证据范围上。这一篇固定检索单元，换排序链路：**证据在哪一步丢了，是召回没给机会、融合改坏了顺序，还是最终预算容不下？**

先把实验身份说清楚：**本文实际运行的是两路词法检索：BM25 原查询，以及使用固定全局词表扩展后的 BM25；之后做 RRF 与可解释规则重排。人工合成语料和问题只用于机制实验，没有调用 Embedding、向量数据库、神经重排器或生成模型，不是生产 RAG 或大模型 benchmark。** 规则与数据都经过教学设计，不提供泛化性能证明。资料核对日期：2026 年 10 月 3 日。

生产中常见的词法与向量混合检索，是这套实验可迁移的应用场景；本文没有把查询扩展假装成语义模型。先把候选、融合、重排和预算这几层接对，再谈替换成什么模型。

## 1. 第二条路带来的，是新证据还是第二张赞成票？

两条路径若总给出相同列表，融合只是在重复同一种偏好。真正值得测的是互补性：第一路漏掉哪些证据，第二路是否补上；第一路已经找对时，第二路会不会把它拖下去。

词法检索擅长处理精确名称、错误码和有共同词项的表达；换了说法，相关文本可能得不到分数。本文用固定扩展词表制造一个可审计的第二入口：例如把业务中确实可能互换的词项加入查询。但词表没有上下文理解能力，一个多义词也可能把另一主题召回来。

这里有两个必须分开的行为：

- 扩展改变了发给检索器的查询，因此可能带来新候选，也可能带来新干扰
- 融合只利用已经返回的列表，不能搜索列表外的材料

![两条独立词法路径分别执行原查询BM25和固定扩展查询BM25，按相同身份做RRF融合，之后进行只读查询及候选正文的规则重排和预算选择。两路都遗漏的证据无法凭空出现。](https://ai-knowledgepoints.cn/images/articles/rag-evaluation-03/rank-fusion.svg)

作者原创机制图。A、B 两路独立检索；没有向量模型或神经重排，图中不是效果数据。

[查看竖版大图](https://ai-knowledgepoints.cn/images/articles/rag-evaluation-03/rank-fusion-mobile.svg)

所以比较时不能只写“打开混合检索后召回升了”。至少应保留原查询路、扩展路、融合、融合后重排这几个对照，分别检查候选与最终上下文。若只有融合结果，不知道另一条路究竟补了什么，也不知道代价落在哪道题上。

## 2. 这次实现 BM25，不再用词项交集冒名顶替

上一篇刻意采用不同词项的交集数量，它没有逆文档频率、词频饱和或长度归一化。这次三部分都实现，才能明确叫作 BM25。

对于查询中一个词，本文使用：

```text
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 文档](https://lucene.apache.org/core/9_12_1/core/org/apache/lucene/search/similarities/BM25Similarity.html)，但这是独立教学实现，没有复现 Lucene 的完整分析器、字段统计和底层编码。因此不可把本例分数与 Elasticsearch 查询分数逐位对账。

原文使用 ASCII 英文与固定分词规则，是为了让词项、字符范围和每个贡献可以逐项查账。真实中文知识库必须另行固定分词器、停用词、规范化规则及版本；把一段中文直接塞进这个英文分词器，不能得到有意义的中文检索评测。

## 3. RRF 不用统一分数量纲，但仍然有参数

BM25 的 8 分与另一条路径的 0.8 分，不能靠肉眼认为前者强十倍。RRF 绕开原始分数的量纲，使用各列表中的名次：

```text
RRF(d) = sum(1 / (rankConstant + rankInEachList(d)))
```

名次从 1 开始；某条路径没有返回 d，就不为该路径加分。相同身份的条目合并成一项，保留各路贡献供解释，而不是把重复返回当成多个最终片段。

例如常数为 60 时，一项在两路都排第 2，得到 2/62，约 0.032258；另一项只在一路排第 1，得到 1/61，约 0.016393。前者会领先。这是多路一致性产生的排序偏好，并不代表第一项具有 3.2% 的正确概率。

[2009 年 RRF 原论文](https://plg.uwaterloo.ca/~gvcormac/cormacksigir09-rrf.pdf)在先导实验中选定 60，再用于后续验证。这个历史设置不是“所有知识库最优常数”的证明。常数增大时，靠前与靠后的名次贡献更接近；参与融合的路径数量、每路深度、路径相关性，也都会影响结果。

[Elasticsearch RRF 文档](https://www.elastic.co/docs/reference/elasticsearch/rest-apis/reciprocal-rank-fusion)把 rank_constant 与 rank_window_size 分开配置；后者决定进入融合的各路窗口。不能把“无须直接校准原始分数”误读成“没有任何参数可影响结果”。[2023 年融合函数分析](https://arxiv.org/abs/2210.11934v2)也在其研究设置中发现 RRF 对参数敏感；本文引用这一点提醒读者验证配置，不移植论文的性能数字。

还有一个身份陷阱：同一 chunk 通过两路返回，应获得两路的贡献，但最终只占一个上下文位置。相同列表内重复出现同一身份，不能无上限刷票。不同身份却含有同样文字，是另一种近重复问题；只按 ID 去重不会解决它，不能在报告里把两者混为一谈。

## 4. 把候选深度、重排工作量和最终输入分开

候选深度决定每条路径先保留多少项。重排数量决定有多少查询—文本对被进一步检查。最终输入预算决定哪些文本真正送到后续答案环节。它们不是一个参数的三种名字。

![三个预算分别控制每路候选深度、重排候选数量和最终上下文体积；逐题区分候选缺失、融合退化、预算挤出与资格不符。操作计数和字符数不等于模型费用。](https://ai-knowledgepoints.cn/images/articles/rag-evaluation-03/three-budgets.svg)

作者原创检查路径。字符预算与规则操作量是本例的可审计代理，不是模型 token、API 账单或线上延迟。

[查看竖版大图](https://ai-knowledgepoints.cn/images/articles/rag-evaluation-03/three-budgets-mobile.svg)

本文固定原文短段作为检索单元，不再改切块。最终按排序扫描，整段放不下就跳过，继续尝试后面的较短段；不截断，不偷偷合并，也不做最优背包选择。即使最终字符上限相同，不同方案实际使用的字符仍可能不同，结果必须把实际体积一起留下。

资格过滤也不是相关性重排。本例只演示 fixture 声明的 public 可见性标记与当前有效版本；旧版材料即使得分很高，也不能充当当前问题的证据。它不实现 ACL，不提供真实租户权限隔离。生产系统的权限约束必须在所有召回路径与缓存边界一致生效，不能靠平均召回提高来抵消越权。

评分继续沿用前两篇的基本约定：证据绑定原文文档、版本与半开区间；同一事实允许备选支持，但每个事实最多得一分。本文报告的是“上下文是否包含必需证据”，不是生成答案是否真的正确使用证据。无答案题没有必需事实分母，覆盖记为 null，不把它当成满分，也不把没有候选等同于正确拒答。

## 5. 下载后先跑，再检查逐题变化

五个附件放到同一目录即可运行，不需要 API key、数据库、模型下载或第三方依赖：

- [evaluate.mjs：BM25、扩展、RRF、重排与计分](https://ai-knowledgepoints.cn/examples/rag-evaluation-03/evaluate.mjs)
- [fixture.json：原文、查询与证据标注](https://ai-knowledgepoints.cn/examples/rag-evaluation-03/fixture.json)
- [test.mjs：可独立运行的边界测试](https://ai-knowledgepoints.cn/examples/rag-evaluation-03/test.mjs)
- [results.json：本次运行的完整逐题记录](https://ai-knowledgepoints.cn/examples/rag-evaluation-03/results.json)
- [README.md：参数、命令与边界](https://ai-knowledgepoints.cn/examples/rag-evaluation-03/README.md)

```bash
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 常数或重排阈值，最终报告应使用没参与这些选择的数据。

最后再接回[第一篇的答案侧评测](https://ai-knowledgepoints.cn/blog/rag-evaluation-01-evidence-to-answer)：给定实际最终上下文，分别检查答案事实覆盖、引用支持与拒答。候选有证据、重排靠前、上下文装得下，只是通向正确答案的条件，不是答案已经正确的替代证明。

这一篇真正想留下的不是一套“最优 RRF 参数”，而是一条排错顺序：先问证据有没有进入候选，再问融合与重排怎样改变顺序，最后看预算是否保住了它。链路每一层都有可查记录时，分数变化才有明确归属。

系列导航：

- [第一篇：检索命中了，为什么答案还是错的？](https://ai-knowledgepoints.cn/blog/rag-evaluation-01-evidence-to-answer)
- [第二篇：切块、稳定证据映射与预算对照](https://ai-knowledgepoints.cn/blog/rag-evaluation-02-chunking-evidence-mapping)
- 本篇：两路词法、RRF 与重排机制实验
- 下一篇计划：从评测到发布，检查标注质量、评审校准与失败回归；验证后发布
- [RAG 主题文章](https://ai-knowledgepoints.cn/blog?tag=RAG)

想讨论自己的融合退化，可以先看 [AI 实践路线](https://ai-knowledgepoints.cn/planet)，了解知识星球的内容方向；公众号入口在[关于页](https://ai-knowledgepoints.cn/about#wechat)。带上脱敏后的各路排名、稳定证据位置和实际上下文，通常比只说“重排没效果”更容易定位问题。
