RAG量化评估实战:自建题库+AI考官批量打分

文章目录

    • 前言
    • 一、先把标准答案攒成"题库"
      • [1.1 十二道客服送命题](#1.1 十二道客服送命题)
      • [1.2 幂等建集:先查后建](#1.2 幂等建集:先查后建)
      • [1.3 inputs 和 outputs 的投喂关系](#1.3 inputs 和 outputs 的投喂关系)
    • [二、请三个 AI 考官](#二、请三个 AI 考官)
      • [2.1 openevals:裁判都给你备好了](#2.1 openevals:裁判都给你备好了)
      • [2.2 三个考官各管一摊](#2.2 三个考官各管一摊)
      • [2.3 三个考官手里的材料不一样](#2.3 三个考官手里的材料不一样)
    • 三、批量阅卷出报告
      • [3.1 被测函数:一层薄薄的适配](#3.1 被测函数:一层薄薄的适配)
      • [3.2 evaluate:一行调用,批量跑完](#3.2 evaluate:一行调用,批量跑完)
      • [3.3 一个历史遗留小坑:两个环境变量名](#3.3 一个历史遗留小坑:两个环境变量名)
    • 四、整条链路串起来
    • 五、为什么这套东西比听起来重要
      • [5.1 三件可以立刻用上的事](#5.1 三件可以立刻用上的事)

P.S. 无意间发现了一个巨牛的人工智能教程,非常通俗易懂,对AI感兴趣的朋友强烈推荐去看看, 传送门https://blog.csdn.net/qq_34419312

前言

上篇我给 RAG 装了一台心电监护仪------ask() 把中间产物全抖出来、数据灌进 Milvus、跑一次 CLI 看效果、再故意抛个错验证观测链路。这下你能回答"这次跑得怎么样"了。

但老板的问题还没完:这套系统整体能打几分?

监护仪只能实时看,单次、瞬时。而"我这版比上版好还是差"这种问题,要的是批量、有标准的答案。所以这篇干的事就三件:建题库、请考官、批量阅卷。

说白了,就是让 RAG 从"凭感觉"进化成"看分数"。感觉这东西,在代码里一文不值------就像面试官说"我觉得你不错",你永远不知道他是真觉得,还是单纯急着下班。

一、先把标准答案攒成"题库"

要考试,总得有标准答案。不然你让 AI 自己给自己打分,它能把 59 分写成 99 分,脸都不带红的。

1.1 十二道客服送命题

建题库的文件叫 build_dataset.mjs:

javascript 复制代码
import "dotenv/config";
import { Client } from "langsmith";
const DATASET_NAME = "rag-eval-v1"
const EXAMPLES = [
  {
    inputs: { question: "无理由退货要在几天内申请?" },
    outputs: { answer: "自签收之日起 7 天内支持无理由退货。" },
  },
  {
    inputs: { question: "质量问题换货期限是多久?" },
    outputs: { answer: "15 天内出现质量问题可免费换货。" },
  },
  // ... 共 12 条
];

这 12 道题,看着平平无奇,其实全是坑。完整清单先放这:

问题 标准答案
无理由退货要在几天内申请? 自签收之日起 7 天内
质量问题换货期限是多久? 15 天内免费换货
无理由退货运费谁承担? 买家承担退货运费
客服工作时间是什么? 周一至周五 9:00-18:00,周六 10:00-17:00,法定节假日顺延
满多少元包邮? 满 99 元(部分大件/冷链除外)
现货商品多久发货? 付款后 24 小时内,大促期间 48 小时内
支持哪些支付方式? 微信、支付宝、银联云闪付、花呗/信用卡分期(满 500 元可选 3/6/12 期)
价保是多久? 下单后 7 天内同款降价可申请差价退还
金卡会员有什么折扣? 95 折 + 专属客服 + 每月满 200 减 30 券
积分多少可以抵 1 元? 100 积分抵 1 元,单笔最多抵扣实付金额的 30%
手机保修多久? 手机、平板、耳机全国联保 1 年
紧急问题怎么联系? 拨打 400-800-1234 转 2,接通后报订单号

你仔细品:退货 7 天、换货 15 天、价保 7 天、保修 1 年、发货 24 小时、满 99 包邮、100 积分抵 1 元......好家伙,这哪是客服题库,这是数字大乱炖。

一堆容易串台的数字堆在一起,正是 RAG 最容易翻车的地方。你让一个记性不太好的 AI 背这一桌子数字,就跟让同桌背圆周率似的,能记住 3.14 就已经谢天谢地了。

这 12 道题的设计还挺讲究,四个特点:

**第一,全是"答案唯一"的事实型问题。**不问"你们服务好不好"这种送命题------AI 能给你答出八百种花样,判卷老师看了想辞职。

**第二,大量数字,而且全是容易混淆的数字。**专挑软肋打。

**第三,难度分层。**有"7 天"这种单词题,也有"满 500 元可选 3/6/12 期分期、法定节假日顺延"这种组合题。简单的测基础召回,复杂的测综合能力。

第四,答案里带边界条件。"满 99 元包邮(部分大件/冷链除外)"------括号里那几个字是精华。AI 要是答漏了例外条款,就该扣分。

一个好的评估集,本质是一份"挑刺清单"。不是随便凑几个问题,而是专门对着系统最可能出错的地方出题。这道理跟学生时代一样:考试重点永远在老师上课反复强调的地方,你懂的。

1.2 幂等建集:先查后建

javascript 复制代码
async function main() {
  const client = new Client({
    apiKey: process.env.LANGSMITH_API_KEY,
  });
  let dataset;
  try {
    dataset = await client.readDataset({ datasetName: DATASET_NAME });
    console.log('数据集已存在');
  } catch (error) {
    dataset = await client.createDataset(DATASET_NAME, {
      description: "RAG Agent 回归评估集",
    });
    console.log('已创建数据集');
  }
  const created = await client.createExamples(
    EXAMPLES.map(e => ({
      dataset_id: dataset.id,
      inputs: e.inputs,
      outputs: e.outputs,
    }))
  );
  console.log(`已创建 ${created.length} 条样例`);
}

"先 read,失败就 create"------先看看数据集存不存在,不存在再建。这个套路叫幂等,好处是脚本跑一百遍,也不会给你整出一百个一模一样的题库。比某些只会"再点一次确认"的软件靠谱多了。

javascript 复制代码
description: "RAG Agent 回归评估集",

"回归评估集"这词听着专业,意思其实很朴素:每次改完代码都跑一遍这套题,确保原来能过的现在还能过。不然你改个 prompt,把退货期限从 7 天改成 7 年,自己还浑然不觉,那就热闹了。

数据集叫 rag-eval-v1,带版本号的。今天 v1,明天业务逻辑改了建 v2。固定一套题 + 版本号,才能回答"这次改动到底是变好了还是变坏了"这种灵魂拷问。

没有版本号的评估集,跟没有日期的体检报告一样,只能看看。

1.3 inputs 和 outputs 的投喂关系

javascript 复制代码
EXAMPLES.map(e => ({
  dataset_id: dataset.id,
  inputs: e.inputs,   // { question: "..." }
  outputs: e.outputs, // { answer: "..." }
}))

inputs 是喂给 Agent 的(问题),outputs 是期望它答出的(答案)。一个当考题,一个当标准答案。

评估的时候,系统会自动把这两样拆开,分别派给不同的考官。接下来全靠它俩撑场面。

二、请三个 AI 考官

题库有了,谁批卷?evaluators.mjs,今天最有意思的一个文件。

2.1 openevals:裁判都给你备好了

javascript 复制代码
import {
  createLLMAsJudge,               // 创建一个LLM作为判断器
  RAG_GROUNDEDNESS_PROMPT,        // RAG幻觉检测提示词
  RAG_HELPFULNESS_PROMPT,         // RAG帮助性检测提示词
  RAG_RETRIEVAL_RELEVANCE_PROMPT, // RAG 检索相关性检测提示词
} from 'openevals';

openevals 是 LangChain 官方的开源评估库,里面封装了一批"LLM 当评委"的现成工具。RAG 场景该用啥提示词,人家早总结好了,直接 import 就行。

这跟"不要重复造轮子"是同一个道理。裁判都给你请好了,你还非要自己下场当裁判,图啥?图给 AI 考生递小抄吗?

2.2 三个考官各管一摊

javascript 复制代码
const judge = new ChatOpenAI({
  apiKey: process.env.OPENAI_API_KEY,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  },
  model: process.env.MODEL_NAME ?? "qwen-plus",
  temperature: 0,
});
const ragGroundnessJudge = createLLMAsJudge({
  prompt: RAG_GROUNDEDNESS_PROMPT,
  feedbackKey: "rag_groundness",
  judge,
  continuous: true,
});

三个考官长得像三胞胎,区别只在 prompt 和 feedbackKey:

考官 用的提示词 评什么 feedbackKey
一号 RAG_GROUNDEDNESS_PROMPT 幻觉检测------答案有没有根据 rag_groundness
二号 RAG_HELPFULNESS_PROMPT 帮助性------答案有没有用 rag_helpfulness
三号 RAG_RETRIEVAL_RELEVANCE_PROMPT 检索相关性------召回的对不对 rag_retrieval_relevance

注意 temperature: 0------判卷要稳定,不能让它自由发挥。考官不能今天心情好给 0.9,明天心情差给 0.3。要是这样,那不叫评估,那叫抽签。

再注意 continuous: true------这个参数决定打分方式:

打分方式 输出
非连续(布尔) true / false(通过 / 不通过)
continuous: true 连续分数(比如 0.83)

"通过/不通过"只能告诉你"这版不行",0.83 能告诉你"这版挺接近了,再努努力就能过"。信息量完全不是一个量级。

这就像考试,60 分和 59.5 分一个天堂一个地狱,但你要只看"过没过",这 0.5 分就白丢了。能看出趋势,才能持续优化。

2.3 三个考官手里的材料不一样

这是全篇最值得细看的部分------三个包装函数传进去的参数完全不同:

javascript 复制代码
// RAG 幻觉评估函数
export async function ragGroundnessEvaluator({ outputs }) {
  return ragGroundnessJudge({
    context: { documents: outputs.context },
    outputs: { answer: outputs.answer },
  })
}
// RAG 帮助性评估函数
export async function ragHelpfulnessEvaluator({ inputs, outputs }) {
  return ragHelpfulnessJudge({
    inputs,
    outputs: { answer: outputs.answer },
  })
}
// RAG 检索相关性评估函数
export async function ragRetrievalRelevanceEvaluator({ inputs, outputs }) {
  return ragRetrievalRelevanceJudge({
    inputs,
    context: { documents: outputs.context },
  })
}

整理成一张表,差异一目了然:

考官 拿到输入 inputs 拿到上下文 context 拿到答案 outputs.answer
幻觉检测 ✗ ✓ ✓
帮助性 ✓ ✗ ✓
检索相关性 ✓ ✓ ✗

为什么差这么明显?因为三个考官要回答的问题,本质就不同:

幻觉检测 问的是:"这段答案,能不能从给的资料里推出来?"所以它要 context + answer,不需要原始问题。它只管答案有没有依据,不管问的是什么。

帮助性 问的是:"这个答案,对用户的问题有帮助吗?"所以它要 inputs + answer,不需要 context。它关心答得有没有用,不关心料从哪来。

检索相关性 问的是:"检索回来的这些文档,跟问题相关吗?"所以它要 inputs + context,不需要 answer。它评的是检索环节,根本不看最终答案。

打个比方:幻觉检测查的是"作文是不是抄的",帮助性看的是"作文写得好不好",检索相关性检查的是"参考书拿对了没有"。一个 RAG 要是三个全挂,那基本可以回炉重造了。

一个 RAG 系统烂了,可能是三种病之一:

复制代码
问题 → [检索] → [生成] → 答案
   ↑          ↑          │
   │          │          └── 幻觉检测(groundedness):答案有没有依据?
   │          └── 检索相关性(retrieval_relevance):召回的文档对不对?
   └── 帮助性(helpfulness):整体答得有没有用?

检索相关性评的是中间产物,幻觉检测和帮助性评的是最终产物。三者配合,你才能定位"到底是检索环节烂了,还是生成环节烂了"。

这也是为什么上篇 ask() 必须同时返回 answer 和 context------上篇埋的伏笔,在这收上了。代码圈也讲因果报应,上篇埋的雷,下篇要拆。

注意 context: { documents: outputs.context } 这个包装------评估器期望的上下文是 { documents: [...] } 这个形状,所以要把数组包一层。这是接口约定,不是多余的嵌套。

另外 feedbackKey 用的是 rag_groundness,注意拼写,是 groundness 不是 groundedness,少个 ed。这个 key 会作为字段名出现在 LangSmith 界面上,三个 key 必须分开,否则分数互相覆盖,白测。

最后打包导出:

javascript 复制代码
export const ragEvaluators = [
  ragGroundnessEvaluator,
  ragHelpfulnessEvaluator,
  ragRetrievalRelevanceEvaluator,
]

一个数组打包三个考官,下一份文件直接引用。干净利落。

三、批量阅卷出报告

run_eval.mjs,全篇最短,但它是"按下开始键"的那一下。

javascript 复制代码
// RAG 量化评估
import "dotenv/config";
import { Client } from "langsmith";
import { evaluate } from "langsmith/evaluation";
import { ask } from "../rag_agent.mjs";
import { ragEvaluators } from "./evaluators.mjs";
const DATASET_NAME = "rag-eval-v1";
const client = new Client({
  apiKey: process.env.LANGCHAIN_API_KEY
});
async function runRagAgent(inputs) {
  const { answer, context } = await ask(inputs.question);
  return {
    answer,
    context: context.map(d => d.pageContent)
  }
}
async function main() {
  const result = await evaluate(runRagAgent, {
    data: DATASET_NAME,
    evaluators: ragEvaluators,
    client,
    experimentPrefix: `rag-openevals-${process.env.MODEL_NAME ?? "qwen"}`,
    maxConcurrency: 2
  })
}
main()
  .catch(err => {
    console.error(err);
    process.exit(1)
  })

这十几行,就是"一键体检"的全部。

3.1 被测函数:一层薄薄的适配

javascript 复制代码
async function runRagAgent(inputs) {
  const { answer, context } = await ask(inputs.question);
  return {
    answer,
    context: context.map(d => d.pageContent)
  }
}

它几乎啥也没干,就是把 ask() 的返回值换了个形状。为什么要这一层?两个原因:

第一,把文档对象拍成纯文本数组。 ask() 返回的 context 是 Milvus 文档对象数组,每个对象带着 pageContent、metadata 一堆东西。评估器只想要纯文本列表,所以用 .map() 把 pageContent 抽出来。

这就像甲方要个 Excel,你非给他一个排版精美的 PPT。人家不关心你排版多漂亮,只要那几行字。接口对齐,中间加一层转换,比强行让一方迁就另一方干净。

**第二,隔离变化。**哪天 ask() 的返回结构改了,只改这一个适配函数,评估器不用动。

这种薄适配层在工程里很常见------它的存在意义就是让两个不该互相知道的东西能对上话。有点像介绍人:男女双方不用互相加微信,有事找介绍人。

3.2 evaluate:一行调用,批量跑完

javascript 复制代码
const result = await evaluate(runRagAgent, {
  data: DATASET_NAME,
  evaluators: ragEvaluators,
  client,
  experimentPrefix: `rag-openevals-${process.env.MODEL_NAME ?? "qwen"}`,
  maxConcurrency: 2
})

五个参数,各管一摊:

参数 作用
第一个参数 runRagAgent 被测对象(怎么跑)
data 用哪个数据集出题
evaluators 用哪些考官批卷
client LangSmith 客户端(结果上传到哪)
experimentPrefix 实验名前缀
maxConcurrency 并发控制

experimentPrefix 是这里最有工程价值的参数:它把模型名拼进了实验名。

想想看,你要对比"换个模型会不会更好"------用 qwen-plus 跑一次,换另一个模型再跑一次,两次实验的名字自动区分开了,界面上直接并排比较。

如果实验名写死,两次结果混在一起,你分不清谁是谁------跟双胞胎叫一个名字一样麻烦。

maxConcurrency: 2 是个务实的克制。12 道题,理论上一把梭哈并发跑完最快。但设成 2,意味着"同时最多跑两个"。为什么?

考虑 说明
成本 每次跑都要调 LLM + embedding,并发高了账单飙升
限流 大部分 API 有频率限制,打太猛会被拒
稳定性 并发过高容易触发超时,反而拖慢整体

"跑得快"不是评估的目标,"跑得稳、跑得起"才是。评估是要反复跑的活(每次改代码都跑),成本敏感度极高。

说白了,这玩意儿必须便宜到能天天跑------不然就是"这周没钱,下周再测",然后下周永远不来。

3.3 一个历史遗留小坑:两个环境变量名

javascript 复制代码
// build_dataset.mjs
apiKey: process.env.LANGSMITH_API_KEY
// run_eval.mjs
apiKey: process.env.LANGCHAIN_API_KEY

两份文件用的环境变量名不一样------一个 LANGSMITH_API_KEY,一个 LANGCHAIN_API_KEY。

这不是笔误,是历史遗留:这套平台早年叫 LangChain 平台,后来独立成了 LangSmith,旧名新名目前都还能用。

实践建议:两个都配上,值填同一个。不然就会出现"建数据集成功了,跑评估却报 401"这种让人抓头的现象。

排查半天代码,结果是环境变量少了一个------那种感觉,就像找了半天钥匙,结果钥匙就在兜里,只是穿的是另一条裤子。

四、整条链路串起来

把上下两篇的文件按顺序排一遍,看看它们各自站在哪一环:

复制代码
【一次性的准备】
① milvus_insert.mjs  把 ./data 切块、转向量、灌进 Milvus
② build_dataset.mjs  建 12 道题的评估集(题库 + 标准答案)
【被测对象】
③ rag_agent.mjs  客服 RAG Agent(retrieve → generate)
                 export ask() → 同时交出 answer 和 context
【验证观测链路】
④ trigger-error.mjs  故意抛错,去 LangSmith 看红色轨迹
【手动试跑】
⑤ cli.mjs  命令行问一句,看答案和命中条数
【批量评估】
⑥ evaluators.mjs  三个 AI 考官(幻觉 / 帮助性 / 检索相关性)
⑦ run_eval.mjs    一键跑完 12 道题,出分
【结果】
⑧ ⑨ LangSmith 界面 trace 轨迹 + 分数报告

①到⑨九个文件,排起来像一支足球队------有前锋有后卫,但真正负责进球的是 ⑦ run_eval.mjs。

整条链路浓缩成三个词:标准化、批量化、自动化。

标准化:三个固定维度、固定的提示词、固定的打分方式------不受心情影响。这点 AI 比人类考官强,它真的没有心情。

批量化:12 道题一次跑完,不用手点。

自动化:改完代码重跑一遍,就知道"这次改动到底有没有用"。

五、为什么这套东西比听起来重要

之前我们一直在加东西------加路由、加拆解、加评估节点、加联网、加混合检索。每次加完,怎么验证效果?

朴素的做法是:手动问几个问题,看看答案像不像样。这个做法毛病可太多了:

问题 后果
只问了 3 个问题 样本太小,可能刚好都是会的
只看"像不像样" 没有标准,看不出细微差别
改完再问一次 记不住上次什么样,没法比较
换个模型 完全不知道是变好了还是变差了

这套流程走下来,你的优化基本等于开盲盒,抽到啥算啥。

而一套评估系统的价值,就在于把"感觉还行"变成"0.83 分"。

数字是可以比较的:

上次 0.72,这次 0.83 → 改动有效,保留

上次 0.83,这次 0.68 → 改动有害,回滚

检索相关性 0.9、幻觉检测 0.4 → 问题出在生成环节,不是检索环节

最后一条特别关键------三个考官的分工设计,让你不仅知道"总分多少",还知道"该去哪个环节找问题"。这就跟体检一样,光知道"身体不行"没用,得知道是肝不行还是肾不行。

"如果你无法度量它,你就无法管理它。"这话在别的行业可能是鸡汤,在工程里是实打实的:没分数之前,你连"这次改坏了"都说不出口。

5.1 三件可以立刻用上的事

**第一,把 LLM 当评委是最省力的评估方式。**不用人工标注、不用写复杂的规则引擎------给评委几句提示词、一个打分标准,它就能批量批卷。openevals 连 RAG 场景的标准提示词都给你备好了。

**第二,评估维度要拆到"能定位问题"的粒度。**一个总分没用,它只能告诉你"系统不行"。三个维度分拆,才能告诉你"是检索环节的问题,还是生成环节的问题"。评估的价值不在于打分本身,而在于分数背后的归因能力。

一个只会说"不行"的评估,等于一个只会说"多喝热水"的医生------方向感为零。

**第三,测试集是要维护的资产。**那 12 道题不是随便写的,它记录了"这个系统的正常行为应该是什么样"。以后改了 prompt、换了模型、动了检索参数------跑一遍这套题,就知道有没有踩坏东西。它跟代码一样,值得放进版本管理。

顺便说一句,那 12 道客服题真的值得收藏。它示范了什么叫"好题库":不是随便找几个问题,而是瞄准系统最可能翻车的地方专门出题。一屋子容易串台的数字、几个带例外的规则,精准戳在 RAG 的软肋上。

毕竟,一套只会说"你真好"的测试集,是测不出问题的------那是夸夸群,不是评估集。

行了,就这些。下次老板再问系统行不行,你就可以甩个 0.83 过去,顺便告诉他:这是三位 AI 考官联名打分,童叟无欺。

P.S. 无意间发现了一个巨牛的人工智能教程,非常通俗易懂,对AI感兴趣的朋友强烈推荐去看看,传送门https://blog.csdn.net/qq_34419312

相关推荐
愤怒火龙果43 分钟前
AI攻防 中转站链路安全
人工智能·网络安全
python机器学习ML44 分钟前
机器学习——XGBoost+贝叶斯优化+特征工程+SHAP+ROC+Calibration+Lift+七视角拆解模型
人工智能·机器学习
soft_hub144 分钟前
客服通话内容如何快速整理分析?
人工智能·智能手机
Regentsoft丽晶软件1 小时前
全渠道一盘货驱动零售品牌DTC转型,破除传统零售割裂和库存冗余
大数据·人工智能·零售
johnsong1 小时前
硬件供应链战争:当你的加密钱包被物理劫持
人工智能
轻松,带微笑1 小时前
如何让AI帮你阅读长篇资料,并整理出重点和行动清单?
人工智能
Dawson Zhu1 小时前
《Agentic Design Patterns》第 20 章导读:优先级排序
人工智能·语言模型·架构·aigc·agi
xianghongtao01161 小时前
MultiAlloc_论文解读报告
人工智能
寻道码路1 小时前
AI Agent 工程化开发实战(一):Agent 开发的四个层次——积木、运行时、薄壳、套件
人工智能·langchain·大模型·ai agent·langgraph·ai工程化·deep agents