第2151篇:RAGAS评估实战——RAG系统质量量化的Java工程实现
第2151篇:RAGAS评估实战——RAG系统质量量化的Java工程实现
适读人群:构建和维护RAG系统的AI工程师 | 阅读时长:约20分钟 | 核心价值:用RAGAS框架量化RAG系统的检索和生成质量,找到你RAG系统真正的瓶颈在哪里
"我们的RAG效果不太好"——这句话几乎是每个RAG项目到中期都会冒出来的。
问题在于,"不太好"是一个没有工程价值的描述。是检索阶段没找到对的文档?还是找到了但生成时没用上?还是文档本身质量差?还是问题本身就无法从知识库里找到答案?
这四种情况的修复方法完全不同,如果不能量化区分,你就只能凭感觉乱调——有时改了检索参数,有时改了生成Prompt,但不知道哪个真的有效。
RAGAS(RAG Assessment)是一个专门为RAG系统设计的评估框架,它把RAG的质量拆解成几个可独立测量的维度,帮你定位瓶颈。这篇文章讲怎么在Java项目里落地。
RAGAS的四个核心指标
RAGAS的设计思路很清晰:把RAG的两个阶段(检索和生成)分开评估。
RAG系统质量 = 检索质量 × 生成质量
检索质量:
- Context Precision(上下文精确率):检索到的文档有多少是真正有用的?
- Context Recall(上下文召回率):需要的信息有多少被检索到了?
生成质量:
- Faithfulness(忠实性):生成的答案有多少来自检索到的文档,而不是凭空捏造?
- Answer Relevancy(答案相关性):答案有多切题?这四个指标可以独立看,组合起来能精准定位问题:
- Recall低 + Faithfulness高 → 检索的问题,改向量化或检索策略
- Recall高 + Faithfulness低 → 生成的问题,改Prompt或换模型
- Recall高 + Faithfulness高 + Relevancy低 → Prompt设计问题,模型用了文档但跑题了
Java实现RAGAS评估
原版RAGAS是Python实现的,我们需要在Java项目里复现核心逻辑。核心思路是用LLM做评估器。
/**
* RAGAS评估框架的Java实现
*
* 依赖:Spring AI(用于LLM调用)
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class RagasEvaluationService {
private final ChatClient evaluatorClient;
private final EmbeddingModel embeddingModel;
/**
* 完整的RAGAS评估
*
* @param question 用户问题
* @param contexts 检索到的文档片段列表
* @param answer RAG系统生成的答案
* @param groundTruth 标准答案(用于计算recall,可选)
*/
public RagasScore evaluate(String question,
List<String> contexts,
String answer,
String groundTruth) {
// 并行计算四个指标
CompletableFuture<Double> faithfulnessFuture =
CompletableFuture.supplyAsync(() -> computeFaithfulness(answer, contexts));
CompletableFuture<Double> answerRelevancyFuture =
CompletableFuture.supplyAsync(() -> computeAnswerRelevancy(question, answer));
CompletableFuture<Double> contextPrecisionFuture =
CompletableFuture.supplyAsync(() -> computeContextPrecision(question, contexts, groundTruth));
CompletableFuture<Double> contextRecallFuture = groundTruth != null
? CompletableFuture.supplyAsync(() -> computeContextRecall(contexts, groundTruth))
: CompletableFuture.completedFuture(null);
try {
double faithfulness = faithfulnessFuture.get(60, TimeUnit.SECONDS);
double answerRelevancy = answerRelevancyFuture.get(60, TimeUnit.SECONDS);
double contextPrecision = contextPrecisionFuture.get(60, TimeUnit.SECONDS);
Double contextRecall = contextRecallFuture.get(60, TimeUnit.SECONDS);
return RagasScore.builder()
.faithfulness(faithfulness)
.answerRelevancy(answerRelevancy)
.contextPrecision(contextPrecision)
.contextRecall(contextRecall)
.overallScore(computeOverall(faithfulness, answerRelevancy, contextPrecision, contextRecall))
.build();
} catch (Exception e) {
log.error("RAGAS评估失败", e);
throw new EvaluationException("RAGAS评估执行失败", e);
}
}
/**
* 计算Faithfulness(忠实性)
*
* 算法:
* 1. 从答案中提取所有陈述(claims)
* 2. 对每个陈述,判断是否能从contexts中找到支撑
* 3. faithfulness = 有支撑的陈述数 / 总陈述数
*/
private double computeFaithfulness(String answer, List<String> contexts) {
// 第一步:提取答案中的陈述
List<String> claims = extractClaims(answer);
if (claims.isEmpty()) return 1.0;
String contextText = String.join("\n\n---\n\n", contexts);
// 第二步:对每个陈述判断是否有文档支撑
int supportedCount = 0;
for (String claim : claims) {
if (isClaimSupported(claim, contextText)) {
supportedCount++;
}
}
return (double) supportedCount / claims.size();
}
private List<String> extractClaims(String answer) {
String prompt = String.format("""
请从以下文本中提取所有独立的事实陈述(claims)。
每个陈述应该是一个完整的、可独立验证的事实。
每行输出一个陈述,以数字编号开头(1. 2. 3.)。
文本:
%s
陈述列表:
""", answer);
String response = evaluatorClient.prompt().user(prompt).call().content();
// 解析编号列表
return Arrays.stream(response.split("\n"))
.filter(line -> line.matches("^\\d+\\..*"))
.map(line -> line.replaceFirst("^\\d+\\.\\s*", "").trim())
.filter(s -> !s.isEmpty())
.collect(Collectors.toList());
}
private boolean isClaimSupported(String claim, String contextText) {
String prompt = String.format("""
根据以下参考资料,判断给定的陈述是否有依据支撑。
参考资料:
%s
陈述:%s
判断(只输出YES或NO):
""", contextText, claim);
String response = evaluatorClient.prompt().user(prompt).call().content().trim().toUpperCase();
return response.startsWith("YES");
}
/**
* 计算Answer Relevancy(答案相关性)
*
* 算法:
* 1. 让LLM根据答案反向生成多个可能的问题
* 2. 计算这些生成的问题与原始问题的语义相似度
* 3. 相似度均值 = 答案相关性
*
* 直觉:好的答案应该能让你"猜出"原始问题是什么
*/
private double computeAnswerRelevancy(String question, String answer) {
// 生成多个候选问题(3个)
String prompt = String.format("""
给定以下回答,请生成3个可能触发该回答的问题。
每行一个问题,格式:Q1: xxx / Q2: xxx / Q3: xxx
回答:%s
""", answer);
String response = evaluatorClient.prompt().user(prompt).call().content();
List<String> generatedQuestions = Arrays.stream(response.split("\n"))
.filter(line -> line.matches("^Q\\d+:.*"))
.map(line -> line.replaceFirst("^Q\\d+:\\s*", "").trim())
.filter(s -> !s.isEmpty())
.collect(Collectors.toList());
if (generatedQuestions.isEmpty()) return 0.5;
// 计算与原始问题的语义相似度
float[] questionEmbedding = embeddingModel.embed(question);
double totalSimilarity = 0;
for (String genQuestion : generatedQuestions) {
float[] genEmbedding = embeddingModel.embed(genQuestion);
totalSimilarity += cosineSimilarity(questionEmbedding, genEmbedding);
}
return totalSimilarity / generatedQuestions.size();
}
/**
* 计算Context Precision(上下文精确率)
*
* 算法:
* 对检索到的每个文档片段,判断它是否对回答当前问题有用
* 精确率 = 有用的文档数 / 总文档数
* 并考虑排序权重(排在前面的有用文档贡献更大)
*/
private double computeContextPrecision(String question,
List<String> contexts,
String groundTruth) {
if (contexts.isEmpty()) return 0.0;
List<Boolean> relevanceList = new ArrayList<>();
for (String ctx : contexts) {
String prompt = String.format("""
判断以下文档片段是否对回答给定问题有帮助。
问题:%s
%s
文档片段:%s
判断(只输出YES或NO):
""",
question,
groundTruth != null ? "参考答案:" + groundTruth + "\n" : "",
ctx
);
String response = evaluatorClient.prompt().user(prompt).call().content().trim().toUpperCase();
relevanceList.add(response.startsWith("YES"));
}
// 计算加权精确率(考虑排序位置)
double numerator = 0;
double denominator = 0;
int relevantCount = 0;
for (int i = 0; i < relevanceList.size(); i++) {
if (relevanceList.get(i)) {
relevantCount++;
// precision@k * relevant_at_k
double precisionAtK = (double) relevantCount / (i + 1);
numerator += precisionAtK;
denominator++;
}
}
return denominator > 0 ? numerator / denominator : 0.0;
}
/**
* 计算Context Recall(上下文召回率)
*
* 需要ground_truth:标准答案中的信息有多少被检索到了
*/
private double computeContextRecall(List<String> contexts, String groundTruth) {
String contextText = String.join("\n\n", contexts);
String prompt = String.format("""
参考答案中的每个句子,判断是否可以从给定的文档片段中推导出来。
参考答案:
%s
文档片段:
%s
请逐句分析,输出格式:
句子1: [原句] -> [YES/NO]
句子2: [原句] -> [YES/NO]
...
""", groundTruth, contextText);
String response = evaluatorClient.prompt().user(prompt).call().content();
long total = Arrays.stream(response.split("\n"))
.filter(line -> line.contains("->"))
.count();
long supported = Arrays.stream(response.split("\n"))
.filter(line -> line.contains("-> YES"))
.count();
return total > 0 ? (double) supported / total : 0.0;
}
private double computeOverall(double faithfulness, double relevancy,
double precision, Double recall) {
double sum = faithfulness + relevancy + precision;
int count = 3;
if (recall != null) { sum += recall; count++; }
return sum / count;
}
private double cosineSimilarity(float[] a, float[] b) {
double dot = 0, normA = 0, normB = 0;
for (int i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
return dot / (Math.sqrt(normA) * Math.sqrt(normB) + 1e-8);
}
}批量评估与结果分析
单条评估意义不大,我们需要在测试集上批量跑,统计分布。
/**
* 批量RAGAS评估,用于测试集质量分析
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class RagasBatchEvaluationService {
private final RagasEvaluationService ragasService;
private final RagPipeline ragPipeline; // 你的RAG实现
/**
* 在测试集上批量评估RAG系统
*
* @param testDataset 测试数据集(问题+标准答案)
*/
public RagasBatchReport evaluateOnDataset(List<TestCase> testDataset) {
log.info("开始批量RAGAS评估,测试集大小={}", testDataset.size());
List<RagasScore> scores = new ArrayList<>();
List<String> failedCases = new ArrayList<>();
for (int i = 0; i < testDataset.size(); i++) {
TestCase tc = testDataset.get(i);
try {
// 执行RAG流程
RagResult ragResult = ragPipeline.query(tc.getQuestion());
// 评估
RagasScore score = ragasService.evaluate(
tc.getQuestion(),
ragResult.getContexts(),
ragResult.getAnswer(),
tc.getGroundTruth()
);
scores.add(score);
if (i % 10 == 0) {
log.info("评估进度: {}/{}", i + 1, testDataset.size());
}
// 避免频繁调用LLM被限速
Thread.sleep(500);
} catch (Exception e) {
log.error("评估用例失败: {}", tc.getQuestion(), e);
failedCases.add(tc.getQuestion());
}
}
return computeBatchReport(scores, failedCases, testDataset.size());
}
private RagasBatchReport computeBatchReport(List<RagasScore> scores,
List<String> failedCases,
int totalCount) {
DoubleSummaryStatistics faithfulnessStats = scores.stream()
.mapToDouble(RagasScore::getFaithfulness)
.summaryStatistics();
DoubleSummaryStatistics relevancyStats = scores.stream()
.mapToDouble(RagasScore::getAnswerRelevancy)
.summaryStatistics();
DoubleSummaryStatistics precisionStats = scores.stream()
.mapToDouble(RagasScore::getContextPrecision)
.summaryStatistics();
// 找出分数最低的用例(需要重点改进)
List<RagasScore> bottomCases = scores.stream()
.sorted(Comparator.comparingDouble(RagasScore::getOverallScore))
.limit(10)
.collect(Collectors.toList());
return RagasBatchReport.builder()
.totalCount(totalCount)
.evaluatedCount(scores.size())
.failedCount(failedCases.size())
.avgFaithfulness(faithfulnessStats.getAverage())
.avgAnswerRelevancy(relevancyStats.getAverage())
.avgContextPrecision(precisionStats.getAverage())
.minFaithfulness(faithfulnessStats.getMin())
.bottomCases(bottomCases)
.diagnosis(generateDiagnosis(faithfulnessStats, relevancyStats, precisionStats))
.build();
}
/**
* 根据指标组合自动诊断RAG系统的主要问题
*/
private String generateDiagnosis(DoubleSummaryStatistics faithfulness,
DoubleSummaryStatistics relevancy,
DoubleSummaryStatistics precision) {
StringBuilder diagnosis = new StringBuilder();
double avgFaithfulness = faithfulness.getAverage();
double avgRelevancy = relevancy.getAverage();
double avgPrecision = precision.getAverage();
if (avgFaithfulness < 0.7) {
diagnosis.append("【严重】忠实性低(").append(String.format("%.2f", avgFaithfulness))
.append("):模型在生成答案时产生了大量幻觉,没有忠实于检索到的文档。")
.append("建议:强化Prompt中的"只根据提供的资料回答"指令,或使用支持引用的生成模式。\n");
}
if (avgPrecision < 0.6) {
diagnosis.append("【问题】检索精确率低(").append(String.format("%.2f", avgPrecision))
.append("):检索到的文档中有大量与问题无关的噪音。")
.append("建议:调整检索的TopK数量,优化向量化策略,或引入重排序模型。\n");
}
if (avgRelevancy < 0.7) {
diagnosis.append("【问题】答案相关性低(").append(String.format("%.2f", avgRelevancy))
.append("):答案虽然来自文档,但没有直接回答用户问题。")
.append("建议:优化生成Prompt,明确要求"直接回答用户的问题"。\n");
}
if (diagnosis.length() == 0) {
diagnosis.append("各项指标良好,RAG系统运行正常。");
}
return diagnosis.toString();
}
}工程经验:RAGAS落地的真实挑战
挑战1:Ground Truth的获取成本
Context Recall需要标准答案(ground truth),但标注成本很高。
我们的做法是:只对核心场景准备ground truth,大概覆盖80个最高频的问题类型。其他场景用三个不需要ground truth的指标(Faithfulness、Relevancy、Precision)评估。
挑战2:评估成本
每次RAGAS评估需要调用LLM好几次(提取claims、判断支撑、生成候选问题),成本比业务本身还高。
解决方案:RAGAS只用于离线测试集评估(每次发版前跑),不在生产流量上实时运行。生产监控用轻量级规则指标。
挑战3:指标值的解读
RAGAS的分数是相对的,不同业务场景的合理值差异很大。技术文档类RAG的Faithfulness应该在0.85以上,但开放问答类可能0.7也算合理。
一定要在你自己的测试集上建立基线,而不是对着论文里的数字评判好坏。
