🚦 请三个 AI 考官给 RAG 打分:量化评估实战(下)

写在前面:上篇我们给 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 的软肋上。毕竟,一套只会说"你真好"的测试集,是测不出问题的。

相关推荐
阿坨1 小时前
灵感闪存卡:一款 Tampermonkey用户脚本
javascript·tampermonkey
Joy T1 小时前
聊天系统中五大ID的演进与实战解析
java·前端·人工智能·spring·agent入门
Seraphina362 小时前
DVWA(SQL注入-low,medium,XSS反射-low)
前端·数据库·笔记·sql·网络安全·web·xss
Access开发易登软件3 小时前
Access 导入 Markdown 表格:把文档里的数据写进表
前端·html·vba·markdown·access开发
朝朝辞暮i3 小时前
C++ 第 25 课:getter / setter + this
java·javascript·c++
扶风ff3 小时前
求职笔试资料太散?用练题簿在线刷题,按岗位整理练习清单
前端·学习·小程序
蜗牛互联网3 小时前
Python接入Gemini 3.8 Flash实现票据视觉抽取与规则校验
java·javascript·网络·人工智能·python
浪浪山_大橙子3 小时前
公司里的 AI,终于不只会聊天:我用 GPT‑6 把企业工作伙伴开源了
前端·后端·面试
琹箐3 小时前
命令行调用接口
前端