前端开发者做 RAG:别靠肉眼验收,用 20 条评测集拦住上线翻车

2 阅读10分钟

作者:前端转 AI 深度实践者

【省流助手/核心观点】:RAG 优化不能靠“我问了几句,感觉还不错”。只要你改了切块、Embedding、Rerank、Prompt 或引用逻辑,就可能出现“这个问题变好了,另一个问题又坏了”。前端开发者做知识库问答,至少要准备一套 20 条左右的 RAG 评测集,用固定问题验证命中率、拒答准确率、引用命中率和回归风险。


很多 RAG 项目不是死在“做不出来”,而是死在“上线后才发现答错了”。

前面几篇我们已经聊过:

  • 文档切块会影响检索质量
  • 检索范围不能无脑全量搜索
  • Rerank 能把更关键的资料排到前面
  • 答案要带引用来源,不能只会说

这些优化都很有价值。
但你还需要回答一个更现实的问题:

这次改动真的让系统变好了吗?

很多团队调 RAG 的流程是这样的:

  1. 改一下 Prompt。
  2. 手动问 3 个熟悉的问题。
  3. 觉得答案更顺了。
  4. 上线。
  5. 用户用另一个问法把系统问崩。

问题不在于手动验证没用,而在于它太不稳定。你问的几个问题只能证明“这几个问题暂时没问题”,不能证明整个知识库问答系统没有回归。

这篇文章讲一个特别适合前端开发者落地的动作:给 RAG 做一套最小评测集

1. 痛点:RAG 最怕“局部变好,全局变差”

RAG 不是一个简单函数,而是一条链路:

用户问题 -> 查询处理 -> 检索 -> Rerank -> 上下文拼装 -> 生成 -> 引用校验 -> 前端展示

你改其中任何一环,都可能影响最终答案。

常见情况是:

  • 切块变大:上下文更完整,但检索噪音也变多。
  • Rerank 加严:错误引用减少,但本来能回答的问题被拒答。
  • Prompt 更保守:幻觉少了,但用户经常看到“无法确定”。
  • 引用校验更严格:证据链更可信,但老问题大量降级。

这就是 RAG 优化的麻烦之处:单个 case 变好,不代表整体质量变好。

前端同学应该很熟悉这种风险。你修了一个移动端样式问题,桌面端可能被影响;你改了一个状态判断,另一个交互可能回归。

所以 RAG 也需要测试用例,而且要能反复跑。

2. 错误做法:只靠 console 和肉眼判断

很多早期项目会这样验收:

const questions = [
  "怎么申请退款?",
  "会员可以开发票吗?",
  "接口超时怎么办?"
];

for (const question of questions) {
  const answer = await askRag(question);
  console.log(question, answer);
}

这段代码能帮你快速看效果,但它有 4 个明显缺口:

  1. 不知道标准答案应该是什么。
  2. 不知道应该命中哪些文档片段。
  3. 不知道这个问题是否应该拒答。
  4. 不知道和上一个版本相比是变好还是变差。

最后就会变成凭感觉判断:

  • “这句好像还行。”
  • “这个引用看起来也对。”
  • “这版回答比上版自然。”

但 RAG 最危险的答案,往往就是“看起来很自然”的错误答案。

3. 正确做法:把问题、答案和证据写成评测集

一套最小可用的 RAG 评测集,不需要一开始就做到几百条。
先准备 20 条高频问题,就能拦住很多明显回归。

type RagEvalCase = {
  id: string;
  question: string;
  expectedAnswer: string;
  expectedChunkIds: string[];
  shouldRefuse: boolean;
  tags: Array<"policy" | "api" | "pricing" | "edge-case">;
};

const evalCases: RagEvalCase[] = [
  {
    id: "refund-001",
    question: "订单超过 7 天还能退款吗?",
    expectedAnswer: "超过 7 天通常不支持无理由退款,特殊情况需要人工审核。",
    expectedChunkIds: ["refund_policy_03"],
    shouldRefuse: false,
    tags: ["policy"]
  },
  {
    id: "invoice-001",
    question: "个人用户可以开专票吗?",
    expectedAnswer: "当前资料没有说明个人用户可以开专票。",
    expectedChunkIds: [],
    shouldRefuse: true,
    tags: ["edge-case"]
  }
];

这份数据里最重要的不是 expectedAnswer 写得多漂亮,而是 3 个判断边界:

  • 这个问题应该回答,还是应该拒答?
  • 正确证据应该来自哪些 chunk?
  • 这个问题属于高频问题、边界问题,还是历史事故?

有了这些边界,你再做 RAG 优化,就不是“问两句看看”,而是可以稳定对比。

4. 评测不要只看答案文本,要看 4 个指标

很多人做 AI 评测时,只盯着答案文本像不像。
但知识库问答更关心的是:答案有没有依据、该拒答时有没有拒答、证据有没有命中。

先假设你的 RAG 接口返回这样的结构:

type RagAnswer = {
  answer: string;
  citations: Array<{
    chunkId: string;
    quote: string;
  }>;
  confidence: "high" | "medium" | "low";
};

指标 1:可回答问题是否答出来

资料里有答案的问题,系统不能过度保守。

function isAnswerHit(result: RagAnswer, testCase: RagEvalCase) {
  if (testCase.shouldRefuse) return true;

  return result.confidence !== "low" && result.answer.trim().length > 0;
}

这个指标可以快速发现一种问题:你为了减少幻觉,把系统调得什么都不敢答。

指标 2:应该拒答的问题有没有拒答

资料里没有答案的问题,系统不能强行编。

function isRefusalCorrect(result: RagAnswer, testCase: RagEvalCase) {
  if (!testCase.shouldRefuse) return true;

  return (
    result.confidence === "low" ||
    result.answer.includes("根据当前资料无法确定")
  );
}

对企业知识库、客服知识库、内部制度问答来说,这个指标比“回答是否流畅”更重要。

指标 3:引用来源是否命中

答案说得像,不代表证据对。

function isCitationHit(result: RagAnswer, testCase: RagEvalCase) {
  if (testCase.shouldRefuse) {
    return result.citations.length === 0;
  }

  const citedChunkIds = new Set(
    result.citations.map((citation) => citation.chunkId)
  );

  return testCase.expectedChunkIds.some((chunkId) =>
    citedChunkIds.has(chunkId)
  );
}

这个指标能帮你判断问题出在哪一层:

  • 没命中正确 chunk:优先看切块、Embedding、检索和 Rerank。
  • 命中了正确 chunk 但答案错:优先看 Prompt、上下文拼装和生成约束。

指标 4:是否出现回归

每次改动都要问一句:

这次修好了几个问题,又弄坏了几个问题?

type EvalResult = {
  caseId: string;
  answerHit: boolean;
  refusalCorrect: boolean;
  citationHit: boolean;
};

type EvalSummary = {
  total: number;
  answerHitRate: number;
  refusalAccuracy: number;
  citationHitRate: number;
};

function summarize(results: EvalResult[]): EvalSummary {
  const total = results.length;

  return {
    total,
    answerHitRate:
      results.filter((item) => item.answerHit).length / total,
    refusalAccuracy:
      results.filter((item) => item.refusalCorrect).length / total,
    citationHitRate:
      results.filter((item) => item.citationHit).length / total
  };
}

这几个数字不一定能代表全部体验,但它们能帮你从“感觉变好”进入“有数据可比”。

5. 一个可以直接改造的 RAG 评测脚本

下面这个脚本适合作为本地命令、CI 任务或发布前检查的雏形。

async function runRagEval(cases: RagEvalCase[]) {
  const results: EvalResult[] = [];

  for (const testCase of cases) {
    const result = await askRag(testCase.question);

    results.push({
      caseId: testCase.id,
      answerHit: isAnswerHit(result, testCase),
      refusalCorrect: isRefusalCorrect(result, testCase),
      citationHit: isCitationHit(result, testCase)
    });
  }

  const summary = summarize(results);

  console.table(results);
  console.table([summary]);

  if (summary.refusalAccuracy < 0.9) {
    throw new Error("拒答准确率过低,存在无依据回答风险");
  }

  if (summary.citationHitRate < 0.8) {
    throw new Error("引用命中率过低,证据链不可靠");
  }

  return { results, summary };
}

这套脚本的价值不是“自动判断所有答案对不对”,而是让你每次改动后都能快速发现明显退化。

对前端开发者来说,它就像给 RAG 加了一层端到端回归测试。

6. 再进一步:把新旧版本差异打出来

如果你想让评测结果更适合团队协作,可以把上一次结果保存成 JSON,再和本次结果做 diff。

function diffEvalResults(previous: EvalResult[], current: EvalResult[]) {
  const previousMap = new Map(
    previous.map((item) => [item.caseId, item])
  );

  return current
    .map((item) => {
      const old = previousMap.get(item.caseId);
      if (!old) return null;

      const wasPassed =
        old.answerHit && old.refusalCorrect && old.citationHit;
      const isPassed =
        item.answerHit && item.refusalCorrect && item.citationHit;

      if (wasPassed === isPassed) return null;

      return {
        caseId: item.caseId,
        type: isPassed ? "fixed" : "regression",
        previous: old,
        current: item
      };
    })
    .filter(Boolean);
}

这样评审时就不用争论“感觉如何”,而是直接看:

  • 哪些 case 被修复了
  • 哪些 case 回归了
  • 回归发生在回答、拒答,还是引用命中上

这比只给一个总分更有用。

7. 生产环境避坑指南

1. 评测集不要只放标准问题

如果评测集全是 FAQ 里的标准问法,系统会显得很优秀。
真实用户不会这么客气。

建议至少加入这些问题:

  • 资料里完全没有答案的问题
  • 多份文档结论冲突的问题
  • 文档过期但仍会被检索命中的问题
  • 需要精确字段、金额、日期、比例的问题
  • 用户表达口语化、错别字、缩写的问题

RAG 的事故经常发生在边界问题上,而不是标准问题上。

2. 线上错答要沉淀成 regression case

用户反馈“答错了”的问题,不要只修一次。
最好把它加入评测集,让以后每次上线前都会跑到它。

const regressionCase: RagEvalCase = {
  id: "regression-20260427-001",
  question: "免费版能导出团队数据吗?",
  expectedAnswer: "免费版不支持导出团队数据。",
  expectedChunkIds: ["pricing_limit_02"],
  shouldRefuse: false,
  tags: ["pricing", "edge-case"]
};

每一个线上事故,都应该变成下一次上线的护栏。

3. 不要把所有评判都交给大模型

LLM-as-a-judge 很有用,但不要把所有判断都交给模型。

优先用确定性规则判断:

  • 是否触发拒答
  • citation 的 chunkId 是否存在
  • quote 是否出现在原文里
  • 是否命中 expectedChunkIds
  • 是否出现禁用承诺、敏感话术或越权建议

模型裁判可以用来判断语义相似度,但证据链校验尽量用代码做。

4. 小评测集要稳定,大评测集要分层

建议准备两套:

  • smoke eval:20 条左右,每次改动都跑。
  • full eval:100 到 300 条,上线前或每天定时跑。

smoke eval 负责挡住明显回归。
full eval 负责观察长期趋势。

5. 评测报告必须能定位问题

只输出一个 82 分,实际帮助很有限。
更有用的是每条 case 都记录这些信息:

  • 用户问题
  • 期望 chunkId
  • 实际命中 chunkId
  • 检索分数和 Rerank 分数
  • 是否拒答
  • 最终 answer
  • citation 是否有效

这样你才能判断下一步该改切块、检索、排序、Prompt,还是引用校验。

8. 常见误区

误区 1:评测集要很大才有意义

不需要。刚开始 20 条就有价值。重点是覆盖高频问题、边界问题和历史事故。

误区 2:答案文本不完全一致就是错

不一定。自然语言回答可以有多种表达。RAG 评测更应该关注事实是否正确、引用是否命中、资料不足时是否拒答。

误区 3:线上有监控,就不需要离线评测

监控能告诉你事故已经发生。
评测集能帮你在上线前发现事故。

它们不是替代关系,而是互补关系。

误区 4:评测集写完就不用维护

错。评测集要跟业务一起演进。文档更新、产品策略变化、用户高频问题变化,都应该同步更新。

9. 给前端开发者的落地清单

如果你正在做知识库问答,可以按这个顺序补评测:

  1. 从客服记录、用户搜索词、产品 FAQ 里挑 20 个高频问题。
  2. 给每个问题标注是否应该拒答。
  3. 给可回答问题标注 expectedChunkIds。
  4. 每次改 Prompt、切块、Rerank 前后都跑一遍。
  5. 把线上错答问题加入 regression cases。
  6. 在 CI 或发布脚本里加入 smoke eval。

做到这一步,你的 RAG 优化就不再是调参碰运气,而是进入可验证、可回归、可持续迭代的工程状态。

结语

RAG 从 demo 到生产,最关键的变化不是代码变复杂,而是你开始用工程方式管理不确定性。

没有评测集时,每次优化都像凭感觉开车。
有了评测集后,你至少能回答三个问题:

  • 这次改动真的变好吗?
  • 哪些问题被修复了?
  • 哪些问题被弄坏了?

前端开发者做 AI 应用,不要只满足于“页面能展示答案”。真正可交付的 RAG 系统,应该能被评测、能被复盘,也能在一次次改动后持续变稳。

能被评测的 RAG,才有资格持续优化。