从零到一搭建企业级智能问答系统:Ch11 ·大模型调参与效果评测

0 阅读33分钟

改一行配置,怎么知道它变好了:检索评测的黄金集、指标与门禁

一、同一个"没命中",是两种完全相反的故障

你把 top-k 从 5 调到 10,看了一圈,说"好像好点了"。

但如果答案根本没被召回到,调 top-k 一点用都没有;如果答案排在第 12 位,调 top-k 才有用。这两种情况在"感觉"的层面长得一模一样。

没有度量的时候,代价不只是不准,而是会把两种方向相反的故障报成同一个结果。

环节当时在调什么
混合检索融合策略(dense + BM25 + RRF)
问答主链路检索 → 父块回填 → 出处
向量化批量编码 / 维度一致 / 增量
精排与去冗余Reranker + MMR
权限下推过滤必须下推

五个环节全在调检索,而尺子是最后才补上的,等于先盲调五轮,才把量尺递过来。

把这件事拆开,只需要一个自定义指标:bury。

# 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 成本,秒级
答案层答案是否忠于上下文要裁判模型,分钟级
端到端用户的问题被回答了吗要裁判模型,分钟级

为什么先做检索层,模块头把理由写死了:

# evalkit/harness_retrieval.py:5-9
# 为什么先做检索层:
# RAG 系统答错,绝大多数不是"模型笨",而是"根本没把正确的段落喂给它"。
# 拿一个答错的问题去调 prompt、换更大的模型,往往是在错误的方向上使劲。
# 检索层评测能在零 LLM 成本、秒级的前提下告诉你:正确段落到底进没进上下文。

现在说三件套里最容易出问题的那一件。理论上"faithfulness 是幻觉的可观测化"讲得很漂亮,代码里的接线状态却是这样:

# 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 自己写了一套"差不多的检索"

这个坑的病根非常反直觉,它不是"评测写错了",是评测写得太好了。

# evalkit/harness_retrieval.py:30-37
# 为什么要复用线上代码而不是自己重写一遍检索逻辑
#
# Harness 自己实现一套"差不多的检索",就会与线上实现慢慢漂移,
# 最后评测分数很好看,线上依然拉胯。所以 pipeline 模式直接调
# LangGraphRAGApp._do_retrieve,评的就是线上那份代码本身。

"差不多"是这里最危险的三个字。你自己写一套检索,第一天它确实差不多;一周后线上加了图页召回、改了距离归一化、加了一层权限兜底,你那套副本一项都没跟上。于是:

评测:100% 通过
线上:答错
你以为的问题:模型不行
真实的问题:评测测的不是线上

怎么做到复用又不启动整个 App?因为 _do_retrieve 只依赖三份状态:

# 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 调用,评测不会崩。这正是防止漂移的落地形态,不是靠"我记得去同步评测脚本",而是靠结构上让它无法不同步。

# 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 长什么样

// 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,读成一张判定树:

① 文件不匹配?            → 否(直接出局)
② 没标 pages/keywords?   → 是(文件对了就算命中)
③ 页码对上了?            → 是(命中)
④ 关键词出现在正文里?    → 是(命中)
⑤ 以上都不成立            → 否

"或"是这里的关键。为什么不全用页码、不全用关键词?因为两者都会失效,而失效的原因正好相反:

判据优点失效场景
pages 页码精确依赖 PDF 排版不变,重新导出或换版本后页码全错
keywords 关键词抗变化可能误判,关键词出现在无关段落
两者是"或"任一成立即命中pages 因换版失效时,keywords 还兜得住

一种"两个都标了但依然会误判"的场景:标注 pages: [1] 加 keywords: ["优先级"],page 1 命中是真的;但同一份文档的 page 12 上只要出现"优先级"两个字,这条也会被判成命中,而它可能讲的根本不是定位数据的上报优先级。

项目的回答是:接受这个噪声,但不假装它不存在。note 字段就是让人写清"为什么这段是答案"的,而垃圾关键词在挖掘阶段会被过滤。

抗重建:别用 chunk_index

先说错误做法,它非常自然,你几乎一定会先这么干:

// 错误示范:用 chunk_index 当文档标识
{"query": "登录包的协议号是多少", "relevant": [{"chunk_index": 47}]}

然后你把知识库重建了一遍,改了切片参数、补了一份文档、升级了切片器。代码一行没改,结果:

[evalkit] 检索评测开始:15 条 case
  [1/15] ✗ jm-001   未召回   登录包的协议号是多少
通过率 0.0%

测试全红,而代码没有问题。你会花掉一整天去查检索链路,最后发现是标注过期了。因为 chunk_index 是切片过程的产物,不是文档的属性:

原来(chunk_size=500):  第 47 片 = 登录包那一节
重建后(chunk_size=800): 第 47 片 = 心跳包那一节   ← 边界全移了

挖掘脚本的头部注释把这个设计讲得很清楚:

# 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 里。

用户提问 → 检索(召回 7 篇) → LLM 判定 5/7 相关
         → 落盘到 task_checkpoints.state_json → 挖出来就是一条带标注的 case

这是"副产品思维"的一个漂亮例子:系统为了另一个目的产出的信号,恰好就是你评测最缺的东西。值得问一句,我现在跑的流程,有没有已经顺手产出了我后来要花大力气补的数据?

还有一个更朴素的约束:数据不出内网。内网语料不能上传给第三方评测平台替你算指标,一上传边界就破了。所以黄金集只能从本地数据库挖,Harness 只能用你机器上那一份线上代码,不调 LLM 的模式才能保证 query 不出网。

从 trace 挖标注:四个门槛加一次自校验

"免费"不等于"照单全收"。挖掘脚本的第二个注释标题就是:质量门槛,宁缺毋滥,脏数据比没数据更糟。

#门槛挡掉什么
1至少有 1 篇被判定相关全不相关的记录
2query 长度 ≥ MIN_QUERY_LEN = 4"你好"之类的闲聊
3同题只保留相关文档最多的那次同题重复问,取信息最全的
4与既有黄金集按归一化 query 去重制造重复题目

还有两个防伪造 case 的设计,它们都属于"不这么做就会造出一条永远失败的 case":

# 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 全跑一遍,分成"可信"与"存疑"两组:

# scripts/mine_golden.py: verify()
"""
用当前检索器把挖出来的 case 跑一遍,分成可信与存疑两组。

为什么必须做:标注来自 LLM 的 doc_grades,本身带噪声。实测挖到过
"输出一下通信流程图"被标到"设备信息结构""白名单获取"这种明显无关的章节上。
这类标注进了黄金集,评测就会长期报红,而问题其实出在标注、不在系统。

注意:验证不通过 ≠ 系统有 bug,只是"这条标注不够可信",
因此不丢弃,而是单独存到 *_review.jsonl 供人工裁决。
"""

这段注释里最值钱的是最后那句判据:验证不通过不等于系统有 bug,只是"这条标注不够可信"。两种处理方式的差别很大:

处理后果
把未命中的直接丢弃你丢掉的可能正是"检索真的错了"的那些样本
放进 *_review.jsonl 等人工裁决既不失真,也不污染主集

落地的产物就是三个文件:

evalkit/golden/retrieval.jsonl        15 条  主集(人工编写,逐条有 note)
evalkit/golden/mined_retrieval.jsonl   7 条  挖掘出的可信 case
evalkit/golden/mined_review.jsonl      1 条  挖掘出的存疑 case(待人工裁决)

条数是当时快照,会随挖掘跑动而变。要讲的不是"几个",而是"分成两个池子"这件事本身。

隔离负例:另一种 case

权限怎么评测?答案是另一种 case,它没有正确答案,只有禁止出现的答案:

// 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:该找的找到了几成

# 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 篇,排第 31/1 = 1.0
2 篇1 篇,另一篇没进来1/2 = 0.5
2 篇2 篇2/2 = 1.0

它最大的问题也在"直观"上:Recall 只回答"在不在前 k",完全不看排第几。而"排第 3"和"排第 12"在用户眼里是天差地别的两件事。

MRR:用户要往下翻多久

# evalkit/schema.py:403-404
# ---- MRR ----
out["mrr"] = 1.0 / min(valid) if valid else 0.0

MRR 是第一个相关文档排名的倒数,惩罚很陡:

第一个相关文档排第MRR
11.000
20.500
30.333
50.200
100.100
没召回0

为什么"排第 5 只有 0.2"这件事重要?因为它和线上 top_k = 5 正好卡在同一个位置:

排第 5:MRR = 0.2,但刚好还在上下文里,用户看得到
排第 6:MRR = 0.167,但已经被截掉了,用户看不到

0.2 与 0.167 在指标上只差 0.033,在用户体验上是"看到"与"没看到"的区别。这就是为什么单靠 MRR 还不够。

nDCG@k:考虑相关度分级的排序质量

前两个指标把"相关"当成有或无,但真实标注是有分级的,用 gain 表达:

gain含义
3直接写着答案
2需推理
1背景相关
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 平均

这里有一个很容易写错的地方:

# evalkit/schema.py:384-391(节选)
# 注意 IDCG 要用"按 gain 降序"的理想排列,而不是原始顺序,
# 否则当高 gain 文档在标注里排后面时,nDCG 会算出大于 1 的值。

为什么"降序"这么关键?因为 IDCG 是理想排序的上界。如果标注里恰好把 gain=2 写在前面、gain=3 写在后面,而 IDCG 按原始顺序算,理想值就被人为压低了,实际的 DCG 就可能超过它,于是你得到一个 nDCG > 1 的数。

可以带走的经验:凡是"归一化指标",都要问一句"分母是理论最优吗"。分母算错不会报错,它只会让指标在特定标注顺序下悄悄越界,而你通常只会在看到大于 1 的那天才发现。

bury 成立的前提:检索深度必须比线上深

# 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,差别是这样的:

评测取 5(错):bury = -1 与 bury = 12 都输出"未命中"  →  两类故障又混在一起
评测取 20(对):bury = -1 输出"未召回",bury = 12 输出"rank 12"  →  一眼分清

注意这两个字的区别:线上取 5 是为了给模型省上下文,评测取 20 是为了看清故障在哪。同一个参数,两个目的,取值就该不同。pipeline 模式还会临时把线上参数顶到 fetch_k,用完再还原,否则同进程里跑第二个 suite 就被污染了。

一个判据:凡是"为了诊断而临时改配置",离开前必须还原。这类 bug 的表现是"第二次跑结果不一样",而你上一次跑得很正常,所以你会怀疑模型、怀疑数据,就是不怀疑那个没被还原的全局变量。

两种模式,定位故障在哪一层

模式做了什么评的是
raw只做向量库检索(dense + BM25 混合),不做融合、不做精排embedding 质量 / 切片策略 / 权限过滤是否误杀
pipelineraw + 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 里。

$ 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):

[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 的价值:报表不给你一个笼统的分数,而是直接把问题分到两个抽屉里,然后告诉你两个抽屉都是空的。

再看那条率:

指标数值样本量能不能手算复核
MRR0.85313 正例能,见下
Recall@51.00013 正例能,13 条全在 top_k=5 内
nDCG@50.89713 正例需要 gain 分布,较长
avg_bury1.4613 正例能

MRR = 0.853 的手算复核,这是全文唯一一个"能把数字算回去"的范例:

逐条 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召回侧完全丢失highbury <= 0 / Recall@5 = 0换/微调 embedding、调 chunk_size 与重叠、重新 ingest
R2排序侧埋没medium召回但排在 5 名之后,或 nDCG@5 偏低调 rerank 阈值/模型、调 RRF 权重、加大 candidate_k
R3改写/精排负优化mediumraw 明显好于 pipeline,同一条 query关掉/弱化 query rewrite、单独验证 rerank 模型、A/B 对比
R4权限过滤误杀highraw 命中但 pipeline 未命中,或 admin 命中、user 未命中查租户/角色过滤 expr、验证 AccessControlFilter 逻辑、补单测
R5生成幻觉high检索正常(bury > 0)但答案出现禁词 / faithfulness < 0.6强化 generate prompt 约束、补充相关文档、降温/加 cite 要求
R6答非所问medium检索正常但 relevancy < 0.6修 classify 路由、强化 multi-hop 拆解、校验对齐
R7拒答错误mediumshould_refuse 却编了答案(漏拒),或不该拒却拒了(误拒)调整拒答判定阈值、补充安全策略样例、校准标注
R8跨租户泄漏critical本租户 query 召回了其他租户文档立即检查租户过滤 expr、确认索引是否混库、加隔离回归测试

注意严重度分布:R5 是 high,R6 和 R7 是 medium,而 R8 是唯一的 critical。这一列不是装饰,它决定了你先看哪一行。R1 到 R7 全是质量问题,只有 R8 是安全事件。质量差一点,用户多问一次;泄漏一次,就是一次数据事故,而且不可撤回。

分类器的实际输出长这样:

[evalkit] Bad Case 根因分布(3 条失败)
  R5 生成幻觉 [high] ×2
  R7 拒答错误 [medium] ×1
   - ans-jm-003: R5 生成幻觉 — faithfulness=0.00 低于阈值0.6,答案不忠于上下文。
   - ans-yh-001: R7 拒答错误 — 不该拒答的问题被拒了(误拒)。

那个"×2"的计数比任何分数都有用,它告诉你这一轮的主要矛盾是幻觉,不是路由。

优先级:安全 → 召回 → 拒答 → 生成

八个编号不是并列的,文档字符串给出了一条排序:

# evalkit/triage.py:32-34
安全类(R8)→ 召回类(R1/R4/R2/R3)→ 拒答类(R7)→ 生成类(R5/R6)。
即:先确认"有没有把错的文档放出来 / 有没有把对的挡住",再看"生成质量"。
因为召回错了,后面生成再好也是建立在错误材料上,先修召回。

画成阶梯:

① 安全类   R8                     ← 有泄漏,其他一切免谈
② 召回类   R1 / R4 / R2 / R3      ← 材料都没拿对,后面全部无效
③ 拒答类   R7                     ← 该说"不知道"的时候说了
④ 生成类   R5 / R6                ← 材料对了,是模型没用好

为什么"先修召回"这句这么重要?因为它对应一个非常常见的错误动作:

报表:Recall@5 = 0.62,faithfulness = 0.55(一堆 R5)

错误的第一反应:调 generate 的 prompt,让它"更严格地只依据上下文"。
   材料本身就是错的,你把生成约束得再死,也只是忠实地复述错误材料。

正确的第一反应:先看 R1/R2,那 38% 没召回的,是因为答案压根没进来,
   还是因为排太后被截了?把材料修对,faithfulness 会自己上来。

可以带走的判据:当"上游"和"下游"同时有失败时,永远先修上游。数据管道要先修抽取再修清洗,前端要先修接口再修渲染,都是同一个道理:下游的一切努力,都可能只是在忠实地放大上游的错误。

一个反直觉的判定顺序:R4 必须先于 R1

召回类内部,R4 排在 R1 前面。这不是随便排的,它是一个代码实现上的顺序:

# 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})。")

问题的本质是"条件重叠":

一条 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,一起算平均分。为什么不行?聚合函数的注释写得非常清楚:

# 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,因为它的口径由统一的判定函数给出,正负例各自反转:

# 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:

# 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
样本量315,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 读起来像能力断言,而它其实是回归结论:

回归结论(成立):这 13 条里答案全在前 5 名,本次改动没有让它们退化。
能力断言(不成立):系统的召回率是 100%。这需要远大于 13 的样本、
                    且样本要覆盖真实的 query 分布(这正是 mined 用例存在的理由)。

一句话:样本量小的时候,"看起来很好"本身就是一种风险,因为它会让你停止找问题。能负责任地说的只有一句:"这套集子目前没抓到问题",后面紧跟"所以下一步是把它变大"。

门禁:让评测真的拦得住

评测最大的敌人不是"测不准",是"没人跑"。而"没人跑"的根本原因通常是,跑不跑结果都一样。所以最后一环是门禁,落地形态是一个退出码:

# 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?因为人的注意力是稀缺资源。

靠自觉:"每次改完记得跑一下评测" → 第 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 不用 scorescore 只做展示。判定走 rank,因为 score 的语义有失真可能
8注释会过期实测两处:一条注释把权限误杀写成 R5,模块头写了三种模式而代码只有两种

第二条值得单独看一眼,它藏着一个很容易被忽略的盲区:

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 是切片流程的产物,边界一变就失效高危,标注失效
3Harness 自建一套"差不多的检索"评测 100% 通过,线上照样答错副本与线上慢慢漂移;"等价"是个不成立的假设高危,评测绿线上红
4不筛标注噪声评测长期报红,最后没人再打开报告LLM 判定本身带噪声,照单全收等于自己骗自己高危,摧毁信任
5n 小却给百分比报了个漂亮数字,没人能复现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 省事、不建门禁省事。

所以药方不是"更细心",是把标准写下来。因为省事的冲动是持续的,而标准是唯一能对抗它的东西,不依赖你每次都记得。

只能带走一句的话:

度量必须先于优化。没有尺子,你的每一次改动都只是一次猜测。

再加一句:

最危险的降级,是那个让指标"看起来还行"的降级。