没有评估,所有调优都是玄学

没有评估的 RAG 调优都是赌博

前四篇番外讲了 embedding 选型、切分、向量库、混合检索、reranker——任意一个环节都有十几种选择。换 chunk_size 从 400 到 600、加上 reranker、把 BM25 权重从 0.5 调到 0.7——你怎么知道这次改动让系统变好了还是变差了?

靠"试一两个 query 看看"是不行的。RAG 系统的失败模式很微妙——可能 90 个常见问题都 OK,但 10 个边角问题召回完全错;可能召回都对了但 LLM 回答时编造细节;可能中文 query 上工作、遇到英文 query 就崩。这些"局部翻车"靠点开几个 case 看根本发现不了,必须靠系统化评估才能暴露。

而 RAG 是 检索 + 生成 两段管线,评估也得分两层——这是这一篇的核心结构。

检索质量:从"我想问什么"反推用什么指标

检索这一层评估的核心问题是:你召回的 top-k 文档里,相关的有几篇?相关的排得够前吗? 看起来一句话讲完了,但不同业务关心的具体角度不一样,对应的指标也不同。

最简单的问题是 "top-k 里有没有任何一篇相关的?"——这就是 Hit Rate @ k。它最粗,但当你的下游 LLM 只需要"找到一篇"就能回答时(比如直接给一个 FAQ 答案),这个指标就够了:

def hit_rate_at_k(retrieved: list[str], relevant: set[str], k: int) -> int:
    return int(any(doc_id in relevant for doc_id in retrieved[:k]))

如果你关心的是**"该召回的有没有都召回"**——比如一个问题需要综合多篇文档才能答好——那要看 Recall @ k:top-k 里相关文档的占比(相对于全部相关文档)。这是多数 RAG 工程里的主指标,因为你只能塞有限几个 chunk 给 LLM,希望相关的尽量都在这几个里:

def recall_at_k(retrieved: list[str], relevant: set[str], k: int) -> float:
    if not relevant:
        return 0.0
    hits = sum(1 for d in retrieved[:k] if d in relevant)
    return hits / len(relevant)

但 Recall@10 这种指标只看"在不在 top-10",不看排第几位。如果你关心**"第一篇相关文档排得有多前"**——比如用户只看第一条、或者你只塞 top-3 给 LLM——那要看 MRR(Mean Reciprocal Rank):相关文档第一次出现位置的倒数。第 1 位是 1.0、第 2 位是 0.5、第 5 位是 0.2,越靠前分数越高:

def reciprocal_rank(retrieved: list[str], relevant: set[str]) -> float:
    for rank, doc_id in enumerate(retrieved, start=1):
        if doc_id in relevant:
            return 1.0 / rank
    return 0.0

最严格的问题是**"既要靠前、还要分级(很相关比有点相关分高)"**——这就是 NDCG @ k(Normalized Discounted Cumulative Gain)。它要求的标注更精细——不是"相关 / 无关"二元判断,而是 0/1/2/3 这种分级。学术 IR 评测的金标准,但工程业务里很少做到这么细的标注:

import numpy as np

def dcg(relevances: list[float]) -> float:
    return sum(rel / np.log2(i + 2) for i, rel in enumerate(relevances))

def ndcg_at_k(retrieved_ids: list[str], relevance_scores: dict[str, float], k: int) -> float:
    actual = [relevance_scores.get(d, 0.0) for d in retrieved_ids[:k]]
    ideal = sorted(relevance_scores.values(), reverse=True)[:k]
    if not ideal or sum(ideal) == 0:
        return 0.0
    return dcg(actual) / dcg(ideal)

实战起手用 Recall@10 + MRR 两个指标够了——前者抓"该来的有没有来",后者抓"来的够不够靠前",两者一起就能覆盖 90% 的检索调优信号。NDCG 等到你需要做学术对比或细粒度排序优化时再加。

评测集怎么造:从最快到最准的三条路

评测集是 RAG 工程里最容易被忽视的部分——很多人觉得"造一份评测集太麻烦",结果调系统时全靠感觉。其实最起码的评测集只要 200 条就有用,造法上也有从快到慢的明确选择。

最快的路是 LLM 自动生成——让 LLM 反过来从你的文档生成 query。给 LLM 一段 chunk,让它生成"针对这段内容用户可能会问什么"——这样自动得到(query,相关 chunk)对,每条 chunk 配 2~3 个问题。用 GPT-4 / Claude 来做,质量已经够用,生成 1000 对评测数据成本不到 5 美金。这是绝大多数项目应该选的起手式:

from openai import OpenAI
client = OpenAI()

PROMPT = """以下是一段文档内容。请生成 3 个一个用户可能会问的、能从这段文档中找到答案的问题。
要求每个问题都是自然语言、避免太宽泛、避免直接复述原文。

文档:
{content}

返回 JSON 格式:{{"queries": ["问题1", "问题2", "问题3"]}}"""

def synthesize_queries(chunk_id: str, content: str) -> list[dict]:
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": PROMPT.format(content=content)}],
        response_format={"type": "json_object"},
    )
    queries = json.loads(resp.choices[0].message.content)["queries"]
    return [{"query": q, "relevant_chunk_ids": [chunk_id]} for q in queries]

合成评测的局限是它的 query 是 LLM "猜"用户会问什么,和真实用户的口吻常常有偏差。所以下一步该做的是 真实用户日志 + 弱标注——产品上线后,把用户真实 query + 用户最终点击 / 复制的回答关联回到的 chunk 作为弱信号。这种数据规模大、便宜,但噪声多(用户点的不一定是最相关的);适合作为合成评测的补充。

最准但最贵的是 人工标注——从真实用户日志里抽 100~200 条有代表性的 query,让业务方 / 客服 / 标注员标出每个 query 真正应该召回的文档。这是金标准,但成本高,主要用作"复核"——抽 50 条对合成或日志评测进行人工 sanity check,看自动指标和人的判断是否一致。

实战流程:起手 200~500 条 LLM 合成做 baseline,上线后逐步用真实日志补充,每月人工抽检 50 条做校准。最重要的一条经验:完全不要等"理想评测集"做完才开始评估——一个不完美的评测集也比没有强 100 倍。

回答质量:把 RAG 失败模式拆成四个独立问题

检索这一层调好之后,下一个问题是"LLM 基于这些 chunk 给出的最终答案,究竟有没有问题?"。这一层评估有意思的地方在于——RAG 答案翻车的方式不止一种,每种翻车方式对应一个独立指标,分别看才能定位问题。

最常见的翻车是**"LLM 编造了文档里没有的事实"**——典型的幻觉。对应的指标叫 Faithfulness(忠实度):把答案拆成一组"原子事实声明",逐条检查每个声明是否能在召回的 context 里找到依据。Faithfulness 低意味着 LLM 在编。

第二种翻车是**"答案没有真正回答用户的问题"**——比如用户问"密码怎么重置",LLM 答了一通"什么是密码 / 为什么密码很重要"。这看起来没编造,但答非所问。对应指标是 Answer Relevance(答案相关性)——评的是答案和问题的语义对应程度。

第三种翻车比较隐蔽:"召回的 chunk 里虽然有相关信息,但夹杂了大量无关内容,LLM 被噪声带偏"。这时候 Faithfulness 可能也高(每句都能找到出处)、Answer Relevance 也凑合,但答案就是不对。对应指标是 Context Relevance(上下文相关性)——评的是召回的 context 里有多少比例是真正相关的。这个指标和检索质量层有些重叠,但它考察的是 "进了 LLM 的那几条" 而不是 "向量库里挑出来的那几十条"。

最后一种是**"召回的 chunk 不够覆盖问题需要的信息"**——比如问题需要 A、B、C 三个事实才能完整回答,召回的 context 里只有 A 和 B。这时候 LLM 要么答不全、要么编 C。对应指标是 Context Recall——从"参考答案"反推:标准答案里需要的信息,召回的 context 是否都覆盖了?

把这四个指标对应到失败模式上:

LLM 编造事实           → Faithfulness 低
答非所问               → Answer Relevance 低
被噪声带偏             → Context Relevance 低
召回不够导致回答不全   → Context Recall 低

前三个合起来叫 RAG Triad(Ragas 论文里的术语),加上 Context Recall 就是 RAG 评估的标准四指标。Ragas 库把它们都实现了。

Ragas:RAG 评估的事实标准工具

Ragas 是当前 Python 生态里最成熟的 RAG 评估库,基本所有指标都自动算(用 LLM-as-judge):

pip install ragas datasets
from ragas import evaluate
from ragas.metrics import (
    faithfulness,
    answer_relevancy,
    context_precision,
    context_recall,
)
from datasets import Dataset

# 准备评测样本
samples = {
    "question": ["如何重置密码?", "支付失败怎么办?"],
    "contexts": [
        ["重置密码的步骤是登录后...", "..."],   # 召回的 chunk 列表
        ["支付失败的常见原因...", "..."],
    ],
    "answer": [
        "你可以登录后在设置里点击'重置密码'...",  # LLM 生成的回答
        "支付失败一般是网络问题或卡余额不足...",
    ],
    "ground_truth": [                            # 参考答案(可选,给 context_recall 用)
        "登录系统 → 设置 → 安全 → 重置密码",
        "检查网络连接 → 检查卡余额 → 联系银行",
    ],
}
dataset = Dataset.from_dict(samples)

result = evaluate(
    dataset,
    metrics=[faithfulness, answer_relevancy, context_precision, context_recall],
)
print(result)
# {'faithfulness': 0.85, 'answer_relevancy': 0.91, 'context_precision': 0.78, 'context_recall': 0.83}

每个指标都是 0~1 之间,越大越好。

Ragas 内部用 LLM 来判断这些事——比如算 faithfulness 时,它先把答案拆成一组"原子事实声明",再让 LLM 判断每个声明是否在 context 里找得到依据。整个评测过程会调几十次 LLM API,所以对评测集大小有上限——评 100 条样本可能花 0.5~1 美金(GPT-4o-mini)。

LLM-as-judge 的几个坑

Ragas 这种"用 LLM 评估 LLM"的做法叫 LLM-as-judge。它解决了"自动算指标"的问题,但有几个实战必须知道的坑

评判用的 LLM 要比被评估的 LLM 至少强一档。让 GPT-4 评 GPT-3.5 的输出靠谱;反过来不行。强弱差太多评判会带偏。

LLM 有 position bias:让 LLM 比较两个答案 A 和 B,A 在前面通常更容易被评高。规避办法是同一对答案评两次——一次 A 在前、一次 B 在前——结果一致才采信。

LLM 对细节敏感度低。涉及具体数字、代码细节的对错,LLM 经常会"觉得差不多"。这种场景必须用基于正则 / exact match 的程序化评估。

评判结果有方差。同一对答案 LLM 评 5 次可能给出 0.6、0.8、0.7、0.7、0.9 不同的分。生产评估要么固定 seed、要么取多次平均。

实战建议:LLM-as-judge 适合做大规模快速筛查、不适合做最终决策。重要的发布前评估,最后还是得人工抽检几十条样本拍板。

别忘了"传统"指标:延迟、成本、稳定性

RAG 不是只有质量指标。生产系统还要看几个朴素的工程指标:P50 / P99 延迟(检索 + LLM 总耗时,加 reranker 通常会让延迟翻倍)、每次 query 的成本(embedding API + LLM API + 向量库查询加起来)、缓存命中率(embedding 缓存和答案缓存都重要,命中率上去成本能降一半)、以及OOD 拒答率(用户问了一个完全不在文档范围内的问题,系统是说"我不知道"还是硬编一个答案?后者就是幻觉)。

这些指标不需要 LLM-as-judge,传统监控就够用,但它们和质量指标同等重要。延迟从 2 秒涨到 10 秒、质量只提升 5%——这种"改进"在生产里几乎肯定不值得。评估系统的时候必须把质量和工程指标一起看,否则会做出错误优化。

把评估接进 CI:让评估变成基础设施

到这里所有指标都讲完了,但最容易被忽略的一步反而是把评估自动化跑起来。手动跑评估的团队,几个月后大概率不再跑——评测脚本会变得过时、指标会变得没人看、改动是否有 regression 完全靠运气。要避免这个命运,把评估当成 CI 基础设施来对待。

按运行频率分三档配置:

每次 commit 跑的最小评测(smoke test)。20~30 条最常见的 query,断言答案里必须包含特定关键词或不能包含特定错误。这一档要快——一两分钟内跑完,否则没人愿意每个 PR 都等。一旦 fail,新代码就不能合:

# tests/test_rag_smoke.py
import pytest
from your_rag import answer

@pytest.mark.parametrize("query,must_contain", load_smoke_set())
def test_smoke(query: str, must_contain: list[str]):
    response = answer(query)
    for keyword in must_contain:
        assert keyword in response, f"'{keyword}' missing in answer to '{query}'"

每天 nightly 跑的完整评测(500~1000 条)。这一档跑完整的 Ragas 指标和检索指标,结果存表,可以画质量随时间变化的趋势图。当某天指标突然回退,你能从 commit 历史定位到是哪个改动引入的问题。

人工触发的 A/B 评测脚本。当你想测"加 reranker 是否值得"、"换 chunk_size 是否好"这种具体决策时,对同一个评测集跑新旧两版配置,输出每个指标的 delta:

def compare_configs(eval_set, config_a: dict, config_b: dict):
    metrics_a = run_evaluation(eval_set, **config_a)
    metrics_b = run_evaluation(eval_set, **config_b)
    for metric in ["recall@10", "mrr", "faithfulness"]:
        delta = metrics_b[metric] - metrics_a[metric]
        print(f"{metric}: {metrics_a[metric]:.3f}{metrics_b[metric]:.3f}  ({delta:+.3f})")

这三档加起来,每次改动都能用具体数字说话——而不是"感觉好像变好了"。这是 RAG 工程从手工艺到真正工程化的分界线。

新搭一个 RAG 时这套评估的 minimum viable 配置是:20 条 smoke test 跑每个 commit、200 条 LLM 合成评测跑每天 nightly、50 条人工标注样本每月抽检校准、再加上真实用户行为埋点(用户是否复制答案、是否追问同一问题——追问意味着第一次没答好)。这套搭起来不需要一周时间,但能把后续半年的所有调优变成有数据支撑的决策。

收尾

把这五篇番外(11~15)放在一起看,就是 RAG 工程的完整地图——番外 11 给出 NLP 演化的历史坐标让你理解 LLM 来自哪里,番外 12 讲 embedding 模型选型这道 RAG 第一生死关,番外 13 讲文本切分这道第二生死关,番外 14 把向量库、混合检索、reranker 串成召回栈,番外 15 把所有调优变成可量化的工程动作。读完这五篇你应该已经具备从零搭一个生产级 RAG 系统的全部知识。

但坦白说——真正的功夫不在博客里,而在你自己的业务数据上反复跑评测、看 case、调参数。这五篇能给你工具箱、概念地图、和一份有依据的 default 选择,但具体到你的业务里 chunk_size = 400 还是 600、bge-m3 还是要换、加 reranker 是否值得——这些只能在真实数据上磨出来。所以五篇里讲的所有"建议"都附带一个隐含的前提:先把评估搭起来,再用评估的结果指导每一个决定。否则就是在凭感觉调参——这条路上的人通常半年后还在原地。

下一篇我们回到主线——第 06 篇 RAG 实战,把这五篇番外里讲的所有零件用一段能跑的代码串起来,搭一个真正能回答关于一堆 markdown 文档的 RAG。

参考资料

版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。

(采用 CC BY-NC-SA 4.0 许可协议进行授权)

本文标题:番外 15:RAG 评估方法,把感觉变成数字

本文链接:https://www.sshipanoo.com/blog/ai/ai-for-python/番外15-RAG评估方法/

本文最后一次更新为 天前,文章中的某些内容可能已过时!