给检索改动装一盏红绿灯:评测门禁怎么建、怎么用
改一句 Prompt、调一个召回阈值、换一个模型,改完之后,怎么知道检索质量没变差?
肉眼看不出来。你随便问几个问题,答得都还行,但「还行」不是结论。质量回退最麻烦的地方在于它不报错:没有异常、没有告警,只是答案悄悄变差了一点,等你从用户反馈里发现,已经过了好几版。
所以需要一套能自动跑、能给出明确通过/不通过的东西。这就是评测门禁。
一、先有评测集,才谈得上门禁
门禁要能判「过不过」,得先有个基准:一批问题,加上每个问题期望召回哪几篇文章。这叫黄金问答集。
我这份评测集一共 24 条,刻意分成三类:
| 类型 | 条数 | 期望什么 |
|---|---|---|
| 事实检索 | 18 | 问某个技术点,期望召回指定的那篇(或那几篇) |
| 多跳 | 4 | 一题牵涉两个知识点,期望同时召回两篇 |
| 无答案拒绝 | 2 | 语料里没有的问题,期望礼貌拒答,不硬编 |
为什么要有后两类,而不是清一色的事实题?
多跳题考的是「关联」:如果检索只认字面相似,它能找到最像的那一篇,但找不到第二篇该一起出现的。这类题能把「词面匹配」和「语义检索」区分开。
无答案拒绝题考的是「克制」 :语料里没有答案时,模型最省事的做法是拿最相近的几篇硬凑一个回答。这类题盯的就是这个行为:期望它引用为空、明确说没找到,而不是硬编。
这些题从哪来? 问题不用凭空编,日常用户问过、答得不好的那些,攒起来就是最好的素材,因为它们是真实分布,不是拍脑袋想的。真正花时间的是第二列:期望召回哪几篇,得有人先读过语料才标得出来。这一步没有捷径,标注量就是集子的成本。也正因为贵,起步不需要多,几十条能覆盖住主要场景就够。
二、四项指标,各管一件事
门禁盯四个指标,每一项都对着一个真实的翻车方式:
| 指标 | 含义 | 盯着什么问题 |
|---|---|---|
avgRecall@5 |
期望文章进入前 5 名的比例 | 该召回的没召回到 |
avgCitedPrecision |
引用的文章里真属期望的占比 | 引了不相关的文章 |
avgUnsupportedRate |
判为疑似无资料支撑的句子占比 | 答案里编了资料没说的话 |
refusalRate |
无答案题里引用为空的比例 | 没资料还硬编 |
这四项的分母不一样,这正是三类题不能混着看的原因:avgRecall@5 在有效题 上取平均(事实题加多跳题,一题若有多个期望文章,按命中比例算);avgCitedPrecision 和 avgUnsupportedRate 只在有期望文章的题 上算;refusalRate 则只统计无答案题。所以每类题只能用能体现它的那项指标来衡量,没有哪个数字是「总体分」。
前两项来自检索侧和引用侧:一个是「该找的找到了吗」,一个是「找的东西对不对」。
第三项要单独说。它衡量的是未溯源率:答案里的每一句,能不能在引用的资料里找到支撑。这句判定在生成时其实已经算过一遍(忠实度校验),评测直接复用它,不额外跑一次调用。
第四项的判定很直白:答案有内容、但引用为空,就算拒绝成功;引了一堆文章硬凑,就算失败。
这四项最后会汇总成一张检查表,每项一行:指标名、实测值、阈值、比较方向、是否通过。
三、阈值从哪来
阈值走配置,代码里只留一份默认值:
yaml
eval.gate:
min-avg-recall: 60 # avgRecall@5 >= 60
min-cited-precision: 50 # avgCitedPrecision >= 50
max-unsupported-rate: 30 # avgUnsupportedRate <= 30
min-refusal-rate: 50 # refusalRate >= 50,负值则禁用该项
min-cases: 10 # 召回有效题数下限
四个指标分两类方向:下限类 (召回率、引用精确率、拒绝率,value >= threshold)和上限类 (未溯源率,value <= threshold)。最后一行 min-cases 不是指标,是题数下限,后面单独讲。
边界含等号:实测值正好等于阈值,算通过。这条不是随手定的:含糊的边界会在「刚好卡线」时让人反复怀疑判定逻辑,不如写死。
调阈值这件事我的态度是:不追求「科学」,只追求「能挡住明显回退」。 召回率掉到 45,不用算统计显著性,就是明显退步了。
四、fail-closed:跑不出来,就是不通过
这条是整个机制里我最坚持的。
门禁的判定结果只有两种:通过、不通过。但评测本身可能跑不出来:向量服务挂了、模型不可用了、评测集文件读不到了。这时候按哪种?
最省事的做法是「跑不出来就当通过」,毕竟门禁不该因为基础设施抖动卡住流程。但这么写,门禁就废了:只要向量服务一挂,所有改动都会自动通过。 挡不住回退的门禁,不如没有。
所以判定方向是反的:报告缺失、报告里带 error、召回有效题数不足,一律按 0 分判定,直接不通过。
java
// 报告无效 → 按 0 分并强制 fail(fail-closed)
boolean recallOk = recall != null && !recall.containsKey("error");
int recallCases = recallOk ? numInt(recall.get("cases"), 0) : 0;
double avgRecall5 = recallOk ? numDouble(recall.get("avgRecall@5"), 0d) : 0d;
// recallEnough 为假时,这一项一律 fail,并在 detail 里写清楚为什么
失败原因还要写进报告明细,不能只给一个「不通过」:是报告缺失、带 error,还是题数不够,得让人一眼看出来该去修哪个。
fail-closed 有一处例外,要单独说:评测集里根本没有 某一类题时(比如压根没写无答案条目,refusalCases = 0),对应那一项跳过、视为通过。
这不是自相矛盾。fail-closed 管的是「该测却没测成」,那是一次故障;这里管的是「这类样本压根没打算测」,那是评测集的覆盖范围。两者不该一视同仁:门禁只能约束存在的样本,硬判它失败,等于逼所有人先往集子里塞几道无答案题,才有资格过门禁。
而且这个跳过是带了标记的 :结果里带 skipped: true,和「真的通过了」在报告里长得不一样。哪天你发现 refusalRate 一直是 skipped,就说明集子里缺这类题,该补了。
五、题数下限:一条题都没跑,不能算通过
评测集加载时会先过一道筛子:问题为空的、或者没写期望文章的题,直接跳过,不计入有效题数。所以「加载成功」不等于「真的跑到了题」。那批期望文章一旦被误清空(改格式、批量替换 ID 时很容易发生),有效题数可能就是 0。
这时候召回率会被算成 0,恰好低于阈值,看起来像一次正常的判失败,其实是巧合:它靠的是「默认分一定低于阈值」这个假设。阈值一调低、或者空集换成了别的默认值,一个什么都没测的评测就会被判通过。
所以加了一道显式的闸:有效题数低于 min-cases,直接判失败。它挡的不是分数低,是根本没跑。
六、离线评测必须和线上走同一套召回
这一条决定了整套评测有没有意义。
评测算的是 recall@k:期望的文章有没有进前 k 名。这个名次从哪来?从召回服务来。
如果评测自己另起一套召回(只用向量、或只用关键词),那算出来的 recall@k 反映的是那套实现,跟线上实际用的那套没关系。线上是向量和关键词两路召回、各自排名后用 RRF 融合(把两路的名次取倒数相加再重排),评测要是只取一路,两个数就失去了可比性。 改完线上看评测分涨了,也说明不了什么。
所以离线评测直接复用线上那个召回入口,连参数都一致:
java
// 与线上 RAG 同口径:向量与关键词两路召回,各自排名后 RRF 融合
Recall result = recallService.recall(q, emb, TOP_KS[TOP_KS.length - 1]);
// 按融合排名的顺序取前 k,与 golden 求交 ------ 顺序错了 recall@k 就没意义
这里还有个细节:取前 k 名必须严格按召回服务返回的排名顺序切。如果先把候选塞进一个无序集合再算命中,算出来的其实是 recall@all,「@k」这个限制就丢了。所以代码里按下标遍历:
java
for (int i = 0; i < recalled.size() && i < k; i++) {
if (golden.contains(recalled.get(i))) {
cnt++;
}
}
边界
- 门禁接到哪一步,是使用方式的选择。 做成一个手动调的接口,改完自己跑一次,成本最低;接进 CI 定时跑、不过就拦住合并,约束力更强,但要先接受它每轮几分钟的开销。手动跑最大的风险是「忘了跑」,改动照样能合进去。
- 24 条是起点,不是终点。 样本量小,个别题波动就会明显影响均值。让评测集长大的常见做法,是把线上收到的问题反馈沉淀成候选问题,人工补上期望文章后并入。
- 期望文章一旦从语料里消失,那几道题会永久失分。 期望是绑在具体文章上的,文章下架或被替换后,对应的题再也不可能召回成功,得分会莫名下跌。看到某几道题整题归零,先去查语料,别急着当成质量回退。
- 判断「拒答成功」的规则是简化过的。 现在只看引用是否为空,一个引用了资料但仍说「没找到」的答案会被算作失败。真要细分,得让判定同时看引用和措辞。