Skip to content

课 1 · 评测集与检索指标

本课目标

建立标注问答数据集,用 Hit Rate、MRR、Recall@K 量化检索质量。课后你会有一个可重复运行的检索评测脚本。

关键理解:评测集是"已知答案应该在哪里",检索评测是看系统能不能找到那里。

为什么需要评测集

没有评测集的 RAG 优化是在黑箱里猜拳:

  • 改了切块策略,感觉好多了?怎么知道没把其他问题弄差?
  • 加了 Rerank,效果提升了?提升多少?
  • 模型换了,召回质量是变好还是变差?

评测集把这些问题变成可量化的对比。

评测集结构

每条数据包含:问题 + 标准答案应该来自哪些文档片段(ground truth)。

typescript
// datasets/eval/questions.jsonl
// 每行一条 JSON
{"id":"q001","question":"什么是 RAG?","groundTruth":["chunk-001","chunk-002"]}
{"id":"q002","question":"向量检索的余弦相似度如何计算?","groundTruth":["chunk-045"]}
{"id":"q003","question":"BM25 算法的核心参数是什么?","groundTruth":["chunk-023","chunk-024"]}

groundTruth 里存的是应该被召回的 chunk ID,需要人工标注(或者借助 LLM 辅助生成后人工验证)。

怎么标注(LLM 辅助 + 人工审核)

手工逐条标注太慢,用 LLM 辅助生成初版,人工审核即可:

typescript
// scripts/generate-eval-dataset.ts
import { generateObject } from 'ai'
import { z } from 'zod'

const qaSchema = z.object({
  questions: z.array(
    z.object({
      question: z.string(),
      groundTruth: z.array(z.string()),  // chunk IDs
    })
  ),
})

async function generateQAFromChunk(chunk: { id: string; content: string }) {
  const { object } = await generateObject({
    model: getModel(),
    schema: qaSchema,
    prompt: `
根据以下文档片段(ID: ${chunk.id}),生成 2-3 个典型问题,这些问题的答案应该主要来自这个片段。

文档片段:
${chunk.content}

要求:问题要自然,像真实用户会问的。
    `.trim(),
  })

  return object.questions.map((q) => ({
    ...q,
    groundTruth: [chunk.id],  // 这个问题的标准答案来自这个 chunk
  }))
}

检索指标

Hit Rate(命中率)

Hit Rate=至少命中一个 ground truth 的问题数总问题数

只要 Top-K 里有一个 ground truth chunk,就算命中。是最宽松的指标。

MRR(Mean Reciprocal Rank)

MRR=1|Q|i=1|Q|1ranki

ranki 是第 i 个问题中第一个 ground truth 出现在结果列表的位置。MRR 关注第一个命中的位置,排名越靠前越好。

Recall@K

Recall@K=|(Top-K 结果)ground truth||ground truth|

在 Top-K 里找到了多少比例的 ground truth。

评测脚本

typescript
// packages/eval-core/src/retrieval-eval.ts
import type { IngestJob } from '@shared/types/job'

interface EvalQuestion {
  id: string
  question: string
  groundTruth: string[]  // chunk IDs
}

interface RetrievalMetrics {
  hitRate: number
  mrr: number
  recallAtK: Record<number, number>
}

export async function evaluateRetrieval(
  questions: EvalQuestion[],
  retrieveFn: (q: string, topK: number) => Promise<Array<{ id: string }>>,
  ks = [1, 3, 5],
): Promise<RetrievalMetrics> {
  const maxK = Math.max(...ks)
  let hits = 0
  let reciprocalRankSum = 0
  const recallSums: Record<number, number> = Object.fromEntries(ks.map((k) => [k, 0]))

  for (const q of questions) {
    const results = await retrieveFn(q.question, maxK)
    const resultIds = results.map((r) => r.id)
    const groundTruthSet = new Set(q.groundTruth)

    // Hit Rate
    const hasHit = resultIds.some((id) => groundTruthSet.has(id))
    if (hasHit) hits++

    // MRR
    const firstHitIndex = resultIds.findIndex((id) => groundTruthSet.has(id))
    if (firstHitIndex >= 0) reciprocalRankSum += 1 / (firstHitIndex + 1)

    // Recall@K
    for (const k of ks) {
      const topK = resultIds.slice(0, k)
      const recalled = topK.filter((id) => groundTruthSet.has(id)).length
      recallSums[k] += recalled / q.groundTruth.length
    }
  }

  const n = questions.length
  return {
    hitRate: hits / n,
    mrr: reciprocalRankSum / n,
    recallAtK: Object.fromEntries(ks.map((k) => [k, recallSums[k] / n])),
  }
}

运行评测

typescript
// scripts/run-eval.ts
import { readFileSync } from 'node:fs'
import { evaluate } from '../packages/eval-core/src/retrieval-eval'
import { retrieve } from '../packages/rag-core/src/retrieval'

const questions = readFileSync('datasets/eval/questions.jsonl', 'utf-8')
  .trim()
  .split('\n')
  .map(JSON.parse)

const metrics = await evaluate(questions, (q, k) => retrieve(q, k))

console.log('=== 检索评测结果 ===')
console.log(`Hit Rate: ${(metrics.hitRate * 100).toFixed(1)}%`)
console.log(`MRR: ${metrics.mrr.toFixed(3)}`)
console.log(`Recall@1: ${(metrics.recallAtK[1] * 100).toFixed(1)}%`)
console.log(`Recall@3: ${(metrics.recallAtK[3] * 100).toFixed(1)}%`)
console.log(`Recall@5: ${(metrics.recallAtK[5] * 100).toFixed(1)}%`)

本节产物

datasets/eval/
  questions.jsonl         # 标注问答集
scripts/
  generate-eval-dataset.ts
  run-eval.ts
packages/eval-core/src/
  retrieval-eval.ts       # 检索指标计算

面试追问

MRR 和 Recall@K 分别衡量什么?

MRR 关注"第一个正确答案出现在第几位",衡量排序质量,适合用户只看第一个结果的场景(搜索引擎)。Recall@K 关注"Top-K 里覆盖了多少正确答案",衡量覆盖率,适合 RAG 这种需要多个相关片段的场景。进阶项目更关注 Recall@5,因为 LLM 会综合多个 chunk 生成答案。

评测集多大才够用?

100-200 条对进阶课足够,关键是要覆盖典型场景(精确词匹配、语义相关、多跳推理、无答案问题)。太少的评测集会让指标有较大方差,很难看出改动的真实效果。

面向前端工程师和独立开发者的 AI 应用工程课程