🐸 青蛙编程网
首页 / AI / 检索增强 RAG / 7. 评估与常见问题

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 动手改

  1. 给你的知识库写 20 条评测问题,跑出 Hit Rate@3 和 MRR 的基线
  2. 故意把 chunk_size 调到 100,再跑一次评测,对比两个指标
  3. 换 BM25 混合检索再跑一次,把三个指标记成一张表

7.10 小结

  • 评估分两层:检索层(召回没召回、排第几)+ 生成层(忠实度、正确率)
  • 20–50 条评测集就够用,但要包含关键词型和语义型两类问题
  • Hit Rate 看「找没找到」,MRR 看「排得靠不靠前」
  • 排查顺序永远是:先看召回列表,再看生成结果
  • 「LLM 当裁判」省事但会错,评测集要人工核对

下一章:MinerU 解析 PDF,先把「原料」这一环补上——检索不准往往是解析没做好 🐸

📥 下载本章代码

ZIP 内含本章全部代码文件与说明,解压即用,与教程内容同步生成。