公司内部的 Agent 基本都要用 RAG。LLM 会思考,但它不知道你们公司的文档------所以得先把文档检索出来,塞进 prompt。
问题是,这套「检索 → 生成」的流程太固定了。固定就意味着死板,而死板在真实问答里有五个绕不过去的坑。
这篇用一个《天龙八部》问答助手的四个版本,把这五个坑和对应的解法讲清楚:从最朴素的 RAG,一路演进到会路由、拆解、评估、补充的 Agentic RAG。
一、先说清楚:为什么需要 RAG
面试常问的第一个问题就是「你用的什么向量数据库」。常见答案:
| 向量库 | 常见搭配 |
|---|---|
| Milvus | Node.js / 自建 |
| Qdrant | Python |
| Pinecone | 托管服务 |
| pgvector | 已有 Postgres,不想加组件 |
选型先放一边,为什么要 RAG 才是根本:
LLM 能思考,但它不知道公司内部的文档。
把私有文档喂给模型有两条路:一是微调(贵、慢、改了文档还得重训),二是 RAG------把相关文档检索出来,拼进 prompt 里让它带着证据回答。
RAG 的标准流程就两步:
用户问题 → 向量检索 → 拼进 prompt → 生成回答
简单、直接、能跑通。但恰恰因为太固定,它在真实场景里会出问题。
二、基线版本:一条直线的 RAG
先看最简单能跑的形态(src/naive-rag.mjs):
js
const GraphState = Annotation.Root({
question: Annotation, // 问题
k: Annotation, // 检索数量
documents: Annotation, // 检索到的文档
generation: Annotation // 生成的内容
});
const graph = new StateGraph(GraphState)
.addNode("retrieve", retrieveNode)
.addNode("generate", generateNode)
.addEdge(START, "retrieve")
.addEdge("retrieve", "generate")
.addEdge("generate", END)
.compile();
画出来就是一条直线:
检索部分用 Milvus + HNSW 索引:
js
vectorStore = await Milvus.fromExistingCollection(embeddings, {
collectionName: "ebook_collection",
url: "localhost:19530",
textField: "content",
primaryField: "id",
vectorField: "vector",
indexCreateOptions: {
metric_type: "COSINE",
index_type: "HNSW", // 多层近邻图向量索引
param: { M: 16, efConstruction: 200 },
search_params: { ef: 64 }
}
});
生成节点把召回片段拼成上下文,再流式输出:
js
const generateNode = async (state) => {
const context = state.documents
.map((item, i) => `[片段 ${i+1}]
章节: 第 ${item.chapter_num}章
内容:${item.content}`)
.join("\n\n----------\n\n");
const prompt = `
你是一个专业的《天龙八部》小说助手...
请根据以下《天龙八部》小说片段内容回答问题:
${context}
用户问题:${state.question}
...`;
let generation = "";
const stream = await model.stream(prompt);
for await (const chunk of stream) { /* 流式打印 */ }
return { generation };
}
这就是基线。 下面五个毛病,都是在这个基线上暴露出来的。
三、毛病一:所有问题都走检索
1 + 1 = ?
这个问题需要检索《天龙八部》吗?显然不需要。但基线版本会老老实实去向量库捞 5 个片段,然后把这些毫不相关的上下文塞进 prompt。
代价是双重的:
- 浪费资源:一次 embedding 调用 + 一次向量检索 + 5 个片段的 token;
- 污染回答:无关片段进了 prompt,就是纯粹的干扰项。
解法:查询路由
在检索之前加一个路由节点 ,用 LLM 判断问题该走哪条路(src/rag-query-router.mjs):
js
const RouteSchema = z.object({
strategy: z.enum(["simple", "complex"]),
reason: z.string()
});
const routeQuestionNode = async (state) => {
const router = model.withStructuredOutput(RouteSchema); // 结构化输出约束
const route = await router.invoke(`
你是问答路由器,请判断用户问题是否需要外部检索。
规则:
- simple: 常识问答、简短定义、无需特定小说细节即可回答。
- complex: 需要《天龙八部》具体情节、人物关系、章节事实、原文细节或证据支持。
用户问题: ${state.question}
`);
console.log(`路由策略:${route.strategy} ${route.reason}`);
return { strategy: route.strategy, routeReason: route.reason };
}
然后用条件边分流:
js
const decideNext = (state) => (state.strategy === 'simple' ? "direct_answer" : "retrieve")
const graph = new StateGraph(GraphState)
.addNode("route_question", routeQuestionNode)
.addNode("direct_answer", directAnswerNode) // 直接答
.addNode("retrieve", retrieveNode) // 走检索
.addNode("rag_generate", generateNode)
.addEdge(START, "route_question")
.addConditionalEdges("route_question", decideNext, {
direct_answer: "direct_answer",
retrieve: "retrieve"
})
.addEdge("retrieve", "rag_generate")
.addEdge("direct_answer", END)
.addEdge("rag_generate", END)
.compile();
图从一条直线变成了一个分叉:
有个细节值得注意:路由结果用 withStructuredOutput + zod 枚举 来约束,而不是让模型自由输出一段话再解析。这样 strategy 只会是 simple 或 complex,不会出现「可能吧」「大概属于复杂问题」这种没法用 if 判断的返回值------用自然语言做控制流,是个隐蔽的坑。
四、毛病二:多步检索的问题答不了
这是五个毛病里最硬的一个。看这个问题:
《天龙八部》中【四大恶人】排行第二的是谁?此人之子在身世揭晓前,其生父在武林中的公开身份是什么?
它其实是一条推理链:
四大恶人 → 排行第二的是谁 → 叶二娘
叶二娘的儿子 → 虚竹
虚竹的生父 → 揭晓前是什么身份 → 少林寺方丈玄慈
要答对,得连着查三次。但基线版本会把整句话一次性做向量检索------一个包含两跳关系的复合问句,它的 embedding 和任何一个原文片段都不像,召回结果自然一塌糊涂。
解法:拆解 + 规划循环
先说结论:光有「拆解」还不够,拆完还得有人决定「这条查够了没有、要不要继续查」。 所以是两步。
第一步:把问题拆成有序子问题
js
const DecomposeSchema = z.object({
sub_questions: z.array(z.string()).min(1).max(8),
reason: z.string()
})
const decomposeQuestionNode = async (state) => {
const decomposer = model.withStructuredOutput(DecomposeSchema);
const out = await decomposer.invoke(`
你是《天龙八部》多跳问答的【子问题拆解器】。
用户原始问题:
${state.question}
任务:将问题拆成**有序**子问题列表 sub_questions, 用于**依次向量检索**。要求:
1. 链式推理、多层关系、因果先后的问题,必须拆成多条;单跳即可答的也可只输出1条。
2. 每条子问题必须是**可独立检索**的完整中文问句,**禁止**使用「他/她/此人/上文」等指代;
可写全人物名与事件名。
3. 顺序必须符合推理链:先搞清前置实体/事实,再查后续结论。
4. **不要**把整句原题原样复制成唯一一条(除非确实无法拆分);不要拆成过碎的关键词列表。
5. 输出 1~8 条即可。
`);
// ...
}
这段 prompt 里有三条是踩过坑才会写出来的,值得单独拎出来说:
| 约束 | 不写会怎样 |
|---|---|
| 「禁止使用他/她/此人等指代」 | 拆出「他的生父是谁?」这种问题,脱离上文根本没法检索------向量库里没有「他」 |
| 「顺序必须符合推理链」 | 顺序错了,第二轮检索时第一轮的答案还不知道,链就断了 |
| 「不要拆成过碎的关键词列表」 | 拆成 ["四大恶人", "叶二娘", "虚竹", "玄慈"]------向量检索用的是语义相似度,一堆关键词的检索效果反而不如一个完整问句 |
第 2 条是最容易忽略的:拆解器要输出的是「检索用的查询」,不是「给人看的思考步骤」。 查询必须自包含。
第二步:规划节点决定「够不够了」
拆完之后不能闷头把 8 条全查完------可能查到第 3 条就够了,剩下 5 次检索纯属浪费。所以要有个规划节点,每轮重新判断:
js
const NextStepSchema = z.object({
nextAction: z.enum(["retrieve", "generate"]),
reason: z.string()
})
const planNextStepNode = async (state) => {
const { nextAction, reason } = await planModel.invoke(`
你是多跳 RAG 规划器。检索查询已由前置步骤拆解为**有序子问题**,
若需要继续检索,下一轮将自动使用 [下一条子问题] 做向量检索,你**不要**自拟新的检索句。
用户原始问题: ${state.question}
子问题序列:
${subList} // 每条标注「已检索 / 下一轮将检索 / 未检索」
已检索轮次:${state.retrievalCount}; 剩余未检索子问题条数:${remaining}
最大检索轮数上限:${state.maxRetrievals}
已召回文档摘要:
${docStr}
请判断下一步:
1) 已有足够依据回答用户原始问题 -> nextAction=generate
2) 仍缺关键事实、且仍存在未检索的子问题、且未超过轮数上限 -> nextAction=retrieve
硬性规则:
- 若剩余未检索子问题条数为0, 必须 nextAction=generate。
- 若已检索轮数已到达或超过最大检索轮数,必须 nextAction=generate。
`);
// ...
}
这里最该学的是**「硬性规则」那两句**------它和模型判断是双重保险:
js
let finalNext = nextAction;
if (state.retrievalCount >= state.maxRetrievals) finalNext = "generate";
if (remaining <= 0) finalNext = "generate";
为什么不能只靠 prompt? 因为 LLM 是会不听话的。你在 prompt 里写了「剩余为 0 时必须 generate」,它照样可能返回 retrieve。而这里如果返回了 retrieve,就轮到 retrieveNode 去取 subs[idx]------下标越界,取到 undefined,然后抛错整个图崩掉。
所以作者在代码层面又兜了一道:prompt 负责「应该怎么判断」,代码负责「不许越界」。 循环 + LLM 的组合里,这个模式几乎是标配。
还有个小细节是在 prompt 里明说的:
若需要继续检索,下一轮将自动使用 下一条子问题 做向量检索,你「不要」自拟新的检索句。
因为一旦允许规划器自己造查询,它就可能造出和原问题同样模糊的句子,把多跳检索又拉回单跳的老路。让规划器只做「继续 / 停止」的二选一,把「查什么」锁死在拆解阶段------职责单一,行为可控。
循环长这样
js
const afterPlan = (state) => state.strategy === "retrieve" ? "retrieve" : "generate"
.addEdge("decompose_question", "retrieve")
.addEdge("retrieve", "plan_next_step")
.addConditionalEdges("plan_next_step", afterPlan, {
retrieve: "retrieve", // ← 指回检索,形成循环
generate: "generate"
})
那条 plan_next_step -.-> retrieve 指回上游,就是整个 Agentic RAG 的心脏------它让 RAG 从「查一次就完事」变成了「查、看、再决定查不查」。
⚠️ 但这个 demo 里,这条边永远不会被走到
写完上面那张图我去核代码,发现了一个很有代表性的 bug------图是对的,边却永远走不到。
规划节点把决策结果写进了 plannedNext:
js
const planNextStepNode = async (state) => {
// ...
console.log(`[决策] plannedNext=${finalNext} (模型建议=${nextAction})(${reason})`)
return {
plannedNext: finalNext // ← 决策写在这里
}
}
但条件边的路由函数读的是另一个字段:
js
function afterPlan(state) {
return state.strategy === "retrieve" ? "retrieve" : "generate"
// ^^^^^^^^^^^^^^^ 读的是 strategy
}
而 strategy 这个字段,全流程只可能被赋三个值 :""(初始)、"simple"、"complex"(来自路由节点的 z.enum(["simple","complex"]))。
state.strategy 永远不可能等于 "retrieve" ------ 所以 afterPlan 永远返回 "generate",那条指回 retrieve 的边一次都不会走。
实际跑起来,整个多跳流程会退化成:
route → decompose → retrieve(只查第一条子问题)→ plan(永远说 generate)→ generate
多跳检索等于没生效。
最坑的地方在于:drawMermaid() 画出来的图和正确版本一模一样 ,那条 plan_next_step -.-> retrieve 清清楚楚地在那儿。可视化能帮你发现「线连错了」,但发现不了「条件永远不成立」------后者只有读路由函数、或者跑起来打日志才看得出来。
所以正确的写法是:
js
function afterPlan(state) {
return state.plannedNext === "retrieve" ? "retrieve" : "generate"
// ^^^^^^^^^^^^^^^^^ 读规划节点真正写进去的那个字段
}
教训 :条件边的路由函数和它上游节点的返回值之间,是一个没有类型检查的隐式契约 。上游写
plannedNext、下游读strategy,两边都不会报错,图也照常编译运行------只是那条边静悄悄地死了。这是 Agentic RAG 里最容易犯、也最难发现的一类 bug:流程图看起来完整,实际走的是另一条路。
附赠一个工程细节:多轮召回要去重
多轮检索必然召回重复片段。重复不只是浪费:
重复可能让 LLM 产生「我们在强调这一点」的错觉,从而给这段内容过高的权重。
所以每轮结果都要按 id 去重,并且保留分数更高的那条:
js
const mergeUnique = (existingDocs, newDocs) => {
const map = new Map();
for (const d of [...existingDocs, ...newDocs]) {
const key = String(d.id);
const prev = map.get(key);
if (!prev || Number(d.score) > Number(prev.score)) {
map.set(key, d); // 同一片段,留分数高的
}
}
return Array.from(map.values()).sort((a, b) => Number(b.score) - Number(a.score));
}
五、毛病三:没有评估和纠错机制
前两版解决的是「要不要查 」和「查几次」。但还有个问题没人管:
查到的内容,到底够不够、准不准?
基线版本对召回结果照单全收------检索质量没有任何校验。向量库没找到相关内容时,它不会说「我不知道」,而是把几段不相关的文字硬塞进 prompt,逼着模型从中憋出一个答案。
解法:加一个评估节点
设计思路是:先查本地,查到的上下文交给 LLM 评估「够不够」;不够就带着缺失点去联网补,补完再评估一次。
js
const EvaluateSchema = z.object({
enough: z.boolean(), // 是否足够生成
missing: z.array(z.string()).max(6), // 上下文缺的方面
reason: z.string(),
web_query: z.string().optional() // 可选的 web 搜索关键词
})
const evaluateNode = async (state) => {
const hasWeb = Boolean(state.webContext && String(state.webContext).trim());
const evaluator = llm.withStructuredOutput(EvaluateSchema);
const out = await evaluator.invoke(`
你是信息充分性评估器。判断当前上下文是否足以回答用户问题。
用户问题: ${state.question}
已检索上下文(来自本地知识库) :
${state.localContext || "(空)"}
${hasWeb ? `联网搜索结果:\n ${state.webContext || "(空)"}` : ""}
输出字段:
- enough: 是否足够回答(true/false)
- missing: 若不够,列出缺失信息点(最多6条)
- reason: 简短原因
${hasWeb ? "" : "- web_query: 若不够,给出一个适合互联网搜索的中文查询句(完整句)"}
`);
console.log(`${hasWeb ? "二次评估" : "评估"}: enough=${out.enough} (${out.reason})`);
return { evaluation: JSON.stringify(out) };
}
这个设计里有两点挺巧:
① 同一个节点,跑两次,靠 hasWeb 区分语义。
第一次评估:只有本地上下文 → 越不过就产出 web_query 去联网。 第二次评估:本地 + 联网 → 输出里不再提供 web_query 字段(prompt 里那句三元表达式),断了「无限联网」的路。
用 schema 字段的有无来限制能力 ,比在代码里写 if (round >= 2) 更省事,也更难写错。
② missing 是「诊断结果」,不只是个布尔值。
enough: false 只告诉你「不行」,missing: [...] 才告诉你缺什么 ------而这个恰恰就是下一步联网要搜的东西。让评估节点顺手把「缺什么」结构化输出,等于免费得到了下一轮的搜索意图。
图长这样:
说明一下这段代码的状态 :评估节点本身是完整的,但联网搜索那一段还只到设计为止 ------webContext 已经在 state 里了、web_query 也已经在 schema 里了,但 web_search 节点还没接进图,generate 节点也还没写完(for await 循环还是空的,函数没有返回值)。所以上图最后停在 evaluate_local → END。
要把它补完,缺的是这一条路径:
rust
evaluate_local --不够且无webContext--> web_search --有webContext--> evaluate_local(二次评估)
--够了--> generate
这也是这篇的诚实之处:Agentic RAG 的骨架搭起来不难,难的是把每个节点的「出口条件」都堵严实------少堵一个,就是无限循环或者直接崩。
六、毛病四:语义检索匹配不了专业术语
这条比较反直觉,但很真实:
「高血糖」和「低血糖」在自然语义上相似度很高。
对向量检索来说,它俩的 embedding 非常接近。你搜「高血糖」,很可能把「低血糖」的段落召回来------语义相近,但结论完全相反。
类似的问题还有一批:专业术语、人名、编号、代码标识符、精确实体。这类内容的检索,关键词匹配(LIKE 查询、正则、全文索引)往往比纯语义检索准得多。
所以成熟的 RAG 系统通常是混合检索:
| 检索方式 | 擅长 | 短板 |
|---|---|---|
| 向量检索 | 语义相近、换种说法也能找到 | 精确术语、反义词容易混淆 |
| 关键词检索 | 精确匹配人名/术语/编号 | 换个说法就找不到 |
两家各查一遍,再用 RRF(倒数排名融合)之类的策略合并结果------这在工程上是「加法」,但在效果上是乘法。
七、毛病五:本地没有的内容,模型就开始胡说
这个最简单也最致命:
本地知识库没有的内容,LLM 不会说「我不知道」,它会编。
而且它编得很像真的------有细节、有语气、有自信。这正是评估节点存在的另一个理由,也是 prompt 里必须写死的那条:
markdown
3. 如果片段中没有相关信息,请如实告知用户
但光靠 prompt 是不够的 。真正靠得住的是让「有没有相关信息」变成代码里的判断 ------这就是评估节点在做的事:把「够不够」变成一个 enough: boolean,让程序能据此决定下一步,而不是指望模型自觉。
八、四个版本对照:状态字段的膨胀记录了一切
把四版的 state 声明摆在一起看,会发现一个很有意思的现象------架构的复杂度,全写在状态字段的膨胀里:
| 版本 | state 字段 | 数量 |
|---|---|---|
| 基线 RAG | question k documents generation |
4 |
| + 查询路由 | 上面 4 个 + strategy routeReason |
6 |
| + 多跳检索 | 上面 6 个 + subQuestions nextSubIdx currentQuery retrievalCount maxRetrievals plannedNext |
12 |
| + 评估纠错 | 上面若干 + retrievedDocs localContext webContext evaluation |
16 |
多跳那一版多出来的 6 个字段,几乎全是循环控制变量:
js
subQuestions, // 拆解出的子问题队列
nextSubIdx, // 查到第几条了 ------ 循环游标
currentQuery, // 本轮实际检索的句子
retrievalCount, // 已查轮次
maxRetrievals, // 轮数上限 ------ 防止跑飞
plannedNext, // 上一轮的决策结果
一个朴素的「检索 → 生成」,一旦加进循环,就必须凭空长出一整套循环控制状态。 这是 Agentic RAG 相比普通 RAG 最实质的成本,也是它为什么必须用 LangGraph 这类「图」来编排------线性链没有地方安放「回到上一步」这件事。
九、总结
回到开头那五个毛病,对应的四个解法:
| # | 毛病 | 解法 | demo |
|---|---|---|---|
| 1 | 所有问题都走检索,浪费且干扰 | 路由:LLM 先判断 simple / complex | rag-query-router.mjs |
| 2 | 多跳问题一次性检索召不回 | 拆解 + 规划循环:拆成有序子问题,逐条检索,每轮判断够不够 | rag-multihop.mjs |
| 3 | 没有纠错和评估机制 | 评估:LLM 判断上下文是否充分,输出缺失点,不够就补 | rag-webfallback.mjs |
| 4 | 专业术语、精确实体检索不准 | 混合检索:向量 + 关键词,结果融合 | ------ |
| 5 | 本地没有的内容模型就胡说 | 评估节点把「够不够」变成代码可判断的布尔值 | 同上 |
把这篇串成一条线:
- 固定流程的 RAG 能跑通,但它不会思考------它只会「检索、拼 prompt、生成」,不管该不该检索、检索得够不够、检索得准不准。
- 加路由,让它先决定「要不要查」。
- 加拆解和规划 ,让它能沿着推理链一条条查,并且每轮重新判断要不要继续------那条指回上游的条件边,就是 Agentic RAG 的心脏。
- 加评估,让它能说「我不确定」而不是硬编。
- 状态字段的膨胀是这一切的代价,也是必须上图编排的原因。
- 但图对不等于流程对 :条件边的路由函数读错字段,边就永远走不到,而
drawMermaid一点异常都看不出来。复杂图的正确性,最终只能靠日志和断点来保证。
最后记一句:
普通 RAG 是「把文档喂给模型」,Agentic RAG 是「让模型自己决定要哪份文档」。
差别不在用了什么向量库,而在那个决定「下一步该干什么」的节点,到底存不存在。
本文 demo 都在
agentic_rag/advanced-rag/src下:ebook-writter.mjs负责把《天龙八部》epub 切块入库,另外四个是四版检索架构。跑之前需要先建.env(OPENAI_API_KEY/OPENAI_BASE_URL/MODEL_NAME/EMBEDDINGS_MODEL_NAME),并确保 Milvus 里已经有ebook_collection。如果你也在做 RAG 落地,欢迎评论区交流 👋