第1840篇:RAG系统的评估体系设计——怎么用数据说清楚"我的系统到底好不好"
第1840篇:RAG系统的评估体系设计——怎么用数据说清楚"我的系统到底好不好"
适读人群:正在迭代优化RAG系统的工程师 | 阅读时长:约18分钟 | 核心价值:建立一套可量化、可重复执行的RAG评估体系,让优化有依据、有方向
有段时间,我们团队每次上线新版本之前都会做一轮"人工测评":几个人轮流问系统问题,感觉比之前好,就上;感觉差不多,就不上。
这套流程不是不可用,而是太依赖人的主观感受,可重复性差,而且随着问题量增大,人工测评会变得耗时到不可持续。
更麻烦的是:它无法告诉你"好在哪里,差在哪里"。你只能说"这次感觉比上次好",但说不清楚是检索变好了、还是生成变好了、还是只是碰巧测的几个问题恰好好答。
真正需要的是一套可量化的评估体系,能把RAG系统的质量分解成几个维度,每个维度有明确的指标,可以自动计算,可以跨版本横向比对。
这篇文章就来搭这套体系。
RAG评估的三个维度
RAG系统可以拆解成两个核心环节:检索和生成。评估也要分开来看:
三个维度要分开评估的原因是:当系统表现变差时,你需要知道是检索出了问题,还是生成出了问题,这样才能对症下药。
一个常见的反例:系统给出了错误答案,团队把原因归结为"模型理解不够好",换了个更贵的模型,结果发现还是同样的错误。实际上是检索没找到正确的文档,给了LLM错误的上下文,LLM生成的内容再好也是基于错误前提的。
检索质量评估
构建评估数据集
评估检索质量,首先需要一批"有正确答案的测试用例":每个查询,我们知道哪些文档是相关的(Ground Truth)。
构建方式有几种:
方式一:人工标注(精度最高,成本最高)
让领域专家标注:给定查询Q,在知识库里哪些文档段落是相关的?
适合做一次性的基准集构建,几百个样本已经足够。
方式二:用LLM生成(效率高,质量尚可)
让LLM扮演"测试用例生成器",对知识库里的每个文档段落,自动生成可能的查询问题:
@Service
public class EvaluationDatasetBuilder {
private final ChatClient chatClient;
public List<EvaluationCase> generateFromDocuments(List<Document> documents) {
List<EvaluationCase> cases = new ArrayList<>();
for (Document doc : documents) {
String prompt = """
以下是一段知识文档内容:
---
%s
---
请基于这段内容生成3个测试问题。
要求:
1. 问题的答案必须完全基于这段内容
2. 问题要有一定难度,不能过于表面
3. 用JSON格式输出,每个问题包含 question 字段
示例格式:
[
{"question": "..."},
{"question": "..."},
{"question": "..."}
]
""".formatted(doc.getText());
String response = chatClient.prompt()
.user(prompt)
.call()
.content();
List<GeneratedQuestion> questions = parseQuestions(response);
for (GeneratedQuestion q : questions) {
cases.add(EvaluationCase.builder()
.query(q.getQuestion())
.expectedDocIds(Collections.singletonList(doc.getId()))
.build());
}
}
return cases;
}
}方式三:从用户真实日志里采样(最贴近实际,需要积累)
生产系统运行一段时间之后,从用户查询日志里采样,人工标注正确答案。这类数据最贴近实际用户需求,评估结果最有参考价值。
计算检索指标
@Service
public class RetrievalEvaluationService {
private final SearchService searchService;
/**
* 计算Recall@K:前K个结果里,有多少包含了正确文档
*/
public double computeRecallAtK(List<EvaluationCase> cases, int k) {
int hitCount = 0;
for (EvaluationCase testCase : cases) {
List<Document> results = searchService.search(testCase.getQuery(), k);
Set<String> retrievedIds = results.stream()
.map(d -> (String) d.getMetadata().get("doc_id"))
.collect(Collectors.toSet());
Set<String> expectedIds = new HashSet<>(testCase.getExpectedDocIds());
boolean hit = !Collections.disjoint(retrievedIds, expectedIds);
if (hit) hitCount++;
}
return (double) hitCount / cases.size();
}
/**
* 计算MRR(Mean Reciprocal Rank):第一个正确结果的平均排名倒数
* MRR=1表示每次第一条就是正确的,MRR越低说明正确结果越靠后
*/
public double computeMRR(List<EvaluationCase> cases, int k) {
double sumReciprocalRank = 0;
for (EvaluationCase testCase : cases) {
List<Document> results = searchService.search(testCase.getQuery(), k);
Set<String> expectedIds = new HashSet<>(testCase.getExpectedDocIds());
for (int rank = 0; rank < results.size(); rank++) {
String docId = (String) results.get(rank).getMetadata().get("doc_id");
if (expectedIds.contains(docId)) {
sumReciprocalRank += 1.0 / (rank + 1);
break;
}
}
}
return sumReciprocalRank / cases.size();
}
}生成质量评估
生成质量评估比检索质量评估难,因为"生成的答案是不是好的"本质上是一个主观判断。
业界现在常用的做法是用LLM来评估LLM的生成结果(LLM-as-Judge),让评估模型扮演裁判角色,按照给定标准打分。
这个做法听起来像是在循环论证,但实践中发现,用一个强的Judge模型(比如GPT-4o、Claude Opus)来评估一个较弱模型的输出,相关性还是比较高的——前提是给Judge提供清晰的评分标准,而不是让它自由裁量。
忠实度(Faithfulness)评估
忠实度检验的是:模型的回答,有没有超出检索到的上下文去"自由发挥"?
@Service
public class FaithfulnessEvaluator {
private final ChatClient judgeChatClient; // 使用更强的Judge模型
public FaithfulnessScore evaluate(
String query,
String generatedAnswer,
List<Document> retrievedContext) {
String contextText = retrievedContext.stream()
.map(Document::getText)
.collect(Collectors.joining("\n\n---\n\n"));
String judgePrompt = """
你是一个严格的AI回答质量评估员,专门评估"忠实度"指标。
忠实度定义:AI回答中的每一个陈述,都必须能从提供的上下文中找到支撑依据。
如果回答包含上下文中没有的信息(即使这些信息在现实中是正确的),也要扣分。
评估范围:0.0 到 1.0
- 1.0:回答完全基于上下文,没有任何超出上下文的陈述
- 0.7:回答大部分基于上下文,有个别轻微超出
- 0.5:约一半陈述有上下文支撑,一半没有
- 0.0:回答完全是幻觉,没有上下文支撑
用户问题:
%s
提供的上下文:
%s
AI回答:
%s
请给出忠实度评分(0.0-1.0)和简短的理由。
输出格式(JSON):
{"score": 0.8, "reason": "..."}
""".formatted(query, contextText, generatedAnswer);
String response = judgeChatClient.prompt()
.user(judgePrompt)
.call()
.content();
return parseFaithfulnessScore(response);
}
}答案相关性(Answer Relevancy)评估
答案相关性检验的是:模型的回答,有没有真正回答用户的问题?
有时候模型会给出一大段正确但没有回答问题的内容,也需要被识别出来:
@Service
public class AnswerRelevancyEvaluator {
private final ChatClient judgeChatClient;
/**
* 通过"反向生成"评估答案相关性:
* 让模型基于答案生成可能的问题,看这些问题和原始问题的语义相似度
*/
public double evaluate(String originalQuery, String generatedAnswer) {
// 让模型基于答案生成3个可能的原始问题
String reversePrompt = """
给定以下AI回答,请推断出可能触发这个回答的3个不同问题。
只输出问题列表,不要其他内容。
AI回答:
%s
""".formatted(generatedAnswer);
String questionsText = judgeChatClient.prompt()
.user(reversePrompt)
.call()
.content();
List<String> generatedQuestions = parseQuestionList(questionsText);
// 计算每个生成问题与原始问题的语义相似度(用Embedding)
float[] originalEmbedding = embeddingModel.embed(originalQuery);
double maxSimilarity = generatedQuestions.stream()
.mapToDouble(q -> cosineSimilarity(
originalEmbedding,
embeddingModel.embed(q)))
.max()
.orElse(0.0);
return maxSimilarity;
}
}端到端评估:答案正确率
最终用户关心的不是"检索召回率是多少""忠实度是多少",他们只关心"这个答案对不对"。
这需要一个"正确答案"的标准,也就是Ground Truth Answer:
@Service
public class EndToEndEvaluationService {
private final RagService ragService;
private final ChatClient judgeChatClient;
public EndToEndEvaluationReport runEvaluation(
List<EndToEndEvaluationCase> cases) {
List<CaseResult> results = cases.parallelStream()
.map(this::evaluateCase)
.collect(Collectors.toList());
double avgCorrectnessScore = results.stream()
.mapToDouble(CaseResult::getCorrectnessScore)
.average()
.orElse(0);
long correctCount = results.stream()
.filter(r -> r.getCorrectnessScore() >= 0.7)
.count();
return EndToEndEvaluationReport.builder()
.totalCases(cases.size())
.correctCount((int) correctCount)
.correctRate((double) correctCount / cases.size())
.avgCorrectnessScore(avgCorrectnessScore)
.caseResults(results)
.build();
}
private CaseResult evaluateCase(EndToEndEvaluationCase testCase) {
// 运行RAG系统获取答案
String actualAnswer = ragService.query(testCase.getQuery());
// 用Judge模型评估答案是否正确
String judgePrompt = """
你需要评估AI回答与参考答案的一致性。
问题:%s
参考答案(正确答案):%s
AI回答:%s
评分标准:
- 1.0:AI回答与参考答案完全一致,覆盖了所有关键信息
- 0.8:AI回答基本正确,只遗漏了次要细节
- 0.5:AI回答部分正确,有关键信息遗漏或轻微错误
- 0.2:AI回答有严重错误,与参考答案大相径庭
- 0.0:AI回答完全错误
输出格式(JSON):{"score": 0.8, "reason": "..."}
""".formatted(
testCase.getQuery(),
testCase.getExpectedAnswer(),
actualAnswer);
String judgeResponse = judgeChatClient.prompt()
.user(judgePrompt)
.call()
.content();
CorrectnessScore score = parseCorrectnessScore(judgeResponse);
return CaseResult.builder()
.query(testCase.getQuery())
.expectedAnswer(testCase.getExpectedAnswer())
.actualAnswer(actualAnswer)
.correctnessScore(score.getScore())
.judgeReason(score.getReason())
.build();
}
}把评估流水线集成到CI/CD
评估不是一次性的,应该在每次发布新版本时自动运行:
@Component
public class RagEvaluationPipeline {
private final RetrievalEvaluationService retrievalEval;
private final FaithfulnessEvaluator faithfulnessEval;
private final EndToEndEvaluationService e2eEval;
public EvaluationReport runFullEvaluation(String version) {
log.info("开始RAG系统全量评估, 版本: {}", version);
// 加载评估数据集
List<EvaluationCase> retrievalCases = loadRetrievalCases();
List<EndToEndEvaluationCase> e2eCases = loadEndToEndCases();
// 检索质量评估
double recallAt5 = retrievalEval.computeRecallAtK(retrievalCases, 5);
double mrr = retrievalEval.computeMRR(retrievalCases, 10);
// 端到端质量评估
EndToEndEvaluationReport e2eReport = e2eEval.runEvaluation(e2eCases);
EvaluationReport report = EvaluationReport.builder()
.version(version)
.evaluatedAt(Instant.now())
.recallAt5(recallAt5)
.mrr(mrr)
.endToEndCorrectRate(e2eReport.getCorrectRate())
.totalCasesEvaluated(e2eCases.size())
.build();
// 存储评估结果(用于跨版本对比)
evaluationResultRepository.save(report);
// 检查是否有回退(指标低于上个版本的90%认为有回退)
checkForRegression(report);
log.info("评估完成: Recall@5={}, MRR={}, 答案正确率={}",
recallAt5, mrr, e2eReport.getCorrectRate());
return report;
}
private void checkForRegression(EvaluationReport current) {
EvaluationReport previous = evaluationResultRepository.findLatest();
if (previous == null) return;
if (current.getEndToEndCorrectRate() < previous.getEndToEndCorrectRate() * 0.9) {
alertService.sendAlert(AlertLevel.HIGH,
String.format("RAG系统质量回退警告:答案正确率从%.1f%%降至%.1f%%",
previous.getEndToEndCorrectRate() * 100,
current.getEndToEndCorrectRate() * 100));
}
}
}一个容易被忽视的细节:测试用例的质量比数量更重要
很多团队一上来就想构建几千个测试用例,但我的经验是:100个高质量的测试用例,比1000个低质量的用例有价值得多。
什么是高质量的测试用例?
- 覆盖边界情况:有答案的问题、没答案的问题、需要多文档综合的问题、包含精确实体的问题
- 来自真实用户需求:不要自己编问题,从用户日志里来
- 有明确的Ground Truth:答案要清晰,不能是"可能是这样"这种模糊的期望
测试用例是评估体系的基础,它的质量决定了整个评估体系的价值。花时间把这100个核心用例做好,比凑出1000个凑数的用例有价值。
有了这套评估体系之后,每次迭代的逻辑就清晰了很多:
改了检索策略,先看Recall@K和MRR有没有提升; 调了Prompt,先看忠实度和答案相关性有没有变化; 换了模型,跑一遍端到端评估,比较前后的答案正确率。
从"感觉好一些"到"数据好了X%",这个转变让整个团队的迭代效率提升了不少。
