文章目录
-
- 前言
- 一、先把标准答案攒成"题库"
-
- [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