公司内部的 Agent 基本都要用到 RAG。
因为大模型能思考,但它不知道公司内部的文档,而我们需要它能基于内部文档来回答。
传统 RAG 是这样的:查询的时候,把 query 用嵌入模型向量化,根据余弦相似度,匹配向量数据库中最相近的文档返回:
但这个流程太固定,会有一些问题:
解决这些问题,显然要在 RAG 的固定流程中,引入大模型来思考:
最终把原本"死板的检索 - 生成"流程,升级为可思考、可判断、可纠错的智能 RAG 架构。这种由大模型自主决策怎么检索、检索的信息是否足够、是否要重新检索等的 RAG 流程就叫 Agentic RAG。
这很适合用 LangGraph 的多 Agent 架构来做,每个 Agent 负责其中一块功能。
我们先把工程建起来:
mkdir advanced-rag
cd advanced-rag
npm init -y安装依赖:
pnpm install @langchain/langgraph @langchain/core @langchain/openai @langchain/community创建 .env:
OPENAI_API_KEY=sk-xxx
OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
MODEL_NAME=qwen-plus后续每一版代码都依赖一个已存在的 Milvus 向量库(集合名 ebook_collection,存的是《天龙八部》小说切片),模型用阿里的 qwen-plus 兼容 OpenAI 接口,embedding 用 text-embedding-v3(1024 维)。下面各小节只贴关键代码,连接 Milvus 和流式打印回答的样板代码都是重复的,理解思路即可。
文件:src/naive-rag.mjs
LangGraph 的核心是 Annotation.Root 定义图的状态(state),再用 StateGraph 把节点(node)和边(edge)拼成一张图。
import "dotenv/config";
import { ChatOpenAI, OpenAIEmbeddings } from "@langchain/openai";
import { Annotation, END, START, StateGraph } from "@langchain/langgraph";
import { Milvus } from "@langchain/community/vectorstores/milvus";
const COLLECTION_NAME = "ebook_collection";
const TOP_K = 5;
const GraphState = Annotation.Root({
question: Annotation,
k: Annotation,
documents: Annotation,
generation: Annotation,
});
const model = new ChatOpenAI({
temperature: 0,
model: "qwen-plus",
configuration: { baseURL: process.env.OPENAI_BASE_URL },
apiKey: process.env.OPENAI_API_KEY,
});
const embeddings = new OpenAIEmbeddings({
model: "text-embedding-v3",
dimensions: 1024,
});retrieveNode 把 query 向量化、从 Milvus 里相似度检索:
let vectorStore;
async function retrieveRelevantContent(question, k = TOP_K) {
try {
const docsWithScores = await vectorStore.similaritySearchWithScore(question, k);
return docsWithScores.map(([doc, score]) => ({
score,
content: doc.pageContent,
id: doc.metadata?.id ?? "unknown",
book_id: doc.metadata?.book_id ?? "未知",
chapter_num: doc.metadata?.chapter_num ?? "未知",
index: doc.metadata?.index ?? "未知",
}));
} catch (error) {
console.error("检索内容时出错:", error.message);
return [];
}
}
const retrieveNode = async (state) => {
const documents = await retrieveRelevantContent(state.question, state.k);
return { question: state.question, k: state.k, documents };
};generateNode 把命中的文档拼进 prompt,调用大模型生成回答(这里用 model.stream 流式输出):
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}
回答要求:
1. 如果片段中有相关信息,请结合小说内容给出详细、准确的回答
2. 可以综合多个片段的内容,提供完整的答案
3. 如果片段中没有相关信息,请如实告知用户
4. 回答要准确,符合小说的情节和人物设定
5. 可以引用原文内容来支持你的回答
AI 助手的回答:`;
process.stdout.write("\n【AI 回答(流式)】\n");
let generation = "";
const stream = await model.stream(prompt);
for await (const chunk of stream) {
const text = typeof chunk.content === "string" ? chunk.content : "";
if (!text) continue;
generation += text;
process.stdout.write(text);
}
process.stdout.write("\n");
return { question: state.question, k: state.k, documents: state.documents, generation };
};把两个节点和边拼起来编译成图:
const graph = new StateGraph(GraphState)
.addNode("retrieve", retrieveNode)
.addNode("generate", generateNode)
.addEdge(START, "retrieve")
.addEdge("retrieve", "generate")
.addEdge("generate", END)
.compile();RAG 是一个线性的流程,之前我们用 LCEL 的链写过,这次用 LangGraph 来写:
跑一下就能得到回答。下面我们一条条来解决上面提出来的传统 RAG 问题。
第一个要解决的问题:简单常识问题也走向量检索,浪费资源。
做法是加一个节点来判断:是直接回答,还是先检索向量库再回答。
文件:src/rag-query-router.mjs
用 zod 定义路由的结构化输出 schema,再用 llm.withStructuredOutput 让模型按 schema 返回,从而控制输出格式:
import { z } from "zod";
const RouteSchema = z.object({
strategy: z.enum(["simple", "complex"]),
reason: z.string(),
});
const GraphState = Annotation.Root({
question: Annotation,
k: Annotation,
strategy: Annotation,
routeReason: Annotation,
documents: Annotation,
generation: Annotation,
});路由节点:让模型判断 query 是 simple 还是 complex,并给出原因。
const routeQuestionNode = async (state) => {
console.log("---ROUTE_QUESTION---");
const router = llm.withStructuredOutput(RouteSchema);
const route = await router.invoke(`
你是问答路由器。请判断用户问题是否需要外部检索。
规则:
- simple: 常识问答、简短定义、无需特定小说细节即可回答。
- complex: 需要《天龙八部》具体情节、人物关系、章节事实、原文细节或证据支持。
用户问题:${state.question}
`);
console.log(`路由策略: ${route.strategy} (${route.reason})`);
return { question: state.question, k: state.k, strategy: route.strategy, routeReason: route.reason };
};directAnswerNode 走直接回答,ragGenerateNode 走"检索 + 生成"。关键是 decideNext 用 conditional edge(条件边) 根据 state.strategy 把流程分到不同分支:
function decideNext(state) {
return 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", ragGenerateNode)
.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();现在的 graph 如下:
我们加了一个对问题做路由的节点:
withStructuredOutput 来控制结构化输出跑下试试,这样就能识别出与小说相关的问题才走检索了。
继续优化:处理不了需要多步检索的复杂问题,比如先查 A、再查 B 才能得出结论。
比如这种问题:"段誉遇到的第一个神仙姐姐画像,是谁的弟子?"直接把这个 query 向量化匹配显然不够准确——应该是先检索神仙姐姐画像是谁,有了结果再去检索她是谁的弟子。
所以我们要支持子问题的拆分。
文件:src/rag-multihop.mjs
state 里多了几个字段来支撑"多轮检索":subQuestions(拆解得到的有序子问题)、nextSubIdx(下一轮要检索的下标)、retrievalCount(已检索轮数)、maxRetrievals(上限)。
const GraphState = Annotation.Root({
question: Annotation,
k: Annotation,
strategy: Annotation,
routeReason: Annotation,
/** 拆解得到的有序子问题,仅用于检索 */
subQuestions: Annotation,
/** 下一轮 retrieve 要用的下标(指向 subQuestions 中尚未检索的那一条) */
nextSubIdx: Annotation,
documents: Annotation,
currentQuery: Annotation,
retrievalCount: Annotation,
maxRetrievals: Annotation,
plannedNext: Annotation,
generation: Annotation,
});三个结构化 schema:
const RouteSchema = z.object({
strategy: z.enum(["simple", "complex"]),
reason: z.string(),
});
const DecomposeSchema = z.object({
sub_questions: z.array(z.string()).min(1).max(8),
reason: z.string(),
});
const NextStepSchema = z.object({
nextAction: z.enum(["retrieve", "generate"]),
reason: z.string(),
});拆解节点:让模型把原始问题拆成有序的子问题数组,并且要求每条子问题可独立检索、不能用"他/她/上文"等指代(否则循环里检索会失准)。
const decomposeQuestionNode = async (state) => {
console.log("---DECOMPOSE_QUESTION---");
const decomposer = llm.withStructuredOutput(DecomposeSchema);
const out = await decomposer.invoke(`你是《天龙八部》多跳问答的「子问题拆解器」。
用户原始问题:
${state.question}
任务:将问题拆成 **有序** 子问题列表 sub_questions,用于 **依次向量检索**。要求:
1. 链式推理、多层关系、因果先后的问题,必须拆成多条;单跳即可答的也可只输出 1 条。
2. 每条子问题必须是 **可独立检索** 的完整中文问句,**禁止** 使用「他/她/此⼈/上文」等指代;可写全人物名与事件
3. 顺序必须符合推理链:先搞清前置实体/事实,再查后续结论。
4. **不要** 把整句原题原样复制成唯一一条(除非确实无法拆分);不要拆成过碎的关键词列表。
5. 输出 1~8 条即可。
请输出 sub_questions 与简短 reason。`);
const subQuestions = out.sub_questions.map((s) => s.trim()).filter(Boolean);
if (subQuestions.length === 0) throw new Error("decompose_question: sub_questions 为空");
console.log(`拆解 ${subQuestions.length} 条子问题 (${out.reason})`);
subQuestions.forEach((q, i) => console.log(` ${i + 1}. ${q}`));
return { subQuestions, nextSubIdx: 0, currentQuery: subQuestions[0] };
};检索节点:根据 state.nextSubIdx 取出当前要检索的子问题,检索完把文档合并(按 id 去重,同 id 保留更高 score),并把 nextSubIdx 加 1、轮数加 1。
/** 按 id 合并;同 id 保留更高 score */
function 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));
}
const retrieveNode = async (state) => {
const subs = state.subQuestions ?? [];
const idx = state.nextSubIdx ?? 0;
const q = subs[idx]?.trim();
if (!q) throw new Error(`retrieve: 子问题下标 ${idx} 无有效文本(共 ${subs.length} 条)`);
const round = state.retrievalCount + 1;
console.log(`---RETRIEVE (第 ${round} 轮,子问题 ${idx + 1}/${subs.length})---`);
console.log(`查询: ${q}`);
const newDocs = await retrieveRelevantContent(q, state.k);
const merged = mergeUnique(state.documents ?? [], newDocs);
console.log(`本轮命中 ${newDocs.length} 条,累计去重后 ${merged.length} 条`);
return { documents: merged, retrievalCount: round, nextSubIdx: idx + 1, currentQuery: q };
};规划节点:判断是继续检索还是可以生成回答了。这里用 conditional edge 实现循环——plannedNext === "retrieve" 就回到 retrieve 节点,否则去 generate。同时用两条"硬性规则"兜底(剩余子问题为 0 必须生成、超过轮数上限必须生成),防止模型决策出错而无限循环。
const planNextStepNode = async (state) => {
console.log("---PLAN_NEXT_STEP---");
const subs = state.subQuestions ?? [];
const nextIdx = state.nextSubIdx ?? 0;
const remaining = subs.length - nextIdx;
// ... 把子问题序列、已召回文档摘要拼成 prompt
const model = llm.withStructuredOutput(NextStepSchema);
const { nextAction, reason } = await model.invoke(prompt);
let finalNext = nextAction;
if (state.retrievalCount >= state.maxRetrievals) finalNext = "generate";
if (remaining <= 0) finalNext = "generate";
console.log(`[决策] plannedNext=${finalNext} (模型建议=${nextAction}) (${reason})`);
return { plannedNext: finalNext };
};
function afterRoute(state) {
return state.strategy === "simple" ? "direct_answer" : "decompose_question";
}
function afterPlan(state) {
return state.plannedNext === "retrieve" ? "retrieve" : "generate";
}
const graph = new StateGraph(GraphState)
.addNode("route_question", routeQuestionNode)
.addNode("direct_answer", directAnswerNode)
.addNode("decompose_question", decomposeQuestionNode)
.addNode("retrieve", retrieveNode)
.addNode("plan_next_step", planNextStepNode)
.addNode("generate", generateNode)
.addEdge(START, "route_question")
.addConditionalEdges("route_question", afterRoute, {
direct_answer: "direct_answer",
decompose_question: "decompose_question",
})
.addEdge("decompose_question", "retrieve")
.addEdge("retrieve", "plan_next_step")
.addConditionalEdges("plan_next_step", afterPlan, {
retrieve: "retrieve",
generate: "generate",
})
.addEdge("direct_answer", END)
.addEdge("generate", END)
.compile();整体流程如图:
跑一下:对于"《天龙八部》中「四大恶人」排行第二的是谁?此人之子在身世揭晓前,其生父在武林中的公开身份是什么?"这类链式问题,模型会先拆成"四大恶人第二是谁"→"此人(叶二娘)之子是谁"→"段誉身世揭晓前的生父公开身份"几步,逐轮检索后综合回答。
继续看传统 RAG 的另一个问题:本地知识库没有的内容,不会主动去网络搜索补充,容易编造答案。
如果知识库中没有的内容,这时候 agent 就不知道怎么回答了。这种情况我们可以调用网络搜索来兜底,把搜索结果放到 prompt 里来参考生成回答。
文件:src/rag-webfallback.mjs
state 里加了 localContext(本地检索结果)、webContext(联网搜索结果)、evaluation(评估结论)。
评估节点:让模型判断当前上下文是否足够回答问题。不够的话,生成一个适合联网搜索的 query。
const EvaluateSchema = z.object({
enough: z.boolean(),
missing: z.array(z.string()).max(6),
reason: z.string(),
web_query: z.string().optional(),
});
const evaluateNode = async (state) => {
const hasWeb = Boolean(state.webContext && String(state.webContext).trim());
console.log(hasWeb ? "---EVALUATE_CONTEXT_WITH_WEB---" : "---EVALUATE_LOCAL_CONTEXT---");
const evaluator = llm.withStructuredOutput(EvaluateSchema);
const out = await evaluator.invoke(`你是信息充分性评估器。判断当前上下文是否足以回答用户问题。
用户问题:${state.question}
已检索上下文(来自本地知识库):
${state.localContext || "(空)"}
${hasWeb ? `联网搜索结果:\n${state.webContext || "(空)"}\n` : ""}
输出字段:
- enough: 是否足够回答(true/false)
- missing: 若不够,列出缺失信息点(最多 6 条)
- reason: 简短原因
${hasWeb ? "" : "- web_query: 若不够,给出一个适合联网搜索的中文查询句(完整句,不用代词;为空也可)"}
`);
console.log(`${hasWeb ? "二次评估" : "评估"}: enough=${out.enough} (${out.reason})`);
return { evaluation: JSON.stringify(out) };
};联网搜索节点:调博查(Bocha)Web Search API——这也是 DeepSeek 同款搜索服务。注意 Authorization 头里 Bearer 后面要有空格,少了空格会一直报错(这是一个容易踩的坑)。
async function bochaWebSearch(query, count) {
const apiKey = process.env.BOCHA_API_KEY;
if (!apiKey) throw new Error("Bocha Web Search 的 API Key 未配置(环境变量 BOCHA_API_KEY)。");
const url = "https://api.bochaai.com/v1/web-search";
const body = { query, freshness: "noLimit", summary: true, count: count ?? 10 };
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!response.ok) {
const errorText = await response.text().catch(() => "");
throw new Error(`搜索 API 请求失败,状态码: ${response.status}, 错误信息: ${errorText}`);
}
const json = await response.json();
if (json?.code !== 200 || !json?.data) {
throw new Error(`搜索 API 返回失败:${json?.msg ?? "未知错误"}`);
}
const webpages = json.data.webPages?.value ?? [];
if (!webpages.length) return "未找到相关结果。";
return webpages
.map(
(page, idx) => `引用: ${idx + 1}
标题: ${page.name}
URL: ${page.url}
摘要: ${page.summary}
网站名称: ${page.siteName}
网站图标: ${page.siteIcon}
发布时间: ${page.dateLastCrawled}`,
)
.join("\n\n");
}
const webSearchNode = async (state) => {
console.log("---WEB_SEARCH---");
const parsed = (() => {
try { return JSON.parse(state.evaluation || "{}"); } catch { return {}; }
})();
const query = (parsed.web_query ?? "").trim() || state.question;
console.log(`联网查询: ${query}`);
const webContext = await bochaWebSearch(query, 8);
console.log(`联网结果长度: ${webContext.length}`);
return { webContext };
};生成节点把本地上下文和联网上下文拼在一起:
const generateNode = async (state) => {
console.log("---GENERATE---");
const context = [state.localContext, state.webContext].filter(Boolean).join("\n\n===== 联网补充 =====\n\n");
const stream = await llm.stream(`你是一个严谨的中文问答助手。优先依据上下文作答,不要编造。
上下文(本地知识库 + 可选联网补充):
${context || "(空)"}
用户问题:${state.question}
回答要求:
1. 如果上下文足够,给出清晰、可核对的回答;需要时引用 “引用: n / URL” 或小说片段来支撑。
2. 如果上下文仍不足以确定关键事实,明确说明 “不确定 / 无法从上下文确认”,并说明缺失点。
3. 不要输出表情符号。
回答:`);
// ... 流式输出,略
return { generation };
};图的编排:路由之后走本地检索 → 评估 →(不够就联网搜索 → 再评估)→ 生成。注意 web_search 之后又连回 evaluate_local,形成"评估 → 搜索 → 再评估"的小闭环。
function afterRoute(state) {
return state.strategy === "simple" ? "direct_answer" : "local_retrieve";
}
function afterEvaluateLocal(state) {
if (state.webContext && String(state.webContext).trim()) return "generate";
const parsed = (() => {
try { return JSON.parse(state.evaluation || "{}"); } catch { return {}; }
})();
return parsed.enough === true ? "generate" : "web_search";
}
const graph = new StateGraph(GraphState)
.addNode("route_question", routeQuestionNode)
.addNode("direct_answer", directAnswerNode)
.addNode("local_retrieve", retrieveLocalNode)
.addNode("evaluate_local", evaluateNode)
.addNode("web_search", webSearchNode)
.addNode("generate", generateNode)
.addEdge(START, "route_question")
.addConditionalEdges("route_question", afterRoute, {
direct_answer: "direct_answer",
local_retrieve: "local_retrieve",
})
.addEdge("local_retrieve", "evaluate_local")
.addConditionalEdges("evaluate_local", afterEvaluateLocal, {
generate: "generate",
web_search: "web_search",
})
.addEdge("web_search", "evaluate_local")
.addEdge("direct_answer", END)
.addEdge("generate", END)
.compile();现在的流程如下:
.env 加 BOCHA_API_KEY=xxx)跑一下:"请回答《天龙八部》小说里'雁门关事件'的主谋是谁,并说明其儿子的最终结局;另外请补充:在《天龙八部》2000 年后改编的影视作品中,哪位演员饰演过虚竹?"——本地库能答前半句,后半句(影视改编)本地库没有,模型就会自动触发联网搜索补充。
继续看 RAG 的其他问题:
至此,我们基于 LangGraph 的多 Agent 架构实现了自主决策的 Agentic RAG 流程。
将 LLM 作为系统的决策大脑,让它自主决定如何检索、检索多少次、判断检索结果是否足够可靠,以及是否需要补充检索、优化查询或切换数据源,这种自我决策、自我反思、自我修正的自主检索闭环,就叫 Agentic RAG。
当然,具体要根据业务场景来设计方案。比如我们公司项目的 RAG 是这样的:
有意图识别(也就是路由),后面按照不同的流程来检索,之后合并生成回答。并不是完全按照 Agentic RAG 那种有评估、有重新检索的闭环来的。但这是适合我们业务场景的 RAG 流程。
所以,学了 Agentic RAG 的各种策略并不是说都得用上,具体还是得根据业务场景来设计方案。
传统的 RAG 流程很固定:用户问题向量化 → 相似度检索 → prompt 拼接 → 生成回答。但它有一系列的问题:
解决方案就是 Agentic RAG。Agentic RAG 是由大模型作为决策中枢,自主控制检索方式、评估检索效果、判断是否需要补充检索或发起网络搜索,形成自主思考与迭代优化的闭环检索系统。我们基于 LangGraph 的图,实现了这个闭环的决策循环,用多 Agent 架构实现了 Agentic RAG。比如加入了意图识别路由、多跳检索的循环、效果评估和网络搜索(ElasticSearch 的关键词检索后面再学)。
当然,具体的 Agentic RAG 还是要根据业务场景来设计,不是完全照搬,比如我们公司项目就是简化版相对固定的检索流程。主要是理解什么是 Agentic RAG,如何基于 LangGraph 实现这个决策循环,然后针对传统 RAG 的不同问题怎么解决就可以了。
代码上传了课程仓库:https://github.com/QuarkGluonPlasma/ai-agent-course-code
这一节的评论区有不少值得注意的点,整理如下:
routeQuestionNode、evaluateNode、webSearchNode、generateNode),是同一套流程里的不同步骤,而不是彼此独立的 Agent。"Agentic" 的意思是大模型参与决策、自己思考怎么检索,能 agentic 的关键是有这些判断逻辑,而不是节点数量。evaluateNode 的二次评估(搜过一次 web 后再评估)确实只是输出了结果,web_query 在 hasWeb 为 true 时被去掉了,相当于"搜过一次就绝不再搜"。作者回应:这个节点是复用的,enough 不只是评估 web 搜索的信息,还会评估向量检索的信息是否够;跑通流程后还会优化,主要是理解 agentic rag 的闭环。想做成真正循环 web query 的读者,可以改成每次累加查询结果、每次重新生成查询句。pgvector 扩展)应该是足够的。预览到此为止,输入密码解锁全文
解锁后本机会记住,同密码的其他文章也无需重复输入