从意图到检索:指标拆解
上一篇讲了 runner 的双接口聚合——一条 query 跑两个接口,SSE 拿答案和 TTFT,JSON 拿检索证据和意图分类,合并成 EvalRecord 落进 runs/*.jsonl。20 条样本跑完,20 行 JSON 静静躺在那里。
打开 JSONL 随便看一行,里面有 intent_pred 等于 S14_退换货咨询、retrieved_doc_ids 包含 POLICY_RETURN_002 和 FAQ_RET_001、reference_doc_ids 包含 POLICY_RETURN_002。这些字段怎么变成一个能量化好坏的分数?
评测体系有两套指标。这篇先讲自建指标里最核心的两组:意图分类准确率和检索质量。意图是链路最上游的 闸门——判错了后面全白干;检索是召回阶段的底线——文档都没找对,答案不可能对。这两组指标有个共同特点:不依赖 LLM,纯集合运算,秒级出结果,每次提交都能跑。
Part 1:意图分类——错了后面全白干
1. 为什么意图是最上游闸门
RAG 系统的处理链路是一条单向管道:意图识别 → 路由 → 检索 → 生成。意图是管道的第一个阀门,错了之后水流方向就偏了,后面每一步都在错误的方向上努力。
拿一个具体例子来说。用户问“买了两周的耳机能退吗”,正确意图是 S14_退换货咨询,系统应该去政策库里查退换货规则。但如果意图错判成了 S2_商品详情,系统会去商品库里检索耳机的产品参数——频响范围、蓝牙版本、续航时间——然后一本正经地回答一堆跟退货毫无关系的参数信息。
用户看到这个回答只会觉得:这系统是不是傻?
关键在于:RAGAS 的 5 个指标(faithfulness / answer_relevancy / answer_correctness / context_precision / context_recall)完全不评意图分类。它们只看给了什么上下文、答了 什么内容,不管路由对不对。意图错判但恰好检索到了沾边的内容,RAGAS 可能还会给个不错的分数——但用户体验已经崩了。
所以意图指标必须自建,而且要放在所有指标的最前面看。
2. Top-1 Accuracy 怎么算
2.1 先搞懂这个名字
Top-1 Accuracy 拆开看两个词:
- Top-1:只看系统给出的第一个(最有信心的)预测结果
- Accuracy:准确率,就是猜对了多少
合起来就是:系统最有信心的那个意图预测,猜对了多少比例。
为什么要强调 Top-1?因为系统有时候会对一个问题给出多个候选意图(比如复杂问题被拆成几个子问题,每个子问题各识别出一个意图)。Top-1 的意思是只拿排第一的那个来判对错,是最严格的标准。如果将来做 Top-3 Accuracy,就是看前三个预测里有没有包含正确答案——条件更宽松,分数自然更高。
代码里把这个指标命名为 intent_top1,和 Top-1 Accuracy 是同一个东西,只不过一个是概念名,一个是代码里的变量名。
2.2 怎么算
算法极其简单:逐条样本对答案,算命中比例。
假设有 10 条测试题,每条事先标注好了正确意图,让系统去核对,核对完逐条对答案:
| 题号 | 正确答案(人工标注) | 系统的预测 | 对了吗 |
|---|---|---|---|
| 1 | 退换货咨询 | 退换货咨询 | 对 |
| 2 | 商品详情 | 商品详情 | 对 |
| 3 | 退换货咨询 | 保修维修 | 错 |
| 4 | 闲聊 | 闲聊 | 对 |
| 5 | 选购推荐 | 对比选购 | 错 |
| 6 | 商品详情 | 商品详情 | 对 |
| 7 | 退换货咨询 | 退换货咨询 | 对 |
| 8 | 对比选购 | 对比选购 | 对 |
| 9 | 闲聊 | 闲聊 | 对 |
| 10 | 选购推荐 | 选购推荐 | 对 |
对了 8 条,总共 10 条:
intent_top1 = 8 / 10 = 80%
没有加权,没有部分得分——完全匹配就是对,不匹配就是错。
用公式写就是:
intent_top1 = count(intent_pred == intent_l2) / count(有标注 的样本)
两个字段分别来自 EvalRecord 的不同段:
intent_pred(系统预测值):来自/rag/eval接口返回的intentLeafIds,经过build_record取第一个非空值intent_l2(人工标注值):从评估集eval_set_v1.jsonl原样复制的正确答案
对齐方式就是字符串直接比较——intent_pred 和 intent_l2 都是 S14_退换货咨询 就算对,不一致就算错。
2.3 哪些样本参与计算
所有有 intent_l2 标注的样本都参与计算,不限 requires_rag。闲聊类(CHAT)和反馈类(FEEDBACK)样本虽然不走 RAG 检索,但它们同样需要正确的意图分类来路由——闲聊判成知识检索,系统会无意义地去查知识库,白白浪费算力和用户等待时间。
2.4 多子问题的取值
Ragent 处理复杂 query 时会做子问题拆分,每个子问题独立跑意图识别。EvalRecord 里的 intent_pred_all 记录了所有子问题的意图列表,intent_pred 取的是第一个非空值:
intent_pred = next((c for c in intent_codes if c), None)
绝大多数情况下只有一个子问题,intent_pred_all 长度为 1,intent_pred 就等于列表里唯一的那个值。多子问题的场景极少,目前评估集里的 query 都是单轮单意图的,不需要过多纠结这个细节。
3. 分层聚合:找最差的几个意图定向优化
总体准确率如果是 85%,意味着 20 条里有 3 条判错了。但这 3 条错在哪?是均匀分散在各个意图里,还是集中在某一两个意图上?
答案几乎总是后者——错分集中在少数几个容易混淆的意图里。所以指标不只看总体数字,还按二级意图(intent_l2)切片,每个意图单独算准确率。
拿到分层结果后,按准确率从低到高排序,直接找到最差的几个:
| 二级意图 | 准确率 | 命中 / 总数 | 典型错分方向 |
|---|---|---|---|
S3_对比选购 | 50.0% | 1 / 2 | 错分到 S1_选购推荐 |
S14_退换货咨询 | 66.7% | 2 / 3 | 错分到 S15_保修维修 |
以上数字仅为示意,实际结果取决于 ragent 的意图模型效果。
20 条样本量不大,单个意图可能只有两三条,一条判错准确率就掉到 50%。数字本身的置信度有限,但错分方向是有价值的——它告诉你哪两个意图之间的边界最模糊。后续扩充评估集时,优先补这些易混淆意图的样本,让数字更可靠。
两种典型错分模式反复出现:
跨意图相近词。S1_选购推荐 和 S3_对比选购 都跟购买决策相关,“3000 元手机推荐”容易在两者之间漂移——S1 是单品推荐,S3 是多品对比,边界本身就模糊。
上下文歧义。“能退吗”三个字脱离上下文,无法判断是退换货(S14)还是会员退订(S16)。评估集里的 query 是单轮的,这类歧义只能靠 query 本身的措辞来区分。
实操上的优化手段很直接:对最差的几个意图,调整意图树节点的描述文案让区分度更大,或者在意图分类 Prompt 里补 few-shot 示例,明确告诉模型这两类的区别。改完之后重跑一次评测,看这几个意图的准确率有没有上来——这就是评测驱动优化的基本循环。
4. 代码实现
intent.py 的核心实现只有十几行:
def compute(records: list[EvalRecord]) -> MetricResult:
"""意图分类 Top-1 准确率。"""
def _score(r: EvalRecord) -> float | None:
if not r.intent_l2:
return None # 没有标注的样本不参与计算
return 1.0 if r.intent_pred == r.intent_l2 else 0.0
overall, by_l1, by_l2, per_sample = slice_mean(records, _score)
return MetricResult(
name="intent_top1",
overall=overall,
by_intent_l1=by_l1,
by_intent_l2=by_l2,
per_sample=per_sample,
is_pct=True,
)
逻辑一目了然:逐样本判 intent_pred == intent_l2,命中给 1.0,不命中给 0.0,intent_l2 为空返回 None 表示跳过。 然后交给 slice_mean 做聚合。
slice_mean 是所有指标共用的分层聚合工具,接受一个评分函数和一个可选的过滤函数,返回四元组:总体均值、按一级意图分组的均值、按二级意图分组的均值、逐样本明细字典。意图指标和下面要讲的检索指标都用它来聚合,一套逻辑,两组指标。