本文以 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 的本质:大模型负责语言能力,外部知识库负责事实,检索负责把两者接上,评估负责保证这条链子没断。