评测方法
评测不是项目末尾补的脚本,而是检索能力的证据链。这一篇讲用例怎么标注、指标怎么算、安全门禁怎么保证不是自证,以及当前数字的局限在哪里。
一、先说结论
- 证据标注为「文档版本 + 原文引用」,不绑定 chunk id,因此切分策略可以在同一份标注上比较。
- 安全门禁与人工标注的可见集合比较,不与 policy compiler 自己比较,避免「用编译器验证编译器」。
- 评测由 Python CLI 驱动,只走公开 API,不读数据库;服务端没有评测资源。
- 每次运行记录数据集版本、commit、策略、policy 版本与运行平台,报告由结果文件生成,不手写。
- 结论必须带区间:108 条用例里 57 条是 test 可回答用例,单条约值 2 个百分点,差异只有在配对区间不跨零时才算数。
二、数据集结构
data/
├── corpus/northstar/*.md 17 篇虚构文档:HR、工程 runbook / 事故复盘 / 值班 / 备份、Support、Sales、Security
├── corpus/external/*.md 4 篇:Orbit Labs,刻意与 Northstar 共享词汇
├── manifests/<tenant>.yaml 文档 key、标题、文件、访问标签与版本历史
├── principals.yaml demo 身份与属性
└── eval/v2/
├── cases.jsonl 108 条用例(dev 30 / test 78),其中 30 条授权负例
└── visibility.yaml 人工标注:每个身份可见的文档集合一条用例:
{
"id": "hr-volunteer-eu-001",
"split": "dev",
"query": "How many paid volunteer days do EU employees receive?",
"principal": "alice-engineer",
"evidence": [{
"document": "hr-volunteer-policy",
"version": 1,
"section": "European Union",
"quote": "Employees based in the EU receive two paid volunteer days per calendar year."
}],
"expected_facts": ["two paid volunteer days per calendar year"],
"must_abstain": false,
"unauthorized_documents": [],
"hard_negative_documents": [],
"tags": ["policy", "single-hop", "lexical"]
}三、为什么不标注 chunk id
chunk id 依赖切分策略。一旦调整 chunk 大小、重叠或切分规则,全部标注失效,而切分恰恰是对检索质量影响最大的因素之一——等于永远无法评测它。
改用引用之后,评测时在规范化文本中定位这段引文得到字符区间,再与 API 返回的 chunk 区间求交:
relevant(result) = 同一文档 and 同一版本 and [result.charStart, result.charEnd) ∩ [span.start, span.end) ≠ ∅这要求 Python 侧的文本规范化与 Java 侧的切分器完全一致(CRLF 转换、BOM、首尾空白),两边各有对照测试锁住这条约定。
四、指标与门禁
| 层次 | 指标 | 是否作为 CI 门禁 |
|---|---|---|
| 安全 | 越权候选数、跨租户候选数 | 是,必须为 0 |
| 检索 | Recall@5、Recall@10、MRR@10 | 是(CI 子集上不允许回退) |
| 质量(计划) | nDCG@10、hard negative 的误召回率与名次 | 报告 |
| 回答(M3) | 引用有效性、拒答精确率与召回率 | 报告 |
安全门禁的关键在于 oracle 的独立性:visibility.yaml 由人工维护,与 policy compiler 无关。只要返回的文档不在该身份的可见集合中,ga-eval run 以退出码 2 结束,CI 随之失败。
当前门禁只比较文档,还发现不了「同一文档的错误版本」或「可见文档内受限 chunk」两类问题。M2 会把标注与门禁细化到版本与 chunk 粒度,并把授权失败与适用范围失败分开统计。
五、可复现规则
每次运行写出三个文件:
| 文件 | 内容 |
|---|---|
run.json | 数据集版本、git commit、策略、k、policy 版本、运行平台、时间、汇总指标 |
cases.jsonl | 逐用例逐策略的排名、指标与越权明细 |
report.md | 由前两者渲染,不手工编辑 |
几条硬规则:失败用例不删除;所有策略一起公布,不只挑最好的;README 或文章里的每个数字都必须链接到仓库中提交的运行目录;虚构语料上的运行一律标注为 demo benchmark。
六、当前结果与它的局限
数据集 v2、test 划分、57 条可回答用例、95% bootstrap 区间,由 CI runner(Linux x86_64)生成:
| 策略 | Recall@10 | MRR@10 | nDCG@10 | 越权结果 |
|---|---|---|---|---|
sparse-only(PostgreSQL FTS) | 0.930 [0.86, 0.98] | 0.640 [0.54, 0.74] | 0.711 [0.63, 0.79] | 0 |
dense-only(pgvector 精确检索) | 0.965 [0.91, 1.00] | 0.856 [0.78, 0.93] | 0.884 [0.81, 0.94] | 0 |
hybrid-rrf(两者的 RRF 融合) | 0.965 [0.91, 1.00] | 0.797 [0.71, 0.88] | 0.837 [0.77, 0.90] | 0 |
bm25-reference(离线,同一批已授权 chunk) | 0.921 [0.84, 0.98] | 0.689 [0.59, 0.78] | 0.744 [0.66, 0.82] | 0 |
配对比较支持的结论:
- dense 把正确证据排得比 FTS 更靠前:MRR@10 +0.22 [+0.12, +0.31];但证据能否进入前 10,看不出可检测的差异(Recall@10 +0.04 [−0.04, +0.11])。
- hybrid 没有超过 dense:相对 dense,MRR@10 −0.06 [−0.13, +0.01],没有可检测的差异,点估计偏向 dense;相对 FTS 则明显更好(+0.16 [+0.10, +0.22])。逐条分析见下文「hybrid 的结论」(那份分析基于数据集 v1 的运行,v2 上结论相同)。
- dense 优于 BM25:MRR@10 +0.17 [+0.07, +0.26]。
- 一个被撤回的结论:FTS 弱于 BM25。 在数据集 v1(70 条用例)上,BM25 的 MRR@10 比 FTS 高 0.10 [+0.02, +0.18],区间不跨零,当时写成了「FTS 确实弱于 BM25」。数据集扩到 v2 之后,差值变成 +0.05 [−0.02, +0.12],没有可检测的差异。旧报告仍留在仓库里,README 明确写了这条结论撤回——区间下界只有 0.02 的「显著」,本来就该更谨慎地表述。
安全门禁的结果:108 条用例的越权结果为 0,其中 30 条授权负例专门去够身份无权查看的文档(跨租户 7 条,租户内部按密级、项目、部门共 23 条)。门禁比 v1 时严了两处:每个返回的 chunk 都按「文档 + 版本」检查,返回已被替换版本的 chunk 也算越权;在任何查询运行之前,每个身份能列出的全部 chunk 要和人工标注的可见集合逐一比较,多了算越权,少了说明标注与系统不一致、直接中止。为了确认门禁真的能拦住错误,开发时在运行中的环境里把一篇 restricted 文档改标成 public,评测随即以退出码 2 失败。
它没有证明什么:这是 28 篇虚构文档上的 demo benchmark,展示的是方法与差异的方向,不代表生产效果;授权负例衡量的是「有没有泄漏」,系统此时是否明确拒答要等回答生成(M3)才能衡量;多跳用例只有 3 条。数据集 v1 与 v2 的数字不能直接比较:原有 70 条用例没改,但能访问新文档的身份多了候选。
关于可复现:关键词与 BM25 的排名在任何机器上逐位一致。dense 不能做这个保证——ONNX Runtime 在 arm64 与 x86_64 上使用不同的向量内核,得分只差末几位的候选会交换位置。实测在 Apple Silicon Mac 上有一条用例的正确证据从第 3 名变成第 2 名,dense MRR@10 从 0.860 变为 0.864。因此发布的数字统一取自公开可重跑的 CI 环境,并在 benchmarks/README.md 里写明了这一效应的量级:本地重跑差一条用例属于预期,更大的差异才是 bug。
后来发现同样的现象在托管的 x86_64 runner 之间也会出现,只是更轻:runner 的 CPU 并不都相同,有一次 CI 运行与已发布的 v2 报告相比,432 个排序列表里有 2 个不同——各有一对得分几乎相同的相邻 dense 候选在第 7 到第 10 名之间互换,没有任何指标变化。所以准确的说法是:已发布的数字在报告的精度内可复现,dense 的结果列表除「得分接近的候选之间的顺序」外可复现。run.json 现在会记录 CPU 型号。
七、防止数据集太简单
LLM 辅助生成的用例容易把原文词句照搬。校验器会计算每条用例的查询与证据之间的词汇重叠,报告分布,并要求最低重叠区间(改写类)占到一定比例;指标也按 tag 分组报告,让「只在字面匹配上好看」无所遁形。
此外校验器还检查:引文在文档中恰好出现一次、证据对该身份可见、unauthorized_documents 确实不可见、可见集合不跨租户、拒答用例没有证据。数据集与实现不一致时,ga-eval validate 直接失败。
八、常见误区
- 「用例越多越好」:数量不保证显著性。判断依据是逐题配对差值与置信区间,加上预先声明的最小有意义提升。
- 「评测通过就说明权限没问题」:门禁只覆盖被标注的攻击面。排序、命中数量、时延推断属于威胁模型的残余风险,要单列而不是笼统称「零泄漏」。
- 「LLM 打分可以当门禁」:判分模型有随机性,只能作为补充指标,且必须记录模型与 prompt 版本。
小结
评测的价值不在当前那两行数字,而在这套规则:标注独立于实现、结果可复现、失败一并公布。数据集变难之后,这套规则会立刻把「hybrid 到底有没有用」这个问题变成可回答的。接着看架构与边界。
chunker v1:一次「没有效果」的改动
M1b 的 chunker v1(markdown/2、text/2)加入了三件事:长段落按句(列表项也算一句)拆分;同一小节被拆成多个 chunk 时,后一个 chunk 以前一个的末尾整句开头,重叠不超过 30 词并计入 180 词上限;纯文本不识别标题和代码围栏。每个 chunk 仍是规范化文本中的一段连续区间,证据映射不受影响。
它在当前数据集上的评测影响是零:CI 上 210 个「用例 × 策略」组合的排序与 M1a 基线逐条相同。原因不在代码,而在数据——demo 语料没有任何小节超过 120 词,重叠只在小节被拆开时出现,所以每篇文档切出的 chunk 与之前完全一样。把这个结论写进 PR,而不是去找一个能「显示提升」的配置,是评测驱动的应有之义;要衡量 chunker 的作用,需要先补充长文档,这是 M1b 之后的数据集工作。
两个配套决定:
- chunker 升级需要显式重建索引。 如果新版 chunker 在下次入库时自动重切已有文档,内容未变的文档会得到新版本号,而标注用「key + 版本」定位证据。所以和更换 embedding 模型一样,升级即重新全量导入;评测在语料混有多个 chunker 版本时直接拒绝运行,并把版本写进
run.json。 - 格式参与幂等判断。 同一段文字以 Markdown 和纯文本提交,切分结果不同,因此算新版本。
hybrid 的结论:没有超过 dense
M1b 的收尾问题只有一个:在同一批用例上,hybrid-rrf 是否超过最好的单通道?答案是没有。
| 比较(后者减前者) | ΔRecall@10 | ΔMRR@10 | ΔnDCG@10 |
|---|---|---|---|
| dense → hybrid | +0.000 [−0.06, +0.06] | −0.050 [−0.12, +0.03] | −0.040 [−0.10, +0.02] |
| FTS → hybrid | +0.043 [+0.00, +0.11] | +0.194 [+0.12, +0.27] | +0.154 [+0.10, +0.22] |
按「第一条相关结果的名次」逐条比较 hybrid 与 dense:4 条更好、10 条更差、33 条相同。
- 输的 10 条里,有 3 条是关键词通道根本没有返回证据(改写类问题):正确 chunk 只从 dense 一个通道得分,被两个通道都返回的干扰项超过——dense 第 1 名变成 hybrid 第 3、第 5 名,dense 第 8 名掉出前 10。其余 7 条是关键词通道给证据的名次更低,把它往下拉了一两位。
- 赢的 4 条是 dense 漏掉或排得很低、关键词通道排得高的用例:一条 dense 前 10 里没有的证据回到第 8 名,一条从第 7 名升到第 1 名。所以 Recall@5 从 0.926 升到 0.957,而 MRR@10 从 0.860 降到 0.810。
等权 RRF 让较弱的通道拥有和较强通道同样的投票权。当时的 M1a 结果显示 FTS 弱于 BM25(这一条在数据集 v2 上已不成立,见上文),而数据集里近一半可回答用例是关键词匹配不到的改写——在这样的搭档下,融合是拿头部精度换后段稳健性。
这是「这份配置在这份数据集上」的结论,不是对 hybrid 检索的一般判断:候选数 50、RRF 常数 60 都是常用默认值,没有调过;没有任何参数在 test 划分上调整。接下来最直接的实验是通道加权和更强的关键词排序器,只在 dev 上调,再在 test 上报告一次。demo 命令的默认策略仍是 dense-only。
每个响应都带 planHash——检索配置(策略、k、候选数、RRF 常数、是否去重)固定序列化后的哈希,评测把它写进 run.json。hybrid 在查询向量算不出来时会降级为仅关键词并标记 degraded,评测遇到降级响应直接中止:那不是它要测的策略。