第1662篇:检索增强生成的质量评估体系——如何量化RAG系统的好坏
第1662篇:检索增强生成的质量评估体系——如何量化RAG系统的好坏
上一篇聊了幻觉问题,很多朋友私信问:搭了RAG系统之后,怎么判断它有没有用?靠人工测一测感觉太主观了,有没有量化的方法?
这个问题问得很好,而且很实际。
我见过太多团队搭完RAG上线之后,就靠"感觉不错"或者"用户没投诉"来判断效果。这种做法在早期无所谓,但随着系统规模变大、场景变复杂,你根本不知道哪个环节出了问题,优化也无从下手。
今天系统讲一下RAG评估体系,从指标设计到工程实现,以及我们团队踩过的一些坑。
一、为什么RAG评估很难?
RAG系统有个特殊性:它由两个模块串联——检索模块和生成模块。这两个模块都可能出问题,而且错误会叠加。
检索好但生成差:找到了正确文档,但LLM没有用好这些文档。 检索差但生成看起来还行:没找到相关文档,但LLM凭记忆给了个"看起来合理"的答案。 两个都差:检索到了错误文档,LLM基于错误文档生成了错误但听起来很有根据的答案。
更麻烦的是,端到端的评估(只看最终答案好不好)无法区分是哪个模块出了问题。你必须有模块级别的评估指标。
二、核心评估指标体系
2.1 检索层指标
检索层的评估相对传统,借鉴了信息检索领域的成熟指标。
Context Precision(上下文精确率)
在检索到的文档中,有多少是真正相关的?
Context Recall(上下文召回率)
在所有真正相关的文档中,检索到了多少?
MRR(Mean Reciprocal Rank)
最相关的文档排在第几位?这个指标比Precision更关注排序质量。
NDCG(Normalized Discounted Cumulative Gain)
考虑相关性分级(不只是相关/不相关)的排序指标,在有多级相关性标注时更精确。
2.2 生成层指标
生成层的评估是RAG特有的,需要评估生成内容与检索文档的关系。
Faithfulness(忠实度)
生成的内容有多少是有文档依据的,有多少是"自由发挥"的?
这是我认为最重要的单个指标,直接量化了幻觉程度。
Answer Relevancy(答案相关性)
生成的内容有多大程度上回答了用户的问题?可能内容都是真实的,但答非所问。
Answer Correctness(答案正确性)
最终答案与标准答案的吻合程度,需要Ground Truth标注。
三、工程实现:基于LLM的自动评估
有了指标定义,下一步是怎么计算。对于RAG这类生成任务,传统的字符串匹配(BLEU、ROUGE)效果很差,更好的方法是用LLM来做评估——就是所谓的"LLM-as-Judge"。
@Service
public class RAGEvaluationService {
@Autowired
private LLMClient llmClient;
@Autowired
private EmbeddingService embeddingService;
/**
* 计算忠实度(Faithfulness)
* 检测答案中的声明是否有上下文支撑
*/
public double evaluateFaithfulness(String answer, List<String> contextDocs) {
// 1. 提取答案中的事实性声明
String extractPrompt = String.format("""
请从以下回答中提取所有事实性声明,每行一条,不加序号:
回答:%s
只输出声明列表,每行一条。
""", answer);
String claimsText = llmClient.chat(extractPrompt);
List<String> claims = Arrays.asList(claimsText.split("\n"));
claims = claims.stream()
.filter(c -> !c.isBlank())
.collect(Collectors.toList());
if (claims.isEmpty()) return 1.0;
String contextCombined = String.join("\n\n", contextDocs);
// 2. 逐条验证声明是否有上下文支撑
int supportedCount = 0;
for (String claim : claims) {
String verifyPrompt = String.format("""
请判断以下声明是否可以从给定的上下文中推断出来。
声明:%s
上下文:
%s
请只回答"yes"(可以推断)或"no"(无法推断)。
""", claim, contextCombined);
String verdict = llmClient.chat(verifyPrompt).trim().toLowerCase();
if (verdict.startsWith("yes")) {
supportedCount++;
}
}
return (double) supportedCount / claims.size();
}
/**
* 计算答案相关性(Answer Relevancy)
* 用逆向生成法:根据答案生成问题,看生成的问题与原问题的语义相似度
*/
public double evaluateAnswerRelevancy(String question, String answer, int genCount) {
List<Float[]> generatedQuestionEmbeddings = new ArrayList<>();
// 1. 根据答案生成多个问题
for (int i = 0; i < genCount; i++) {
String genPrompt = String.format("""
根据以下回答,生成一个能引出这个回答的问题。
回答:%s
只输出问题,不要其他内容。
""", answer);
String generatedQuestion = llmClient.chat(genPrompt);
Float[] embedding = embeddingService.embed(generatedQuestion);
generatedQuestionEmbeddings.add(embedding);
}
// 2. 计算原问题与生成问题的平均余弦相似度
Float[] originalEmbedding = embeddingService.embed(question);
double totalSimilarity = generatedQuestionEmbeddings.stream()
.mapToDouble(genEmb -> cosineSimilarity(originalEmbedding, genEmb))
.sum();
return totalSimilarity / genCount;
}
/**
* 计算上下文精确率(Context Precision)
* 判断检索到的文档中有多少是真正相关的
*/
public double evaluateContextPrecision(String question,
String answer,
List<String> contexts) {
if (contexts.isEmpty()) return 0.0;
int relevantCount = 0;
for (String context : contexts) {
String evalPrompt = String.format("""
判断给定的上下文是否对回答问题有用。
问题:%s
期望的回答类型:%s
上下文:%s
如果这段上下文包含对回答问题有帮助的信息,回答"yes",否则回答"no"。
""", question, answer, context);
String verdict = llmClient.chat(evalPrompt).trim().toLowerCase();
if (verdict.startsWith("yes")) {
relevantCount++;
}
}
return (double) relevantCount / contexts.size();
}
/**
* 计算上下文召回率(Context Recall)
* 需要Ground Truth答案
*/
public double evaluateContextRecall(String groundTruth,
List<String> contexts) {
String contextCombined = String.join("\n\n", contexts);
// 从标准答案中提取关键信息点
String extractPrompt = String.format("""
从以下标准答案中提取关键信息点,每行一条:
标准答案:%s
只输出信息点列表。
""", groundTruth);
String keyPointsText = llmClient.chat(extractPrompt);
List<String> keyPoints = Arrays.asList(keyPointsText.split("\n"));
keyPoints = keyPoints.stream().filter(p -> !p.isBlank()).collect(Collectors.toList());
if (keyPoints.isEmpty()) return 1.0;
int coveredCount = 0;
for (String point : keyPoints) {
String coverPrompt = String.format("""
判断以下关键信息点是否可以从给定上下文中找到支撑。
关键信息点:%s
上下文:%s
回答"yes"或"no"。
""", point, contextCombined);
String verdict = llmClient.chat(coverPrompt).trim().toLowerCase();
if (verdict.startsWith("yes")) {
coveredCount++;
}
}
return (double) coveredCount / keyPoints.size();
}
private double cosineSimilarity(Float[] a, Float[] b) {
double dotProduct = 0, normA = 0, normB = 0;
for (int i = 0; i < a.length; i++) {
dotProduct += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}
}3.1 综合评估报告
把上面的指标整合到一个评估报告里:
@Service
public class RAGEvaluationReport {
@Autowired
private RAGEvaluationService evaluationService;
/**
* 生成完整的RAG评估报告
*/
public EvaluationReport evaluate(RAGTestCase testCase) {
String question = testCase.getQuestion();
String answer = testCase.getAnswer();
List<String> contexts = testCase.getRetrievedContexts();
String groundTruth = testCase.getGroundTruth();
// 并行计算各项指标(实际中可以用CompletableFuture)
double faithfulness = evaluationService.evaluateFaithfulness(answer, contexts);
double answerRelevancy = evaluationService.evaluateAnswerRelevancy(question, answer, 3);
double contextPrecision = evaluationService.evaluateContextPrecision(question, answer, contexts);
double contextRecall = groundTruth != null ?
evaluationService.evaluateContextRecall(groundTruth, contexts) : -1;
// RAG综合分(Ragas Score)
double ragasScore = calculateRagasScore(
faithfulness, answerRelevancy, contextPrecision, contextRecall
);
return EvaluationReport.builder()
.testCaseId(testCase.getId())
.question(question)
.answer(answer)
.faithfulness(faithfulness)
.answerRelevancy(answerRelevancy)
.contextPrecision(contextPrecision)
.contextRecall(contextRecall)
.ragasScore(ragasScore)
.grade(gradeScore(ragasScore))
.suggestions(generateSuggestions(faithfulness, answerRelevancy,
contextPrecision, contextRecall))
.build();
}
/**
* 加权综合得分
* 各指标权重可根据业务场景调整
*/
private double calculateRagasScore(double faithfulness, double answerRelevancy,
double contextPrecision, double contextRecall) {
if (contextRecall < 0) {
// 没有Ground Truth,只用三个指标
return (faithfulness * 0.4 + answerRelevancy * 0.35 + contextPrecision * 0.25);
}
return (faithfulness * 0.3 + answerRelevancy * 0.3 +
contextPrecision * 0.2 + contextRecall * 0.2);
}
/**
* 根据各维度分数给出改进建议
*/
private List<String> generateSuggestions(double faithfulness, double answerRelevancy,
double contextPrecision, double contextRecall) {
List<String> suggestions = new ArrayList<>();
if (faithfulness < 0.7) {
suggestions.add("忠实度偏低:考虑在提示词中更严格约束模型只使用上下文信息," +
"或增加输出验证步骤");
}
if (answerRelevancy < 0.7) {
suggestions.add("答案相关性偏低:检查是否存在答非所问的情况," +
"可能需要改善查询理解或生成策略");
}
if (contextPrecision < 0.6) {
suggestions.add("上下文精确率偏低:检索到太多不相关文档," +
"考虑提高相似度阈值或改善检索策略");
}
if (contextRecall >= 0 && contextRecall < 0.6) {
suggestions.add("上下文召回率偏低:关键文档没有被检索到," +
"考虑增加检索数量、改善分块策略或使用混合检索");
}
return suggestions;
}
private String gradeScore(double score) {
if (score >= 0.85) return "A";
if (score >= 0.70) return "B";
if (score >= 0.55) return "C";
return "D";
}
}四、批量评估框架——构建测试集
有了单条评估的能力,接下来要构建自动化的批量评估框架。这是从实验阶段走向工程化的关键步骤。
@Service
public class BatchEvaluationFramework {
@Autowired
private RAGPipeline ragPipeline;
@Autowired
private RAGEvaluationReport evaluationReport;
@Autowired
private TestDataRepository testDataRepo;
/**
* 运行批量评估
*/
public BatchEvaluationResult runBatchEvaluation(String datasetName,
EvaluationConfig config) {
List<TestCase> testCases = testDataRepo.loadDataset(datasetName);
log.info("开始批量评估,数据集:{},共{}条", datasetName, testCases.size());
List<EvaluationReport> reports = new ArrayList<>();
int successCount = 0;
int errorCount = 0;
for (TestCase testCase : testCases) {
try {
// 运行RAG管道
RAGResult ragResult = ragPipeline.query(testCase.getQuestion());
// 构建评估测试用例
RAGTestCase evalCase = RAGTestCase.builder()
.id(testCase.getId())
.question(testCase.getQuestion())
.answer(ragResult.getAnswer())
.retrievedContexts(ragResult.getContexts())
.groundTruth(testCase.getGroundTruth())
.build();
// 评估
EvaluationReport report = evaluationReport.evaluate(evalCase);
reports.add(report);
successCount++;
// 进度日志
if (successCount % 10 == 0) {
log.info("已评估 {}/{}", successCount, testCases.size());
}
} catch (Exception e) {
log.error("评估失败,testCase: {}", testCase.getId(), e);
errorCount++;
}
}
// 汇总统计
return aggregateResults(reports, datasetName, successCount, errorCount);
}
private BatchEvaluationResult aggregateResults(List<EvaluationReport> reports,
String datasetName,
int successCount, int errorCount) {
DoubleSummaryStatistics faithfulnessStats = reports.stream()
.mapToDouble(EvaluationReport::getFaithfulness)
.summaryStatistics();
DoubleSummaryStatistics relevancyStats = reports.stream()
.mapToDouble(EvaluationReport::getAnswerRelevancy)
.summaryStatistics();
DoubleSummaryStatistics precisionStats = reports.stream()
.mapToDouble(EvaluationReport::getContextPrecision)
.summaryStatistics();
DoubleSummaryStatistics ragasStats = reports.stream()
.mapToDouble(EvaluationReport::getRagasScore)
.summaryStatistics();
// 找出最差的N条,用于错误分析
List<EvaluationReport> worstCases = reports.stream()
.sorted(Comparator.comparingDouble(EvaluationReport::getRagasScore))
.limit(10)
.collect(Collectors.toList());
return BatchEvaluationResult.builder()
.datasetName(datasetName)
.totalCases(successCount + errorCount)
.successCount(successCount)
.errorCount(errorCount)
.avgFaithfulness(faithfulnessStats.getAverage())
.avgAnswerRelevancy(relevancyStats.getAverage())
.avgContextPrecision(precisionStats.getAverage())
.avgRagasScore(ragasStats.getAverage())
.worstCases(worstCases)
.scoreDistribution(buildScoreDistribution(reports))
.timestamp(LocalDateTime.now())
.build();
}
/**
* 分数分布直方图数据
*/
private Map<String, Long> buildScoreDistribution(List<EvaluationReport> reports) {
return reports.stream().collect(Collectors.groupingBy(
r -> {
double score = r.getRagasScore();
if (score >= 0.9) return "0.9-1.0";
if (score >= 0.8) return "0.8-0.9";
if (score >= 0.7) return "0.7-0.8";
if (score >= 0.6) return "0.6-0.7";
return "0.0-0.6";
},
Collectors.counting()
));
}
}五、测试集构建策略
评估框架有了,但测试集怎么来?这是实际工作中最费时间的部分。
5.1 人工标注(最可靠,但贵)
对于高风险场景,比如金融、医疗、法律,必须有人工标注的Gold Standard测试集。
我们的做法是:
- 领域专家写50-100个有代表性的问题
- 专家手动给出标准答案
- 标注检索应该找到的文档(有时候一个问题需要多个文档的信息)
这是最准确的,但成本很高,一般100条问答需要1-2天的专家时间。
5.2 LLM合成测试集(成本低,但质量要把控)
@Service
public class TestSetGenerator {
@Autowired
private LLMClient llmClient;
@Autowired
private VectorStore vectorStore;
/**
* 从知识库自动生成测试问题
* 思路:采样文档 -> 生成问题 -> 生成标准答案 -> 人工抽检
*/
public List<TestCase> generateTestSet(int targetCount) {
List<TestCase> testCases = new ArrayList<>();
// 从向量库随机采样文档
List<Document> sampledDocs = vectorStore.randomSample(targetCount * 2);
for (Document doc : sampledDocs) {
if (testCases.size() >= targetCount) break;
try {
// 生成问题
String questionGenPrompt = String.format("""
根据以下文档内容,生成一个需要理解文档才能回答的问题。
问题应该:
1. 有明确的答案
2. 答案在文档中可以找到
3. 不是简单的是非题
4. 用自然的口语化方式提问
文档:%s
只输出问题,不要其他内容。
""", doc.getContent());
String question = llmClient.chat(questionGenPrompt).trim();
// 基于文档生成标准答案
String answerGenPrompt = String.format("""
根据以下文档,回答问题。答案应该简洁准确,只包含文档中有依据的内容。
文档:%s
问题:%s
""", doc.getContent(), question);
String groundTruth = llmClient.chat(answerGenPrompt).trim();
// 质量过滤:问题不能太简单
if (isQuestionGoodQuality(question, doc)) {
testCases.add(TestCase.builder()
.question(question)
.groundTruth(groundTruth)
.sourceDocId(doc.getId())
.generatedBy("llm_synthetic")
.build());
}
} catch (Exception e) {
log.warn("生成测试用例失败,docId: {}", doc.getId(), e);
}
}
log.info("生成测试集完成,共{}条", testCases.size());
return testCases;
}
private boolean isQuestionGoodQuality(String question, Document doc) {
// 过滤掉太短的问题
if (question.length() < 10) return false;
// 过滤掉不是问句的情况
if (!question.contains("?") && !question.contains("?") &&
!question.contains("什么") && !question.contains("如何") &&
!question.contains("为什么") && !question.contains("哪")) {
return false;
}
return true;
}
}5.3 真实用户问题采样(最接近实际)
这是我最推荐的方式:把真实用户的问题(脱敏后)和专家标注答案结合起来。
具体操作:
- 接入真实流量,记录用户问题
- 抽取有代表性的问题(覆盖不同类别)
- 专家标注答案和相关文档
- 这些成为"黄金测试集",定期用来评估系统
六、持续评估——CI/CD集成
评估不是一次性的,是持续的。每次更新检索策略、更新知识库、更换模型,都要重跑评估。
@Component
public class ContinuousEvaluationJob {
@Autowired
private BatchEvaluationFramework batchEval;
@Autowired
private EvaluationHistoryRepository historyRepo;
@Autowired
private AlertService alertService;
/**
* 定时评估任务(每天运行)
*/
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点
public void dailyEvaluation() {
log.info("开始日常评估任务");
BatchEvaluationResult result = batchEval.runBatchEvaluation(
"gold_standard_v2",
EvaluationConfig.defaultConfig()
);
// 保存评估历史
historyRepo.save(result);
// 与昨天的结果对比
BatchEvaluationResult yesterday = historyRepo.getLatestBefore(
result.getTimestamp().minusDays(1)
);
if (yesterday != null) {
double ragasDiff = result.getAvgRagasScore() - yesterday.getAvgRagasScore();
double faithfulnessDiff = result.getAvgFaithfulness() - yesterday.getAvgFaithfulness();
// 如果关键指标下降超过阈值,告警
if (ragasDiff < -0.05) {
alertService.sendAlert(AlertLevel.WARNING, "RAG综合分下降",
String.format("今日Ragas分: %.3f,昨日: %.3f,下降: %.3f",
result.getAvgRagasScore(), yesterday.getAvgRagasScore(), ragasDiff));
}
if (faithfulnessDiff < -0.08) {
alertService.sendAlert(AlertLevel.CRITICAL, "忠实度显著下降",
String.format("幻觉率上升,今日忠实度: %.3f,昨日: %.3f",
result.getAvgFaithfulness(), yesterday.getAvgFaithfulness()));
}
}
log.info("日常评估完成,Ragas分: {}", result.getAvgRagasScore());
}
}七、我踩过的几个坑
坑1:LLM评估者本身也会幻觉
用LLM来做评估(LLM-as-Judge),评估者本身也可能出错。特别是评估"上下文是否支持答案中的声明"时,LLM评估者可能会根据自己的知识(而不是上下文)来判断,导致评估偏高。
解决方案:在评估提示词里明确说"只能基于给定上下文判断,不能使用你自己的知识"。
坑2:测试集污染
如果用LLM生成测试集,而且生成测试集和被评估的RAG用的是同一个LLM,会有系统性偏差。评估者倾向于对自己风格的输出打高分。
解决方案:评估模型和生成模型最好不一样,或者加入人工抽检环节。
坑3:过度依赖单一指标
Ragas框架的综合分看起来很方便,但掩盖了各维度的问题。我们有个场景,Ragas综合分很高,但实际用户满意度很低。后来发现是答案相关性分虽然高,但答案太学术化,用户看不懂——这个问题Ragas根本检测不到。
评估体系必须和用户反馈结合,量化指标和定性反馈不能偏废。
坑4:忽略检索速度的评估
功能评估完了别忘了性能评估。检索延迟直接影响用户体验。我们的要求是P95延迟 < 500ms,超过这个就要优化。
八、评估体系的整体架构
评估是RAG工程化中最容易被忽视但又最关键的环节。没有量化评估,所有的优化都是在盲飞。
建议的落地路径:先搭一个最小可用的评估框架(只计算Faithfulness和Answer Relevancy),跑通之后再逐步扩展指标和测试集规模。
下一篇讲知识图谱与RAG的结合,Graph RAG是目前处理复杂推理问题最有效的方案之一。
