# RAG 评测实战（三）：两路词法检索、RRF 与规则重排实验

这是8道手工合成题的机制探针，不是 benchmark，也不是模型表现报告。固定原文、查询与证据 span，比较真正 BM25、全局显式同义词扩展 BM25、Reciprocal Rank Fusion（RRF）及可解释规则重排。两路都是词法检索，没有 dense retrieval、Embedding、LLM、神经重排器、生成答案、网络请求、密钥或第三方依赖。

数据和规则共同为教学设计；不存在留出集。某配置在本样例达到1不能证明它在新数据上有效，也不能用本表推荐生产方案。

## 下载后独立运行

把 `evaluate.mjs` 和 `fixture.json` 放在同一目录，先审阅代码，再用 Node.js 18+：

```sh
node evaluate.mjs fixture.json
node evaluate.mjs fixture.json > my-results.json
```

下载 `test.mjs` 和已发布的 `results.json` 后，四个文件同目录即可运行全部公开测试：

```sh
node test.mjs
```

在仓库内运行：

```sh
node scripts/tests/test-rag-fusion.mjs
```

27项测试包含一次复制到临时目录后的独立执行，比较原样下载的快照、重新计算的 JSON 与 CLI stdout 字节；不依赖仓库包、工作目录或隐藏模块。修改 fixture 后测试应失败，需要先审核变化，不能先覆盖快照再声称验证了旧结果。

可选耗时诊断：

```sh
node evaluate.mjs fixture.json --timing > my-results.json
```

stdout 仍是完全相同的确定性 JSON；只有 stderr 输出非确定性毫秒数。计时只包围一次 `evaluate()`，包含校验、逐题建索引、检索、融合、选择和证据评分，不含文件读取与 JSON 序列化。没有预热、重复采样、并发或资源隔离，不能据此得出效果结论、生产延迟、真实请求成本或策略性能排名。本实验主要报告可核验的操作次数和输入正文字符量。

## 固定数据与问题

19条文档版本记录、18个稳定文档 ID；其中 `timeout/v1` 已过时，`timeout-internal/v1` 标记为 internal。资格过滤开时只保留17条 public 且版本等于 `currentVersions` 的记录。这里的 visibility/currentVersions 是 fixture 中给定的合成元数据，不是账户权限查询、ACL认证或安全实现。

每条原文直接作为一个固定 passage，范围是 `[0,text.length)`；本篇不改变切块。所有文本均为英文 ASCII，偏移与长度用 JavaScript UTF-16 code unit。在这个 fixture 内也恰好等于字符数和 ASCII 正文字节数；不等于任意语言的字符数或 tokenizer token 数。

| 问题 ID | 原始查询 | 参考事实数 | 完整证据所在文档 |
| --- | --- | ---: | --- |
| exact-identifier | R-17 retry wait | 1 | retry-rule：30秒且仅幂等请求可重试，完整条件算1个事实 |
| synonym-only | terminate credential | 1 | key-rule：取消 key 会立即撤销访问 |
| two-facts | logs retention export format | 2 | logs-retention：7天；logs-export：JSON |
| negation-distractor | cache tokens | 1 | cache-rule：不缓存 tokens，仅缓存公开响应正文 |
| fusion-regression | vault deletion undo | 1 | vault-policy：24小时撤销窗口及其限制 |
| depth-omission | atlas backup restore | 1 | atlas-rule：3天保留期 |
| eligibility | current service timeout | 1 | timeout/v2：当前公开接口7秒超时 |
| no-answer | maximum extended archive duration | 0 | 无支持该问题的证据 |

共7道可回答题、8个参考事实。短关键词查询不代表自然语言查询分布；相似导航条目、措辞和 ID 同分次序均是演示条件，不能外推到真实文档。`facts` 只有独立评分器使用。

## 完整算法约定

### 1. 隔离标签与检索输入

`retrievalInput()` 只投影文档 id/version/text/visibility、版本表、全局同义词组、BM25参数和RRF k。`retrieve()` 接收这个投影、查询字符串和实验配置，不接收题号、事实、答案、gold、证据标注或 span 标签。返回候选和最终上下文后，才调用 `scoreEvidence()`。

资格过滤在建索引前执行；关闭过滤时N、df和平均长度也随索引集合改变，不能把它误解为排序完成后删除几项。对照同时变化了资格集合和该集合上的BM25统计。

### 2. 真正的 BM25

小写化，用 `[a-z0-9]+(?:-[a-z0-9]+)*` 抽词，保留 `R-17` 这样的完整标识符。没有词干化、停用词表、隐式分词词典或语义理解。文档保留真实词频；查询词先去重排序，不按查询重复次数增权。

给定 k1=1.2、b=0.75：

```text
idf(t) = ln(1 + (N - df(t) + 0.5) / (df(t) + 0.5))
score(d,q) = sum over unique q terms t:
  idf(t) * tf(t,d) * (k1+1)
  / (tf(t,d) + k1 * (1-b + b * dl(d)/avgdl))
```

N为当前合资格 passage 数；df为含该词的 passage 数；dl为正文 token 数；avgdl为其平均值。空索引或全空 token 正文不会产生候选。只保留正分项，按未舍入分数降序、稳定 passage ID 字典序升序排列；输出分数保留12位小数只是展示，不参与排序。

### 3. 第二路仍是词法检索

全局同义词组公开在 fixture：

```text
terminate / cancel / revoke / disable
credential / key / token
deletion / delete / erase / remove
undo / restore / rollback
retry / repeat
wait / delay
logs / records
export / download
```

以原始查询词为触发条件，一次加入被触发组的全部词；不递归扩展、没有逐题分支、没有词权重调参，原词与新增词在 BM25 中权重相同。这些组是人为设定的词汇关系，不保证语境等价；例如 restore 既会引入撤销文档，也会引入备份文档。token 不会自动匹配复数 tokens。

两路使用同一合资格索引，但独立算分和截断；每路只取前 depth=2 或4。单路BM25和expanded也使用同样的每路depth。

### 4. RRF、相同 ID 去重与稳定次序

对各路截断后的排名，从1起计：

```text
RRF(d) = sum over branches containing d: 1 / (k + rank_in_branch(d))
```

默认 k=60，可在 fixture 改成任意非负整数。缺席某一路不贡献分数。按RRF分数降序、稳定ID升序排序；不累加原始BM25分数。

同一 passage ID 在单路内重复只保留首次出现，不占新名次、不重复投票；不同路命中同一 ID 合并为一个候选。两路d4的唯一候选最多8个，不是最终Top-4；实际数量看每题输出。去重只按相同原文位置ID，不检测不同文档的语义重复，也不会把相似导航条目自动合并。

### 5. 可解释规则重排是机制探针

仅对已经进入RRF候选集的正文执行以下统一规则：

```text
ruleScore = 原始查询唯一词命中数
          + 4 * 精确标识符命中数
          + 是否含独立数字
          + 是否含 only / never / unless 中任一词
          - 4 * 是否含 overview / guide / glossary / index 中任一词
```

标识符定义是原始查询中符合 `^[a-z]+-\d+$` 的词。数字与约束/导航特征都是0或1。同分保留原RRF名次，再按ID稳定排序；BM25/RRF数值不与规则分数混加。规则只重排，不增加、删除或改写候选，也没有查询级别的专门规则。

这不是交叉编码器，不理解否定、数字是否相关、证据是否充分或跨文档推理。导航词可能出现在正确规则正文，数字也可能属于无关会议。本fixture与规则共同设计，不能把主对照的满分写成一般性收益。

公开单测还保留一个不计入主表的规则反例：查询 `backup restore`，原候选先是 `Backup restore is unavailable.`（规则分2），后是 `Backup restore meeting starts at 9.`（规则分3）。40字符预算下，数字奖励使会议文本排前；证据覆盖从1降为0。原始候选与标签同样在测试中公开。它说明即使不读取gold，规则也能做出错误选择。

### 6. 最终预算与证据计分

主实验正文预算140；额外一次80字符探针。逐候选扫描，只在整段可放入剩余预算时选择，否则记录 `does-not-fit` 并继续。没有截断、最优装箱、额外最终k、压缩或回答生成。字符量不含分隔符、元数据、提示词，也不是价格或真实模型输入tokens。

候选覆盖和最终覆盖使用同一个原文证据评分器：同 docId/version 的选中区间并集必须完整覆盖一条半开span；相接区间可合并，有缺口或版本不同则不能补齐。一个事实的支持组之间为OR，组内span之间为AND；每个事实最多计1次。

- `candidateEvidence`：进入去重候选集的事实覆盖，发生在规则重排与最终预算之前
- `finalEvidence`：最终送入假设上下文的事实覆盖
- 单题 recall = 完整覆盖事实数 / 必需事实数；无答案题记null
- 宏平均：只平均7道可回答题；两事实题权重仍为一道题
- `candidateCoveredFacts` / `finalCoveredFacts` 的分母为8；不要与7题宏平均混淆
- 无答案题即使返回候选，或因80预算返回空上下文，也不能推断正确拒答；本实验没有模型或拒答决策

## 已运行结果

`C→F` 表示候选覆盖到最终覆盖。前七列依次为：精确标识、同义、两事实、否定干扰、融合反降、深度遗漏、资格。无答案题在全部10组均为null。

| 配置 | 精确 | 同义 | 两事实 | 否定 | 反降 | 深度 | 资格 | 候选宏平均 | 最终宏平均 |
| --- | --- | --- | --- | --- | --- | --- | --- | ---: | ---: |
| bm25-d2 | 1→1 | 0→0 | 1/2→1/2 | 1→1 | 1→1 | 0→0 | 1→1 | 9/14 | 9/14 |
| expanded-d2 | 1→1 | 1→1 | 1/2→1/2 | 1→1 | 1→0 | 0→0 | 1→1 | 11/14 | 9/14 |
| rrf-d2 | 1→1 | 1→1 | 1/2→1/2 | 1→1 | 1→0 | 0→0 | 1→1 | 11/14 | 9/14 |
| rrf-rule-d2 | 1→1 | 1→1 | 1/2→1/2 | 1→1 | 1→1 | 0→0 | 1→1 | 11/14 | 11/14 |
| bm25-d4 | 1→1 | 0→0 | 1→1/2 | 1→1 | 1→1 | 1→0 | 1→1 | 6/7 | 9/14 |
| expanded-d4 | 1→1 | 1→1 | 1→1/2 | 1→1 | 1→0 | 0→0 | 1→1 | 6/7 | 9/14 |
| rrf-d4 | 1→1 | 1→1 | 1→1/2 | 1→1 | 1→0 | 1→0 | 1→1 | 1 | 9/14 |
| rrf-rule-d4 | 1→1 | 1→1 | 1→1 | 1→1 | 1→1 | 1→1 | 1→1 | 1 | 1 |
| rrf-d4-unfiltered | 1→1 | 1→1 | 1→1/2 | 1→1 | 1→0 | 1→0 | 1→0 | 1 | 1/2 |
| rrf-rule-d4-small-budget | 1→1 | 1→1 | 1→1 | 1→1 | 1→0 | 1→0 | 1→1 | 1 | 5/7 |

除 `unfiltered` 外都开启资格过滤；除 `small-budget` 用80外都用140字符预算。BM25-d4候选覆盖6/7，包含7/8事实；最终9/14，包含5/8事实。RRF-d4候选覆盖1，包含8/8事实；最终9/14，仍只包含5/8事实。

### 逐题定位损失

1. **同义不是dense**：原始 `terminate credential` 只命中57字符 `key-overview`，证据0；显式扩展命中44字符 `key-rule`，RRF把两段都放入101字符，证据1。它只证明公开词表把这两个词汇表面连接起来了。
2. **融合反降**：`vault-policy` 在原始路排名1、扩展路排名2，`vault-faq` 正好相反。两者RRF分数均为 `1/61+1/62 ≈ 0.032522474881`；ID稳定次序让 `vault-faq` 先。原始BM25先选86字符policy，证据1；RRF先选93字符faq，剩47放不下policy，却还能放30字符atlas-a，最终123字符、证据0。相关主题和重复词不是证据。规则版选policy+atlas-a共116字符，恢复这条刻意设计的证据。
3. **两事实分阶段看**：d2只包含export和overview，候选1/2，规则无法补回retention；d4候选包含两条事实，但原顺序选93字符overview+27字符export=120，25字符retention差5字符放不下，最终仍1/2。规则版先选27+25=52字符，完整覆盖2/2，再也放不下93字符overview或103字符atlas-rule。
4. **候选遗漏无解**：Atlas原始BM25中三个短导航名次1–3，103字符规则第4。d2没有规则，无论怎么重排都是0；d4原始路带入规则，扩展路第4却是vault-faq，扩大词表仍会污染候选。RRF-d4唯一候选5条，正文合280字符，原顺序先选三个导航共84，剩56，规则放不下；规则版先选103字符规则及30字符atlas-a，共133，覆盖1。
5. **资格是另一变量**：过滤后当前timeout/v2为79字符，覆盖1。关过滤后 `timeout-internal/v1` 首先入选76字符，剩64，装不下当前79字符，覆盖0。旧版78字符也进入候选。过滤检查版本和public标签，不能把这项变化归因于融合，更不是生产ACL实现。
6. **否定词出现不代表被理解**：缓存题中35字符overview靠重复词排前，但66字符规则也能装入140预算，共101，所有主配置覆盖1。这只是完整否定证据确实在上下文，不能推断生成模型会保留“never”。
7. **更好的次序不能缩短原文**：80预算下，86字符Vault证据、103字符Atlas证据都超过整个预算，规则版候选覆盖仍1，最终都0；无答案题87字符段落也被跳过，不能自动算正确拒答。
8. **精确标识不是必然排名第一**：原始BM25中75字符retry-rule排在57字符重复措辞overview之后，但合132可放入140预算，完整条件覆盖1。规则提高标识符命中的次序；本配置没有因此凭空获得额外覆盖收益。

## 操作计数与文本体积

表内操作为8题合计，字符量为8题均值，包含无答案题。规则评估一次/唯一候选，RRF访问一次/一路候选记录；正文体积按相同ID去重后计算。不同文档的相似文本仍重复收费。

| 配置 | 词项×文档检查 | 非零词项贡献 | RRF排名访问 | 跨路合并重复ID | 规则候选评估 | 候选平均字符 | 最终平均字符 |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| bm25-d2 | 408 | 45 | 0 | 0 | 0 | 114.375 | 89.875 |
| expanded-d2 | 680 | 59 | 0 | 0 | 0 | 119.875 | 96.25 |
| rrf-d2 | 1088 | 104 | 29 | 14 | 0 | 119.875 | 96.25 |
| rrf-rule-d2 | 1088 | 104 | 29 | 14 | 15 | 119.875 | 95.375 |
| bm25-d4 | 408 | 45 | 0 | 0 | 0 | 146.625 | 93.25 |
| expanded-d4 | 680 | 59 | 0 | 0 | 0 | 158 | 103.375 |
| rrf-d4 | 1088 | 104 | 39 | 17 | 0 | 170.875 | 103.375 |
| rrf-rule-d4 | 1088 | 104 | 39 | 17 | 22 | 170.875 | 100.125 |
| rrf-d4-unfiltered | 1216 | 116 | 43 | 19 | 0 | 190.125 | 103 |
| rrf-rule-d4-small-budget | 1088 | 104 | 39 | 17 | 22 | 170.875 | 53.75 |

本实现为易审查的穷举参考版本：遍历所有查询词和合资格文档，完整排序后才截断，所以增加候选深度不会改变其BM25词项检查数；会改变输出候选、融合访问和规则评估数。它不是倒排索引加速实现，也不计排序比较次数、内存分配或系统调用。

同一题两路共享一次索引，但不同题重新构建索引：过滤开启时每题17条、154个token，总计136条索引记录处理、1232个token计数；未过滤每题19条、177个token，总计152条、1416个token。每组开启扩展时检查8个词组×8题=64次，共新增16个查询词。正文分词和查询扩展操作不是LLM tokens消耗；不能把次数直接换成美元或延迟。

输出还包含每题原始/扩展词表、各路截断候选及其BM25词频/df/idf贡献、RRF逐路名次、规则特征、最终顺序、跳过原因、完整正文和证据事实计数。不要只读宏平均。

## 测试范围与解释边界

公开测试覆盖：BM25手算与空语料、稳定同分、重复查询词、单跳全局扩展、RRF公式/分数尺度/重复ID/换路不变性、预算刚好填满/超长跳过后继续、规则稳定性和反降反例、gold隔离poison getter与整套标签替换、浅候选遗漏、主样例融合反降、资格过滤、分阶段覆盖、80预算无解、无答案null、最终子集/去重/体积、span半开边界/相接/缺口/版本/OR/AND、文档与词表置换、查询词序不变、坏参数与标注、重复运行不变、快照与CLI字节相等、临时目录独立运行。

证据span完整覆盖只表示“已标注原文存在于选择结果”。它不评价答案正确性、引用是否蕴含答案、否定保留、跨证据拼接、拒答、模型上下文顺序效应、真实token价格、生产吞吐、用户权限或提示注入安全。参考事实与同义词表都由人设计，仍可能漏标或偏置；本实验没有统计显著性或外部有效性保证。
