RAG 实战(五):给 RAG 做量化评估

目录


一、为什么得先有尺子

前四篇里,每一次优化都是这样验证的:跑几个查询,看看结果,觉得「好像准了点」。

这个做法有个致命缺陷——它无法区分「真的变好了」和「我看到的这几个例子恰好变好了」。 更糟的是,当你投入了半天时间实现 Rerank,你的判断天然倾向于确认它有效。这不是不诚实,是人正常的认知偏差。

把评估做起来,本质上是把「我觉得不错」替换成「在一组固定的问题上,指标从 A 变成了 B」。它带来三件具体的好处:

  1. 优化有方向:知道是召回不够还是排序不准,才不会瞎调参数。
  2. 改动有护栏:调 Prompt 修好了这个问题,有没有弄坏另一个问题,回归测试会告诉你。
  3. 结论可复现:换个人、过一个月,跑同一套评估得到同一组数字。

评估要分成两层来做,因为它们的失败模式完全不同:

层面评的是典型的失败衡量方式
检索相关文档有没有被捞进 Top-K正确文档压根没召回,模型无机可用Recall@K、Precision@K、MRR
生成回答是否忠于材料、是否切题材料在手,模型还是编Faithfulness、Answer Relevancy

分层是必须的。 如果只有一个笼统的「准确率」,当它下降时你完全不知道是哪一层坏了——是检索退化了,还是模型变懒了。


二、检索指标

检索指标的好处是可以精确计算,不依赖任何模型判断。因为「哪些文档是相关的」这件事,在测试集里是人工标注好的。

2.1 Recall@K

Recall@K = |Top-K 结果 ∩ 相关文档| / |全部相关文档|

它回答的问题是:该找的,找回来多少。

打个捕鱼的比方:水里有 10 条你想要的目标鱼,一网下去捞上来 8 条,召回率就是 0.8。注意分母是「水里总共有多少条鱼」,而不是「你捞上来多少条」。

这里有个容易踩的坑:Recall@K 的分母是全部相关文档数,所以当 K 小于相关文档总数时,Recall@K 存在一个无法突破的上限。

相关文档共 10 篇,只看 Top-5:
最多只能捞到 5 篇 → Recall@5 ≤ 5/10 = 0.5

也就是说,看到 Recall@5 = 0.67 而相关文档有 10 篇时,可以直接断定这个数字算错了——它在数学上不可能出现。这不是吹毛求疵,评估代码里分母取错(比如误用 K 当分母)是常见 bug,而它会静默地给你一个偏低的分数,让你去优化一个根本不存在的问题。

2.2 Precision@K

Precision@K = |Top-K 结果 ∩ 相关文档| / K

它回答的问题是:捞上来的,有多少是真正要的。

分母是 K——你交出去的结果条数。这次捞上 8 条鱼加 2 只虾,精确率就是 0.8。

Recall 和 Precision 天然互相拉扯:想提高召回,就把 K 调大,但精确率会掉;想提高精确率,就把门槛抬高,召回又会掉。

在 RAG 里,两者不对称。 漏掉相关文档(低 Recall)模型就失去了回答依据,后果严重且不可挽回;多进来几条不相关文档(低 Precision)还有精排和 Prompt 指令兜底。所以优先保 Recall——这也正是粗排-精排架构成立的理由:粗排放宽保召回,精排收紧保精确。

2.3 MRR

RR  = 1 / 第一篇相关文档的排名
MRR = 所有查询 RR 的平均值

它衡量的是:第一篇有用的结果排在第几位。

相关文档排第 1 位 → RR = 1/1 = 1.0
相关文档排第 2 位 → RR = 1/2 = 0.5
相关文档排第 5 位 → RR = 1/5 = 0.2

只看第一篇命中,后面的不管——这不是偷懒,而是因为 RAG 的实际消费方式是「把 Top-K 塞进 Prompt」。 排在第 1 位和第 5 位,对模型的注意力影响是真实存在的(这也是第五篇延伸阅读里那篇 Lost in the Middle 的主题)。MRR 用一个小数概括了「好东西来得够不够早」。

MRR 的局限也要清楚:它完全忽略第二篇及之后的相关文档。如果你的场景是「需要综合多篇材料回答」,MRR 会低估实际效果,这时更适合 NDCG 或 MAP。


三、生成指标

检索指标能精确算,生成指标不行。

原因在于「回答是否忠于材料」这个判断,本质上是自然语言理解任务——它没有可以代入公式的答案。业界主流做法是用 LLM 当裁判:把回答拆成若干条独立陈述,逐条判断能否由检索到的材料支持,能支持的占比就是 Faithfulness。

先说清楚一件事:本节的示例代码里,这两个生成指标是简化实现,不是 RAGAS,也不是 LLM 裁判。 它们的定位是「把评估流程先搭通」,不是「拿去汇报的数字」。下面逐个说明它们在做什么、以及为什么不能当真。

3.1 Faithfulness(忠实度)

真正的 Faithfulness 回答的是:回答里的每一句话,是否都能在检索到的材料里找到依据。

问题:"年假有多少天?"
材料:"员工入职满一年后,年假 10 天。"

回答 A:"年假是 10 天。"              → 忠实,完全有依据
回答 B:"年假是 15 天。"              → 不忠实,数字与材料冲突
回答 C:"年假是 10 天,并且公司提供免费午餐。" → 部分不忠实,后半句是编的

示例代码里的实现是:抽取回答中出现的所有数字,检查这些数字是否也出现在检索材料中,用命中比例作为分数;如果回答里一个数字都没有,直接返回硬编码的 0.5。

这个实现能抓住「回答 B」这类数字冲突——这是它唯一的用处,而且确实覆盖了 RAG 里最常见的幻觉形态(乱报数字)。但它有三个明显的空洞:

  • 只查数字。 回答 C 里那句「提供免费午餐」,一个数字都不涉及,完全不会被发现。
  • 无数字的回答一律 0.5。 一个纯文字编造的回答,和白卷一样拿到 0.5。这个值不代表任何含义。
  • 一个数字命中就是 1.0。 回答里其余全是编的,只要那个数字对,忠实度满分。

结论:这个分数只能用作「有没有数字级冲突」的信号,绝不能作为 Faithfulness 对外引用。 要拿真指标,得接 RAGAS 或自己写一个 LLM 裁判。

3.2 Answer Relevancy(回答相关性)

RAGAS 里的 Answer Relevancy 是这么算的:让 LLM 根据生成的回答反推「这段话大概在回答什么问题」,再算这个反推问题跟原始问题的相似度。 回答越切题,反推出来的问题就越接近原问题。

而示例代码里实现的是另一个东西:回答与「期望答案」之间的相似度。这更接近「答案正确性」或「与参考答案的一致度」,而不是「回答切不切题」。名字和实际语义对不上,是看这类评估代码时最容易误解的地方。

更要注意的是它的相似度算法——按空白字符切词,然后算 Jaccard 相似度:

Set<String> expectedWords = new HashSet<>(Arrays.asList(expected.split("\\s+")));
Set<String> actualWords = new HashSet<>(Arrays.asList(actual.split("\\s+")));

对中文来说这个切分是失效的,因为中文句子内部没有空格。"年假是10天" 经 split("\\s+") 之后得到的是一个只含整句话的单元素集合,而 "年假为10天" 得到的是另一个单元素集合。两个集合的交集为空,Jaccard = 0。

也就是说,这个指标对中文实际上退化成二值判断:两句话一字不差得 1,否则得 0。 它在英文文本上还能勉强工作,在中文上基本没有分辨力。

这不是在挑刺——它恰恰是「评估代码本身也需要被验证」的典型案例。一个指标算错了不会有任何报错,它只会安静地给出一个看起来像那么回事的数字,然后你把优化方向建在这个数字上。


四、测试集怎么建

测试集是评估的地基,它的质量决定了所有指标的可信度。每个用例需要三样东西:问题、期望答案、以及相关文档的 ID 列表。

最后一项是关键,也是最容易被省掉的。没有它,检索指标就算不出来——Recall 的分母、MRR 的排名,全都依赖「哪些文档算是相关的」这个人工标注。

标注相关文档时有几个实际经验:

  • 相关性是分级的。 一篇直接回答了问题的文档,和一篇只是提到了相关概念的文档,不该被同等对待。哪怕先只标「强相关」和「弱相关」两档,也比一刀切好。
  • 相关文档往往不止一篇。 只标一篇会导致 Recall 的分母偏小、指标虚高。把材料里所有能支撑答案的片段都标上。
  • 一定要包含「材料里没有答案」的用例。 这类问题的正确行为是承认「资料不足」。如果测试集里全是能答上来的问题,你永远测不出模型会不会编。

覆盖面上,至少要包含这四种:

类型例子主要考察
直接事实入职 3 年年假多少天基础链路是否通
换个说法带薪休假能休几天语义召回能力
专有名词GPT-4 的上下文窗口多大关键词召回能力(第三篇的靶子)
材料无答案公司年会在哪里办会不会编(Faithfulness 的靶子)

规模上,几十条就能看出趋势,不必追求上千条。但要注意:样本量小的时候,单条用例的翻转对均值的扰动很大。所以评估结果要连同「原始逐条结果」一起看,不要只看汇总的均值和总分。


五、完整代码

5.1 评估模型

EvaluationResult.java

package com.rag.evaluation;

import lombok.Builder;
import lombok.Data;

import java.util.List;

/**
 * 评估结果
 */
@Data
@Builder
public class EvaluationResult {
    /**
     * 查询问题
     */
    private String question;

    /**
     * 期望的答案
     */
    private String expectedAnswer;

    /**
     * 实际生成的答案
     */
    private String actualAnswer;

    /**
     * 检索到的文档 ID 列表
     */
    private List<Long> retrievedDocIds;

    /**
     * 相关的文档 ID 列表(标准答案)
     */
    private List<Long> relevantDocIds;

    /**
     * 检索指标
     */
    private RetrievalMetrics retrievalMetrics;

    /**
     * 生成指标
     */
    private GenerationMetrics generationMetrics;
}

/**
 * 检索指标
 */
@Data
@Builder
public class RetrievalMetrics {
    /**
     * Recall@K
     */
    private double recallAtK;

    /**
     * Precision@K
     */
    private double precisionAtK;

    /**
     * MRR(平均倒数排名)
     */
    private double mrr;
}

/**
 * 生成指标
 */
@Data
@Builder
public class GenerationMetrics {
    /**
     * Faithfulness(忠实度)
     * 回答是否基于检索到的文档
     */
    private double faithfulness;

    /**
     * Answer Relevancy(回答相关性)
     * 回答是否回答了问题
     */
    private double answerRelevancy;
}

5.2 评估服务

EvaluationService.java

package com.rag.evaluation;

import com.rag.model.Document;
import com.rag.service.EmbeddingService;
import com.rag.service.RagService;
import com.rag.service.VectorStoreService;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.*;
import java.util.stream.Collectors;

/**
 * RAG 评估服务
 *
 * 职责:自动化评估 RAG 系统的检索和生成质量
 *
 * 类比:考试阅卷老师
 * - 准备标准答案(测试集)
 * - 让学生答题(RAG 系统回答)
 * - 对比答案,打分
 */
@Slf4j
@Service
public class EvaluationService {

    private final RagService ragService;
    private final VectorStoreService vectorStoreService;
    private final EmbeddingService embeddingService;

    public EvaluationService(RagService ragService,
                             VectorStoreService vectorStoreService,
                             EmbeddingService embeddingService) {
        this.ragService = ragService;
        this.vectorStoreService = vectorStoreService;
        this.embeddingService = embeddingService;
    }

    /**
     * 运行评估
     *
     * @param testCases 测试用例
     * @return 评估结果列表
     */
    public List<EvaluationResult> evaluate(List<TestCase> testCases) {
        log.info("开始评估,测试用例数量: {}", testCases.size());

        List<EvaluationResult> results = new ArrayList<>();

        for (TestCase testCase : testCases) {
            try {
                EvaluationResult result = evaluateSingle(testCase);
                results.add(result);
                log.info("评估完成: {} - Recall@5: {}, Precision@5: {}",
                        testCase.getQuestion(),
                        result.getRetrievalMetrics().getRecallAtK(),
                        result.getRetrievalMetrics().getPrecisionAtK());
            } catch (Exception e) {
                log.error("评估失败: {}", testCase.getQuestion(), e);
            }
        }

        log.info("评估完成,成功: {}/{}", results.size(), testCases.size());
        return results;
    }

    /**
     * 评估单个测试用例
     */
    private EvaluationResult evaluateSingle(TestCase testCase) {
        // 第一步:获取 RAG 系统的回答
        String actualAnswer = ragService.ask(testCase.getQuestion());

        // 第二步:获取检索到的文档
        float[] queryEmbedding = embeddingService.embed(testCase.getQuestion());
        List<Document> retrievedDocs = vectorStoreService.searchByVector(queryEmbedding, 5);
        List<Long> retrievedDocIds = retrievedDocs.stream()
                .map(Document::getId)
                .collect(Collectors.toList());

        // 第三步:计算检索指标
        RetrievalMetrics retrievalMetrics = calculateRetrievalMetrics(
                retrievedDocIds, testCase.getRelevantDocIds());

        // 第四步:计算生成指标
        GenerationMetrics generationMetrics = calculateGenerationMetrics(
                testCase.getQuestion(), testCase.getExpectedAnswer(),
                actualAnswer, retrievedDocs);

        return EvaluationResult.builder()
                .question(testCase.getQuestion())
                .expectedAnswer(testCase.getExpectedAnswer())
                .actualAnswer(actualAnswer)
                .retrievedDocIds(retrievedDocIds)
                .relevantDocIds(testCase.getRelevantDocIds())
                .retrievalMetrics(retrievalMetrics)
                .generationMetrics(generationMetrics)
                .build();
    }

    /**
     * 计算检索指标
     */
    private RetrievalMetrics calculateRetrievalMetrics(List<Long> retrievedIds,
                                                       List<Long> relevantIds) {
        // 转换为 Set,方便查找
        Set<Long> retrievedSet = new HashSet<>(retrievedIds);
        Set<Long> relevantSet = new HashSet<>(relevantIds);

        // 计算 Recall@K
        // Recall = 检索到的相关文档数 / 总相关文档数
        long hitCount = retrievedSet.stream()
                .filter(relevantSet::contains)
                .count();
        double recall = relevantSet.isEmpty() ? 0 : (double) hitCount / relevantSet.size();

        // 计算 Precision@K
        // Precision = 检索到的相关文档数 / 检索到的文档总数
        double precision = retrievedSet.isEmpty() ? 0 : (double) hitCount / retrievedSet.size();

        // 计算 MRR
        // MRR = 1 / 第一个相关文档的排名
        double mrr = 0;
        for (int i = 0; i < retrievedIds.size(); i++) {
            if (relevantSet.contains(retrievedIds.get(i))) {
                mrr = 1.0 / (i + 1);
                break;
            }
        }

        return RetrievalMetrics.builder()
                .recallAtK(recall)
                .precisionAtK(precision)
                .mrr(mrr)
                .build();
    }

    /**
     * 计算生成指标
     *
     * 简化实现:使用字符串相似度作为近似
     * 生产环境建议使用 LLM 进行更精确的评估
     */
    private GenerationMetrics calculateGenerationMetrics(String question,
                                                         String expectedAnswer,
                                                         String actualAnswer,
                                                         List<Document> retrievedDocs) {
        // 计算 Faithfulness(简化版)
        // 检查回答是否包含检索到的文档中的关键信息
        double faithfulness = calculateFaithfulness(actualAnswer, retrievedDocs);

        // 计算 Answer Relevancy(简化版)
        // 检查回答和期望答案的相似度
        double answerRelevancy = calculateAnswerRelevancy(expectedAnswer, actualAnswer);

        return GenerationMetrics.builder()
                .faithfulness(faithfulness)
                .answerRelevancy(answerRelevancy)
                .build();
    }

    /**
     * 计算 Faithfulness(忠实度)
     *
     * 简化实现:检查回答中的关键信息是否出现在检索到的文档中
     */
    private double calculateFaithfulness(String answer, List<Document> retrievedDocs) {
        if (answer == null || answer.isEmpty()) {
            return 0;
        }

        // 合并所有检索到的文档内容
        String allContent = retrievedDocs.stream()
                .map(Document::getContent)
                .collect(Collectors.joining(" "));

        // 简单实现:检查回答中的数字是否出现在文档中
        // 生产环境应该使用 LLM 进行更精确的判断
        List<String> numbersInAnswer = extractNumbers(answer);
        List<String> numbersInDocs = extractNumbers(allContent);

        if (numbersInAnswer.isEmpty()) {
            return 0.5; // 如果回答中没有数字,给一个中间分数
        }

        long matchCount = numbersInAnswer.stream()
                .filter(numbersInDocs::contains)
                .count();

        return (double) matchCount / numbersInAnswer.size();
    }

    /**
     * 计算 Answer Relevancy(回答相关性)
     *
     * 简化实现:使用字符串相似度
     */
    private double calculateAnswerRelevancy(String expected, String actual) {
        if (expected == null || actual == null) {
            return 0;
        }

        // 简单的 Jaccard 相似度
        Set<String> expectedWords = new HashSet<>(Arrays.asList(expected.split("\\s+")));
        Set<String> actualWords = new HashSet<>(Arrays.asList(actual.split("\\s+")));

        Set<String> intersection = new HashSet<>(expectedWords);
        intersection.retainAll(actualWords);

        Set<String> union = new HashSet<>(expectedWords);
        union.addAll(actualWords);

        return union.isEmpty() ? 0 : (double) intersection.size() / union.size();
    }

    /**
     * 提取字符串中的数字
     */
    private List<String> extractNumbers(String text) {
        List<String> numbers = new ArrayList<>();
        java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("\\d+").matcher(text);
        while (matcher.find()) {
            numbers.add(matcher.group());
        }
        return numbers;
    }

    /**
     * 计算汇总统计
     */
    public EvaluationSummary summarize(List<EvaluationResult> results) {
        if (results.isEmpty()) {
            return EvaluationSummary.builder().build();
        }

        double avgRecall = results.stream()
                .mapToDouble(r -> r.getRetrievalMetrics().getRecallAtK())
                .average().orElse(0);

        double avgPrecision = results.stream()
                .mapToDouble(r -> r.getRetrievalMetrics().getPrecisionAtK())
                .average().orElse(0);

        double avgMrr = results.stream()
                .mapToDouble(r -> r.getRetrievalMetrics().getMrr())
                .average().orElse(0);

        double avgFaithfulness = results.stream()
                .mapToDouble(r -> r.getGenerationMetrics().getFaithfulness())
                .average().orElse(0);

        double avgAnswerRelevancy = results.stream()
                .mapToDouble(r -> r.getGenerationMetrics().getAnswerRelevancy())
                .average().orElse(0);

        return EvaluationSummary.builder()
                .totalQuestions(results.size())
                .avgRecallAtK(avgRecall)
                .avgPrecisionAtK(avgPrecision)
                .avgMrr(avgMrr)
                .avgFaithfulness(avgFaithfulness)
                .avgAnswerRelevancy(avgAnswerRelevancy)
                .build();
    }

    // ========== 内部类 ==========

    @Data
    public static class TestCase {
        private String question;
        private String expectedAnswer;
        private List<Long> relevantDocIds;
    }

    @Data
    @Builder
    public static class EvaluationSummary {
        @Builder.Default
        private int totalQuestions = 0;
        @Builder.Default
        private double avgRecallAtK = 0;
        @Builder.Default
        private double avgPrecisionAtK = 0;
        @Builder.Default
        private double avgMrr = 0;
        @Builder.Default
        private double avgFaithfulness = 0;
        @Builder.Default
        private double avgAnswerRelevancy = 0;
    }
}

这个类是整篇的核心。读它的时候请对照上面两节的说明:calculateRetrievalMetrics 是精确的集合运算,可以信任;calculateGenerationMetrics 及其下游两个方法是占位实现,理解流程即可,不要引用它的数值。

还有一处实现细节值得称赞:代码里明确写了「生产环境应该使用 LLM 进行更精确的判断」。把简化实现和它的适用边界写在注释里,是比假装它很准更负责的做法——接手的人不会误用。

5.3 评估控制器

EvaluationController.java

package com.rag.evaluation;

import com.rag.evaluation.EvaluationService.EvaluationSummary;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

/**
 * 评估 API 控制器
 */
@Slf4j
@RestController
@RequestMapping("/api/evaluation")
public class EvaluationController {

    private final EvaluationService evaluationService;

    public EvaluationController(EvaluationService evaluationService) {
        this.evaluationService = evaluationService;
    }

    /**
     * 运行评估
     */
    @PostMapping("/run")
    public ResponseEntity<Map<String, Object>> runEvaluation(@RequestBody EvaluationRequest request) {
        log.info("收到评估请求,测试用例数量: {}", request.getTestCases().size());

        try {
            // 运行评估
            List<EvaluationResult> results = evaluationService.evaluate(request.getTestCases());

            // 计算汇总
            EvaluationSummary summary = evaluationService.summarize(results);

            // 返回结果
            return ResponseEntity.ok(Map.of(
                    "success", true,
                    "summary", summary,
                    "details", results.stream().map(r -> Map.of(
                            "question", r.getQuestion(),
                            "expectedAnswer", r.getExpectedAnswer(),
                            "actualAnswer", r.getActualAnswer(),
                            "recall", r.getRetrievalMetrics().getRecallAtK(),
                            "precision", r.getRetrievalMetrics().getPrecisionAtK(),
                            "faithfulness", r.getGenerationMetrics().getFaithfulness()
                    )).collect(Collectors.toList())
            ));
        } catch (Exception e) {
            log.error("评估失败", e);
            return ResponseEntity.badRequest().body(Map.of(
                    "success", false,
                    "message", "评估失败: " + e.getMessage()
            ));
        }
    }

    @Data
    public static class EvaluationRequest {
        private List<EvaluationService.TestCase> testCases;
    }
}

六、跑一次评估

6.1 准备测试集

测试集 JSON 格式

[
  {
    "question": "入职3年能休多少天年假?",
    "expected_answer": "入职满一年后,年假10天。",
    "relevant_doc_ids": [1, 3]
  },
  {
    "question": "病假需要什么手续?",
    "expected_answer": "病假需提供医院证明,3天以内由直属领导批准。",
    "relevant_doc_ids": [5]
  }
]

一份可用的测试集

{
  "testCases": [
    {
      "question": "入职3年能休多少天年假?",
      "expectedAnswer": "入职满一年后,可享受带薪年假10天。",
      "relevantDocIds": [1]
    },
    {
      "question": "病假需要什么手续?",
      "expectedAnswer": "病假需提供医院证明,3天以内由直属领导批准。",
      "relevantDocIds": [2]
    },
    {
      "question": "年假可以累积到明年吗?",
      "expectedAnswer": "年假不可跨年累积,未使用的年假将在年底清零。",
      "relevantDocIds": [3]
    }
  ]
}

6.2 运行评估

# 运行评估
curl -X POST http://localhost:8080/api/evaluation/run \
  -H "Content-Type: application/json" \
  -d @test-cases.json

6.3 分析结果

输出示例

{
  "success": true,
  "summary": {
    "totalQuestions": 3,
    "avgRecallAtK": 0.67,
    "avgPrecisionAtK": 0.33,
    "avgMrr": 0.83,
    "avgFaithfulness": 0.85,
    "avgAnswerRelevancy": 0.72
  },
  "details": [...]
}

下面这段解读里的数字只是示例输出,不是任何一次真实运行的结果,请勿引用:

Recall@5 = 0.67:Top-5 命中的相关文档占全部相关文档的 67%
Precision@5 = 0.33:Top-5 里有约 1.7 条是相关的
MRR = 0.83:第一篇相关文档平均排在第 1.2 位
Faithfulness = 0.85:简化的数字比对实现给出的分数(见 3.1,不可当真)
Answer Relevancy = 0.72:简化的 Jaccard 实现给出的分数(见 3.2,中文下无效)

注意这份示例数据本身是自相矛盾的:它标注的相关文档有 10 篇,却给出 Recall@5 = 0.67。按 2.1 推导,相关文档 10 篇、K = 5 时 Recall@5 最大只能到 0.5,0.67 不可能出现。这正好说明为什么要把「K 与相关文档数的关系」在脑子里过一遍——评估代码算错了不会报错,只会给你一个错误的优化方向。

拿到结果的正确读法,是去看那些「翻转的样本」:

  • Recall 低 → 检索层的问题。先看是分词没生效(第三篇),还是 Embedding 对这类查询不敏感,还是切分把答案切碎了。
  • Recall 高但 MRR 低 → 找得到但排得后。这正是 Rerank 该解决的问题(第四篇)。
  • Recall 高、MRR 高,但 Faithfulness 低 → 检索没问题,是 Prompt 或模型的问题。回去检查 system 指令里有没有写「资料不足就说不知道」。

指标是分诊工具,不是考试分数。 它的用途是把你指向正确的那一层,而不是告诉你「你考了 85 分」。


七、常见误区

❌「评估器写好了,指标就可信了」 评估代码自己就是个程序,它也会算错,而且不会抛异常。分母取错、除零保护返回 0 被误读成「效果差」、中文分词失效——这些都会静默地产出一个数字。所以评估体系上线前,必须拿几个手工算得出答案的用例去校验评估器本身:两个已知完全无关的答案,相似度指标应该接近 0;三个已知的 Top-5 结果,Recall 应该能手算对得上。今天这篇里那两个生成指标的问题,就是靠这种方式才能发现的。

❌「Recall@K 可以跨 K 值比较」 Recall@5 和 Recall@10 不是同一个指标,前者天然更低(因为分母里那些没进 Top-5 的文档注定算不中)。报告指标时必须带上 K,说「Recall 是 0.8」而不说是 @5 还是 @10,等于没说。这也意味着「把 K 从 5 调到 10,Recall 涨了」不是优化成果,只是换了刻度。

❌「只用 LLM 当裁判,不用人工看」 LLM 裁判有明确的系统性偏差:倾向给冗长的回答高分、倾向给和自己风格接近的回答高分、对同一份材料换个顺序可能给出不同判断。 它的正确用法是处理规模(人工看 500 条不现实),但必须定期抽查——尤其是指标突然大幅变动的那一批样本,逐条人工看一眼。指标是仪表盘,不是最终判决。

❌「测试集建一次就够了」 测试集会过期。文档更新了、切分策略改了、Prompt 模板换了,旧测试集里的期望答案和相关文档标注可能全部失效,但指标照算不误,还会给你一种「系统很稳定」的错觉。文档一改,测试集必须跟着复核。

❌「只看总分」 汇总成一个平均值会掩盖最关键的信息——错误全集中在某一类问题上,均值完全看不出来。方差比均值更有信息量:如果 20 条用例里 15 条是满分、5 条是零分,均值 0.75 看起来很健康,但真实情况是「有五类问题系统性地答不了」。按用例类型分组统计,比一个总分有用得多。


八、自检清单

  • 能写出 Recall@K 和 Precision@K 的公式,并说出各自分母是什么
  • 知道相关文档 10 篇时,Recall@5 的理论上限是多少
  • 说得清 RAG 里为什么优先保 Recall 而不是 Precision
  • 知道 MRR 忽略第一篇之后的相关文档,以及这适合什么场景
  • 说得清 RAGAS 的 Answer Relevancy 是怎么算的(反推问题)
  • 知道示例代码里的 Answer Relevancy 为什么在中文上失效
  • 知道示例 Faithfulness 只检查了数字,会漏掉哪类幻觉
  • 能说出测试集为什么必须包含「材料里没有答案」的用例

附:延伸阅读

相关资料

资源说明
RAGAS: Automated Evaluation of RAGFaithfulness / Answer Relevancy 等指标的原始定义
RAGAS 文档各指标的实现细节与已知局限
Judging LLM-as-a-Judge (MT-Bench)LLM 裁判的偏差来源与缓解手段
Introduction to Information Retrieval, Ch. 8Recall / Precision / MAP / NDCG 的标准教材定义
Lost in the Middle为什么 MRR 关心的「排第几位」在 RAG 里真的重要
BEIR Benchmark跨领域检索评测集,构建测试集时的参考

关联笔记


最后更新:2026-09-28