10. 评估与优化

4 min

RAG 能跑≠跑得好。以下问题没有评估就无法回答:

  • 检索是否召回了正确文档?
  • 分块大小取多少合适?
  • topK 应该设为几?

没有评估,调参就是盲猜。回答质量由检索和生成共同决定,检索错了,LLM 再强也救不回来。

1. 检索质量指标

指标 含义 公式
Hit Rate topK 里有没有命中 命中数 / 总用例数
MRR 命中结果排第几 Σ(1/rank) / 总用例数
  • Hit Rate 只看"有没有":第一名和第五名都算命中
  • MRR 还看"排得好不好":第一名得 1 分、第二名 0.5 分、第五名 0.2 分

为什么两个都要看? 正确结果从第 1 掉到第 2,MRR 从 1→0.5 直接腰斩,Hit Rate 却纹丝不动。单看命中率会漏掉排序劣化的问题。

1.1 多少算合理

没有绝对标准,跟评估集质量、topK 设定、领域都强相关。topK=5 的常见场景下,有一组粗略经验区间可以拿来对照:

区间 Hit Rate MRR 含义
不可用 < 0.6 < 0.4 检索基本没工作,先查文档摄入和分块逻辑
凑合 0.6 ~ 0.8 0.4 ~ 0.6 能用但 LLM 经常拿到不相关上下文,回答质量不稳定
可上线 0.8 ~ 0.9 0.6 ~ 0.8 大部分问题能找到正确文档且排在前列
优秀 > 0.9 > 0.8 检索质量到顶,剩下的优化空间在 Prompt 和生成

两条经验:

  • topK 越大、Hit Rate 越容易高:topK=10 拿到 0.9 没什么稀奇,同 topK 下的 MRR 才更有信息量
  • MRR 受 Hit Rate 上限约束:MRR 不可能超过 Hit Rate,所以 Hit Rate 还在 0.6 时去追 0.8 的 MRR 是徒劳,先把召回拉上去再说

2. 评估框架

src/main/java/com/albertstack/rag/evaluation/EvalCase.java

java
package com.albertstack.rag.evaluation;

public record EvalCase(
        String question, // 评估用例的提问
        String expectedContent // 期望检索结果中包含的文本片段
) {}

src/main/java/com/albertstack/rag/evaluation/EvalResult.java

java
package com.albertstack.rag.evaluation;

public record EvalResult(
        double hitRate, // 命中率:命中数 / 总用例数
        double mrr, // 平均倒数排名:Σ(1 / 命中位置) / 总用例数
        int totalCases,
        int hits
) {
    @Override
    public String toString() {
        return "EvalResult{hitRate=%.2f%%, mrr=%.4f, hits=%d/%d}"
                .formatted(hitRate * 100, mrr, hits, totalCases);
    }
}

src/main/java/com/albertstack/rag/evaluation/RetrievalEvaluator.java

java
package com.albertstack.rag.evaluation;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Component;

import java.util.List;

@Slf4j
@Component
@RequiredArgsConstructor
public class RetrievalEvaluator {

    private final VectorStore vectorStore;

    public EvalResult evaluate(List<EvalCase> cases, int topK) {
        int hits = 0;
        double mrrSum = 0;

        for (var evalCase : cases) {
            var results = vectorStore.similaritySearch(
                    SearchRequest.builder()
                            .query(evalCase.question())
                            .topK(topK)
                            .build()
            );

            // 用 contains 而不是精确匹配,符合"找到相关段落即算命中"的直觉
            for (int i = 0; i < results.size(); i++) {
                if (results.get(i).getText().contains(evalCase.expectedContent())) {
                    hits++;
                    // i 从 0 起,加 1 后第一名得 1 分、第二名得 0.5 分
                    mrrSum += 1.0 / (i + 1);
                    break; // 一个用例只算排名最高的命中,避免重复计分
                }
            }
        }

        var result = new EvalResult(
                (double) hits / cases.size(),
                mrrSum / cases.size(),
                cases.size(),
                hits
        );
        log.info("评估结果: {}", result);
        return result;
    }
}

3. 跑一次评估

评估用例必须基于你实际摄入的文档来编写,不能凭空想象。

src/test/java/com/albertstack/rag/evaluation/RetrievalEvaluatorTest.java

java
package com.albertstack.rag.evaluation;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.List;

@Slf4j
@SpringBootTest
class RetrievalEvaluatorTest {

    @Autowired
    RetrievalEvaluator evaluator;

    @Test
    void shouldEvaluateRetrievalQuality() {
        var cases = List.of(
                new EvalCase("Spring AI 支持哪些向量数据库?", "Qdrant"),
                new EvalCase("Ollama 怎么管理模型?", "ollama"),
                new EvalCase("RAG 的核心思想是什么?", "检索"),
                new EvalCase("ChatClient 怎么使用?", "ChatClient"),
                new EvalCase("Embedding 是什么?", "向量")
                // 实际项目里建议准备 20-50 个用例以获得统计意义
        );

        var result = evaluator.evaluate(cases, 5);
        log.info("命中率={}, MRR={}", result.hitRate(), result.mrr());
    }
}

4. 对比不同参数

分块大小、topK、similarityThreshold 都用同一套方法做对比实验:固定其他参数,遍历目标参数的几个候选值,看 Hit Rate 和 MRR 怎么变。最简单的做法是用 @ParameterizedTest + @ValueSource 把候选值喂给同一个评估方法,每次的结果 log 出来对比。

不要去抄网上的"最佳分块大小"。中文技术文档、英文论文、对话日志的甜点都不一样,必须用自己的数据跑一遍才知道。可以根据调参顺序,影响大的先调:

分块大小 -> topK -> similarityThreshold -> Prompt 模板

5. LLM-as-Judge

检索是基础,回答质量还受生成环节影响。最可靠的评估是人工打分,但不可扩展。折中方案是用另一个 LLM 当评审。

src/main/java/com/albertstack/rag/evaluation/AnswerEvaluator.java

java
package com.albertstack.rag.evaluation;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class AnswerEvaluator {

    private final ChatClient chatClient;

    public double scoreAnswer(String question, String answer, String expectedPoints) {
        // 在 prompt 里明确给出 5 档评分标准,避免 LLM 用自己的标准随意打分
        String prompt = """
                你是一个严格的评估专家。请评估以下 RAG 系统的回答质量。

                评分标准(1-5 分):
                - 5 分:完全正确,覆盖所有要点,没有幻觉
                - 4 分:基本正确,覆盖主要要点
                - 3 分:部分正确,遗漏了一些要点
                - 2 分:大部分不正确或不相关
                - 1 分:完全错误或答非所问

                问题:%s
                期望要点:%s
                实际回答:%s

                只返回一个数字(1-5),不要任何解释。
                """.formatted(question, expectedPoints, answer);

        String score = chatClient.prompt().user(prompt).call().content();

        try {
            return Double.parseDouble(score.trim());
        } catch (NumberFormatException e) {
            // LLM 偶尔不听话返回长篇解释,给个中性分而不是抛异常打断流程
            log.warn("LLM 返回了非数字评分: {},默认给 3 分", score);
            return 3.0;
        }
    }
}

用一个明显正确的回答和一个明显错误的回答各跑一次,对比 LLM 评审给出的分数。

src/test/java/com/albertstack/rag/evaluation/AnswerEvaluatorTest.java

java
package com.albertstack.rag.evaluation;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@Slf4j
@SpringBootTest
class AnswerEvaluatorTest {

    @Autowired
    AnswerEvaluator answerEvaluator;

    @Test
    void shouldScoreAnswerQuality() {
        String question = "Spring AI 支持哪些向量数据库?";
        String expectedPoints = "Qdrant、Pinecone、Milvus、PGVector 等主流向量数据库,通过统一的 VectorStore 抽象屏蔽差异";

        // 用一对反差明显的答案做对照:好答案应该接近 5 分,差答案应该接近 1 分
        String goodAnswer = "Spring AI 通过 VectorStore 抽象支持 Qdrant、Pinecone、Milvus、PGVector 等主流向量数据库,切换实现只需改配置。";
        String badAnswer = "Spring AI 是一个前端可视化框架,主要用来画图表。";

        log.info("好答案: {}", goodAnswer);
        double goodScore = answerEvaluator.scoreAnswer(question, goodAnswer, expectedPoints);
        log.info("好答案得分: {}", goodScore);

        log.info("差答案: {}", badAnswer);
        double badScore = answerEvaluator.scoreAnswer(question, badAnswer, expectedPoints);
        log.info("差答案得分: {}", badScore);
    }
}

跑这个测试时重点看两件事:好答案和差答案的得分差距是不是足够大;同样的输入跑几次,分数会不会大幅波动。差距小或波动大,都说明 prompt 还需要再约束。

6. 问题定位

回答质量不好时,先看检索结果再判断问题出在哪一环:

java
var results = vectorStore.similaritySearch(
        SearchRequest.builder().query("你的测试问题").topK(5).build()
);
results.forEach(doc -> log.info("score={}, text={}",
        doc.getScore(), doc.getText().substring(0, Math.min(100, doc.getText().length()))));
  • 检索结果里 正确文档 -> 问题在生成环节,调 Prompt 模板,强调 "仅基于上下文回答"
  • 检索结果里 没有 正确文档 -> 问题在检索环节,按上面的调参顺序排查

7. 本章小结

搭起一套最小可用的评估框架:用 Hit Rate 和 MRR 量化检索质量,用对比实验找最优参数,用 LLM-as-Judge 兜底端到端答案质量。核心原则是先建评估集,再调参数,用数据说话

知识点 说明
Hit Rate / MRR 前者看"有没有",后者看"排第几",配合使用
RetrievalEvaluator 包装 vectorStore.similaritySearch,遍历用例统计命中和排名
对比实验 固定其他参数,遍历目标参数候选值,记录 Hit Rate 和 MRR 变化
LLM-as-Judge 用 ChatClient 给答案打 1-5 分,开发阶段快速验证端到端质量
调参顺序 分块大小 -> topK -> 阈值 -> Prompt,影响大的先调

接下来回顾整个专栏的路径,梳理架构全景图,并展望进阶方向。