写在前面:上篇我们给 RAG 装了一台心电监护仪------
ask()暴露中间产物、数据灌进 Milvus、跑一次 CLI 看效果、故意抛错验证观测链路。现在你能回答"这次跑得怎么样"了。但还有个问题答不上来:这套系统整体能打几分? 监护仪只能在运行时看,它是实时、单次的;而"我这版比上版好还是差"需要的是批量、有标准 的答案。下篇就来干这件事------建题库、请考官、批量阅卷。readme 把这个过程概括得很准确:"对业务效果做标准化评估------Dataset,测试样本,统一存放用户提问和标准答案。再通过 Evaluator 设定打分,批量完成自动化评测,精准衡量回答质量。" 以下所有代码和测试数据均来自课堂真实文件。
一、评估第一步:把标准答案整理成"题库"
要考试,先得有标准答案。build_dataset.mjs 就是建题库。
12 道客服题
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 小时、积分 100 抵 1、满 99 包邮------一堆数字堆在一起,正是 RAG 最容易串台的地方。 这也是最该被检验的地方。
第三,覆盖不同难度层次。 有单词答案的("7 天"),也有需要组合多个条件的("满 500 元可选 3/6/12 期分期"、"法定节假日顺延")。简单的能测基础召回,复杂的能测综合能力。
第四,答案里带"边界条件"。 比如"满 99 元包邮(部分大件/冷链除外)"------括号里的例外条款是精华。 如果 Agent 只答"满 99 包邮"而漏了例外,这就是个值得扣分的地方。
一个好的评估集,本质上是一份"挑刺清单"。 不是随便凑几个问题,而是针对系统最可能出错的地方设计题目。
幂等建集:先查后建
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" 是个很实用的幂等模式------不用手动去界面上检查数据集存不存在,跑脚本就行。跑第二遍也不会重复建。
注意 createDataset 的第二个参数:
javascript
description:"RAG Agent 回归评估集",
"回归评估集"这个词用得很专业。
"回归测试"(regression test)指的是------每次改完代码,都跑一遍这套题,确保原来能过的现在还能过。
这解释了数据集为什么叫 rag-eval-v1------它是有版本的。 今天 v1,以后改了业务逻辑,可能建 v2。固定的一套题 + 版本号,才能比较"这次改动到底让系统变好了还是变差了"。
inputs / outputs 的对应关系
javascript
EXAMPLES.map(e => ({
dataset_id:dataset.id,
inputs: e.inputs, // { question: "..." }
outputs: e.outputs, // { answer: "..." }
}))
inputs 是"喂给 Agent 的",outputs 是"期望 Agent 答出的"。
这个结构会在评估时被自动拆开用------接下来就会看到,评估器能拿到 inputs 和 outputs 分别做不同的判断。
二、评估第二步:请三个 AI 考官
题库有了,谁来批卷?evaluators.mjs------今天最有意思的一个文件。
openevals:现成的考官
javascript
import {
createLLMAsJudge, // 创建一个LLM作为判断器
RAG_GROUNDEDNESS_PROMPT,// RAG幻觉检测提示词
RAG_HELPFULNESS_PROMPT,// RAG帮助性检测提示词
RAG_RETRIEVAL_RELEVANCE_PROMPT,// RAG 检索相关性检测提示词
} from 'openevals';
openevals 是 LangChain 官方的开源评估库 ,里面封装了一批"LLM 当评委"的现成工具,特别是 RAG 场景常用的几个标准提示词 $TRAE_REF。
这是个很关键的信息------三个评估器不需要自己写提示词。
RAG 评估这件事,业界早就总结出了几套标准范式。openevals 把它们封装成了现成的 prompt 常量,你直接引用就行。
这跟前面学结构化输出时"用 parser.getFormatInstructions() 而不是手搓 prompt"是同一个思路------不要重复造轮子。
三个考官,三个维度
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.8 明天判 0.4,那评估就没意义了。
注意 continuous: true ------这个参数决定了打分方式:
| 打分方式 | 输出 |
|---|---|
| 非连续(布尔) | true / false(通过 / 不通过) |
| continuous: true | 连续分数(比如 0.83) |
连续分数的信息量大得多。 "通过/不通过"只能告诉你"这版不行",0.83 能告诉你"这版挺接近了,再优化一点就能过"------能看出趋势,才能持续优化。
三个考官问的问题不一样
这是全篇最值得细看的部分------三个包装函数传进去的参数不同:
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 系统烂了,可能是三种病之一:
css
问题 → [检索] → [生成] → 答案
↑ ↑
│ └── 幻觉检测(groundedness):答案有没有依据?
└── 检索相关性(retrieval_relevance):召回的文档对不对?
↓
帮助性(helpfulness):整体答得有没有用?
检索相关性评的是中间产物,幻觉检测和帮助性评的是最终产物。 三者配合起来,你才能定位"到底是检索环节烂了,还是生成环节烂了"。
这也是为什么上篇 ask() 必须同时返回 answer 和 context ------ 上篇埋的伏笔,在这里收上了。
注意 context: { documents: outputs.context } 这个包装------评估器期望的上下文是 { documents: [...] } 这个形状,所以要把数组包一层。这是接口约定,不是多余的嵌套。
另外 feedbackKey 用的是 rag_groundness(注意拼写是 groundness )------这个 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)
})
这十几行,就是"一键体检"的全部。
被测函数:一个薄薄的适配层
javascript
async function runRagAgent(inputs) {
const {answer, context} = await ask(inputs.question);
return {
answer,
context: context.map(d => d.pageContent)
}
}
它几乎什么都没干,只是把 ask() 的返回值换了个形状。
为什么需要这一层?两个原因:
第一,把文档对象拍成纯文本数组。
javascript
context: context.map(d => d.pageContent)
ask() 返回的 context 是 Milvus 文档对象数组(每个对象有 pageContent、metadata 等)。而评估器期望的 context.documents 是文本列表 ------所以用 .map() 抽出 pageContent。
这是接口格式的对齐。 两个组件各按自己的约定设计,中间加一层转换------比强行让一方迁就另一方要干净。
第二,隔离变化。 如果哪天 ask() 的返回结构变了,只需要改这一个适配函数,不用动评估器。
这种"薄适配层"在工程里很常见------它存在的意义就是让两个不该互相知道的东西,能对上话。
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 是这里最有工程价值的参数:
javascript
experimentPrefix: `rag-openevals-${process.env.MODEL_NAME ?? "qwen"}`
它把模型名拼进了实验名。
这有什么用?想象一下你要对比"换个模型会不会更好"------用 qwen-plus 跑一次、用另一个模型跑一次,两次实验的名字自动区分开了,在 LangSmith 界面上可以直接并排比较。
如果实验名写死,两次结果就混在一起了。 把变量(模型名、参数配置)拼进实验名,是"可比较的评估"的前提。
maxConcurrency: 2 是个务实的克制。
12 道题,理论上一把并发跑完最快。但设置成 2 意味着"同时最多跑两个"。为什么?
| 考虑 | 说明 |
|---|---|
| 成本 | 每次跑都要调 LLM + embedding,并发高了账单飙升 |
| 限流 | 大部分 API 有频率限制,打太猛会被拒 |
| 稳定性 | 并发过高容易触发超时,反而拖慢整体 |
"跑得快"不是评估的目标,"跑得稳、跑得起"才是。 评估是要反复跑的活儿(每次改代码都跑),成本敏感度很高------这也是"回归测试"的隐含代价:它必须便宜到能天天跑。
一个小提醒:两个环境变量名
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,LANGCHAIN_API_KEY 是旧名,LANGSMITH_API_KEY 是新名,两个目前都还能用。
但实践建议:把两个 key 都配上 (值填同一个)。不然就会出现"建数据集成功了,跑评估却报 401"这种让人抓头的现象------排查半天代码,结果是环境变量少了一个。
四、整条链路串起来
把上下两篇的 9 个文件按顺序排一遍,看看它们各自站在哪一环:
bash
【一次性的准备】
① 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 轨迹 + 分数报告
readme 最后那段话,现在读起来完全能对上了:
"langsmith trace graph 的运行,看到了整体统计的 monitor 数据。Agent 运行情况一目了然,非常方便接入全链路的观测。对业务效果做标准化评估 ------Dataset,测试样本,统一存放用户提问和标准答案(搭建数据集)。再通过 Evaluator 设定打分,批量完成自动化评测,精准衡量回答质量,优化 Agent 和 RAG 相关业务逻辑。"
三个词是重点:标准化、批量化、自动化。
- 标准化:三个固定维度、固定的提示词、固定的打分方式------不受心情影响
- 批量化:12 道题一次跑完,不用手点
- 自动化:改完代码重跑一遍,就能知道"这次改动到底有没有用"
五、为什么这套东西比听起来重要
写到这里,我想说说这节课真正的价值在哪。
前面几节课,我们一直在加东西------加路由、加拆解、加评估节点、加联网、加混合检索。每次加完,怎么验证效果?
朴素的做法是:手动问几个问题,看看答案像不像样。
这个做法有几个致命毛病:
| 问题 | 后果 |
|---|---|
| 只问了 3 个问题 | 样本太小,可能刚好都是会的 |
| 只看"像不像样" | 没有标准,看不出细微差别 |
| 改完再问一次 | 记不住上次什么样,没法比较 |
| 换个模型 | 完全不知道是变好了还是变差了 |
而一套评估系统的价值,就在于把"感觉还行"变成"0.83 分"。
数字是可以比较的。
- 上次 0.72,这次 0.83 → 改动有效,保留
- 上次 0.83,这次 0.68 → 改动有害,回滚
- 检索相关性 0.9、幻觉检测 0.4 → 问题出在生成环节,不是检索环节
最后这一条特别关键------三个考官的分工设计,让你不仅知道"总分多少",还知道"该去哪个环节找问题"。
"如果你无法度量它,你就无法管理它。" 这句话的落点在这里:没有度量,你的所有优化都是盲目的。
三件可以立刻用上的事
第一,把 LLM 当评委是最省力的评估方式。
不用人工标注、不用写复杂的规则引擎------给评委几句提示词、一个打分标准,它就能批量批卷。openevals 甚至把 RAG 场景的标准提示词都准备好了 $TRAE_REF。
第二,评估维度要拆到"能定位问题"的粒度。
一个"总分"没用------它只能告诉你"系统不行"。三个维度分拆,才能告诉你"是检索环节的问题,还是生成环节的问题"。评估的价值不在于打分本身,而在于分数背后的归因能力。
第三,测试集是要维护的资产。
那 12 道题不是随便写的,它记录了"这个系统的正常行为应该是什么样"。以后改了 prompt、换了模型、动了检索参数------跑一遍这套题,就知道有没有踩坏东西。它跟代码一样,值得放进版本管理。
PS:下篇里那 12 道客服测试题值得收藏------它示范了什么叫"好题库":不是随便找几个问题,而是瞄准系统最可能翻车的地方,专门出题。一屋子容易串台的数字、几个带例外的规则,这些都精准地戳在 RAG 的软肋上。毕竟,一套只会说"你真好"的测试集,是测不出问题的。