"换了个 embedding,感觉回答变好了"——这种判断的方差常常比改动本身带来的收益还大。RAG 的效果不是单一数字,它由两段决定:检索有没有把对的材料捞上来,生成有没有照着材料说。这两段的失败表现不同、修法不同,评测也必须分开做。
1. 为什么必须拆成两层
| 层 | 输入 | 输出 | 典型失败 |
|---|---|---|---|
| 检索 | 问题 | top-k 材料 | 该在的没捞到、捞到一堆无关的 |
| 生成 | 问题 + 材料 | 答案 | 有材料也答错、无材料时编造 |
拆开的原因很直接:同一个坏答案,可能来自任一层的任一环节。不拆,就只能靠换模型碰运气;拆了,才能判断"该修索引还是该修提示"。
| 只测总体 | 分层测 |
|---|---|
| 知道变差,不知道差在哪 | 定位到召回、切分、拼装或生成 |
| 换模型成了唯一手段 | 可以只改一层做单变量对比 |
| 改动收益无法归因 | 每层的涨跌各自可解释 |
2. 检索侧的三个指标
命中率 Hit@k = 有至少一条相关文档进入 top-k 的问题数 / 总问题数
召回率 Recall@k = |相关 ∩ top-k| / |全部相关| (每题标注全部相关文档时可用)
MRR = 每题「第一条相关文档的排名」取倒数后求平均
| 指标 | 回答什么 | 敏感于 | 不敏感于 |
|---|---|---|---|
Hit@k |
"有没有捞到" | k 的大小 | 相关文档排在第几位 |
Recall@k |
"捞到了多少" | 相关文档的数量 | 排序质量 |
MRR |
"第一条相关的排多前" | 首条相关的位置 | 第二条之后的相关文档 |
入门阶段先算 Hit@k:它最直观、标注成本最低(只需标"哪几条算相关",不必标全部)。等标注能力上来,再补 Recall@k 与 MRR。
3. 生成侧的四个判定项
| 判定项 | 问法 | 不通过的典型表现 |
|---|---|---|
| 正确性 | 结论与材料是否一致 | 数字对不上、条件漏了 |
| 完整性 | 是否回答了问题的全部要点 | 只答一半、漏了例外条款 |
| 是否编造 | 有没有材料里没有的事实 | 凭空补流程、补人名日期 |
| 是否该说不知道 | 材料里没有时是否明确说没有 | 硬答、或用通用知识糊过去 |
第四项最容易被忽略:"问题超出资料范围"时,正确行为是拒答或声明无依据,而不是给一个听起来合理的答案。评测集里必须有这类题,否则系统会一直在"编造也算对"的口径下被评高分。
4. 评测集怎么造
eval/questions.jsonl (每行一题)
{"id":"q01","q":"试用期多久可以转正","type":"in_scope",
"acceptable":["三个月","3 个月"],"relevant_docs":["hr-001#3"]}
{"id":"q02","q":"年假可以折现吗","type":"out_of_scope",
"acceptable":["没有相关规定","资料中未提及"],"relevant_docs":[]}
| 原则 | 做法 | 反例 |
|---|---|---|
| 从真实问法来 | 客服记录、群聊、搜索日志里抄 | 自己按文档标题出题 |
| 含超出范围的问题 | 至少 20% 是资料里没有的 | 全是能答的题 |
| 每题标可接受答案 | 列几种表述,不写唯一标准句 | 只写一句"标准答案" |
| 一题一义 | 一题只问一件事 | 一句话里塞三个问题 |
| 标注相关文档 | 检索层才有对照 | 只标答案不标出处 |
规模上,几十条即可起步(20–50 条),关键是覆盖问题类型而不是堆数量。一条标得准的题胜过十条含糊的题。
5. LLM 判分的三档提示与偏差
| 档位 | 输出 | 优点 | 偏差来源 |
|---|---|---|---|
| 打分制(1–5) | 分数 | 区分度细 | 分数刻度不稳定,跨批次不可比 |
| 通过/不通过 | 布尔 | 稳定、易聚合 | 边界样本反复横跳 |
| 对比二选一 | A/B/平 | 对相对好坏最敏感 | 有位置偏好,需交换顺序各测一次 |
你是评分员。只依据【资料】判断【答案】,不要用自己的知识补充。
资料:{{context}}
问题:{{question}}
答案:{{answer}}
按顺序判断:
1. 答案的结论是否都能在资料中找到依据?
2. 是否遗漏了问题要求的要点?
3. 资料不足以回答时,答案是否明确说明"资料中未提及"?
只输出一行 JSON:
{"grounded": true|false, "complete": true|false, "should_abstain": true|false, "reason": "一句话"}
三条控制偏差的做法:固定提示与模型版本、要求给理由(理由比分数更可核查)、抽样人工复核(10%–20% 即可发现系统性偏差)。对比档务必交换 A/B 顺序跑两遍,位置偏好是稳定存在的。
6. 标注的最小流程
- 先标 20–30 条,两个人独立标;
- 算一致率,逐条看分歧;
- 分歧集中在哪类问题,就细化那类的判定标准;
- 标准写清楚后再扩大规模。
| 阶段 | 规模 | 目的 |
|---|---|---|
| 试标 | 20–30 条 | 暴露标准歧义 |
| 定标准 | — | 把分歧写成判定条目 |
| 扩标 | 50–100 条 | 建立稳定基线 |
| 维护 | 每次改动加几条 | 覆盖新出现的问题类型 |
一致率不必追高,关键是分歧能被解释。两人对"是否编造"判断不同,往往说明判定标准里缺了"允许改写还是必须逐字引用"这一条。
7. badcase 分类
| 类别 | 表现 | 该改哪里 |
|---|---|---|
| 召回缺失 | top-k 里根本没有对的材料 | 切分、embedding、检索参数、同义词 |
| 切分破坏语义 | 答案被切在两块之间 | 分块大小与重叠、按结构切 |
| 拼装丢上下文 | 材料在,但提示里被截断或顺序混乱 | 上下文预算、去重、排序 |
| 模型忽略资料 | 材料齐全,答案仍跑偏 | 提示约束、引用要求、换模型 |
| 问了资料外的问题 | 系统硬答 | 拒答策略、范围判定 |
| 表述歧义 | 问题本身有多种理解 | 查询改写、澄清式追问 |
分类的价值在于它能直接指向改动。同样是"答错",落在第一类和第四类的修法完全不同——这也是必须分层评测的原因。
8. 改动前后对比与线上信号
对比的三条纪律:同一评测集、单变量、重复多次。
| 纪律 | 含义 | 违反后果 |
|---|---|---|
| 同一评测集 | 基线版与改动版跑同一份题 | 涨跌无法归因 |
| 单变量 | 一次只改一个因素 | 不知道是谁的功劳 |
| 重复多次 | 温度非零时同一题跑多次取分布 | 单次波动被当成改进 |
线上信号作为补充,覆盖评测集到不了的部分:
| 信号 | 含义 | 高了说明什么 |
|---|---|---|
| 无结果率 | 检索没返回任何内容 | 索引缺失或查询改写失败 |
| 追问率 | 用户马上换个说法再问 | 第一次答偏或答不全 |
| 人工介入率 | 转人工 | 置信度低或问题超范围 |
线上信号是方向指示,不是评测结论:追问率高可能是答得差,也可能是产品引导设计问题。用它发现问题,用评测集确认原因。
9. 频率与成本
| 改动类型 | 评测规模 | 频率 |
|---|---|---|
| 提示词微调 | 全量评测集 | 每次改动 |
| 换模型 / 换 embedding | 全量 + 人工复核样本 | 每次改动 |
| 切分策略调整 | 全量(检索与生成都要跑) | 每次改动 |
| 仅在线上发现的问题 | 补题进评测集 | 发现即补 |
成本主要在人工标注与 LLM 判分调用。检索侧指标不需要 LLM,纯脚本即可;生成侧只跑当次评测集,不做历史全量回归。
10. 最小可跑示例
① 20 条评测集(eval/questions.jsonl,结构见第 4 节,先写 20 行)。
② 检索命中率脚本
# eval/hit_rate.py
import json, sys
K = 4
rows = [json.loads(l) for l in open("eval/questions.jsonl", encoding="utf-8")]
hits = total = 0
for r in rows:
if not r["relevant_docs"]:
continue # 超范围题不参与检索侧统计
total += 1
got = retrieve(r["q"], k=K) # 返回 doc_id 列表
if set(got) & set(r["relevant_docs"]):
hits += 1
print(f"Hit@{K} = {hits}/{total} = {hits/total:.2f}")
③ LLM 判分(提示见第 5 节,逐题调用并解析 JSON,汇总 grounded/complete 的通过率)。
④ 对比表
| 版本 | Hit@4 | 正确性通过率 | 编造率 |
|---|---|---|---|
| 基线 | 按实测填 | 按实测填 | 按实测填 |
| 改动后 | 按实测填 | 按实测填 | 按实测填 |
三列必须一起看:召回涨而编造率也涨,说明材料变多但约束变松了。
⑤ 结果存档
mkdir -p eval/runs/$(date +%F)-baseline
cp eval/questions.jsonl eval/runs/$(date +%F)-baseline/questions.snapshot.jsonl
python eval/hit_rate.py | tee eval/runs/$(date +%F)-baseline/hit.txt
评测集随改随变,存档时必须把当次使用的题目快照一起存,否则半个月后无法解释分数差异。
11. 排错
| 现象 | 根因 | 处理 |
|---|---|---|
| 分数很高但线上投诉多 | 评测集是照文档出的题 | 换成真实问法重出题 |
| 只改了提示,检索指标也涨了 | 对比时换了评测集 | 固定评测集重跑 |
| 同一题两次判分结论不同 | 温度高、判分提示模糊 | 降温、要求给理由、重复取多数 |
| 判分与人工判断系统性不一致 | 判分模型用了自己的知识 | 提示里明确"只依据资料" |
| 检索指标很好,答案仍差 | 拼装丢上下文或截断 | 单独测生成层,检查实际送进提示的内容 |
| 全部通过 | 题目太简单或可接受答案列太宽 | 加超范围题、收紧可接受答案 |
| 回归时找不到基线 | 没存版本与评测集快照 | 评测集与结果一起存档 |
12. 边界
| 场景 | 更合适的做法 | 理由 |
|---|---|---|
| 主观问答(风格、语气) | 人工小样本 + 对比二选一 | 自动判分对风格不敏感 |
| 需要精确数值核对 | 规则校验 + 单元测试 | LLM 判分不适合精确比对 |
| 冷启动、还没有评测集 | 先上线并埋线上信号 | 从真实问法里攒题比凭空造题准 |
一句话:评测集是测量工具,回答"这次改动相比基线是涨是跌",不回答"这个系统绝对有多好"。
13. 自测
- 为什么检索质量与生成质量必须分开评?
Hit@k与Recall@k的差别在哪,什么时候才需要后者?- 评测集里为什么必须有"资料里没有"的问题?
- LLM 判分的对比档为什么要交换 A/B 顺序各跑一遍?
- 一个答错的 badcase,怎么判断是召回缺失还是模型忽略资料?
- 改动前后对比的三条纪律分别防的是什么误差?