7. 评估与常见问题
这是什么:RAG 评估 = 给检索效果和回答质量打分的一套办法,本章用命中率和 MRR 量检索,用忠实度查编造。
为什么用:RAG 是个说不清好坏的系统:答得不好时,你不知道该改切分、改检索,还是改提示词。没有度量就没有改进——这一章就是给你一把尺子。
如何用:本章先建一个小评测集,再算命中率与 MRR,最后给一张「症状 → 根因 → 先查什么」的排查表。
7.1 评估分两层
| 层次 | 测什么 | 关键指标 |
|---|---|---|
| 检索层 | 该找到的找到了吗? | 命中率 Hit Rate@k、MRR |
| 生成层 | 找到之后答得对吗? | 忠实度 faithfulness、答案正确率 |
先测检索层。检索没召回,后面全是白搭——这是排查问题的第一站。
7.2 先建一个小的评测集
不用多,20–50 条就够用。每条包含问题和你期望命中的来源:
EVAL_SET = [
{"question": "差旅住宿的报销上限是多少?", "expect": "handbook.md"},
{"question": "报销要在出差结束后多久提交?", "expect": "handbook.md"},
{"question": "发票丢失了怎么办?", "expect": "handbook.md"},
{"question": "年会在哪个城市举办?", "expect": "handbook.md"},
# …… 凑够 20 条以上
]
写评测集时别只写「简单问题」:一定要包含关键词型(编号、人名)和语义型(换句话说)两类,它们暴露的毛病完全不同。
7.3 命中率 Hit Rate@k
前 k 条结果里,有一条命中期望来源就算成功:
def hit_rate(ranker, eval_set, k=3):
"""ranker(question) -> [{'text':..., 'metadata': {...}}, ...]"""
hits = 0
for case in eval_set:
results = ranker(case["question"])[:k]
if any(case["expect"] in r["metadata"]["source"] for r in results):
hits += 1
return hits / len(eval_set)
hit_rate@3 = 0.85 表示 100 个问题里有 85 个在前 3 条里找对了。
7.4 MRR:排第几也算数
命中率高,但答案总排在第 3 位——对模型来说,第 1 位和第 3 位差别很大。MRR(平均倒数排名)把「排第几」也算了进去:
def mrr(ranker, eval_set, k=10):
"""正确答案排第 1 得 1 分,第 2 得 0.5 分,第 3 得 0.33 分……"""
total = 0.0
for case in eval_set:
results = ranker(case["question"])[:k]
for rank, r in enumerate(results, start=1):
if case["expect"] in r["metadata"]["source"]:
total += 1.0 / rank
break
return total / len(eval_set)
| 指标 | 含义 | 及格线(经验) |
|---|---|---|
| Hit Rate@3 | 前 3 条里有答案 | > 0.85 |
| MRR | 答案排得够靠前 | > 0.7 |
每次改一处,就跑一次评测集——这是 RAG 调优唯一靠谱的方法。凭感觉改提示词,很快就会越改越差。
7.5 忠实度:答案是不是编的
检索对了,答案也可能超出资料瞎编。忠实度就是让另一个模型当裁判:
def is_faithful(question, context, answer, client, chat_model):
resp = client.chat.completions.create(
model=chat_model,
messages=[{"role": "user", "content":
"判断【答案】里的每一句是否都能在【资料】中找到依据。"
"只要有一句没有依据就回答 NO,否则回答 YES。只输出 YES 或 NO。\n\n"
f"资料:\n{context}\n\n答案:\n{answer}"}],
)
return resp.choices[0].message.content.strip().upper().startswith("Y")
在评测集上再记两个数:
| 指标 | 含义 |
|---|---|
| 忠实度 | 答案中每句话都能在资料里找到依据的比例 |
| 拒答正确率 | 资料里没有答案时,模型是否老实说「资料中未提及」 |
忠实度低 = 模型在编。改法:提示词写死「只根据资料回答」、把
temperature设为 0、给答案加出处标注。
7.6 端到端评估
检索指标之外,还可以直接问「答案对不对」——用参考答案做对照:
def answer_correct(question, gold, answer, client, chat_model):
resp = client.chat.completions.create(
model=chat_model,
messages=[{"role": "user", "content":
"判断【答案】与【标准答案】是否表达了相同的信息,只输出 YES 或 NO。\n\n"
f"标准答案:{gold}\n答案:{answer}"}],
)
return resp.choices[0].message.content.strip().upper().startswith("Y")
这套「LLM 当裁判」的做法通用、省事,但裁判模型本身也会犯错——所以评测集要人工核对一遍,别全信自动打分。
7.7 症状 → 根因 → 先查什么
排查永远从检索层开始:
| 症状 | 最可能的根因 | 先查什么 |
|---|---|---|
| 召回列表里根本没有正确内容 | 切分太碎 / 嵌入模型不合适 | 打印召回的原文,人眼扫一遍 |
| 召回了,但排在很后面 | 检索排序差 | 换混合检索、加 BM25 |
| 召回了但内容被截断 | 块被切断 | 加大 chunk_size 和 overlap |
| 答案里有资料没有的内容 | 幻觉 | 提示词加强 + temperature=0 |
| 每次回答都不一样 | 温度高 / 资料太少 | 固定温度、增加 top_k |
| 复杂问题答不准 | 一次只搜一次 | 查询改写 + 多路检索融合 |
| 慢 | 重排模型 + 多次 API 调用 | 缓存嵌入、减候选数量 |
| 贵 | 上下文太长 | 减 top_k、精简每块长度 |
7.8 成本与延迟
| 环节 | 成本大头 | 省钱的办法 |
|---|---|---|
| 建库嵌入 | 一次性 | 不用管 |
| 查询嵌入 | 每次 1 次调用 | 缓存相同问题的向量 |
| 重排 | 本地算力 | 减少候选数量,或只在关键场景开 |
| 生成 | top_k × 块长 × 单价 |
用小模型生成、压 top_k |
嵌入向量务必缓存:同一问题重复问,不该重复花钱算嵌入。
7.9 动手改
- 给你的知识库写 20 条评测问题,跑出 Hit Rate@3 和 MRR 的基线
- 故意把
chunk_size调到 100,再跑一次评测,对比两个指标 - 换 BM25 混合检索再跑一次,把三个指标记成一张表
7.10 小结
- 评估分两层:检索层(召回没召回、排第几)+ 生成层(忠实度、正确率)
- 20–50 条评测集就够用,但要包含关键词型和语义型两类问题
- Hit Rate 看「找没找到」,MRR 看「排得靠不靠前」
- 排查顺序永远是:先看召回列表,再看生成结果
- 「LLM 当裁判」省事但会错,评测集要人工核对
下一章:MinerU 解析 PDF,先把「原料」这一环补上——检索不准往往是解析没做好 🐸