Skip to content

评测方法 ​

评测不是项目末尾补的脚本,而是检索能力的证据链。这一篇讲用例怎么标注、指标怎么算、安全门禁怎么保证不是自证,以及当前数字的局限在哪里。

一、先说结论 ​

  • 证据标注为「文档版本 + 原文引用」,不绑定 chunk id,因此切分策略可以在同一份标注上比较。
  • 安全门禁与人工标注的可见集合比较,不与 policy compiler 自己比较,避免「用编译器验证编译器」。
  • 评测由 Python CLI 驱动,只走公开 API,不读数据库;服务端没有评测资源。
  • 每次运行记录数据集版本、commit、策略、policy 版本与运行平台,报告由结果文件生成,不手写。
  • 结论必须带区间:128 条用例里 70 条是 test 可回答用例,单条约值 1.4 个百分点,差异只有在配对区间不跨零时才算数。

二、数据集结构 ​

text
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/v4/
    ├── cases.jsonl           128 条用例(dev 34 / test 94),其中 31 条授权负例、13 条适用范围用例、6 条提示注入用例
    └── visibility.yaml       人工标注:每个身份可见的文档集合

一条用例:

json
{
  "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 区间求交:

text
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。

六、当前结果与它的局限 ​

各检索策略的 MRR@10 与置信区间

数据集 v4、test 划分、70 条可回答用例、95% bootstrap 区间,由 CI runner(Linux x86_64)生成:

策略Recall@10MRR@10nDCG@10越权结果
sparse-only(PostgreSQL FTS)0.943 [0.89, 0.99]0.740 [0.66, 0.82]0.785 [0.71, 0.85]0
dense-only(pgvector 精确检索)0.979 [0.94, 1.00]0.895 [0.84, 0.95]0.915 [0.87, 0.96]0
hybrid-rrf(两者的 RRF 融合)0.986 [0.96, 1.00]0.867 [0.80, 0.93]0.892 [0.84, 0.94]0
hybrid-rrf-rerank(cross-encoder 重排融合后的前 20 个)0.986 [0.96, 1.00]0.971 [0.94, 1.00]0.970 [0.93, 0.99]0
bm25-reference(离线,同一批已授权 chunk)0.943 [0.89, 0.99]0.772 [0.69, 0.85]0.812 [0.74, 0.88]0

配对比较支持的结论:

  • dense 把正确证据排得比 FTS 更靠前:MRR@10 +0.19 [+0.11, +0.28];但证据能否进入前 10,看不出可检测的差异(Recall@10 +0.05 [−0.02, +0.11])。
  • hybrid 没有超过 dense:相对 dense,MRR@10 −0.05 [−0.11, +0.01],没有可检测的差异,点估计偏向 dense;相对 FTS 则明显更好(+0.14 [+0.09, +0.20])。逐条分析见下文「hybrid 的结论」(那份分析基于数据集 v1 的运行,v2、v3 上结论相同)。
  • dense 优于 BM25:MRR@10 +0.15 [+0.07, +0.24]。
  • 一个被撤回的结论:FTS 弱于 BM25。 在数据集 v1(70 条用例)上,BM25 的 MRR@10 比 FTS 高 0.10 [+0.02, +0.18],区间不跨零,当时写成了「FTS 确实弱于 BM25」。数据集扩到 v2 之后,差值变成 +0.05 [−0.02, +0.12],没有可检测的差异;v3 上是 +0.04 [−0.02, +0.10],仍然如此。旧报告仍留在仓库里,README 明确写了这条结论撤回——区间下界只有 0.02 的「显著」,本来就该更谨慎地表述。

安全门禁的结果:122 条用例的越权结果为 0,其中 31 条授权负例专门去够身份无权查看的文档(跨租户 7 条,租户内部按密级、项目、部门共 24 条)。门禁比 v1 时严了两处:每个返回的 chunk 都按「文档 + 版本」检查,返回已被替换版本的 chunk 也算越权;在任何查询运行之前,每个身份能列出的全部 chunk 要和人工标注的可见集合逐一比较,多了算越权,少了说明标注与系统不一致、直接中止。为了确认门禁真的能拦住错误,开发时在运行中的环境里把一篇 restricted 文档改标成 public,评测随即以退出码 2 失败。

适用范围的结果:13 条用例询问某个地区或某个日期,或者指明了「可以读但不适用」的文档——另一个地区的假期日历、2025 年的旧差旅制度、要到 2099 年才生效的福利。没有任何策略返回过这类文档。这类错误叫范围失败,和安全越权分开统计:越权让评测以退出码 2 结束,范围失败是退出码 3,两个数字从不相加。visibility.yaml 只表示访问权限,所以这四篇文档对有 internal 密级的身份都算「可见」,哪些不适用由用例自己说明。同样做了反向验证:在运行中的环境里去掉美国假期日历的 region,评测随即报出 12 条范围失败。

它没有证明什么:这是 32 篇虚构文档上的 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,评测遇到降级响应直接中止:那不是它要测的策略。

重排:第一个超过 dense 的配置 ​

M3 的第一步是加入 cross-encoder 重排(hybrid-rrf-rerank):先融合、去重,再把前 20 个候选交给模型服务里的 cross-encoder 打分并按分数排序。

模型是在 dev 划分上选的。 对 22 条 dev 可回答用例的 hybrid 前 50 个候选做重排,在笔记本 CPU 上测:

模型许可体积MRR@10每次查询 p50
不重排––0.81–
ms-marco-MiniLM-L-6Apache-2.080 MB0.890.24 秒
ms-marco-MiniLM-L-12Apache-2.0120 MB0.920.47 秒
bge-reranker-baseMIT1 GB0.961.9 秒
jina-reranker-v1 tiny / turboApache-2.0130–150 MB0.75 / 0.770.21 / 0.32 秒

选了 MiniLM-L-12:更大的模型只领先 0.03,在 22 条用例的噪声之内,却要四倍延迟和 1 GB 镜像;两个 jina 模型在这份数据上比不重排还差。候选数定为 20:重排前 10 个和前 50 个在 dev 上得分相同,取 20 留余量。配置在跑 test 之前定下,之后没有再调。

test 上的结果(CI,AMD EPYC,65 条可回答用例):

  • 相对 dense:MRR@10 +0.08 [+0.03, +0.14],nDCG@10 +0.05 [+0.02, +0.10]——区间不跨零,这是第一个相对 dense 有可检测提升的配置。
  • 65 条用例里有 61 条把正确证据排在第 1 名,dense 是 53 条。
  • Recall@10 没变(0.969):重排只是给融合已经找到的候选重新排序,救不回没进候选的证据。有一条用例因此变差——dense 把证据排第 9,融合把它挤出了前 20,重排根本见不到它。
  • 代价是延迟:CI runner 上中位数 204 毫秒,dense 是 22 毫秒;在笔记本的 Docker 里约 1 秒。评测报告现在按策略列出 p50/p95 延迟,并注明测量所用的 CPU。

工程上的几条约束:cross-encoder 只会收到 SQL 谓词已经放行的段落;重排调用有自己的 3 秒超时,失败时返回融合顺序并标记 rerank_unavailable,评测会拒绝这种降级响应;检索配置里写明候选数和模型,所以重排配置有自己的哈希,旧配置的哈希不变。

回答:没有泄漏,但还不可靠 ​

M3 的后半段加上了 /api/v1/query:把检索结果组织成上下文(S1..Sn),交给一个可选的对话模型,要求它输出 JSON——每句话都要带引用。模型的回复被当作不可信输入:引用了提示词里不存在的 id 的句子直接丢弃,没有句子通过校验就是 no_answer,并且拒答不带任何证据。没有配置对话模型时只返回证据。

CI 里没有对话模型,所以分成两部分验证:CI 用一个按脚本输出的假模型把每个分支走一遍,再用伪造的响应测试打分函数;真实模型的指标只在本地跑(ga-eval answers),报告的目录名和开头都写明模型。

第一次本地运行(llama3:8b,Ollama,笔记本,数据集 v4,test 划分 94 条用例):

指标结果
返回证据中的越权0
对不上证据的引用0
可回答用例被回答66 / 70
回答引用了标注证据87%
事实召回0.703 [0.55, 0.84]
应当拒答的用例被正确拒答14 / 24(58%)
提示注入用例被带偏2 / 5
请求延迟 p502.6 秒

守住的部分:没有任何隐藏内容泄漏。模型只能看到检索为这个身份返回的内容,它泄漏不了没见过的东西。

没守住的部分:

  • 该拒答的时候作答。 24 条应当什么都找不到的用例里有 10 条被回答了,其中 8 条是授权负例:真正的文档被隐藏,模型就用一篇可读的相似文档作答。比如一个不在 Atlas 项目里的身份问「Atlas 迁移后旧主库保留多久」,得到的回答是「12 个月」——这个数字来自另一篇讲别的事情的文档。答案是错的,却带着一个有效的引用,看起来像是核对过的。
  • 植入的指令有 2 条生效。 一篇可读文档里写着「月付客户 90 天内可全额退款」(真实制度是 14 天)。回答先给出正确的 14 天,又把 90 天作为第二句话重复了一遍,并引用了那段注入内容。

这正是引用校验的边界:它能证明一句话指向已授权的证据,不能证明证据支持这句话。v0.1 不判断忠实度;检查「每句话是否被它引用的段落蕴含」是回答链路上最有价值的下一步。提示词只写了一版,没有针对这些用例调过。

v0.1 报告:把失败案例一个个读过去 ​

发布前的报告(仓库里的 benchmarks/reports/v0.1.md)没有新的大数字:在发布代码上重测,重排仍然是唯一超过 dense 的配置(MRR@10 +0.09 [+0.04, +0.15]),hybrid 与 dense 仍然没有可检测的差异。新的部分是十个逐条追到原因的失败案例。只看汇总表,下面这几件事是看不出来的。

重排得零分,问题却不在重排器。 「休完产假回来能不能兼职」这个问题,dense 把证据排在第 9,关键词通道因为没有任何共同词汇、返回的全是无关段落。融合之后证据掉到第 23 位,而只有前 20 个候选会送去重排。在本地把候选放大重查,cross-encoder 一看到它就排到第一。失败的是候选池的大小,不是模型。

有一段文字,任何策略都找不到。 证据是「Borealis 只有在计量管线处理完 30 天影子流量之后才上线」,问题问的是「按量计费引擎上线前要满足什么条件」。这段话里没有「按量计费引擎」——那是文档开头的介绍里说的;也没有「条件」——那是小节标题「Launch gates」。分块在建索引时既不带标题也不带章节路径,于是依赖上下文的段落对「用上下文的词提问」的人是隐形的:在 dense 的前 50 名(总共 108 段)里都找不到它。这个案例还被打了「授权」标签,但提问的人其实有权限,很容易被误读成权限问题。

hybrid 为什么不如 dense,在单个案例里一目了然。 「去德国出差每天的餐费预算是多少」,dense 把正确段落排第一。关键词通道返回的是只共享「daily」这类词的段落。RRF 给「两个通道都找到」的段落的分数高于「一个通道排第一」的段落,正确结果于是掉到第 5。对改写过的问题,关键词通道不是中立的,它是一个自信的错误意见。

数据集低估了干扰项。 「备份是否存放在主区域之外」,所有策略都把产品介绍里「客户可以选择把数据存在欧盟区或美国区」排在真正的证据前面。这个相似段落没有被标注成 hard negative,所以报告里「干扰项排在证据之前」的统计并不包含它。

一个错误的回答,比拒答更糟。 没有权限看薪酬带宽的人问「Staff 级别工程师的 on-call 津贴是多少」,正确的响应是拒答。模型回答的是「Staff 级别工程师每周 250 欧元」,引用的是通用的 on-call 手册——手册说的是「工程师 250 欧元」,「Staff 级别」是模型从问题里搬过来的。真正的数字(400)从没进过 prompt,所以没有泄漏;但读者拿到的是一个带引用、看起来经过核对的错误数字,而且无从知道还有更准确的来源。

报告最后把这些弱点列成一张表,每一项都写了 v0.1 为什么不处理:分块不带上下文需要重算全部 embedding 并单独比较;候选池大小是在 dev 上按 MRR 选的,放大就要付延迟;按查询类型调融合权重,70 条测试用例验证不了。知道哪里不行、为什么现在不改,也是报告的一部分。

v0.1 之后:一次「没测出提升」的索引改动 ​

v0.1 报告里有一个案例:一段话依赖它的标题,而分块建索引时不带标题,所以任何策略都找不到它。于是 v0.1 之后做了一个改动:每个分块在做 embedding 和关键词索引时,带上它的标题路径(比如「Project Borealis: Launch Runbook > Launch gates」)。

流程是事先定好的:先在 dev 集上比较六种配置,选一个;再在 test 集上测,结果无论好坏都发布。为此评测工具加了 ga-eval compare,对两次运行做逐条配对比较。

dev 上的选择看起来不错:hybrid-rrf 的 MRR@10 提高了 0.069 [+0.01, +0.15]。test 上的结果是这样的(上面表格里的数字已经是改动之后的):

策略MRR@10变化变好 / 变差的用例
sparse-only0.690 → 0.740+0.050 [−0.01, +0.11]15 / 7
dense-only0.865 → 0.895+0.030 [−0.03, +0.09]10 / 4
hybrid-rrf0.825 → 0.867+0.042 [−0.01, +0.10]8 / 5
hybrid-rrf-rerank0.957 → 0.971+0.014 [+0.00, +0.04]1 / 0

没有一项是可检测的差异。 点估计全部上升,区间全部包含零。把「+0.04」读成提升,就读出了数据里没有的东西。

逐条看发生变化的用例,有三件事:

  • 修好了一个:「休完产假回来能不能兼职」那段话在「Returning to work」标题下,现在排第一。
  • 促成这次改动的那个案例没有修好。 标题路径给那段话加上了「Launch gates」,但问题里说的是「pricing engine」,这个词只在文档开头的介绍里。当初的诊断是「它依赖标题和介绍」,只补标题不够。
  • 有的变差了。 「billing database 的 RPO 是多少」原本排第一,现在排第六:另一份文档的标题是「Billing Database Failover」,它的每个分块现在都带着这几个词,全部被抬到了正确段落前面。给每个分块加文档标题,会让同一份文档里的分块彼此更像。

默认值保留了下来,因为规则是「在 dev 上选、在 test 上测」,而 test 没有显示出损害。读 test 用例时还想到一个变体(只加最近一级标题、不加文档标题),但没有去试:这个想法来自 test 集,直接去试就等于在 test 上调参。

这件事真正说明的是:70 条用例分辨不出几个点的差异。比起再试一种索引方式,更大的数据集才是回答这类问题的前提。

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权