改一行配置,怎么知道它变好了:检索评测的黄金集、指标与门禁
一、同一个"没命中",是两种完全相反的故障
你把 top-k 从 5 调到 10,看了一圈,说"好像好点了"。
但如果答案根本没被召回到,调 top-k 一点用都没有;如果答案排在第 12 位,调 top-k 才有用。这两种情况在"感觉"的层面长得一模一样。
没有度量的时候,代价不只是不准,而是会把两种方向相反的故障报成同一个结果。
| 环节 | 当时在调什么 |
|---|---|
| 混合检索 | 融合策略(dense + BM25 + RRF) |
| 问答主链路 | 检索 → 父块回填 → 出处 |
| 向量化 | 批量编码 / 维度一致 / 增量 |
| 精排与去冗余 | Reranker + MMR |
| 权限下推 | 过滤必须下推 |
五个环节全在调检索,而尺子是最后才补上的,等于先盲调五轮,才把量尺递过来。
把这件事拆开,只需要一个自定义指标:bury。
python
# evalkit/schema.py:256-260
# bury = 第一个相关文档的排名(1-based),-1 表示压根没召回
# 这是自定义的诊断指标,专门区分两类完全不同的故障:
# bury = -1 → 召回阶段就丢了(该查 embedding / 切片 / 权限过滤)
# bury > top_k → 召回到了但排太后被截断(该查 rerank / 融合权重)
# 只看 Recall 会把这两种混为一谈,修错方向。
摊开成一张表:
bury |
现象 | 真正的故障在哪 | 该动哪一环 |
|---|---|---|---|
-1 |
那个文档根本不在结果里 | 召回侧 | embedding 模型 / 切片策略 / 权限 expr / 索引是否最新 |
2 |
在,排第 2 | 没问题 | 不用动 |
7 |
在,但被 top_k=5 截掉了 |
排序侧 | reranker / RRF 融合权重 / 候选池 candidate_k |
回到开头的场景,你改了一行 top_k:bury = -1 时文档压根没进来,你把 top_k 改成 100 也没用,因为你扩大的是"看的范围",不是"找到的能力",要改的是 embedding 和切片;bury = 7 时它在,只是排太后,把 top_k 从 5 提到 10 它立刻就进来了,改对了。
这两种情况的 Recall@5 都是 0,都没进前 5。如果没有 bury,你会把两者报成同一个结果,然后有一半的概率去改错的地方。
元命题在这里:一个好的指标,不只是"能比较大小",更要"能分开故障"。Recall 是前者的代表,一个数字越大越好;bury 是后者的代表,它的取值本身就在指路。
二、评测只有三件套,但其中一件可能从来没通电
传统软件能用单元测试证明对错:assert f(2) == 4。LLM 应用不行,输出是非确定性、自然语言、统计生成的,你写不出那个 assert。替代品是三件套:
| 件 | 是什么 | 对应实现 |
|---|---|---|
| 黄金集 | 一批"问题 + 该命中的文档",代表核心场景 | evalkit/golden/retrieval.jsonl(15 条) |
| 评分器 | 把"好不好"变成可比较的数字 | compute_retrieval_metrics(Recall / MRR / nDCG / bury) |
| 门禁 | 掉分就拦住,不让它合进主干 | runner.py:201 退出码 2 |
RAG 的评测可以分三层,这里只碰最下面那一层:
| 层 | 问什么 | 成本 |
|---|---|---|
| 检索层 | 该找的段落找到没、排第几 | 零 LLM 成本,秒级 |
| 答案层 | 答案是否忠于上下文 | 要裁判模型,分钟级 |
| 端到端 | 用户的问题被回答了吗 | 要裁判模型,分钟级 |
为什么先做检索层,模块头把理由写死了:
python
# evalkit/harness_retrieval.py:5-9
# 为什么先做检索层:
# RAG 系统答错,绝大多数不是"模型笨",而是"根本没把正确的段落喂给它"。
# 拿一个答错的问题去调 prompt、换更大的模型,往往是在错误的方向上使劲。
# 检索层评测能在零 LLM 成本、秒级的前提下告诉你:正确段落到底进没进上下文。
现在说三件套里最容易出问题的那一件。理论上"faithfulness 是幻觉的可观测化"讲得很漂亮,代码里的接线状态却是这样:
python
# evolution.py:471-475
fs = state.get("faithfulness_score")
answer_ok = None
if FAITHFULNESS_GRADE_ENABLED and fs is not None:
try:
answer_ok = float(fs) >= ANSWER_FAITH_THRESHOLD
要生效需要同时满足两个条件:开关 FAITHFULNESS_GRADE_ENABLED 打开,且 faithfulness_score 非 None。而这个字段在整个工程里没有一处写入。grep -rn "faithfulness_score" --include=*.py 只命中"读"和"注释",没有任何赋值,于是 state.get(...) 恒为 None,这一层从来没有生效过。
这个结论不是推的,项目自己的课程脚本里已经写明了:L2 因 faithfulness_score 未写入恒为 None,L3 因 evaluate_success 未收到 rating,均未接线。
第一条纪律就在这里:指标定义了不等于指标生效了。信号没流进来,它在报表上就是一个永远的横杠,比没有还危险,因为你会以为它在看着你。
三、为什么"感觉"不算数
假设你刚做完精排优化,做了三件事:换了 rerank 的阈值、把候选池从 20 加到 40、关掉了去冗余。然后你问一句:"好点了吗?"没有评测,你能给出的答案只有三种:
| 你的回答 | 它其实等价于 |
|---|---|
| "感觉好点了" | 你看了 2 到 3 个问题,确认偏误在起作用 |
| "好像差不多" | 你连一条能复现的基准都没有 |
| "应该是好了,因为理论上应该更好" | 把推理当成了证据 |
三种都不是度量。一个可以复用的判据:当你发现自己的优化循环是"改 → 随手试两个例子 → 觉得好 → 提交"时,你缺的不是更好的模型,是一份能重复跑的题。
评测分了两层,成本差一个数量级:
| 检索层 | 答案层 | |
|---|---|---|
| 问什么 | 该找的文档进没进、排第几 | 最终答案对不对 |
| 判据 | 文档标识匹配,确定性 | LLM-as-Judge + 字符串硬判据 |
| 要不要调 LLM | 不要 | 要 |
| 单次耗时 | 秒级 | 分钟级(实测 843.9s / 9 条) |
| 跑的频率 | 每次改动都跑 | 发版前跑 |
| 样本量 | 15 条 | 9 条,约检索集的一半 |
两层的关系是:检索层是下界。检索层挂了,答案层不可能好,因为你把材料喂错了,模型再怎么生成也是错的。所以修的顺序永远是先召回、后生成。
检索层凭什么不调 LLM 也能出结论?关键在判据的性质:
| 问题 | 判据是什么 | 性质 |
|---|---|---|
| 该找的段落找到了吗 | 标注里的 file / pages / keywords 与检索结果的元数据比对 |
统计,可完全脱离模型 |
| 答案措辞对不对 | 语义是否一致、有没有编 | 生成,需要裁判模型 |
零成本带来的是频率上的自由:既然秒级、不花钱,就可以每改一行都跑。而"每次改动都跑"是评测能不能活下来的前提。
第一个致命坑:Harness 自己写了一套"差不多的检索"
这个坑的病根非常反直觉,它不是"评测写错了",是评测写得太好了。
python
# evalkit/harness_retrieval.py:30-37
# 为什么要复用线上代码而不是自己重写一遍检索逻辑
#
# Harness 自己实现一套"差不多的检索",就会与线上实现慢慢漂移,
# 最后评测分数很好看,线上依然拉胯。所以 pipeline 模式直接调
# LangGraphRAGApp._do_retrieve,评的就是线上那份代码本身。
"差不多"是这里最危险的三个字。你自己写一套检索,第一天它确实差不多;一周后线上加了图页召回、改了距离归一化、加了一层权限兜底,你那套副本一项都没跟上。于是:
erlang
评测:100% 通过
线上:答错
你以为的问题:模型不行
真实的问题:评测测的不是线上
怎么做到复用又不启动整个 App?因为 _do_retrieve 只依赖三份状态:
python
# evalkit/harness_retrieval.py:66-101(节选)
class _LiteHost:
"""轻量宿主:检索链路只需要 vector_db / user / tenant_id 三份状态,
因此不必构造完整的 App(省掉 MySQL / Redis / LLM 网关 / 提示词管理器的初始化)。"""
def __init__(self, vector_db, user_id: str, tenant_id: str):
self.vector_db = vector_db
self.user = user_id
self.tenant_id = tenant_id
def __getattr__(self, name: str):
# 凡本对象没有的属性,自动到真实 App 上取同名函数并绑定到 self。
...
if callable(attr):
return attr.__get__(self, type(self)) # 绑定为本对象的方法
return attr
__getattr__ 那一段是整个设计的精髓,目的是一句很工程的话:线上加一个 self._xxx 调用,评测不会崩。这正是防止漂移的落地形态,不是靠"我记得去同步评测脚本",而是靠结构上让它无法不同步。
python
# evalkit/harness_retrieval.py:203-205
host = _LiteHost(self.vector_db, case.user_id, case.tenant_id)
# 只传原句,不做 LLM 改写,保证本层评测零 LLM 成本
pairs = self._lg.LangGraphRAGApp._do_retrieve(host, [case.query], case.role)
一句话:评测的价值不在于"能跑",在于"跑的是线上那一份"。复刻一份等价实现,测出来的是你的副本,不是你的系统。
四、黄金集:评测的地基
评测的质量,上限是黄金集的质量。一份标注错了的黄金集,会让你在错误的方向上使劲,而且它每次都会报红,直到你不再相信这份报告。
一条 case 长什么样
json
// evalkit/golden/retrieval.jsonl 的 jm-001
{"case_id": "jm-001",
"query": "登录包的协议号是多少",
"tenant_id": "jm",
"role": "admin",
"tags": ["fact-lookup", "protocol-id"],
"relevant": [{"file": "个人定位终端通讯协议", "pages": [7, 8],
"keywords": ["登录包0x01", "0x01"], "gain": 3,
"note": "登录包章节,协议号 0x01"}]}
八个字段,各有各的职责:
| 字段 | 作用 | 不写会怎样 |
|---|---|---|
case_id |
唯一标识,报表与根因分析靠它 | 报表里认不出是哪条 |
query |
用户的问法,不是检索关键词 | 测的是"检索器会不会猜你要什么" |
tenant_id / role / user_id |
检索上下文,必须固定 | 结果不可复现 |
tags |
分组标签 | 只能看到总分,看不到在哪类问题上弱 |
relevant[] |
哪些文档算命中 | 没有它就没有题 |
forbidden[] |
哪些文档绝不能出现,负例专用 | 测不出跨租户泄漏 |
为什么 query 必须写"用户的问法"?因为这两件事测的东西完全不同:
| 你写什么 | 你实际在测 |
|---|---|
登录包 0x01 协议号,人造关键词 |
检索器认不认得这个词,一定认得 |
登录包的协议号是多少,真实问法 |
检索器能不能把一句人话映射到正确的段落,这才是用户干的事 |
命中判据:file 子串,加上 pages 或 keywords
这是整份黄金集最容易被误解的一处设计。判定逻辑在 evalkit/schema.py:94-121,读成一张判定树:
bash
① 文件不匹配? → 否(直接出局)
② 没标 pages/keywords? → 是(文件对了就算命中)
③ 页码对上了? → 是(命中)
④ 关键词出现在正文里? → 是(命中)
⑤ 以上都不成立 → 否
"或"是这里的关键。为什么不全用页码、不全用关键词?因为两者都会失效,而失效的原因正好相反:
| 判据 | 优点 | 失效场景 |
|---|---|---|
pages 页码 |
精确 | 依赖 PDF 排版不变,重新导出或换版本后页码全错 |
keywords 关键词 |
抗变化 | 可能误判,关键词出现在无关段落 |
| 两者是"或" | 任一成立即命中 | pages 因换版失效时,keywords 还兜得住 |
一种"两个都标了但依然会误判"的场景:标注 pages: [1] 加 keywords: ["优先级"],page 1 命中是真的;但同一份文档的 page 12 上只要出现"优先级"两个字,这条也会被判成命中,而它可能讲的根本不是定位数据的上报优先级。
项目的回答是:接受这个噪声,但不假装它不存在。note 字段就是让人写清"为什么这段是答案"的,而垃圾关键词在挖掘阶段会被过滤。
抗重建:别用 chunk_index
先说错误做法,它非常自然,你几乎一定会先这么干:
json
// 错误示范:用 chunk_index 当文档标识
{"query": "登录包的协议号是多少", "relevant": [{"chunk_index": 47}]}
然后你把知识库重建了一遍,改了切片参数、补了一份文档、升级了切片器。代码一行没改,结果:
csharp
[evalkit] 检索评测开始:15 条 case
[1/15] ✗ jm-001 未召回 登录包的协议号是多少
通过率 0.0%
测试全红,而代码没有问题。你会花掉一整天去查检索链路,最后发现是标注过期了。因为 chunk_index 是切片过程的产物,不是文档的属性:
ini
原来(chunk_size=500): 第 47 片 = 登录包那一节
重建后(chunk_size=800): 第 47 片 = 心跳包那一节 ← 边界全移了
挖掘脚本的头部注释把这个设计讲得很清楚:
python
# scripts/mine_golden.py:17-22
# 抗重建标注(关键设计):
# 不用 chunk_index 当标识,重新 ingest 后切片边界会变,标注立即失效。
# 改用 file_name + pages + keywords 三重定位(或关系兜底),
# 跨越多次索引重建依然有效。
正确的标识该用什么?用文档自身的、不随处理流程改变的属性:
| 标识 | 属于谁 | 重建后会变吗 |
|---|---|---|
chunk_index |
切片过程的产物 | 会变,边界移了 |
file_name |
文档的属性 | 不变 |
pages |
文档的属性(PDF 排版不变时) | 不变 |
keywords |
内容的属性 | 不变 |
可以带走的判据:当你要给某个东西写"标识"时,先问一句,它是"事物的属性",还是"处理流程的产物"?凡是流程的产物,自增 ID、切片序号、日志行号、缓存位置,都会在下一次流程变化时失效。这条判据跟 RAG 无关,数据仓库的增量断点、定时任务的游标,全是同一个问题。
三条来源,各有各的毛病
source |
怎么来的 | 覆盖的是什么 | 毛病 |
|---|---|---|---|
manual |
人工编写 | 我们以为用户会问的 | 想不全,且越写越像需求文档 |
mined |
从历史 trace 自动挖掘 | 用户真正问的 | 带 LLM 标注噪声 |
feedback |
用户点踩转化 | 真的出过问题的那几条 | 最珍贵,也最少 |
为什么"用户真正问的"更重要?挖掘脚本 scripts/mine_golden.py:1-10 的注释写得很直白:手写黄金集覆盖的是"我们以为用户会问的",而 trace 里是"用户真正问的",后者才是线上质量的真实分布,也是回归测试最该守住的阵地。
关键洞见是:标注不用重新做,它已经躺在你的数据库里了。系统跑问答时,评测节点会让 LLM 判定"每篇召回的文档是否相关",这个结果存在 task_checkpoints.state_json 的 doc_grades 里。
arduino
用户提问 → 检索(召回 7 篇) → LLM 判定 5/7 相关
→ 落盘到 task_checkpoints.state_json → 挖出来就是一条带标注的 case
这是"副产品思维"的一个漂亮例子:系统为了另一个目的产出的信号,恰好就是你评测最缺的东西。值得问一句,我现在跑的流程,有没有已经顺手产出了我后来要花大力气补的数据?
还有一个更朴素的约束:数据不出内网。内网语料不能上传给第三方评测平台替你算指标,一上传边界就破了。所以黄金集只能从本地数据库挖,Harness 只能用你机器上那一份线上代码,不调 LLM 的模式才能保证 query 不出网。
从 trace 挖标注:四个门槛加一次自校验
"免费"不等于"照单全收"。挖掘脚本的第二个注释标题就是:质量门槛,宁缺毋滥,脏数据比没数据更糟。
| # | 门槛 | 挡掉什么 |
|---|---|---|
| 1 | 至少有 1 篇被判定相关 | 全不相关的记录 |
| 2 | query 长度 ≥ MIN_QUERY_LEN = 4 |
"你好"之类的闲聊 |
| 3 | 同题只保留相关文档最多的那次 | 同题重复问,取信息最全的 |
| 4 | 与既有黄金集按归一化 query 去重 | 制造重复题目 |
还有两个防伪造 case 的设计,它们都属于"不这么做就会造出一条永远失败的 case":
python
# scripts/mine_golden.py
# trace 是历史记录,里面会引用早已被删除或改名的文档
# (实测挖到过 Jimi_IoT__V1.21.pdf,当前库里根本不存在)。
# 把这种标注写进黄金集,等于制造了一条永远失败的 case。
# 切片器留下的结构标记不是文档内容,用它们当关键词等于标了个永远匹配不上的锚点:
# 实测挖到过 keywords=["[Page 4]"],同样会伪造出一条永远失败的 case。
_JUNK_KW = re.compile(r"^(\[?page\s*\d+\]?|第?\s*\d+\s*页|图\s*\d+|表\s*\d+|...)$", re.IGNORECASE)
最后一道、也是最有教益的一道是自校验。挖掘用的是 LLM 的判定,而 LLM 会判错。做法是用当前检索器把挖出来的 case 全跑一遍,分成"可信"与"存疑"两组:
python
# scripts/mine_golden.py: verify()
"""
用当前检索器把挖出来的 case 跑一遍,分成可信与存疑两组。
为什么必须做:标注来自 LLM 的 doc_grades,本身带噪声。实测挖到过
"输出一下通信流程图"被标到"设备信息结构""白名单获取"这种明显无关的章节上。
这类标注进了黄金集,评测就会长期报红,而问题其实出在标注、不在系统。
注意:验证不通过 ≠ 系统有 bug,只是"这条标注不够可信",
因此不丢弃,而是单独存到 *_review.jsonl 供人工裁决。
"""
这段注释里最值钱的是最后那句判据:验证不通过不等于系统有 bug,只是"这条标注不够可信"。两种处理方式的差别很大:
| 处理 | 后果 |
|---|---|
| 把未命中的直接丢弃 | 你丢掉的可能正是"检索真的错了"的那些样本 |
放进 *_review.jsonl 等人工裁决 |
既不失真,也不污染主集 |
落地的产物就是三个文件:
bash
evalkit/golden/retrieval.jsonl 15 条 主集(人工编写,逐条有 note)
evalkit/golden/mined_retrieval.jsonl 7 条 挖掘出的可信 case
evalkit/golden/mined_review.jsonl 1 条 挖掘出的存疑 case(待人工裁决)
条数是当时快照,会随挖掘跑动而变。要讲的不是"几个",而是"分成两个池子"这件事本身。
隔离负例:另一种 case
权限怎么评测?答案是另一种 case,它没有正确答案,只有禁止出现的答案:
json
// evalkit/golden/retrieval.jsonl 的 iso-001
{"case_id": "iso-001",
"query": "心跳包 0x36 的字段定义",
"tenant_id": "yh",
"role": "user",
"tags": ["isolation", "negative"],
"relevant": [],
"forbidden": [{"file": "Jimi", "note": "几米协议文档不应出现在 yh 租户检索结果中"}],
"note": "0x36 只存在于 jm 租户文档;在 yh 租户下若召回到几米协议内容即为跨租户泄漏"}
读法:0x36 只存在于 jm 租户的文档里。现在在 yh 租户下问它,正确行为是什么都召不到。一旦召回到 Jimi 的文件,就是跨租户泄漏。relevant 为空、forbidden 非空,这种 case 就是负例,代码上的判据就是 not self.relevant(evalkit/schema.py:183-186)。
判定要整体反转,第六节展开。为什么要单独讲?因为负例把评测从"质量工具"变成了"安全工具"。正例失败等于分数掉了,负例失败等于出事了。这两个数字在门禁里的地位完全不同:通过率掉了可以商量,泄漏数必须为 0。
五、一把尺子:四个指标、两种模式、一次真实 run
一共算四个数。三个是教科书指标,第四个是自定义的,而第四个才是最该带走的东西。k 的取值是固定的四个:DEFAULT_KS = [1, 3, 5, 10]。
Recall@k:该找的找到了几成
python
# evalkit/schema.py:409-412
for k in ks:
# Recall@k:排名在前 k 的命中数 / 相关文档总数
hit_k = sum(1 for r in hit_ranks if 0 < r <= k)
out["recall_at_k"][str(k)] = hit_k / total
它回答的是"找到了几成",最直观,也最常用。一条 case 可以标多个 relevant,所以召回率是按条数算的:
| 标注了 | 找到了 | Recall@5 |
|---|---|---|
| 1 篇 | 1 篇,排第 3 | 1/1 = 1.0 |
| 2 篇 | 1 篇,另一篇没进来 | 1/2 = 0.5 |
| 2 篇 | 2 篇 | 2/2 = 1.0 |
它最大的问题也在"直观"上:Recall 只回答"在不在前 k",完全不看排第几。而"排第 3"和"排第 12"在用户眼里是天差地别的两件事。
MRR:用户要往下翻多久
python
# evalkit/schema.py:403-404
# ---- MRR ----
out["mrr"] = 1.0 / min(valid) if valid else 0.0
MRR 是第一个相关文档排名的倒数,惩罚很陡:
| 第一个相关文档排第 | MRR |
|---|---|
| 1 | 1.000 |
| 2 | 0.500 |
| 3 | 0.333 |
| 5 | 0.200 |
| 10 | 0.100 |
| 没召回 | 0 |
为什么"排第 5 只有 0.2"这件事重要?因为它和线上 top_k = 5 正好卡在同一个位置:
ini
排第 5:MRR = 0.2,但刚好还在上下文里,用户看得到
排第 6:MRR = 0.167,但已经被截掉了,用户看不到
0.2 与 0.167 在指标上只差 0.033,在用户体验上是"看到"与"没看到"的区别。这就是为什么单靠 MRR 还不够。
nDCG@k:考虑相关度分级的排序质量
前两个指标把"相关"当成有或无,但真实标注是有分级的,用 gain 表达:
gain |
含义 |
|---|---|
| 3 | 直接写着答案 |
| 2 | 需推理 |
| 1 | 背景相关 |
ini
DCG@k = Σ gain_i / log2(rank_i + 1) 只累加排名 ≤ k 的命中
IDCG@k = Σ gain_j / log2(j + 1) 把所有 gain 按降序排列后的理想值
nDCG@k = DCG@k / IDCG@k 归一化到 0~1,可跨 case 平均
这里有一个很容易写错的地方:
python
# evalkit/schema.py:384-391(节选)
# 注意 IDCG 要用"按 gain 降序"的理想排列,而不是原始顺序,
# 否则当高 gain 文档在标注里排后面时,nDCG 会算出大于 1 的值。
为什么"降序"这么关键?因为 IDCG 是理想排序的上界。如果标注里恰好把 gain=2 写在前面、gain=3 写在后面,而 IDCG 按原始顺序算,理想值就被人为压低了,实际的 DCG 就可能超过它,于是你得到一个 nDCG > 1 的数。
可以带走的经验:凡是"归一化指标",都要问一句"分母是理论最优吗"。分母算错不会报错,它只会让指标在特定标注顺序下悄悄越界,而你通常只会在看到大于 1 的那天才发现。
bury 成立的前提:检索深度必须比线上深
python
# evalkit/harness_retrieval.py:55-59
DEFAULT_KS = [1, 3, 5, 10]
# 检索深度:比线上实际使用的 top_k 深,这样才能区分
# "压根没召回(bury=-1)" vs "召回了但排在第 12 位(bury=12)"
# 如果只取 top 5,两者都表现为"没命中",会把人引向错误的修复方向。
DEFAULT_FETCH_K = 20
线上 top_k = 5,评测 fetch_k = 20,差别是这样的:
ini
评测取 5(错):bury = -1 与 bury = 12 都输出"未命中" → 两类故障又混在一起
评测取 20(对):bury = -1 输出"未召回",bury = 12 输出"rank 12" → 一眼分清
注意这两个字的区别:线上取 5 是为了给模型省上下文,评测取 20 是为了看清故障在哪。同一个参数,两个目的,取值就该不同。pipeline 模式还会临时把线上参数顶到 fetch_k,用完再还原,否则同进程里跑第二个 suite 就被污染了。
一个判据:凡是"为了诊断而临时改配置",离开前必须还原。这类 bug 的表现是"第二次跑结果不一样",而你上一次跑得很正常,所以你会怀疑模型、怀疑数据,就是不怀疑那个没被还原的全局变量。
两种模式,定位故障在哪一层
| 模式 | 做了什么 | 评的是 |
|---|---|---|
raw |
只做向量库检索(dense + BM25 混合),不做融合、不做精排 | embedding 质量 / 切片策略 / 权限过滤是否误杀 |
pipeline |
raw + RRF 融合 + cross-encoder 精排,复用线上真实代码 |
排序质量 |
对照着跑,结论直接出来:raw 差是召回侧问题,该换 embedding、调切片、查权限 expr;raw 好但 pipeline 差是排序侧问题,rerank 把对的压下去了,或者融合权重不对。
raw 模式有两处必须与线上保持一致,其中一处很容易漏:向量检索之后还有一层应用层权限过滤,评测必须把它也带上(pairs = self._acl.filter_results(pairs, case.role),evalkit/harness_retrieval.py:194-205),否则会漏掉"被权限误杀"这类故障。
filter_results 在权限那一轮里被讲成"第二道防线",主力是下推的 expr。评测里必须把它也带上,否则会漏掉一整类故障:权限把该看的文档挡在了门外。
顺便说一处真实的漂移:这段注释里原本写的是"被权限误杀(根因 R5)",但按根因表定稿,权限过滤误杀是 R4,R5 是生成幻觉。这是注释漂移,R1 到 R8 的编号在演化过程中调整过,这条注释没跟上。代码注释也是会过期的文档,引用一个编号之前先 grep 一遍它现在的定义。
一次真实 run:15 条长什么样
跑一次是 python -m evalkit.runner --suite retrieval --mode pipeline,--mode raw 只看召回侧,--compare last 和上次比。
模块头描述了三种模式,但 full 只存在于那段 docstring 里。
bash
$ grep -n "full" evalkit/harness_retrieval.py
19: full pipeline + LLM query 改写(要调 LLM,有成本,默认不跑)
26: pipeline 好但 full 差 → 改写侧问题:LLM 把问题改跑偏了
只有这两行,没有实现。retrieve() 里只有 raw 与 else 两枝,CLI 的 --mode 也只允许 raw 和 pipeline。"改写侧归因"是一个设计意图,还不是一个功能。
这就是第二条校正:文档比代码乐观,是文档类项目最常见的腐化方式。看到文档里的"三种模式""四个指标",先 grep 一遍再引用,判据永远是"是否存在",而不是"是否合理"。
下面是本项目一次完整输出(出处:evalkit/runs/full_20260810.log):
ini
[evalkit] 检索评测开始:15 条 case,mode=pipeline,fetch_k=20
[evalkit] 配置:hybrid=True rerank=True top_k=20
[1/15] ✓ jm-001 rank 1 ... [13/15] ✓ yh-005 rank 1
[14/15] ✓ iso-001 已隔离 泄漏=0 [15/15] ✓ iso-002 已隔离 泄漏=0
[evalkit] 完成:15 条 | 通过率 100.0% | MRR 0.853 | Recall@5 1.000 | nDCG@5 0.897
[evalkit] 诊断:完全未召回 0 条(召回侧问题)| 召回但排在 5 名外 0 条(排序侧问题)
[evalkit] 隔离负例:2 条,泄漏 0 条
先看最有价值的三行,注意它们全都是计数,不是比率:
| 输出行 | 含义 | 该做什么 |
|---|---|---|
完全未召回 0 条 |
没有 bury = -1 的 case |
召回侧没事,不用动 embedding / 切片 |
召回但排在 5 名外 0 条 |
没有 bury > 5 的 case |
排序侧没事,不用动 reranker |
隔离负例:2 条,泄漏 0 条 |
负例全过 | 没有跨租户泄漏,这一条必须为 0 |
这三行就是 bury 的价值:报表不给你一个笼统的分数,而是直接把问题分到两个抽屉里,然后告诉你两个抽屉都是空的。
再看那条率:
| 指标 | 数值 | 样本量 | 能不能手算复核 |
|---|---|---|---|
MRR |
0.853 | 13 正例 | 能,见下 |
Recall@5 |
1.000 | 13 正例 | 能,13 条全在 top_k=5 内 |
nDCG@5 |
0.897 | 13 正例 | 需要 gain 分布,较长 |
avg_bury |
1.46 | 13 正例 | 能 |
MRR = 0.853 的手算复核,这是全文唯一一个"能把数字算回去"的范例:
ini
逐条 rank(13 条正例的 bury):jm-001..008 为 1,2,1,1,1,1,1,1;yh-001..005 为 4,1,3,1,1
逐条 1/rank:
jm-001..008: 1 + 0.5 + 1 + 1 + 1 + 1 + 1 + 1 = 7.5
yh-001..005: 0.25 + 1 + 0.3333 + 1 + 1 = 3.5833
合计 = 11.0833
11.0833 ÷ 13 = 0.8526 → 四舍五入 = 0.853,与日志一致
为什么要费这个劲?因为后面要立的纪律是"数字必须能指回行"。连自己项目的汇总行都算不回去,它就只是一个不能复现的数字。
最后,关于样本量必须说清楚:这 15 条的构成是 13 条正例加 2 条负例。Recall@5 = 1.000 意味着这 13 条里答案全在前 5 名。它不能推出"系统的召回率是 100%",只说明"这套集子当前没抓到问题"。13 条够做回归,不够做能力断言,两者的区别是下一节的核心。
六、从数字到动作:R1--R8
指标回答"差多少",分类回答"往哪改"
假设你看到一条失败 case,已知它 bury = -1。然后呢?bury = -1 只是把范围缩到了"召回侧",而召回侧至少有三个可能:embedding 不合适、切片把答案劈开了、权限 expr 把它挡了。你要挨个试吗?
evalkit/triage.py 干的就是这件事:把"差多少"翻译成"是哪一个根因、该动哪一环",再给出具体动作。
R1--R8 全表
| 编号 | 名称 | 严重度 | 判据 | 该动哪一环 |
|---|---|---|---|---|
| R1 | 召回侧完全丢失 | high | bury <= 0 / Recall@5 = 0 |
换/微调 embedding、调 chunk_size 与重叠、重新 ingest |
| R2 | 排序侧埋没 | medium | 召回但排在 5 名之后,或 nDCG@5 偏低 |
调 rerank 阈值/模型、调 RRF 权重、加大 candidate_k |
| R3 | 改写/精排负优化 | medium | raw 明显好于 pipeline,同一条 query |
关掉/弱化 query rewrite、单独验证 rerank 模型、A/B 对比 |
| R4 | 权限过滤误杀 | high | raw 命中但 pipeline 未命中,或 admin 命中、user 未命中 |
查租户/角色过滤 expr、验证 AccessControlFilter 逻辑、补单测 |
| R5 | 生成幻觉 | high | 检索正常(bury > 0)但答案出现禁词 / faithfulness < 0.6 |
强化 generate prompt 约束、补充相关文档、降温/加 cite 要求 |
| R6 | 答非所问 | medium | 检索正常但 relevancy < 0.6 |
修 classify 路由、强化 multi-hop 拆解、校验对齐 |
| R7 | 拒答错误 | medium | should_refuse 却编了答案(漏拒),或不该拒却拒了(误拒) |
调整拒答判定阈值、补充安全策略样例、校准标注 |
| R8 | 跨租户泄漏 | critical | 本租户 query 召回了其他租户文档 | 立即检查租户过滤 expr、确认索引是否混库、加隔离回归测试 |
注意严重度分布:R5 是 high,R6 和 R7 是 medium,而 R8 是唯一的 critical。这一列不是装饰,它决定了你先看哪一行。R1 到 R7 全是质量问题,只有 R8 是安全事件。质量差一点,用户多问一次;泄漏一次,就是一次数据事故,而且不可撤回。
分类器的实际输出长这样:
ini
[evalkit] Bad Case 根因分布(3 条失败)
R5 生成幻觉 [high] ×2
R7 拒答错误 [medium] ×1
- ans-jm-003: R5 生成幻觉 --- faithfulness=0.00 低于阈值0.6,答案不忠于上下文。
- ans-yh-001: R7 拒答错误 --- 不该拒答的问题被拒了(误拒)。
那个"×2"的计数比任何分数都有用,它告诉你这一轮的主要矛盾是幻觉,不是路由。
优先级:安全 → 召回 → 拒答 → 生成
八个编号不是并列的,文档字符串给出了一条排序:
python
# evalkit/triage.py:32-34
安全类(R8)→ 召回类(R1/R4/R2/R3)→ 拒答类(R7)→ 生成类(R5/R6)。
即:先确认"有没有把错的文档放出来 / 有没有把对的挡住",再看"生成质量"。
因为召回错了,后面生成再好也是建立在错误材料上,先修召回。
画成阶梯:
arduino
① 安全类 R8 ← 有泄漏,其他一切免谈
② 召回类 R1 / R4 / R2 / R3 ← 材料都没拿对,后面全部无效
③ 拒答类 R7 ← 该说"不知道"的时候说了
④ 生成类 R5 / R6 ← 材料对了,是模型没用好
为什么"先修召回"这句这么重要?因为它对应一个非常常见的错误动作:
css
报表:Recall@5 = 0.62,faithfulness = 0.55(一堆 R5)
错误的第一反应:调 generate 的 prompt,让它"更严格地只依据上下文"。
材料本身就是错的,你把生成约束得再死,也只是忠实地复述错误材料。
正确的第一反应:先看 R1/R2,那 38% 没召回的,是因为答案压根没进来,
还是因为排太后被截了?把材料修对,faithfulness 会自己上来。
可以带走的判据:当"上游"和"下游"同时有失败时,永远先修上游。数据管道要先修抽取再修清洗,前端要先修接口再修渲染,都是同一个道理:下游的一切努力,都可能只是在忠实地放大上游的错误。
一个反直觉的判定顺序:R4 必须先于 R1
召回类内部,R4 排在 R1 前面。这不是随便排的,它是一个代码实现上的顺序:
python
# evalkit/triage.py:155-158 与 :163-166
# R4:raw 命中但 pipeline 未命中 → 权限误杀(比 R1 更具体,优先判定)
# 必须放在 R1 之前:pipeline 未召回时 bury<=0,若不先判 R4 会被 R1 抢走,
# 而"权限误杀"比"泛化的召回丢失"更能直接指导修复。
if raw_bury is not None and raw_bury > 0 and bury <= 0:
return _make("R4",
f"raw 模式能召回(bury={raw_bury})但 pipeline 未召回,"
f"疑似权限/角色过滤误杀。")
# R1:完全没召回(无 raw 对比信号,或 raw 也未召回)
if bury <= 0 or recall5 == 0.0:
return _make("R1",
f"正确文档未进入召回(bury={bury}, Recall@5={recall5:.2f})。")
问题的本质是"条件重叠":
ini
一条 case:raw_bury = 2(raw 能召回到),bury = -1(pipeline 没召回到)
它同时满足两个条件:
· R4 的判据:raw 命中 且 pipeline 未命中 命中
· R1 的判据:bury <= 0 命中
如果 R1 写在前面,这条 case 会被判成 R1,而 R1 的行动建议是"去换 embedding、调切片",方向完全错了。真正的问题是权限 expr 把该看的文档挡在了门外。
R4 之所以必须赢,是因为它更具体:
| 判据的信息量 | 行动的可执行性 | |
|---|---|---|
| R1 召回侧完全丢失 | 只知道"没召回到" | 宽:embedding / 切片 / 索引 / 权限,四个方向都要试 |
| R4 权限过滤误杀 | 知道"raw 能、pipeline 不能" | 窄:差异只在 pipeline 多出来的那一层,也就是权限过滤 |
一个通用到可以写进代码规范的判据:把"更具体的判据"排在"更泛化的判据"前面。因为分类器是首个命中即返回的结构,泛化的条件会吃掉所有本该落到更具体分支的样本,具体异常要先于 Exception 捕获就是这个道理。反过来说,如果你发现某个分支"永远不进",先检查它前面是不是站着一个判定条件更宽的分支。
隔离负例:判定要整体反转
| 正例 | 负例 | |
|---|---|---|
| 及格判据 | 至少召回一个相关文档 | 一条禁止规格都没命中 |
| "什么都没召回" | 失败,该找的没找到 | 正确行为,不该漏的没漏 |
| 失败意味着什么 | 分数掉了 | 出事了 |
如果沿用正例口径去判负例,会把"隔离成功"误报成"失败",直接污染通过率与未召回计数,把人引向一个根本不存在的召回问题。另外,负例的扫描是独立的一轮:一条 case 可以同时有正例要求和禁止要求,所以"禁止规格扫描"与"相关文档扫描"要分开做,对 forbidden 里的每条规格扫一遍召回结果,命中即为泄漏。
这条在工程上还剩很多细节,规格怎么写、命中怎么定位到 rank、泄漏计数怎么汇总,那些属于实现。要记住的是:判据的方向变了,而且必须变。
七、诚实口径与门禁
负例不进均值,只进泄漏计数
直觉做法是把负例当成普通 case,一起算平均分。为什么不行?聚合函数的注释写得非常清楚:
python
# evalkit/schema.py:438-443
# 负例(隔离类)没有正确答案,Recall/nDCG/MRR 对它恒为 0。
# 若混进均值会凭空拉低分数,且让指标随负例条数漂移、跨 run 不可比,
# 所以质量类指标只在正例上计算;负例单独用 leak_count 体现。
两个后果,第二个更严重:
| 后果 | 说明 |
|---|---|
| 凭空拉低分数 | 负例的 recall/mrr/ndcg 恒为 0,混进去会让 MRR 从 0.853 掉到 0.739,而系统一行没变 |
| 指标随条数漂移 | 这是真正致命的:今天 2 条负例、明天加 5 条,指标自己就变了。于是你再也无法跨 run 比较,而"跨 run 比较"正是评测存在的唯一理由 |
所以负例走自己的一条通道,而通过率覆盖全部 case,因为它的口径由统一的判定函数给出,正负例各自反转:
python
# evalkit/schema.py:458-462
# 泄漏数:负例中命中了禁止规格的条数,安全类指标,必须为 0
summary["leak_count"] = sum(1 for r in neg if r.get("forbidden_hits"))
# 通过率覆盖全部 case(正例看召回、负例看隔离),口径由 _passed 统一给出
summary["pass_rate"] = round(sum(1 for r in results if r.get("_passed")) / n, 4)
一张表看清谁进哪个池:
| 指标 | 池子 | 为什么 |
|---|---|---|
recall@k / ndcg@k / mrr |
只算正例 | 负例没有正确答案,这些指标对它无意义 |
miss_count / buried_count / avg_bury |
只算正例 | 同理 |
leak_count |
只算负例 | 负例唯一的产出,也是安全指标 |
pass_rate |
全部 | 正负例判定方向相反,但"通过"含义一致 |
一个可以直接拿去自查的动作:把负例条数从 2 条改成 10 条,看正例的 Recall 会不会变。变了说明口径脏了,指标正在随"你测了多少条负例"而漂移。
n 小就别报百分比
这是最需要纪律的一段,因为它的诱惑最大。你手上有三个漂亮的数字:Recall@5 = 1.000、MRR = 0.853、nDCG@5 = 0.897。拿它去汇报,太顺了。
但在按下发送之前,先看另一个场景。项目里还有一次不同性质的度量,它只有三条 query:
python
# scripts/eval_retrieval_bury.py:160-164
CASE1_Q = "基站信息格式是什么"
EXTRA = ["基站信息格式", "VI 基站信息格式"]
三条 query。如果它输出"救回率 66.7%",你会信吗?所以这个脚本给了一条口径声明:不给"召回率 62%/78%/91%"这类具体百分比。原因有两个,一是真实数字随评估集变化,样本量不足以支撑百分比;二是给出没有复现步骤的数字,等于让读者以为"跑一遍就能得到这个数"。能负责任地讲的结论只有一条:三种合并策略在"gold 排名"上会给出不同的排序结果,而这个脚本让差异可量化、可对比。
这条声明里有两条判据,都很硬:
| 判据 | 意思 |
|---|---|
| 样本量不足以支撑百分比 | n = 3 时,"2/3 命中"和"3/3 命中"的差别没有统计意义 |
| 给出没有复现步骤的数字,等于让读者以为跑一遍就能得到这个数 | 数字不是形容词,它是一个承诺:别人照你的步骤能跑出同一个数 |
那 15 条黄金集呢,它是"能报"还是"不能报"?看三个维度:
| 维度 | 3 条 query 的埋点 | 15 条的黄金集 run |
|---|---|---|
| 样本量 | 3 | 15,13 正例加 2 负例 |
| 是否可复现 | 否,脚本没固定黄金集 | 是,有固定黄金集、run 日志、配置快照(hybrid / rerank / top_k / fetch_k) |
| 数字能否手算复核 | 否,只输出排名 | 能,11.0833 ÷ 13 = 0.8526 |
所以落点是:
| 怎么处理 | |
|---|---|
| 可以报 | 15 条 run 的 MRR 0.853 / Recall@5 1.000 / nDCG@5 0.897,但每次必须紧跟 n = 13 正例 |
| 不许报 | 3 条 query 埋点的任何百分比,连"救回 2/3"都别写成 66.7% |
| 最该报的 | 三个计数:未召回 0 / 埋没 0 / 泄漏 0。它们是形态,不是比率,不受样本量影响 |
| 必须跟一句 | "单次满分只说明这套集子当前没抓到问题" |
那句"必须跟的话"为什么不能省?因为 Recall@5 = 1.000 读起来像能力断言,而它其实是回归结论:
erlang
回归结论(成立):这 13 条里答案全在前 5 名,本次改动没有让它们退化。
能力断言(不成立):系统的召回率是 100%。这需要远大于 13 的样本、
且样本要覆盖真实的 query 分布(这正是 mined 用例存在的理由)。
一句话:样本量小的时候,"看起来很好"本身就是一种风险,因为它会让你停止找问题。能负责任地说的只有一句:"这套集子目前没抓到问题",后面紧跟"所以下一步是把它变大"。
门禁:让评测真的拦得住
评测最大的敌人不是"测不准",是"没人跑"。而"没人跑"的根本原因通常是,跑不跑结果都一样。所以最后一环是门禁,落地形态是一个退出码:
python
# evalkit/runner.py:192-201
# ---- 门禁 ----
# 两个 suite 都以 harness 落盘的 _passed 为准:检索层的正例(看召回)
# 与隔离负例(看有没有泄漏)判定方向相反,不能再用 bury 一刀切。
failed = sum(1 for r in run.results if not r.get("_passed"))
if failed:
any_failure = True
print(f"[evalkit] ⚠ {suite} 有 {failed} 条失败 case")
_print_triage(run)
return 0 if (args.no_fail or not any_failure) else 2
四件事值得逐个说。
第一,退出码是 2,不是 1。
| 退出码 | 含义 |
|---|---|
| 0 | 全部通过 |
| 2 | 有失败 case |
1 被 argparse 占用 |
参数错误,不冲突 |
CI 里靠的就是这个非零码,这是门禁唯一真正生效的机制。
第二,--no-fail 是一个逃生舱,但它必须显式使用。它存在是对的,有时候你就是要跑一遍看看,不想被拦,但它必须是"我知道我在跳过门禁",而不是默认行为。判据:如果你的 CI 里天天带着 --no-fail,那你其实没有门禁。
第三,门禁的判据是 _passed,不是 bury。因为 bury 只对正例有意义:
| case 类型 | 用 bury 判会怎样 |
|---|---|
| 正例 | bury > 0 就是通过 |
| 负例 | 负例的 bury 恒为 -1,它本来就不该召回,用 bury 判会把每一条隔离成功的负例都判成失败 |
这就是"判定反转"在门禁层的体现。同一个坑,在三个地方各出现一次:判定、聚合时正负例分池、门禁里用 _passed 而不是 bury。
第四,失败时顺手把根因打出来。这一步把"拦住"和"下一步"接上了:门禁不只告诉你没过,还告诉你哪一类没过。
为什么必须进 CI?因为人的注意力是稀缺资源。
arduino
靠自觉:"每次改完记得跑一下评测" → 第 1 周认真跑 → 第 3 周改的是文档就不跑了 → 第 6 周没人记得它还存在
靠门禁:不跑过不了 CI → 它会一直跑下去
一个判据:一项检查如果"失败也不影响任何事",它就会在几周内停止运行。要让一件事长期做下去,最可靠的办法不是反复强调它的重要性,而是"让它成为流程的必经之路"。
八条已知边界
| # | 边界 | 说明 |
|---|---|---|
| 1 | 样本量小 | 15 条,13 正例加 2 负例。Recall@5 = 1.000 是回归结论,不是能力断言 |
| 2 | 正例偏向 admin 视角 |
13 条正例全是 role: "admin",两条负例才用 role: "user"。user 角色下的召回质量没有正例覆盖 |
| 3 | 知识库强绑定 | 黄金集绑定两个租户的具体文档。换一套知识库,manual 部分基本要重写 |
| 4 | 第三种模式未实现 | 模块头描述的 LLM 改写模式只有 docstring,改写侧归因只能靠 R3 的间接信号 |
| 5 | 没有精确率指标 | 评分器只算 recall_at_k / ndcg_at_k / mrr / bury,检索的准确率没有实现 |
| 6 | 标注带 LLM 噪声 | mined 用例的标注来自 LLM 判定,已用自校验分组,存疑组仍需人工裁决 |
| 7 | 命中判定用 rank 不用 score |
score 只做展示。判定走 rank,因为 score 的语义有失真可能 |
| 8 | 注释会过期 | 实测两处:一条注释把权限误杀写成 R5,模块头写了三种模式而代码只有两种 |
第二条值得单独看一眼,它藏着一个很容易被忽略的盲区:
sql
13 条正例,role 全都是 admin
2 条负例,role 才是 user
也就是说,普通用户角色下的检索质量,这份黄金集目前测不到。而 role 直接决定权限表达式,admin 看本租户全部,其他角色还要叠加 public or 本人。一个只测 admin 的黄金集,恰好绕过了整个权限维度。
八、九个坑,成因只有两个字
| # | 坑 | 现象 | 根因 | 严重度 |
|---|---|---|---|---|
| 1 | 只报 Recall,不用 bury |
报告里只有"命中率",两类故障混在一起 | Recall 不看排名;bury = -1 与 bury = 12 都表现为"没在前 k 里" |
高危,修错方向 |
| 2 | 黄金集拿 chunk_index 当标识 |
重新 ingest 一遍,测试全红,代码一行没错 | chunk_index 是切片流程的产物,边界一变就失效 |
高危,标注失效 |
| 3 | Harness 自建一套"差不多的检索" | 评测 100% 通过,线上照样答错 | 副本与线上慢慢漂移;"等价"是个不成立的假设 | 高危,评测绿线上红 |
| 4 | 不筛标注噪声 | 评测长期报红,最后没人再打开报告 | LLM 判定本身带噪声,照单全收等于自己骗自己 | 高危,摧毁信任 |
| 5 | n 小却给百分比 |
报了个漂亮数字,没人能复现 | 3 条 query 不支撑百分比;没有复现步骤的数字是一个承诺 | 高危,虚假精确 |
| 6 | 负例混进均值 | 加了几条负例,正例的 Recall 自己变了 |
负例的 recall/mrr/ndcg 恒为 0,导致跨 run 不可比 |
中等,口径污染 |
| 7 | 检索深度设成线上 top_k |
分不清"压根没召回"和"排在 12 位" | fetch_k 必须比线上深,否则 bury 失去分辨力 |
中等,丢失分辨力 |
| 8 | 评测不进 CI | 三周后再没人跑 | 失败不影响任何事,注意力被别的事拿走 | 中等,一定会腐烂 |
| 9 | 只看绝对指标,不看基线 | 每周掉 1%,十周后才发现 | 没有基线对比,缓慢退化不会跳出来 | 次要,缓慢退化 |
第 1 到第 5 条是一组,它们全都是致命的,而且没有一条会报错。复查顺序建议从上往下:先用 bury 把报表拆成"未召回 / 埋没"两列(坑 1),再确认黄金集的标识用文档属性而不是切片序号(坑 2),最后给评测加一个退出码(坑 8)。
这九个坑跟前几轮遇到的坑性质不同。权限那一轮的坑是"想不到",维度漏掉比边界写错危险;而这一轮的坑全是"想省事":自建一套检索省事、用 chunk_index 省事、不建门禁省事。
所以药方不是"更细心",是把标准写下来。因为省事的冲动是持续的,而标准是唯一能对抗它的东西,不依赖你每次都记得。
只能带走一句的话:
度量必须先于优化。没有尺子,你的每一次改动都只是一次猜测。
再加一句:
最危险的降级,是那个让指标"看起来还行"的降级。