一个不会胡说八道的客服机器人:RAG 的原理与工程实现

本文以 rag-demo 项目为样本,讲清楚 RAG(Retrieval-Augmented Generation,检索增强生成)在做什么、每步背后的原理,以及工程落地会踩哪些坑。

一、这个项目在干什么

一句话:做一个只依据自家客服文档回答问题的机器人,并且能给它打分。

路径 作用
data/*.md 客服知识库原文(配送、会员积分、支付发票、商品质保)
src/milvus_insert.mjs 离线建库:读文档 → 分块 → 向量化 → 写入 Milvus
src/rag_agent.mjs 在线问答核心:检索 + 生成的 LangGraph 工作流,导出 ask()
src/cli.mjs 命令行入口,方便手测
src/eval/build_dataset.mjs 把 12 条人工标注问答对上传为 LangSmith 数据集
src/eval/evaluate.mjs 三个 LLM-as-judge 评估器
src/eval/run_eval.mjs 跑评估实验,产出量化分数

整体流程一句话:把文档切碎、变成向量存进数据库;用户提问时,用同样方式把问题变成向量,捞出最相关的几段,连同问题一起塞给大模型,让它"照着材料答题"。

二、为什么不能直接把问题丢给大模型

大模型有两个硬伤。

第一,它不知道你的私有知识。 模型知识来自公开语料,不可能知道"满 99 元包邮""金卡 95 折"这类内部政策;训练数据还有截止日期,之后的事一概不知。

第二,它会一本正经地胡说。 模型不知道答案时,不会天然说"我不知道",而倾向于编一个看起来合理的答案,这叫"幻觉"。客服场景里把退货期限从 7 天说成 15 天,就是实打实的客诉。

业界有三条路。微调 :用你的数据继续训练------贵、慢,知识一更新就要重训,而且仍会幻觉。长上下文硬塞 :把全部文档塞进 prompt------按 token 计费很贵,内容一多模型还容易看漏。RAG:只检索最相关的片段------需要一套检索基础设施,但这正是它的价值。

RAG 的核心思想就一句话:把"记忆"从模型脑子里搬到外部数据库,模型只负责"阅读理解"和"组织语言"。 它还带来一个好处------可追溯:答案来自具体文档,你知道它出自哪份文件的哪一段,出了问题能定位、能修正,而不必重新训练模型。

三、整体流水线:离线建库 + 在线问答

RAG 天然分成两条线,这个划分是理解全篇的钥匙。

离线线(建库,跑一次或知识更新时跑):

kotlin 复制代码
data/*.md → 分块(Chunking) → 向量化(Embedding) → 写入 Milvus 并建索引

在线线(每次提问都跑):

css 复制代码
用户问题 → 向量化 → 向量检索 Top-K → 拼装 Prompt → LLM 生成 → 答案

左边把知识变成可检索的形态,右边把问题变成可检索的形态再取用。两边必须使用同一个 Embedding 模型,原因见 4.4。

四、原理拆解

4.1 向量化:让"意思"变成一串数字

计算机没法直接比较两句话"意思像不像",但很擅长比数字。Embedding 模型的工作,就是把一段文本映射成一个高维浮点向量 (比如 1024 个数字)。关键在于这个映射的性质:语义相近的文本,映射出的向量在空间里距离也近。

例如"无理由退货要在几天内申请?"和"七天无理由退换的期限是多少",字面几乎没有重合的词,但向量非常接近;而"满多少元包邮"则离它们很远。这正是 RAG 强于关键词搜索的地方:它按意思 检索而不是按字面匹配,用户不必记住文档里的精确措辞也能问到。

"距离近"怎么算?常见两种:欧氏距离(L2) ,两点间直线距离,越小越相似,本项目建库时用的就是 MetricType.L2;以及余弦相似度/内积,看向量夹角,方向越一致越相似。

4.2 分块:为什么不整篇存

一个自然的疑问:为什么不把整篇文档变成一个向量?原因有三个。一篇文档往往包含多个主题 ,整篇压成一个向量会成为所有主题的"平均值",反而不像任何一个;检索粒度决定 prompt 质量 ,我们要塞给模型的是最相关的那几段,整篇塞进去既浪费 token,又会让真正有用的信息被淹没;模型上下文有限,不可能无限塞。

本项目用 RecursiveCharacterTextSplitter:

js 复制代码
new RecursiveCharacterTextSplitter({ chunkSize: 500, chunkOverlap: 50 });

"递归"指切分策略:按分隔符优先级一层层尝试------先按段落(\n\n)切,某块还太长就按换行切,再不行按句子、空格。这样切出的块尽量落在语义边界上 ,而不是在句子中间硬生生断开。chunkOverlap: 50 让相邻两块保留 50 字符重叠------一刀切下去很可能把"满 99 元包邮"的上下两半分到不同块,有了重叠,被切断的语义至少能完整出现在其中一块里。

4.3 向量数据库 Milvus:为什么不直接用数组

存向量用数组也行:放进内存,查询时挨个算距离取最近的。这叫暴力检索 ,结果准确,复杂度是 O(n)------1000 条没问题,1000 万条就是每次几千万次浮点运算。所以需要近似最近邻搜索(ANN):牺牲一点准确率,换数量级的速度提升。Milvus 就是专做这件事的向量数据库。建库时做了三件事。

(1)建 Collection 和 Schema。 Collection 相当于关系库里的"表",Schema 定义字段:

js 复制代码
{ name: "langchain_primaryid", is_primary_key: true, data_type: DataType.Int64, autoID: true },
{ name: "langchain_vector", data_type: DataType.FloatVector, dim },
{ name: "langchain_text",   data_type: DataType.VarChar, max_length: 8000 },
{ name: "source",           data_type: DataType.VarChar, max_length: 256 },

前三个字段名不能改,它们是 @langchain/community 的 Milvus 向量存储里写死的常量 ------rag_agent.mjs 用 fromExistingCollection() 连接已有集合时按这三个名字找字段,改名就连不上。向量维度 dim 从真实向量长度动态取(vectors[0].length),以保证和 Embedding 模型输出对齐;对不上,Milvus 会直接拒绝写入。

(2)建索引 IVF_FLAT。

js 复制代码
index_type: IndexType.IVF_FLAT,
metric_type: MetricType.L2,
params: { nlist: 128 },

IVF 是 Inverted File(倒排文件)的缩写,思路直观:先给所有向量聚类 ,分到 nlist = 128 个簇;查询时先算出问题向量离哪些簇中心最近,再只在这几个簇里做暴力检索 ,其他簇跳过------就像查字典先按部首找范围再细找。nprobe 决定查几个簇(本项目用默认值 10);FLAT 表示簇内存原始向量不做压缩,精度损失只来自"少查了几个簇",召回率和速度的平衡好调。

(3)loadCollection,一个容易忽略的步骤。

js 复制代码
await client.loadCollection({ collection_name: COLLECTION });

Milvus 是存算分离架构,数据平时在磁盘/对象存储上。要让集合可被检索,必须先把索引加载进内存------没 load 就查会直接报错,而不是返回空结果。这样也能只加载当前需要的集合,节省内存。

4.4 检索:把问题也变成向量

在线问答第一步,是用同一个 Embedding 模型把问题向量化,再去 Milvus 找距离最近的 Top-K:

js 复制代码
const retriever = vectorStore.asRetriever({ k: 4 });

k: 4 表示每次捞 4 段最相关文本,它们共同成为后续生成的"证据材料"。新手最容易犯的错误是离线建库和在线查询用了不同 Embedding 模型 ------不同模型把文本映射到两个完全不同的向量空间,就像一个人说"经纬度"、另一个人说"门牌号",算出的距离毫无意义,检索会返回看似正常、实则完全无关的结果,而且特别隐蔽。另外建库时把 metadata.source(文件名)一起存了进去,所以每段结果都带着"我来自哪份文档",这是系统可追溯性的数据基础。

4.5 生成:用 LangGraph 把流程画成一张图

到这里,"4 段文本 + 问题"已经够了,剩下就是组装 prompt。但本项目没有简单写两个 await,而是用 LangGraph 把流程显式建模成状态图:

js 复制代码
const GraphState = Annotation.Root({
  context: Annotation,
  question: Annotation,
  answer: Annotation,
});

const workflow = new StateGraph(GraphState)
  .addNode("retrieve", retrieve)
  .addNode("generate", generate)
  .addEdge(START, "retrieve")
  .addEdge("retrieve", "generate")
  .addEdge("generate", END);

export const ragApp = workflow.compile();

理解它需要三个概念。State(状态) :流程共享的一份数据,这里定义了 question、context、answer;用 Annotation.Root() 声明且未指定合并函数的字段,语义是后写覆盖先写 。Node(节点) :一个处理函数,读状态、写状态------retrieve 读 question 写 context,generate 读 context + question 写 answer。Edge(边) :节点间的流向,START → retrieve → generate → END 是一条直线。

为什么不直接写两步 await? 因为这个流程注定要变复杂。RAG 的优化几乎都体现为"加节点":加"问题改写"做指代消解、加"重排序"精排结果、加"自我检查"发现答案不靠谱就打回重检索。写成图之后,加能力就是加节点和边,而不是把代码改成一团嵌套逻辑,图还能可视化便于排查------这是 LangGraph 这类编排框架的真正价值。

再看 generate 里最关键的 prompt:

css 复制代码
你是客服助手。仅根据下面[上下文]回答: 上下文没有的信息请明确说不知道,不要编造。

上下文: {context}

这三句是抑制幻觉的第一道防线,缺一不可:"仅根据上下文回答"限定知识来源,禁止动用预训练知识;"没有的信息请明确说不知道"给模型一个"认输"的出口------不给出口,它就会为了完成任务而编造;"不要编造"直接约束。

另外 temperature: 0 也是有意为之:温度控制采样随机性,置 0 表示每次尽量选概率最高的输出。客服回答需要稳定可复现,同样的政策不能今天说 7 天、明天说 15 天。

4.6 评估:没有度量就没有优化

系统能跑起来只说明它不崩,不说明它答得好。RAG 的评估格外重要,因为它有两个可能出错的环节:检索没找到对的材料,或生成找对了材料却在编。只看最终答案无法区分。所以本项目搭了一套完整评估流水线。

第一步,构建数据集。 build_dataset.mjs 里硬编码了 12 条人工标注问答对,上传到 LangSmith 成为数据集 rag-eval-v1:

js 复制代码
{ inputs: { question: "无理由退货要在几天内申请?" },
  outputs: { answer: "自签收之日起 7 天内支持无理由退货。" } }

标准答案是自动化回归测试的前提:有了它,每改一次 prompt 或换一次模型都能重跑一遍,看分数有没有退化。

第二步,定义被评估对象。 run_eval.mjs 把真实的 ask() 包成一个函数,输入问题、输出答案和检索到的上下文:

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

注意它多返回了一个 context------正因为把中间检索结果暴露出来,评估器才能独立地给"检索"打分。

第三步,用模型当裁判(LLM-as-judge)。 "答得好不好"没法用字符串相等判断------同一个意思有无数种说法,只能用另一个模型来读、来打分。evaluate.mjs 用 openevals 的三个预置 prompt 建了三个评估器,它们的分工是整套评估设计里最精妙的地方:

评估器 看到的输入 回答什么问题 出问题时的含义
rag_retrieval_relevance inputs + context 检索到的材料与问题相关吗? 检索阶段有问题
rag_groundness context + outputs.answer 答案的说法能在材料里找到依据吗? 生成阶段在编造
rag_helpfulness inputs + outputs 这个回答真正解决了问题吗? 端到端最终效果

设计上有两个"故意":幻觉检测器故意不看用户问题 ------"编造"的定义是"答案超出给定材料",与问题无关,只看材料与答案的对应关系才纯粹;检索相关性检测器故意不看最终答案------它要回答的是"检索环节做得好不好",让答案进来就会和端到端效果混在一起,无法归因。

三个指标组合起来就能定位故障:检索相关性低而有据可依高 → 检索没找对材料,模型却老实地基于错误材料作答,问题在检索(分块策略?k 太小?模型不合适?);检索相关性高而有据可依低 → 材料给对了模型却在编,问题在生成(prompt 约束不够?模型能力不足?);三个都高但有用性低 → 材料和依据都有却没答到点子上,可能是问题理解或 prompt 结构的问题。

最后一个工程细节:experimentPrefix 带上了模型名(rag-openevals-qwen-plus)。因为 LangSmith 里每次评估都是独立 experiment,名字带上关键变量,换模型或换 prompt 后就能在网页上并排对比。

五、工程落地时踩到的坑

真实项目里卡你半天的往往不是原理,而是这些"静默失败"------程序不报错,结果却是错的。

1. autoID 不是 auto_id。 Milvus 主键自增属性叫 autoID(驼峰),对应 protobuf 的 FieldSchema.autoID;而同一对象里的 is_primary_key、max_length、dim 又是下划线风格,这种混用极易写错。写成 auto_id 不报错,会被 protobuf 静默丢弃 ,于是建出非自增主键,插入时没给主键值被服务端拒绝,报 expected need long int array, actual got nil;表面现象却是"插入了 0 条记录",真正原因藏在返回体的 reason 字段里。所以批处理失败时别只看"成功了几条",要把完整返回体打出来。

2. 环境变量拼错会被兜底值掩盖。 早期版本读 EMBEDDING_MODEL,而 .env 里是 EMBEDDINGS_MODEL(多个 S)。因为代码写了 ?? "text-embedding-v3" 兜底,程序照常运行,但你在 .env 里改配置根本不会生效,排查时完全想不到是这里。现在统一用 requiredEnv(),缺变量直接抛错------宁可启动就崩,也不要带着错误配置悄悄跑。

3. 脚本要考虑幂等,类型传错要顺着栈帧读。 build_dataset.mjs 最初"只要数据集存在就再插 12 条",重复运行会把同一批问答反复写进数据集、污染评估结果,现在改为先探测是否已有示例再决定。另外两处静默问题:evaluate() 内部是 for (const ev of evaluators),把评估器导成对象会抛 evaluators is not iterable(报错友好,顺栈帧一眼定位);loadChunks() 的过滤正则 /\.(text|md)$/ 只匹配 .text 和 .md,目录里的 data/sample.txt 被静默跳过------写过滤规则时最好把命中文件列表打印出来。

六、总结

把整条链路串起来:

ini 复制代码
【离线建库】
data/*.md → 分块(500/50) → 向量化 → Milvus 建集合 + IVF_FLAT 索引 + load + 插入
        ↓ 存下:向量 + 原文 + 来源文件名
【在线问答】
用户问题 → 同一 Embedding 模型向量化 → 检索 Top-K=4
        → 拼进 prompt("仅根据上下文回答,不知道就说不知道")
        → ChatOpenAI(temperature=0) 生成 → 答案 + 命中来源
【评估闭环】
12 条标注问答对 → 跑 RAG Agent 产出 answer + context
        → 3 个 LLM-as-judge 分别打分(检索相关性 / 有据可依 / 有用性)
        → 每次实验可比对,改动有据可依

回顾每个环节解决的核心问题:分块 让检索粒度合适、语义不被切断;向量化 让机器理解"意思相近";向量索引 (IVF 聚类,只搜部分簇)保证海量向量下还能快速检索;检索 (Top-K 近邻)负责找证据;生成 (严格 prompt + 低温度)让模型基于证据作答、不编造;评估(标注数据集 + 多维度裁判)回答"系统到底好不好、差在哪"。

一句话概括 RAG 的本质:大模型负责语言能力,外部知识库负责事实,检索负责把两者接上,评估负责保证这条链子没断。

相关推荐
AI深栈1 小时前
第 19 章 · AI 工具循环与条件路由:管住 recursionLimit
java·人工智能
YOLO数据集集合1 小时前
小鸡计数检测数据集 |小鸡计数 密集目标检测 智慧畜牧 雏鸡管理9124期
人工智能·目标检测·目标跟踪·小鸡·小鸡计数
KKKlucifer1 小时前
从规则堆叠到策略涌现:AI大模型驱动的数据安全智能策略生成与自动化响应实践
运维·人工智能·自动化
七夜zippoe1 小时前
Agent 自主决策机制:什么时候该检索、什么时候该直接回答
人工智能·ai·agent·检索·自主决策
打工仔折腾 AI2 小时前
蓝耘元生代实测:用WorkBuddy与TextIn xParse拆解43页建模论文
人工智能·后端·python·数学建模·langchain·ai agent 实战
2601_962202982 小时前
万象生鲜系统:首衡集配移动端审批高效
大数据·人工智能
兔兔爱学习兔兔爱学习2 小时前
AI Infra知识点
人工智能
程序员清风2 小时前
基于 Spring Boot 构建生产级 AI 应用平台
大数据·人工智能·spring boot
程序员清风2 小时前
Portkey AI Gateway 实战:构建可治理的多模型网关
人工智能·gateway