LangSmith 全链路观测与 RAG 量化评估(下):用 Dataset 与 LLM Judge 拆解检索、生成质量
上一篇完成了一个可追踪的客服 RAG Agent:业务文档写入 Milvus,用户问题经过 Top 4 检索后进入生成模型,同时保留 question、context 和 answer 三类状态。
Trace 解决的是单次运行的诊断问题:这一次调用执行了哪些节点、召回了什么、在哪里报错、每一步用了多长时间。
但仅靠人工打开 Trace,仍然很难回答另一组问题:
- 修改切块参数后,整体效果是变好还是变差?
- 更换模型后,回答是否更有帮助?
- 某些回答看似流畅,是否其实脱离了知识库?
- 最终答案不好,根因是检索还是生成?
这就需要把零散的人工观察变成一套可重复运行的评测流程。
本文会围绕 12 组客服问答构建 LangSmith Dataset,并用 OpenEvals 的三个 RAG Judge 分别评价:
groundedness:答案是否得到检索上下文支持;helpfulness:答案是否真正回应了用户问题;retrieval relevance:召回内容是否与问题相关。
完整流程如下:
text
固定问答样本
↓
LangSmith Dataset
↓
逐条调用 RAG Agent
↓
{ question, context, answer }
↓
三个 LLM Judge 并行观察不同关系
↓
LangSmith Experiment 中汇总分数与评语
一、为什么 RAG 不能只评最终答案
普通问答只有一个显眼输出:answer。RAG 至少有两个阶段:
text
问题 --检索--> 上下文 --生成--> 答案
最终答案错误,可能来自完全不同的根因。
1. 检索错误
用户问"手机保修多久",Retriever 却召回了会员积分与物流政策。此时模型即使严格遵守"仅根据上下文回答",也无法生成正确答案。
2. 生成错误
Retriever 已经找到了"手机、平板、耳机全国联保 1 年",模型却回答"两年"。此时检索是正确的,问题在于答案没有忠实使用上下文。
3. 答非所问
模型的每一句话都能在知识库中找到,但用户问的是"多久",回答却只介绍保修范围。这种答案可能没有幻觉,却依然没有帮助。
因此,单一总分很难支持调试。三个评估维度分别对准不同的数据关系:
| 评估维度 | 比较对象 | 主要诊断目标 |
|---|---|---|
| Groundedness | context 与 answer |
回答是否有上下文依据 |
| Helpfulness | question 与 answer |
回答是否解决用户问题 |
| Retrieval relevance | question 与 context |
检索是否找对资料 |
这张表也是后面设计 Evaluator 入参的依据。
二、构建一组可重复使用的客服评测集
2.1 样本结构:inputs 与 outputs
每条样本包含问题和参考答案:
js
const example = {
inputs: {
question: "无理由退货要在几天内申请?",
},
outputs: {
answer: "自签收之日起 7 天内支持无理由退货。",
},
};
inputs 的字段结构必须与待评测函数的输入一致。RAG Agent 接收的是 question,因此数据集也使用 inputs.question。
outputs.answer 是参考答案。它不是本次模型执行产生的结果,而是评测集预先保存的期望输出。
2.2 用 12 个问题覆盖不同知识主题
js
const EXAMPLES = [
{
inputs: { question: "无理由退货要在几天内申请?" },
outputs: { answer: "自签收之日起 7 天内支持无理由退货。" },
},
{
inputs: { question: "质量问题换货期限是多久?" },
outputs: { answer: "15 天内出现质量问题可免费换货。" },
},
{
inputs: { question: "无理由退货运费谁承担?" },
outputs: { answer: "无理由退货由买家承担退货运费。" },
},
{
inputs: { question: "客服工作时间是什么?" },
outputs: {
answer: "周一至周五 9:00-18:00,周六 10:00-17:00,法定节假日顺延。",
},
},
{
inputs: { question: "满多少元包邮?" },
outputs: { answer: "满 99 元包邮(部分大件/冷链除外)。" },
},
{
inputs: { question: "现货商品多久发货?" },
outputs: { answer: "付款后 24 小时内发货,大促期间 48 小时内。" },
},
{
inputs: { question: "支持哪些支付方式?" },
outputs: {
answer: "支持微信支付、支付宝、银联云闪付、花呗/信用卡分期(满 500 元可选 3/6/12 期)。",
},
},
{
inputs: { question: "价保是多久?" },
outputs: { answer: "下单后 7 天内同款降价可申请差价退还。" },
},
{
inputs: { question: "金卡会员有什么折扣?" },
outputs: {
answer: "金卡享 95 折,并有专属客服和每月满 200 减 30 券。",
},
},
{
inputs: { question: "积分多少可以抵 1 元?" },
outputs: {
answer: "100 积分可抵 1 元,单笔最多抵扣实付金额的 30%。",
},
},
{
inputs: { question: "手机保修多久?" },
outputs: { answer: "手机、平板、耳机全国联保 1 年。" },
},
{
inputs: { question: "紧急问题怎么联系?" },
outputs: { answer: "可拨打 400-800-1234 转 2,接通后报订单号。" },
},
];
这些问题并不是同一句式的机械复制,而是覆盖了期限、金额、渠道、折扣、联系方式等不同答案形态。它们还横跨售后、物流、支付、会员和质保知识,能帮助我们观察检索是否偏向某一类文档。
其中有几组很适合检查细粒度区分能力:
- "7 天无理由退货"和"15 天质量问题换货"主题相近,答案不能串线;
- "满 99 元包邮"和"满 500 元可分期"都包含金额,但属于不同规则;
- "价保 7 天"和"无理由退货 7 天"数字相同,语义却完全不同。
2.3 创建或读取 Dataset
js
import "dotenv/config";
import { Client } from "langsmith";
const DATASET_NAME = "rag-eval-v1";
async function main() {
const client = new Client({
apiKey: process.env.LANGCHAIN_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((example) => ({
dataset_id: dataset.id,
inputs: example.inputs,
outputs: example.outputs,
}))
);
console.log(`已创建 ${created.length} 条样例`);
}
main().catch(console.error);
这里分成两层对象:
- Dataset 是一组测试样本的容器,名称为
rag-eval-v1; - Example 是容器中的单条
inputs/outputs记录。
脚本发现 Dataset 已存在时会复用它,但仍会继续调用 createExamples()。因此它只避免重复创建 Dataset,并没有避免重复写入 Example。反复执行会产生重复样本。
对于一次性初始化,这个行为足够简单直接;如果把它当成可重复执行的同步脚本,就需要额外处理样本去重。这里至少要清楚它的幂等边界,避免重复运行后误以为评测样本数量仍是 12 条。
三、为评测暴露正确的 RAG 输出结构
RAG Agent 的调用接口返回:
ts
{
answer: string,
context: Document[]
}
而 OpenEvals 的 RAG Prompt 需要可序列化的文档正文数组。因此在待评测函数外增加一层适配:
js
import { ask } from "../rag_agent.mjs";
async function runRagAgent(inputs) {
const { answer, context } = await ask(inputs.question);
return {
answer,
context: context.map((document) => document.pageContent),
};
}
这段代码很短,却是整个评测链路的关键接口。
输入侧:
text
Dataset Example.inputs.question
↓
ask(question)
输出侧:
text
Document[] -> string[]
answer -> string
应用内部继续保留完整 Document,方便查看 metadata.source;评测边界只提取 pageContent,让 Judge 直接阅读召回文本。
如果应用层只返回 answer,检索相关性和忠实度评估都无法执行。这再次说明:可评估性不是项目结束后再补一个脚本,而要在 Agent 输出设计阶段就预留数据。
四、配置统一的 LLM Judge
评估器本身也需要一个模型来阅读问题、上下文和答案:
js
import { ChatOpenAI } from "@langchain/openai";
const judge = new ChatOpenAI({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.MODEL_NAME ?? "qwen-plus",
temperature: 0,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
});
这里继续使用兼容 OpenAI 协议的模型服务,并把温度设为 0,尽量降低 Judge 打分的随机波动。
被评测模型和 Judge 在配置上可以指向同一个模型名。需要理解的是,它们在一次实验中承担不同角色:
runRagAgent()中的模型负责回答问题;judge负责根据评分 Prompt 审查结果。
五、用 OpenEvals 创建三个 RAG 评估器
OpenEvals 已经提供三个与本次目标对应的 Prompt:
js
import {
createLLMAsJudge,
RAG_GROUNDEDNESS_PROMPT,
RAG_HELPFULNESS_PROMPT,
RAG_RETRIEVAL_RELEVANCE_PROMPT,
} from "openevals";
createLLMAsJudge() 把"评分 Prompt + Judge 模型 + 反馈键"组合成一个可执行评分器。
5.1 Groundedness:答案是否忠于上下文
js
const ragGroundednessJudge = createLLMAsJudge({
prompt: RAG_GROUNDEDNESS_PROMPT,
feedbackKey: "rag_groundedness",
judge,
continuous: true,
});
它关心的是:回答中的陈述能否在召回上下文中找到依据,是否出现上下文之外的补充、矛盾或推测。
包装成 LangSmith Evaluator:
js
export async function ragGroundednessEvaluator({ outputs }) {
return ragGroundednessJudge({
context: {
documents: outputs.context,
},
outputs: {
answer: outputs.answer,
},
});
}
它不需要原问题,也不需要参考答案,只比较:
text
召回文档 <-> 实际回答
例如,上下文写着"手机全国联保 1 年",回答却说"2 年",Groundedness 应该降低。反过来,即使回答的事实碰巧符合外部常识,只要没有上下文支持,也不应得到高忠实度分数。
5.2 Helpfulness:是否真正回答问题
js
const ragHelpfulnessJudge = createLLMAsJudge({
prompt: RAG_HELPFULNESS_PROMPT,
feedbackKey: "rag_helpfulness",
judge,
continuous: true,
});
包装函数接收问题和实际答案:
js
export async function ragHelpfulnessEvaluator({ inputs, outputs }) {
return ragHelpfulnessJudge({
inputs,
outputs: {
answer: outputs.answer,
},
});
}
它比较的是:
text
用户问题 <-> 实际回答
例如用户问"无理由退货运费谁承担",回答只说"支持 7 天无理由退货"。这句话可能来自知识库,Groundedness 不一定低,但它没有回答"谁承担运费",因此 Helpfulness 应该降低。
5.3 Retrieval relevance:召回内容是否与问题相关
js
const ragRetrievalRelevanceJudge = createLLMAsJudge({
prompt: RAG_RETRIEVAL_RELEVANCE_PROMPT,
feedbackKey: "rag_retrieval_relevance",
judge,
continuous: true,
});
包装函数只提供问题与检索上下文:
js
export async function ragRetrievalRelevanceEvaluator({ inputs, outputs }) {
return ragRetrievalRelevanceJudge({
inputs,
context: {
documents: outputs.context,
},
});
}
它比较的是:
text
用户问题 <-> 召回文档
最终答案不会参与该项评分。这一点非常重要:生成模型偶然答对,不能掩盖 Retriever 找错资料的问题。
5.4 continuous: true 的含义
三个 Judge 都设置了:
js
continuous: true
这会让评分器返回 0 到 1 之间的连续分数,而不是简单的 true/false。连续分数更适合比较两个实验版本:即使它们都没有达到满分,也能看到相对变化。
反馈键决定实验中显示的指标名。统一、拼写正确的名字很重要,否则同一指标可能因为 groundness/groundedness 或 helpfullness/helpfulness 的差异被拆成多列。
最后导出评估器数组:
js
export const ragEvaluators = [
ragGroundednessEvaluator,
ragHelpfulnessEvaluator,
ragRetrievalRelevanceEvaluator,
];
六、一个容易误解的事实:参考答案当前没有参与评分
Dataset 中为每个问题保存了 outputs.answer,运行评测时 LangSmith 会把它作为 referenceOutputs 提供给 Evaluator。
但是上面的三个包装函数分别只解构了:
js
{ outputs }
{ inputs, outputs }
{ inputs, outputs }
没有任何一个读取 referenceOutputs。因此,本次三项指标都是"无参考答案评估":
- Groundedness 用上下文审查实际答案;
- Helpfulness 用问题审查实际答案;
- Retrieval relevance 用问题审查实际上下文。
这并不是说 Dataset 中的标准答案没有价值,而是要准确理解当前实验测量了什么:它尚未测量"实际答案与标准答案是否一致"。
这个区别也解释了为什么不能把三项分数直接叫作"正确率"。一个回答可能与参考答案措辞不同,却同样忠实且有帮助;也可能非常忠实于一段错误召回的上下文,却不等于业务答案正确。
七、批量运行 LangSmith Experiment
评测入口负责把目标函数、数据集、评估器与实验配置组合起来:
js
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((document) => document.pageContent),
};
}
async function main() {
await evaluate(runRagAgent, {
data: DATASET_NAME,
evaluators: ragEvaluators,
client,
experimentPrefix: `rag-openevals-${process.env.MODEL_NAME ?? "Qwen"}`,
maxConcurrency: 2,
});
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
这段配置中有四个重要参数。
data
js
data: DATASET_NAME
LangSmith 会读取 rag-eval-v1 中的所有 Example,并逐条把 inputs 交给 runRagAgent()。
evaluators
js
evaluators: ragEvaluators
同一条 RAG 输出会分别进入三种 Judge,形成三列互相独立的反馈。
experimentPrefix
js
experimentPrefix: `rag-openevals-${process.env.MODEL_NAME ?? "Qwen"}`
实验前缀中包含模型名,便于区分不同配置的运行结果。环境变量必须拼写为 MODEL_NAME;如果误写成 MODEL_NAEM,表达式会一直走默认值,导致实验名称不能真实反映所用模型。
maxConcurrency
js
maxConcurrency: 2
这表示最多并发处理两个预测/评估任务。每条样本不仅要执行一次 RAG,还要调用三个 Judge,因此并发数会直接影响模型服务压力。设置为 2 是一个相对保守、容易观察的起点。
错误处理同样值得注意:JavaScript 控制台方法是 console.error(),不是 console.err()。如果错误分支本身写错,真正的网络、鉴权或模型错误就会被新的 TypeError 覆盖。
八、完整执行顺序
整个实验需要按依赖关系运行:
bash
# 1. 切块、向量化并重建 Milvus Collection
node src/milvus_insert.mjs
# 2. 先用单个问题验证 RAG 链路
node src/cli.mjs "无理由退货要在几天内?"
# 3. 创建 Dataset 并写入 12 条 Example
node src/eval/build_dataset.mjs
# 4. 批量执行 RAG 与三个 Evaluator
node src/eval/run_eval.mjs
顺序不能随意交换:
- 没有先入库,Retriever 找不到可搜索的 Collection;
- 没有先创建 Dataset,
evaluate()找不到rag-eval-v1; - 单条 CLI 验证不通过时直接跑批量评测,只会同时放大同一个配置错误。
九、如何组合三个分数定位问题
单独看一个分数只能看到局部,组合起来才具有诊断价值。
| Retrieval relevance | Groundedness | Helpfulness | 更可能的情况 |
|---|---|---|---|
| 高 | 高 | 高 | 检索正确,回答有依据且解决了问题 |
| 低 | 高 | 低 | 模型忠实复述了错误或无关的召回内容 |
| 高 | 低 | 低或中 | 资料找对了,但生成阶段出现编造或误读 |
| 高 | 高 | 低 | 答案来自上下文,但没有直接回应问题重点 |
| 低 | 低 | 高 | 模型可能脱离上下文,凭自身知识给出了看似有用的回答 |
这张表不是把分数机械地映射成唯一结论,而是提供排查顺序。
检索相关性低
先检查 Retriever:
- 知识文件是否都成功入库;
- 文本切块是否破坏了规则完整性;
k: 4返回的内容是否噪声过多;- 入库和查询的 Embedding 模型是否一致;
- IVF_FLAT 的查询参数是否与索引一致。
检索相关性高,但忠实度低
Retriever 已经找对资料,优先检查生成阶段:
- Prompt 是否明确要求仅依据上下文;
- 上下文是否正确拼接进
{context}; - 多段相似规则是否让模型混淆期限或金额。
忠实度高,但帮助性低
回答没有乱说,却没有回答重点。可以回到具体评语,看它遗漏的是主体、期限、金额还是操作步骤。
例如用户问"紧急问题怎么联系",回答只说"请提供订单号",这句话可能有上下文依据,但缺失电话号码和转接方式,实用性仍然不足。
十、Trace、Monitoring、Dataset、Evaluator 各自解决什么问题
这四个概念经常同时出现,但不能混为一谈。
Tracing
记录一次 Agent 执行的内部过程,包括图节点、模型调用、耗时、输入输出与异常。它回答的是:
这一次运行发生了什么?
Monitoring
观察应用持续运行时的总体情况,例如调用量、延迟、Token 消耗和错误。它回答的是:
线上系统最近运行得怎么样?
Dataset
保存固定测试样本,让不同版本面对同一组输入。它回答的是:
我们用哪些问题来稳定复现和比较效果?
Evaluator
定义评分规则并对实验输出打分。它回答的是:
"好"到底指什么,以及如何衡量?
对于当前客服 RAG,三者之间的连接是:Trace 保留每次运行细节,Dataset 固定 12 个业务问题,Evaluator 从三个角度给结果打分,Experiment 则把整批结果组织起来供比较。
十一、当前评测设计的能力边界
一套评测流程是否可靠,不只取决于"能不能跑",还取决于是否准确说明自己的边界。
1. 样本规模较小
12 条样本覆盖了多个主题,但仍是一组小型回归集。它适合验证链路与发现明显退化,不能代表所有真实用户表达。
2. 三个 Judge 都不使用参考答案
当前能衡量检索相关性、回答忠实度和帮助性,却没有直接衡量实际答案与标准答案的一致程度。Dataset 虽然保存了参考答案,本次评估器尚未消费它。
3. Judge 仍然是模型调用
temperature: 0 用于降低波动,但连续评分仍来自模型判断。实验结果应结合 Judge 的文字评语与具体 Trace 阅读,而不是只盯着一个平均分。
4. 检索固定返回 4 个文本块
当前没有动态调整 k,也没有接入重排步骤。评测结果反映的是"Top 4 向量召回 + 当前生成 Prompt"这一套完整配置。
5. 数据集初始化不是完全幂等
Dataset 会被复用,但 Example 仍会重复创建。运行评测前要确认样本数量,避免重复数据改变实验权重。
明确这些边界,才能正确解释实验分数,也能避免把"当前没有评估"误写成"已经证明没有问题"。
十二、一次配置变更应该怎样比较
当我们修改下面任意一项时,都可以重新运行同一个 Dataset:
- Embedding 模型;
chunkSize与chunkOverlap;- Retriever 的
k; - IVF_FLAT 搜索参数;
- 对话模型;
- 客服 Prompt。
关键是每次只明确改变待比较的配置,并通过不同的 experimentPrefix 区分实验。然后按三个维度观察:
- Retrieval relevance 是否变化,判断检索端影响;
- Groundedness 是否变化,判断模型是否更忠于上下文;
- Helpfulness 是否变化,判断最终答案是否更能解决问题。
这就把"感觉这个版本更好"转化成了可重复的同集对比。
总结
一套完整的 RAG 质量闭环,不是只把答案上传到某个平台,而是保留并连接四种对象:
text
Dataset:固定问题与参考答案
Target:执行 RAG,返回 answer 与 context
Evaluator:定义三种质量关系
Experiment:批量运行、记录分数与评语
其中最重要的设计,是不要把检索和生成压缩成一个模糊总分:
question -> context用 Retrieval relevance 评估;context -> answer用 Groundedness 评估;question -> answer用 Helpfulness 评估。
再结合上篇的 LangSmith Trace,我们既能从实验层面发现"哪个版本退化了",也能进入单次运行继续追查"具体是哪一个节点、哪一段上下文出了问题"。
至此,RAG Agent 不再只是一个能够回答问题的演示,而成为一条能够被追踪、被复现、被拆解评估的工程链路。