LangChain4j 的评估框架——怎么自动测 RAG 的质量
LangChain4j 的评估框架——怎么自动测 RAG 的质量
我们 RAG 系统上线之后,产品经理问了我一个问题,让我一时语塞:
"你怎么知道 RAG 的质量好不好?"
我想了想,说:"我们测了几十个问题,回答都挺准的。"
"那你怎么知道这几十个问题有没有代表性?如果上了新功能,你怎么保证原来能回答好的问题还是能回答好?"
我当时没能给出一个让他满意的回答。
事实是,我们的"测试"就是几个工程师拿着公司的文档手动问问题,觉得回答合理就算过了。这种方式有几个明显问题:
- 覆盖率不够:几十个问题覆盖不了所有场景
- 不可重复:每次"测"的方式不一样,无法对比版本差异
- 主观判断:不同人对"回答合理"的标准不一样
- 无法自动化:改了 Prompt 或 Chunk 策略之后,只能再手动测一遍
这篇文章讲的就是如何建立一套自动化的 RAG 评估管道,让"RAG 质量好不好"这件事有客观、可重复的答案。
一、评估 RAG 需要衡量什么
先把指标搞清楚。RAG 系统的质量主要体现在两个层面:
检索质量
检索质量问的是:从知识库里找到的内容,和用户的问题有多相关?
核心指标:
- 召回率(Recall):包含答案的文档块,有没有被检索回来
- 精确率(Precision):检索回来的内容,有多少是真正相关的
- NDCG(归一化折扣累积增益):综合考虑相关性和排名位置
生成质量
生成质量问的是:LLM 基于检索内容给出的回答,质量怎么样?
核心指标:
- 忠实性(Faithfulness):回答中的内容是否都有文档依据,有没有"编造"
- 相关性(Answer Relevance):回答是否真正回答了用户的问题
- 完整性(Completeness):问题的各个方面是否都被覆盖到了
二、LLM-as-Judge:用 AI 来评估 AI
手工评估扩展不了,自动化评估的核心思路是:用 LLM 来评判 LLM 的输出质量。
这听起来有点绕,但实际效果很好。GPT-4 在评估文本质量方面的表现,和人工评估的相关系数超过 0.9。
LLM-as-Judge 的基本流程:
输入:(问题, 检索到的文档, RAG 的回答)
↓
评判 Prompt:
"请根据以下文档,评估这个回答的忠实性,满分10分。
重点检查:回答中有没有文档里不存在的信息?
返回:分数(1-10) + 评估理由"
↓
Judge LLM 输出:
分数: 8
理由: "回答主要基于文档内容,但第3段中提到的'通常3个工作日到达'
在提供的文档中没有明确说明,略有推断。"三、评估数据集的构建
自动化评估需要一个基准测试集(Golden Dataset)。
这个测试集包含:
- 问题(Question):覆盖主要业务场景的测试问题
- 参考答案(Ground Truth):人工标注的正确答案
- 相关文档(Relevant Docs):回答这个问题所需的文档块
/**
* 评估数据集条目
*/
@Data
@Builder
public class EvaluationSample {
/** 唯一ID */
private String sampleId;
/** 测试问题 */
private String question;
/** 人工标注的参考答案 */
private String groundTruth;
/** 应该被检索到的相关文档ID列表 */
private List<String> relevantDocIds;
/** 问题类型标签(用于分类统计) */
private String category; // 如:产品咨询、退换货政策、技术支持
/** 难度等级 */
private DifficultyLevel difficulty; // EASY / MEDIUM / HARD
/** 创建时间 */
private LocalDate createdAt;
/** 最后更新时间 */
private LocalDate updatedAt;
}测试集怎么建?两种方法:
方法一:手工创建 让业务专家针对业务场景写 50-200 个有代表性的问题,写好标准答案。慢但质量高。
方法二:半自动生成 从知识库文档中,用 LLM 自动生成问题,人工筛选和审核。快但需要人工把关。
/**
* 半自动生成测试问题
*/
@Service
public class TestDataGenerator {
@Autowired
private ChatLanguageModel model;
public List<EvaluationSample> generateFromDocument(String document, String docId) {
String prompt = String.format("""
请根据以下文档,生成5个不同类型的测试问题,并提供参考答案。
要求:
1. 问题要能考察文档中的关键信息
2. 包含不同难度:2个简单、2个中等、1个困难
3. 覆盖不同类型:事实性问题、解释性问题、比较性问题
文档内容:
%s
以 JSON 数组格式返回,每项包含:question, answer, difficulty(EASY/MEDIUM/HARD)
""", document);
String response = model.generate(prompt).content().text();
// 解析 JSON 并构建 EvaluationSample...
return parseGeneratedSamples(response, docId);
}
}四、完整的评估脚本实现
/**
* RAG 自动化评估器
* 核心功能:对测试集中的每个问题,运行 RAG 并用 LLM 评估质量
*/
@Service
@Slf4j
public class RagEvaluator {
@Autowired
private RagPipeline ragPipeline; // 被评估的 RAG 系统
@Autowired
private ChatLanguageModel judgeModel; // 用于评分的 Judge LLM
@Autowired
private EvaluationSampleRepository sampleRepo;
@Autowired
private EvaluationResultRepository resultRepo;
/**
* 运行完整评估
*/
public EvaluationReport runEvaluation(String evaluationId, EvaluationConfig config) {
log.info("开始评估,evaluationId={}", evaluationId);
List<EvaluationSample> samples = sampleRepo.findAll();
List<SampleEvaluationResult> results = new ArrayList<>();
for (EvaluationSample sample : samples) {
try {
SampleEvaluationResult result = evaluateSample(sample, evaluationId, config);
results.add(result);
// 实时保存(防止中途崩了)
resultRepo.save(result);
log.debug("样本 {} 评估完成,忠实性={}, 相关性={}",
sample.getSampleId(),
result.getFaithfulnessScore(),
result.getAnswerRelevanceScore());
} catch (Exception e) {
log.error("样本评估失败: {}", sample.getSampleId(), e);
}
}
// 汇总报告
EvaluationReport report = buildReport(evaluationId, results);
log.info("评估完成,整体忠实性={:.2f}, 整体相关性={:.2f}",
report.getAverageFaithfulness(), report.getAverageAnswerRelevance());
return report;
}
/**
* 评估单个样本
*/
private SampleEvaluationResult evaluateSample(
EvaluationSample sample,
String evaluationId,
EvaluationConfig config) {
// 1. 运行 RAG,获取回答和检索到的文档
RagResult ragResult = ragPipeline.query(sample.getQuestion());
// 2. 评估忠实性
double faithfulnessScore = evaluateFaithfulness(
sample.getQuestion(),
ragResult.getRetrievedDocs(),
ragResult.getAnswer()
);
// 3. 评估回答相关性
double answerRelevanceScore = evaluateAnswerRelevance(
sample.getQuestion(),
ragResult.getAnswer()
);
// 4. 评估检索质量(基于标注的相关文档ID)
double retrievalScore = evaluateRetrieval(
sample.getRelevantDocIds(),
ragResult.getRetrievedDocIds()
);
// 5. 如果有 Ground Truth,做精确比较
Double groundTruthScore = null;
if (sample.getGroundTruth() != null && config.isEvaluateWithGroundTruth()) {
groundTruthScore = evaluateAgainstGroundTruth(
sample.getQuestion(),
sample.getGroundTruth(),
ragResult.getAnswer()
);
}
return SampleEvaluationResult.builder()
.evaluationId(evaluationId)
.sampleId(sample.getSampleId())
.question(sample.getQuestion())
.answer(ragResult.getAnswer())
.groundTruth(sample.getGroundTruth())
.faithfulnessScore(faithfulnessScore)
.answerRelevanceScore(answerRelevanceScore)
.retrievalScore(retrievalScore)
.groundTruthScore(groundTruthScore)
.retrievedDocIds(ragResult.getRetrievedDocIds())
.evaluatedAt(LocalDateTime.now())
.build();
}
/**
* 评估忠实性(Faithfulness)
* 问题:回答中的信息,是否都能在检索到的文档中找到依据?
*/
private double evaluateFaithfulness(
String question,
List<String> contexts,
String answer) {
String contextText = String.join("\n---\n", contexts);
String prompt = String.format("""
请评估以下 AI 回答的忠实性(Faithfulness)。
忠实性定义:回答中的每个陈述,都应该能在提供的上下文中找到支持。
如果回答包含了上下文之外的信息(即使是常识),也算忠实性缺陷。
问题:
%s
上下文文档:
%s
AI 的回答:
%s
请从 0 到 10 打分(10 分表示完全忠实,没有任何无依据的陈述)。
只返回 JSON 格式:{"score": 数字, "reason": "简短说明扣分原因"}
""",
question, contextText, answer);
return parseScoreFromResponse(judgeModel.generate(prompt).content().text());
}
/**
* 评估回答相关性(Answer Relevance)
* 问题:回答是否真正回答了用户的问题?
*/
private double evaluateAnswerRelevance(String question, String answer) {
String prompt = String.format("""
请评估以下回答对问题的相关性(Answer Relevance)。
相关性定义:回答是否直接、完整地回答了问题?
扣分情况:
- 回答了一个相关但不同的问题
- 回答不完整,遗漏了问题的关键部分
- 回答包含大量与问题无关的内容
问题:%s
回答:%s
请从 0 到 10 打分(10 分表示完全相关、直接、完整)。
只返回 JSON 格式:{"score": 数字, "reason": "简短说明"}
""",
question, answer);
return parseScoreFromResponse(judgeModel.generate(prompt).content().text());
}
/**
* 评估检索质量(Retrieval Quality)
* 基于标注的相关文档,计算 Recall@K
*/
private double evaluateRetrieval(
List<String> expectedDocIds,
List<String> retrievedDocIds) {
if (expectedDocIds == null || expectedDocIds.isEmpty()) {
return 1.0; // 没有标注,默认满分
}
long relevantRetrieved = retrievedDocIds.stream()
.filter(expectedDocIds::contains)
.count();
return (double) relevantRetrieved / expectedDocIds.size(); // Recall
}
/**
* 与标准答案对比(如果有 Ground Truth)
*/
private double evaluateAgainstGroundTruth(
String question,
String groundTruth,
String answer) {
String prompt = String.format("""
请对比 AI 回答和标准答案,评估回答的准确性。
问题:%s
标准答案:%s
AI 的回答:%s
评分标准:
- 10分:内容与标准答案完全一致或等价
- 7-9分:主要信息正确,有少量差异
- 4-6分:部分信息正确,有明显遗漏或错误
- 1-3分:主要信息错误或严重偏离
- 0分:完全错误或无关
只返回 JSON 格式:{"score": 数字, "reason": "简短说明"}
""",
question, groundTruth, answer);
return parseScoreFromResponse(judgeModel.generate(prompt).content().text());
}
private double parseScoreFromResponse(String response) {
try {
JsonNode node = new ObjectMapper().readTree(response);
double score = node.get("score").asDouble();
return Math.max(0, Math.min(10, score)) / 10.0; // 归一化到 [0,1]
} catch (Exception e) {
log.warn("评分解析失败,返回默认值 0.5: {}", response);
return 0.5;
}
}
/**
* 汇总评估报告
*/
private EvaluationReport buildReport(
String evaluationId,
List<SampleEvaluationResult> results) {
DoubleSummaryStatistics faithStats = results.stream()
.mapToDouble(SampleEvaluationResult::getFaithfulnessScore)
.summaryStatistics();
DoubleSummaryStatistics relevanceStats = results.stream()
.mapToDouble(SampleEvaluationResult::getAnswerRelevanceScore)
.summaryStatistics();
DoubleSummaryStatistics retrievalStats = results.stream()
.mapToDouble(SampleEvaluationResult::getRetrievalScore)
.summaryStatistics();
// 按类别统计
Map<String, Double> faithfulnessByCategory = new HashMap<>();
// ... 按 category 分组统计
// 找出最差的样本(用于分析和改进)
List<SampleEvaluationResult> worstSamples = results.stream()
.sorted(Comparator.comparingDouble(r ->
r.getFaithfulnessScore() + r.getAnswerRelevanceScore()))
.limit(5)
.collect(Collectors.toList());
return EvaluationReport.builder()
.evaluationId(evaluationId)
.totalSamples(results.size())
.averageFaithfulness(faithStats.getAverage())
.averageAnswerRelevance(relevanceStats.getAverage())
.averageRetrieval(retrievalStats.getAverage())
.minFaithfulness(faithStats.getMin())
.minAnswerRelevance(relevanceStats.getMin())
.worstSamples(worstSamples)
.evaluatedAt(LocalDateTime.now())
.build();
}
}五、回归测试与版本对比
评估的真正价值在于对比:每次改动(换 Chunk 策略、改 Prompt、升级模型)之后,评分有没有变化?
/**
* 版本对比分析
*/
@Service
public class RagVersionComparator {
@Autowired
private EvaluationResultRepository resultRepo;
public VersionComparisonReport compare(String baselineId, String targetId) {
List<SampleEvaluationResult> baseline = resultRepo.findByEvaluationId(baselineId);
List<SampleEvaluationResult> target = resultRepo.findByEvaluationId(targetId);
// 按 sampleId 对齐
Map<String, SampleEvaluationResult> baselineMap = baseline.stream()
.collect(Collectors.toMap(SampleEvaluationResult::getSampleId, r -> r));
List<SampleDiff> diffs = target.stream()
.filter(t -> baselineMap.containsKey(t.getSampleId()))
.map(t -> {
SampleEvaluationResult b = baselineMap.get(t.getSampleId());
return SampleDiff.builder()
.sampleId(t.getSampleId())
.question(t.getQuestion())
.faithfulnessDelta(t.getFaithfulnessScore() - b.getFaithfulnessScore())
.relevanceDelta(t.getAnswerRelevanceScore() - b.getAnswerRelevanceScore())
.retrievalDelta(t.getRetrievalScore() - b.getRetrievalScore())
.build();
})
.collect(Collectors.toList());
// 找出明显退步的样本(delta < -0.1 表示退步超过 10%)
List<SampleDiff> regressions = diffs.stream()
.filter(d -> d.getFaithfulnessDelta() < -0.1 || d.getRelevanceDelta() < -0.1)
.collect(Collectors.toList());
double avgFaithfulnessDelta = diffs.stream()
.mapToDouble(SampleDiff::getFaithfulnessDelta)
.average().orElse(0);
return VersionComparisonReport.builder()
.baselineId(baselineId)
.targetId(targetId)
.avgFaithfulnessDelta(avgFaithfulnessDelta)
.regressions(regressions)
.improvements(diffs.stream()
.filter(d -> d.getFaithfulnessDelta() > 0.1)
.collect(Collectors.toList()))
.build();
}
}六、评估流水线
七、把评估集成到 CI/CD
每次合代码之前,自动跑一遍评估:
# .github/workflows/rag-evaluation.yml
name: RAG Quality Evaluation
on:
pull_request:
paths:
- 'src/main/resources/prompts/**'
- 'src/main/java/**/rag/**'
jobs:
evaluate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run RAG Evaluation
run: |
./mvnw test -Dtest=RagEvaluationIntegrationTest
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Check Quality Gate
run: |
# 读取评估结果,如果低于阈值则失败
python check_quality_gate.py \
--faithfulness-threshold 0.80 \
--relevance-threshold 0.75 \
--result-file target/evaluation-results.json八、总结
回到那个产品经理的问题:"你怎么知道 RAG 的质量好不好?"
现在我可以这样回答:
- 我们有 150 个覆盖主要业务场景的测试问题
- 每次改动后自动运行评估,忠实性 82%,回答相关性 79%,比上个版本提升了 7%
- 改动 A 导致了 3 个问题的回答质量下降,已经定位到原因并修复
这才是一个工程师应该给出的答案。
自动化评估不是为了追求"满分",而是为了让每一次改动都有数据支撑,让质量变化可见、可追踪、可回归。
