RAG评估自动化:搭建RAG质量持续评估流水线
2026/4/30大约 9 分钟
RAG评估自动化:搭建RAG质量持续评估流水线
适读人群:已经有RAG系统在跑、但不知道如何客观衡量质量的工程师 阅读时长:约20分钟
"感觉还行"不是答案
团队里有个同事叫小赵,他做了一套客服RAG系统,迭代了两个多月。每次我问他:"现在效果怎么样?"
他都会说:"感觉还行,比上个版本好一点。"
我问:"好在哪里?"
他说:"嗯……我测了几条问题,回答更准了。"
"测了多少条?"
"大概……十几条吧。"
我没再说什么,但心里清楚:这套系统根本没有被认真评估过。"感觉还行"在客服场景下是非常危险的——一旦出现回答错误,可能就是投诉和退款。
更糟的是,他每次改了检索参数或者换了Embedding模型,完全不知道这个改动到底是变好了还是变坏了,只能靠"感觉"。
这就是没有评估体系的代价:你在黑暗中迭代,不知道自己走向哪里。
今天我们来聊如何搭建一条RAG质量的持续评估流水线,从手工测几条,升级到自动化、可量化、可对比的质量体系。
RAG评估的核心维度
评估框架技术选型
| 框架 | 语言 | 特点 | 适合场景 |
|---|---|---|---|
| RAGAS | Python | 最成熟,指标最全 | 快速验证,Python栈 |
| TruLens | Python | 支持多种模型评估 | 实验性评估 |
| LLM-as-Judge | 任意语言 | 灵活,成本可控 | Java栈集成 |
| 人工标注 | - | 最准确 | 建立Ground Truth |
对于Java项目,我们用LLM-as-Judge(用LLM来评估LLM的输出)方案,直接集成在Spring AI里,不需要切换语言。
完整实现:RAG评估流水线
评估数据集结构
/**
* RAG评估测试用例
* 每条测试用例包含:问题、期望答案、相关文档(Ground Truth)
*/
@Data
@Builder
@Entity
@Table(name = "rag_test_cases")
public class RagTestCase {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String caseId; // 唯一标识
@Column(length = 2000)
private String question; // 测试问题
@Column(length = 5000)
private String expectedAnswer; // 标准答案(Ground Truth)
@ElementCollection
private List<String> relevantDocIds; // 相关文档ID(检索评估用)
private String category; // 问题类别(如:价格查询/退款政策/售后流程)
private String difficulty; // 难度:EASY / MEDIUM / HARD
private LocalDateTime createdAt;
private String createdBy;
}
/**
* 单次评估结果
*/
@Data
@Builder
@Entity
@Table(name = "rag_eval_results")
public class RagEvalResult {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String evalRunId; // 评估批次ID
private String caseId;
private LocalDateTime evaluatedAt;
// 检索评估结果
private Double retrievalPrecision;
private Double retrievalRecall;
private Double retrievalMrr;
@ElementCollection
private List<String> retrievedDocIds; // 实际检索到的文档ID
// 生成评估结果(由LLM-as-Judge打分)
private Double faithfulnessScore; // 忠实度 [0,1]
private Double answerRelevancyScore; // 答案相关性 [0,1]
private Double correctnessScore; // 正确性 [0,1]
private Double completenessScore; // 完整度 [0,1]
@Column(length = 2000)
private String actualAnswer; // 实际生成的答案
@Column(length = 3000)
private String judgeReasoning; // Judge模型的评分理由
private String evalStatus; // SUCCESS / FAILED / SKIPPED
private Long latencyMs;
}LLM-as-Judge评估器
/**
* 用LLM评估RAG输出质量
* 核心思路:让GPT-4这样强的模型来评估较弱模型(或业务模型)的输出
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class LlmJudgeEvaluator {
private final ChatClient judgeClient; // 用于评估的LLM,建议用最强的模型
private final ObjectMapper objectMapper;
private static final String FAITHFULNESS_PROMPT = """
你是一个专业的RAG系统质量评估专家。
请评估以下AI回答的"忠实度"(Faithfulness):
即AI的回答是否完全基于提供的上下文文档,没有编造文档中不存在的信息。
【用户问题】
{question}
【检索到的上下文文档】
{context}
【AI的回答】
{answer}
评估要求:
1. 仔细检查回答中的每个陈述,判断是否能在上下文文档中找到依据
2. 如果回答包含文档中没有的信息(幻觉),扣分
3. 如果回答完全基于文档内容,给高分
请以JSON格式输出:
{
"score": 0.0到1.0之间的小数(1.0=完全忠实,0.0=严重幻觉),
"reasoning": "评分理由,指出哪些内容有/没有文档依据",
"hallucinations": ["幻觉1", "幻觉2"] // 列出发现的幻觉,如果没有则为空数组
}
""";
private static final String RELEVANCY_PROMPT = """
你是一个专业的RAG系统质量评估专家。
请评估以下AI回答的"答案相关性"(Answer Relevancy):
即AI的回答是否直接回答了用户的问题,没有废话、跑题或答非所问。
【用户问题】
{question}
【AI的回答】
{answer}
评估标准:
- 1.0分:回答完全切题,直接解答了问题的所有方面
- 0.7分:回答基本切题,但有少量无关内容
- 0.4分:回答部分相关,有明显跑题
- 0.0分:回答完全不相关
请以JSON格式输出:
{
"score": 0.0到1.0之间的小数,
"reasoning": "评分理由"
}
""";
private static final String CORRECTNESS_PROMPT = """
你是一个专业的RAG系统质量评估专家。
请对比AI回答与标准答案,评估"正确性"(Correctness)。
【用户问题】
{question}
【标准答案(Ground Truth)】
{expected_answer}
【AI的回答】
{actual_answer}
评估标准:
- 1.0分:内容完全正确,与标准答案一致
- 0.7分:主要信息正确,有少量差异
- 0.4分:部分正确,有明显错误
- 0.0分:主要内容错误
请以JSON格式输出:
{
"score": 0.0到1.0之间的小数,
"reasoning": "评分理由,指出与标准答案的具体差异"
}
""";
/**
* 评估单个RAG响应的综合质量
*/
public EvalScores evaluate(String question, String context,
String actualAnswer, String expectedAnswer) {
// 并行评估多个维度
CompletableFuture<ScoreResult> faithfulnessFuture =
CompletableFuture.supplyAsync(() ->
evaluateSingleDimension(FAITHFULNESS_PROMPT, Map.of(
"question", question,
"context", context,
"answer", actualAnswer
))
);
CompletableFuture<ScoreResult> relevancyFuture =
CompletableFuture.supplyAsync(() ->
evaluateSingleDimension(RELEVANCY_PROMPT, Map.of(
"question", question,
"answer", actualAnswer
))
);
CompletableFuture<ScoreResult> correctnessFuture =
CompletableFuture.supplyAsync(() ->
evaluateSingleDimension(CORRECTNESS_PROMPT, Map.of(
"question", question,
"expected_answer", expectedAnswer,
"actual_answer", actualAnswer
))
);
try {
ScoreResult faithfulness = faithfulnessFuture.get(30, TimeUnit.SECONDS);
ScoreResult relevancy = relevancyFuture.get(30, TimeUnit.SECONDS);
ScoreResult correctness = correctnessFuture.get(30, TimeUnit.SECONDS);
return EvalScores.builder()
.faithfulnessScore(faithfulness.getScore())
.answerRelevancyScore(relevancy.getScore())
.correctnessScore(correctness.getScore())
.reasoning(String.format(
"忠实度: %s\n相关性: %s\n正确性: %s",
faithfulness.getReasoning(),
relevancy.getReasoning(),
correctness.getReasoning()
))
.build();
} catch (Exception e) {
log.error("LLM评估失败", e);
return EvalScores.failed(e.getMessage());
}
}
private ScoreResult evaluateSingleDimension(String promptTemplate,
Map<String, String> variables) {
// 填充模板变量
String filledPrompt = promptTemplate;
for (Map.Entry<String, String> var : variables.entrySet()) {
filledPrompt = filledPrompt.replace("{" + var.getKey() + "}", var.getValue());
}
try {
String response = judgeClient.prompt()
.user(filledPrompt)
.call()
.content();
// 提取JSON部分(LLM有时会在JSON前后加文字)
String jsonPart = extractJson(response);
JsonNode result = objectMapper.readTree(jsonPart);
return ScoreResult.builder()
.score(result.get("score").asDouble())
.reasoning(result.get("reasoning").asText())
.build();
} catch (Exception e) {
log.error("单维度评估失败: {}", e.getMessage());
return ScoreResult.builder().score(-1).reasoning("评估失败: " + e.getMessage()).build();
}
}
private String extractJson(String text) {
int start = text.indexOf('{');
int end = text.lastIndexOf('}');
if (start >= 0 && end > start) {
return text.substring(start, end + 1);
}
return text;
}
}评估流水线调度器
/**
* 评估流水线:自动化执行评估、存储结果、生成报告
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class RagEvalPipeline {
private final RagTestCaseRepository testCaseRepo;
private final RagEvalResultRepository evalResultRepo;
private final LlmJudgeEvaluator judgeEvaluator;
private final HybridSearchService searchService; // 你的RAG检索服务
private final ChatClient ragChatClient; // 你的RAG问答服务
/**
* 执行完整评估流水线
* 可以手动触发,也可以配置定时任务
*/
@Transactional
public EvalRunReport runEvaluation(String description) {
String runId = "eval-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
log.info("开始评估批次: runId={}, description={}", runId, description);
List<RagTestCase> testCases = testCaseRepo.findAllActive();
log.info("共{}条测试用例", testCases.size());
List<RagEvalResult> results = new ArrayList<>();
for (RagTestCase testCase : testCases) {
try {
RagEvalResult result = evaluateSingleCase(runId, testCase);
results.add(result);
evalResultRepo.save(result);
log.debug("完成用例 {}: 正确性={:.2f}, 忠实度={:.2f}",
testCase.getCaseId(),
result.getCorrectnessScore(),
result.getFaithfulnessScore());
} catch (Exception e) {
log.error("用例{}评估失败: {}", testCase.getCaseId(), e.getMessage());
results.add(RagEvalResult.builder()
.evalRunId(runId)
.caseId(testCase.getCaseId())
.evalStatus("FAILED")
.build());
}
}
EvalRunReport report = generateReport(runId, results);
log.info("评估完成: runId={}, 综合分数={:.3f}", runId, report.getOverallScore());
return report;
}
private RagEvalResult evaluateSingleCase(String runId, RagTestCase testCase) {
long startTime = System.currentTimeMillis();
// 1. 执行检索
List<Document> retrievedDocs = searchService.search(testCase.getQuestion());
List<String> retrievedDocIds = retrievedDocs.stream()
.map(doc -> (String) doc.getMetadata().get("id"))
.filter(Objects::nonNull)
.collect(Collectors.toList());
// 2. 计算检索指标
double precision = calculatePrecision(retrievedDocIds, testCase.getRelevantDocIds());
double recall = calculateRecall(retrievedDocIds, testCase.getRelevantDocIds());
double mrr = calculateMrr(retrievedDocIds, testCase.getRelevantDocIds());
// 3. 生成回答
String context = retrievedDocs.stream()
.map(Document::getContent)
.collect(Collectors.joining("\n\n---\n\n"));
String actualAnswer = ragChatClient.prompt()
.system("基于以下文档回答用户问题,不要编造文档中没有的信息。\n\n" + context)
.user(testCase.getQuestion())
.call()
.content();
// 4. LLM评估生成质量
EvalScores scores = judgeEvaluator.evaluate(
testCase.getQuestion(),
context,
actualAnswer,
testCase.getExpectedAnswer()
);
return RagEvalResult.builder()
.evalRunId(runId)
.caseId(testCase.getCaseId())
.evaluatedAt(LocalDateTime.now())
.retrievedDocIds(retrievedDocIds)
.retrievalPrecision(precision)
.retrievalRecall(recall)
.retrievalMrr(mrr)
.faithfulnessScore(scores.getFaithfulnessScore())
.answerRelevancyScore(scores.getAnswerRelevancyScore())
.correctnessScore(scores.getCorrectnessScore())
.actualAnswer(actualAnswer)
.judgeReasoning(scores.getReasoning())
.evalStatus("SUCCESS")
.latencyMs(System.currentTimeMillis() - startTime)
.build();
}
private double calculatePrecision(List<String> retrieved, List<String> relevant) {
if (retrieved.isEmpty()) return 0;
long relevantRetrieved = retrieved.stream()
.filter(relevant::contains).count();
return (double) relevantRetrieved / retrieved.size();
}
private double calculateRecall(List<String> retrieved, List<String> relevant) {
if (relevant.isEmpty()) return 1.0;
long relevantRetrieved = retrieved.stream()
.filter(relevant::contains).count();
return (double) relevantRetrieved / relevant.size();
}
private double calculateMrr(List<String> retrieved, List<String> relevant) {
for (int i = 0; i < retrieved.size(); i++) {
if (relevant.contains(retrieved.get(i))) {
return 1.0 / (i + 1);
}
}
return 0;
}
}定时触发与报告推送
/**
* 定时评估 + 质量下降告警
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class EvalScheduler {
private final RagEvalPipeline evalPipeline;
private final EvalAlertService alertService;
private final EvalRunReportRepository reportRepo;
// 每天凌晨2点执行评估
@Scheduled(cron = "0 0 2 * * ?")
public void dailyEval() {
log.info("开始每日定时评估");
try {
EvalRunReport report = evalPipeline.runEvaluation("每日定时评估");
// 与昨日对比,如果指标下降超过5%则告警
EvalRunReport lastReport = reportRepo.findLatestBefore(report.getRunId());
if (lastReport != null) {
double scoreDrop = lastReport.getOverallScore() - report.getOverallScore();
if (scoreDrop > 0.05) {
alertService.sendAlert(String.format(
"RAG质量告警:综合评分从 %.3f 下降到 %.3f(下降%.1f%%),请及时排查",
lastReport.getOverallScore(),
report.getOverallScore(),
scoreDrop * 100
));
}
}
} catch (Exception e) {
log.error("定时评估失败", e);
alertService.sendAlert("RAG定时评估失败: " + e.getMessage());
}
}
}建立 Ground Truth 的实用方法
很多人问我:标准答案怎么来?
有几种方式:
- 专家标注:让业务专家回答100-500个典型问题,作为Ground Truth。成本高但质量最好
- 历史数据挖掘:从客服记录里找出人工回复,清洗后作为标准答案
- LLM生成+人工审核:先用GPT-4生成,再由业务专家审核修改
- 用户反馈收集:上线后收集用户的"有帮助/没帮助"反馈,积累真实数据
我的建议:从50条核心场景开始,覆盖主要业务场景,先建起体系,再逐步扩充。一百条高质量的测试用例比一千条低质量的更有价值。
