普通 RAG 的思路很直接:用户提出问题,系统从知识库检索相关片段,再让大模型根据这些片段生成回答。
这条链路解决了一个重要问题:模型不必只依赖训练时记住的知识,而是可以在回答前读取企业文档、产品手册、业务数据等外部资料,再根据资料组织答案。
但传统 RAG 的执行流程通常是固定的。这里的“固定”,不是说每次召回的文档都相同,而是说无论用户问什么、检索结果质量如何,程序都会按照上图展示的同一条路径执行。它不会在运行过程中重新判断,也不会主动改变检索策略。
但是,这种固定流程会遇到几个实际问题:
- 不需要检索的问题也会进入知识库。 简单常识、计算或闲聊同样执行向量化和检索,增加了延迟和调用成本。
- 一次检索失败后不会补救。 如果查询表达不准确或没有命中关键文档,流程仍然会直接进入生成,不会改写问题后重新检索。
- 一个查询难以覆盖复杂问题。 当问题包含多个实体、关系或前后依赖的事实时,一次语义检索往往只能覆盖其中一部分。
- 检索相关不等于资料足够。 召回的片段可能与问题有关,却没有包含得出最终结论所需的关键信息。
- 本地知识库之外没有补充来源。 如果答案需要最新信息或外部资料,固定 RAG 不会主动切换到网络搜索等其他来源。
这些问题不能只靠增加 Top K 解决。Top K 只能多返回几条相似内容,无法改变固定的执行流程。真正需要改变的是:让系统先判断当前情况,再决定下一步。
- 检索前,判断问题是否需要外部资料。
- 问题复杂时,判断是否需要拆成多个子问题。
- 检索后,判断现有资料是否足够。
- 资料不足时,决定继续检索还是切换信息来源。
这些判断涉及对问题和文本语义的理解,很难只靠固定的 if/else 完成,却正是大模型擅长的事情。因此,可以让大模型负责判断和规划,再由程序负责执行节点、限制次数并保证流程结束。
当大模型不再只负责生成答案,还参与决定是否检索、怎样检索和何时停止时,这种架构就叫 Agentic RAG。
普通 RAG 让模型读取资料;Agentic RAG 还让模型参与管理寻找资料的过程。
接下来,会通过四种逐步升级的 RAG 流程来理解这个过程的变化。
LangGraph:把判断变成可执行流程
本文使用 LangGraph 组织 Agentic RAG 的执行流程,但不再展开讲解 LangGraph 的基础用法。
这里只需要知道:LangGraph 把一个复杂流程表示成一张图。
- State 保存问题、检索结果和最终答案等运行数据。
- Node 表示路由、检索、评估和生成等具体步骤。
- Edge 连接两个节点,规定执行顺序。
- Conditional Edge 根据 State 选择不同分支,也可以让流程回到前面的节点形成循环。
后面看到流程图时,可以把它简单理解为:数据保存在 State 中,依次经过多个 Node,再由 Edge 决定下一步走向哪里。
如果还不熟悉 LangGraph,可以先阅读前面的文章:LangGraph 多 Agent 入门:从流程图到旅行规划助手。
基础检索:先跑通检索与回答
先从最容易理解的流程开始:每个问题都先检索,再生成答案。
先定义这条流程需要传递的数据:
1const GraphState = Annotation.Root({ 2 question: Annotation, // 用户问题 3 k: Annotation, // 检索数量 4 documents: Annotation, // 召回的文档 5 generation: Annotation,// 最终答案 6}); 7
检索:从知识库找到相关片段
根据用户的问题去向量数据库中查询相关数据。
1/** 根据用户问题检索知识库,并把召回结果写入 Graph State。 */ 2const retrieveNode = async (state) => { 3 // 直接查询向量数据库,返回 [Document, score] 数组。 4 const results = await vectorStore.similaritySearchWithScore( 5 state.question, 6 state.k, 7 ); 8 9 // 只保留后续节点需要的字段。 10 const documents = results.map(([document, score]) => ({ 11 score, 12 content: document.pageContent, 13 id: document.metadata.id, 14 chapter_num: document.metadata.chapter_num, 15 })); 16 17 // 检索节点不负责回答,只把证据写入状态。 18 return { documents }; 19}; 20
Document.pageContent 是正文,Document.metadata 保存片段 ID、章节等附加信息。
检索返回的每条数据可以整理成统一结构:
1{ 2 score: 0.86, // 相似度分数 3 content: "召回的小说原文", // 后续交给模型的正文 4 id: "1_000125", // 文档片段唯一标识 5 chapter_num: 12, // 章节信息 6} 7
检索节点只负责“找证据”,不要在这里顺手让模型回答。把检索和生成分开后,我们才能单独检查:到底是没有召回正确资料,还是模型拿到正确资料后回答错了。
生成:让模型只依据检索片段回答
1/** 把召回片段整理成上下文,再调用模型生成最终答案。 */ 2const generateNode = async (state) => { 3 // 没有任何证据时不继续生成,避免模型脱离资料自由发挥。 4 if (state.documents.length === 0) { 5 return { generation: "" }; 6 } 7 8 // 核心逻辑:给每个片段加上序号和章节,方便模型区分证据。 9 const context = state.documents 10 .map((doc, index) => ` 11[片段 ${index + 1}] 12章节:第 ${doc.chapter_num} 章 13内容:${doc.content}`) 14 .join("\n\n"); 15 16 // 直接调用模型 API,不再封装额外函数。 17 const response = await model.invoke(` 18请只根据下面的小说片段回答问题。 19 20${context} 21 22用户问题:${state.question} 23`); 24 25 // 只返回本节点更新的字段,LangGraph 会把它合并回 State。 26 return { generation: String(response.content) }; 27}; 28
这里最重要的不是 prompt 写得多华丽,而是明确证据边界:模型只能依据检索片段作答;资料不足时必须说明无法确认。
用 LangGraph 串起检索与生成
1const graph = new StateGraph(GraphState) 2 // 注册两个节点。 3 .addNode("retrieve", retrieveNode) 4 .addNode("generate", generateNode) 5 6 // 定义固定执行顺序。 7 .addEdge(START, "retrieve") 8 .addEdge("retrieve", "generate") 9 .addEdge("generate", END) 10 .compile(); 11
基础检索的优点是简单、可预测、容易调试。对于“用户一定是在查询知识库”的场景,它可能已经够用。
但如果系统同时接收普通问题和知识库问题,固定检索就显得浪费。下一步需要让流程先判断“这次到底要不要查”。
查询路由:不是所有问题都需要检索
查询路由在基础检索前增加一个决策节点:
这里的 simple 和 complex 不是在判断语言难度,而是在判断是否需要外部知识:
simple:普通常识、定义、简单计算,不依赖特定资料。complex:需要小说情节、人物关系、原文细节或其他知识库证据。
让路由结果可以被程序读取
如果让模型自由回答,它可能输出“这个问题比较复杂,我建议检索”。程序很难稳定解析这句话。因此先用 schema 约束模型输出:
1const GraphState = Annotation.Root({ 2 question: Annotation, 3 k: Annotation, 4 strategy: Annotation, // simple 或 complex 5 routeReason: Annotation, // 路由原因 6 documents: Annotation, 7 generation: Annotation, 8}); 9 10const RouteSchema = z.object({ 11 // strategy 只能是 simple 或 complex,不能生成其他值。 12 strategy: z.enum(["simple", "complex"]), 13 14 // reason 用于日志和调试,不直接控制流程。 15 reason: z.string(), 16}); 17
路由节点只判断,不检索:
1/** 判断当前问题是否需要查询知识库,不在这里执行检索。 */ 2const routeQuestionNode = async (state) => { 3 const router = model.withStructuredOutput(RouteSchema); 4 5 // 核心逻辑:让模型只返回 simple 或 complex,供条件边直接使用。 6 const route = await router.invoke(` 7你是问答路由器,请判断用户问题是否需要查询小说知识库。 8 9- simple:不依赖小说原文即可回答。 10- complex:需要具体情节、人物关系或原文证据。 11 12用户问题:${state.question} 13`); 14 15 return { 16 strategy: route.strategy, 17 routeReason: route.reason, 18 }; 19}; 20
结构化输出解决的是“程序怎样稳定读取模型判断”,它并不保证模型的判断永远正确。因此 reason 很重要:调试时可以看到模型为什么把问题分到某条路径。
可以看看:
根据路由结果选择回答路径
两条分支的职责很明确。简单问题直接调用模型,复杂问题复用前面已经实现的检索和生成节点:
1/** direct_answer 节点:处理不依赖知识库的普通问题。 */ 2const directAnswerNode = async (state) => { 3 const response = await model.invoke(` 4请直接、简洁地回答下面的问题: 5${state.question} 6`); 7 8 return { generation: String(response.content) }; 9}; 10 11// retrieve 节点:complex 分支仍然使用“检索 -> 生成”的 RAG 节点(复用前面的函数)。 12const ragGenerateNode = generateNode; 13
然后用条件边选择其中一条路径:
1/** 把路由结果转换成下一条要执行的边。 */ 2function decideNext(state) { 3 // 条件函数只返回下一条边的名字。 4 // 核心逻辑:simple 直接回答,complex 进入知识库检索。 5 return state.strategy === "simple" 6 ? "direct_answer" 7 : "retrieve"; 8} 9 10const graph = new StateGraph(GraphState) 11 // 添加节点 12 .addNode("route_question", routeQuestionNode) 13 .addNode("direct_answer", directAnswerNode) 14 .addNode("retrieve", retrieveNode) 15 .addNode("rag_generate", ragGenerateNode) 16 17 // 添加边 18 .addEdge(START, "route_question") 19 // 条件判断,看下一个节点走哪里 20 .addConditionalEdges("route_question", decideNext, { 21 direct_answer: "direct_answer", 22 retrieve: "retrieve", 23 }) 24 25 .addEdge("retrieve", "rag_generate") 26 .addEdge("direct_answer", END) 27 .addEdge("rag_generate", END) 28 .compile(); 29
查询路由是 Agentic RAG 的第一步:模型不再只负责生成文字,它开始影响程序接下来执行什么。
但复杂分支仍然只检索一次。如果问题依赖多个前后关联的事实,一次向量查询还是可能不够。
分步检索:拆开复杂问题,逐个寻找答案
先来考虑这个问题:
1云澈为什么会同时拥有云澈和萧澈两个名字, 2他重生前后分别是什么身份? 3
它至少包含几个相互关联的信息点:
- 云澈重生前是谁。
- 萧澈是谁。
- 重生事件发生了什么。
- 两个名字为什么属于同一个人。
如果把整句话只做一次向量检索,Top K 结果可能全部集中在其中一个事实上。增加 Top K 只能“多取一些相似片段”,不能保证每个事实都被覆盖。
分步检索也就是常说的 Multi-hop RAG(多跳 RAG):先把复杂问题拆成有顺序的子问题,再逐条检索。
为多轮检索记录执行进度(定义 state)
1const GraphState = Annotation.Root({ 2 question: Annotation, 3 k: Annotation, 4 strategy: Annotation, 5 6 // 预先拆好的有序子问题。 7 subQuestions: Annotation, 8 9 // 下一轮应该检索哪一个子问题。 10 nextSubIdx: Annotation, 11 12 // 多轮检索累计得到的文档。 13 documents: Annotation, 14 15 // 已执行的检索次数与最大次数。 16 retrievalCount: Annotation, 17 maxRetrievals: Annotation, 18 19 // 规划节点的决定:retrieve 或 generate。 20 plannedNext: Annotation, 21 22 generation: Annotation, 23}); 24
nextSubIdx 是循环的游标。假设拆出三个子问题:
1subQuestions = ["问题 A", "问题 B", "问题 C"]; 2nextSubIdx = 0; 3
第一轮检索 A,结束后把 nextSubIdx 改成 1;第二轮就会检索 B。
把原问题拆成可独立检索的子问题
1const DecomposeSchema = z.object({ 2 sub_questions: z.array(z.string()).min(1).max(8), 3 reason: z.string(), 4}); 5 6/** 把复杂问题一次性拆成有先后顺序的独立子问题。 */ 7const decomposeQuestionNode = async (state) => { 8 const decomposer = model.withStructuredOutput(DecomposeSchema); 9 10 const result = await decomposer.invoke(` 11把用户问题拆成有序子问题,用于逐条向量检索。 12 13要求: 141. 每条都是可以独立检索的完整问句。 152. 不使用“他、她、此人”等依赖上文的代词。 163. 顺序符合事实依赖:先查前置事实,再查后续结论。 174. 输出 1~8 条,不要拆成零散关键词。 18 19用户问题:${state.question} 20`); 21 22 // 核心逻辑:清理空白结果,并从第一条子问题开始检索。 23 const subQuestions = result.sub_questions 24 .map((question) => question.trim()) 25 .filter(Boolean); 26 27 return { 28 subQuestions, 29 nextSubIdx: 0, 30 }; 31}; 32
为什么不允许“他是谁”“此人后来怎样”这样的子问题?因为向量数据库只会看到当前查询,不知道上一条子问题的上下文。独立、明确的查询更容易召回正确片段。
可以看看效果,LLM 把原问题拆分成了五个问题:
那么接下来就是对每个子问题,进行检索,对检索出来的资料进行汇集咯。
每轮检索一个子问题并累计证据
1/** 每次只检索一条子问题,并累计本轮找到的证据。 */ 2const retrieveNode = async (state) => { 3 // 核心逻辑:nextSubIdx 是循环游标,决定这一轮查询哪条子问题。 4 const index = state.nextSubIdx; 5 const query = state.subQuestions[index]; 6 7 if (!query) { 8 throw new Error(`不存在下标为 ${index} 的子问题`); 9 } 10 11 const results = await vectorStore.similaritySearchWithScore( 12 query, 13 state.k, 14 ); 15 16 const newDocuments = results.map(([document, score]) => ({ 17 score, 18 content: document.pageContent, 19 id: document.metadata.id, 20 chapter_num: document.metadata.chapter_num, 21 })); 22 23 // 多个子问题可能召回同一个片段,因此按 id 合并去重。 24 const documents = mergeUniqueById( 25 state.documents, 26 newDocuments, 27 ); 28 29 return { 30 documents, 31 retrievalCount: state.retrievalCount + 1, 32 nextSubIdx: index + 1, 33 }; 34}; 35
去重不能只用正文字符串。更稳定的方法是使用入库时生成的唯一 id;如果同一片段被多次召回,可以保留相似度更高的那一次。
1/** 合并多轮召回结果;相同文档只保留分数更高的一条。 */ 2function mergeUniqueById(existingDocuments, newDocuments) { 3 const documentMap = new Map(); 4 5 for (const document of [...existingDocuments, ...newDocuments]) { 6 const previous = documentMap.get(document.id); 7 8 // 核心逻辑:文档第一次出现,或本轮分数更高时才覆盖旧值。 9 if (!previous || document.score > previous.score) { 10 documentMap.set(document.id, document); 11 } 12 } 13 14 return [...documentMap.values()] 15 .sort((a, b) => b.score - a.score); 16} 17
每轮检索后判断是否继续
当然检索完成不一定要把剩余子问题全部查完。如果第一轮已经拿到了完整证据,可以提前生成答案。
1const NextStepSchema = z.object({ 2 nextAction: z.enum(["retrieve", "generate"]), 3 reason: z.string(), 4}); 5 6/** 根据当前证据和剩余子问题,决定继续检索还是开始回答。 */ 7const planNextStepNode = async (state) => { 8 const remaining = 9 state.subQuestions.length - state.nextSubIdx; 10 11 // 只取少量摘要,避免规划 prompt 过长。 12 const documentSummary = state.documents 13 .slice(0, 6) 14 .map((document) => document.content.slice(0, 200)) 15 .join("\n\n"); 16 17 const planner = model.withStructuredOutput(NextStepSchema); 18 19 const decision = await planner.invoke(` 20请根据原问题、已检索文档和剩余子问题判断下一步。 21 22- 证据足以回答原问题:generate 23- 仍缺关键事实且还有子问题:retrieve 24 25原问题:${state.question} 26剩余子问题数量:${remaining} 27已检索轮数:${state.retrievalCount} 28最大检索轮数:${state.maxRetrievals} 29已召回文档:${documentSummary} 30`); 31 32 let plannedNext = decision.nextAction; 33 34 // 代码规则覆盖模型决定,保证流程一定能够结束。 35 if (remaining <= 0) plannedNext = "generate"; 36 if (state.retrievalCount >= state.maxRetrievals) { 37 plannedNext = "generate"; 38 } 39 40 return { plannedNext }; 41}; 42
这里体现了一个很重要的工程原则:
模型可以做判断,但程序必须保留确定性的安全边界。
如果完全听模型决定是否继续,它可能一直返回 retrieve。最大轮数和剩余子问题数量就是代码层面的终止保证。
用条件边形成检索循环
1/** 简单问题直接回答,复杂问题先拆成子问题。 */ 2function afterRoute(state) { 3 return state.strategy === "simple" 4 ? "direct_answer" 5 : "decompose_question"; 6} 7 8/** 把规划节点的决定映射到“继续检索”或“生成答案”。 */ 9function afterPlan(state) { 10 // 核心逻辑:返回 retrieve 时,条件边会让图重新进入检索节点。 11 return state.plannedNext === "retrieve" 12 ? "retrieve" 13 : "generate"; 14} 15 16const graph = new StateGraph(GraphState) 17 // 定义节点 18 .addNode("route_question", routeQuestionNode) 19 .addNode("direct_answer", directAnswerNode) 20 .addNode("decompose_question", decomposeQuestionNode) 21 .addNode("retrieve", retrieveNode) 22 .addNode("plan_next_step", planNextStepNode) 23 .addNode("generate", generateNode) 24 25 // 定义边 26 .addEdge(START, "route_question") 27 // 问题判断 28 .addConditionalEdges("route_question", afterRoute, { 29 direct_answer: "direct_answer", 30 decompose_question: "decompose_question", 31 }) 32 .addEdge("decompose_question", "retrieve") 33 .addEdge("retrieve", "plan_next_step") 34 // 继续检索,还是生成答案 35 .addConditionalEdges("plan_next_step", afterPlan, { 36 retrieve: "retrieve", 37 generate: "generate", 38 }) 39 .addEdge("direct_answer", END) 40 .addEdge("generate", END) 41 .compile(); 42
当 plan_next_step 返回 retrieve 时,图重新回到检索节点,这就形成了 LangGraph 循环。
当前实现属于“预先拆解式多跳检索”:子问题在进入循环前一次性生成,循环中只决定继续还是停止。更高级的实现还可以根据上一轮证据临时改写下一条查询,但复杂度和失控风险也会增加。
联网补充:本地资料不足时再去网络查找
向量数据库只能检索已经入库的内容。小说正文可以回答人物和情节,却不一定包含首发平台、最新改编消息或可点击来源链接。
例如:
1《逆天邪神》中云澈拥有的第一部功法是什么? 2另外,这部小说的作者和首发平台是什么?请给出来源链接。 3
这个问题同时需要两类资料:
- 小说内部情节:适合查询本地知识库。
- 作者、平台和来源链接:更适合网络搜索。
联网补充对应这里实现的 Web Fallback。它不是一上来就联网,而是先使用本地资料;只有评估器判断本地内容不够时,才触发搜索。
分开保存本地资料和网络资料(定义 state)
1const GraphState = Annotation.Root({ 2 question: Annotation, 3 k: Annotation, 4 strategy: Annotation, 5 6 // 本地向量数据库返回的原始文档。 7 retrievedDocs: Annotation, 8 9 // 供模型阅读的本地文本上下文。 10 localContext: Annotation, 11 12 // 网络搜索返回的标题、链接和摘要。 13 webContext: Annotation, 14 15 // 评估器的判断结果。 16 evaluation: Annotation, 17 18 generation: Annotation, 19}); 20
本地资料和网络资料必须分开保存。生成答案时可以把它们合并,但调试时需要知道每条事实来自哪里。
优先查询本地知识库
1/** 优先从本地向量数据库检索,并整理出可读的文本上下文。 */ 2const retrieveLocalNode = async (state) => { 3 const results = await vectorStore.similaritySearchWithScore( 4 state.question, 5 state.k, 6 ); 7 8 const retrievedDocs = results.map(([document, score]) => ({ 9 score, 10 content: document.pageContent, 11 id: document.metadata.id, 12 })); 13 14 return { 15 retrievedDocs, 16 17 // 将结构化文档整理成评估器和生成器可读的文本。 18 localContext: retrievedDocs 19 .map((document) => document.content) 20 .join("\n\n"), 21 }; 22}; 23
这里采用的是直接使用完整问题检索一次(也可以采用分步检索)。但复合问题中的“作者、平台、链接”等词可能干扰小说情节的向量召回。实际项目可以再增加查询改写,把本地问题和联网问题分开。
判断本地资料是否足够
1const EvaluateSchema = z.object({ 2 // 当前资料是否足以完整回答用户问题。 3 enough: z.boolean(), 4 5 // 明确列出还缺什么,而不是只返回“不够”。 6 missing: z.array(z.string()).max(6), 7 8 reason: z.string(), 9 10 // 本地不足时,给搜索引擎使用的完整查询句。 11 web_query: z.string().optional(), 12}); 13
评估节点同时服务于第一次本地评估和联网后的第二次评估:
1/** 评估已有资料能否回答问题,并指出还缺少哪些信息。 */ 2const evaluateNode = async (state) => { 3 // 核心逻辑:同一个节点同时处理“本地检索后”和“联网补充后”两次评估。 4 const hasWebContext = Boolean(state.webContext?.trim()); 5 const evaluator = model.withStructuredOutput(EvaluateSchema); 6 7 const evaluation = await evaluator.invoke(` 8判断当前上下文是否足以完整回答用户问题。 9 10用户问题:${state.question} 11 12本地知识库: 13${state.localContext || "(空)"} 14 15${hasWebContext 16 ? `网络搜索结果:\n${state.webContext}` 17 : ""} 18 19如果资料不足,请列出 missing;第一次评估时还要生成 web_query。 20`); 21 22 return { evaluation }; 23}; 24
与“检索到文档数量大于零”相比,让模型评估内容是否充分更接近真实需求。命中八条高度相似的片段,也可能全部在讲同一件事,依然无法覆盖完整问题。
根据缺失信息生成联网查询
这里采用的是 博查,地址是 open.bochaai.com/
进入控制台,注册 key 和购买资源包,有免费的 1000 次调用。
具体使用开发文档,可以看看官网哈,下面就直接列出代码了。
1/** 使用评估器给出的查询词调用搜索 API,并保存可引用的结果。 */ 2const webSearchNode = async (state) => { 3 // 优先使用评估器生成的精准查询;没有时退回原问题。 4 const query = 5 state.evaluation.web_query?.trim() || state.question; 6 7 // 直接调用搜索 API。 8 const response = await fetch("https://api.bochaai.com/v1/web-search", { 9 method: "POST", 10 headers: { 11 // 使用博查注册的key 12 Authorization: [`Bearer ${process.env.BOCHA_API_KEY}`](https://xplanc.org/primers/document/zh/10.Bash/90.%E5%B8%AE%E5%8A%A9%E6%89%8B%E5%86%8C/EX.env.md), 13 "Content-Type": "application/json", 14 }, 15 body: JSON.stringify({ 16 query, 17 count: 8, 18 summary: true, 19 freshness: "noLimit", 20 }), 21 }); 22 23 if (!response.ok) { 24 throw new Error(`网络搜索失败:${response.status}`); 25 } 26 27 const result = await response.json(); 28 const pages = result.data?.webPages?.value ?? []; 29 30 // 核心逻辑:同时保存标题、URL 和摘要,方便最终答案标注来源。 31 const webContext = pages 32 .map((page, index) => ` 33引用:${index + 1} 34标题:${page.name} 35URL:${page.url} 36摘要:${page.summary}`) 37 .join("\n\n"); 38 39 return { webContext }; 40}; 41
联网结果不应该只保留摘要。至少还要保留标题和 URL,最终回答才能提供可核对的来源。
如果是生产环境还需要考虑:
- 过滤低质量或重复域名。
- 区分官方来源、媒体报道和用户内容。
- 防止网页内容中的提示注入影响模型。
- 设置超时、重试和搜索次数上限。
- 对需要时效性的内容保留发布时间。
合并本地与网络资料生成答案
1/** 合并本地和网络上下文,再让模型生成带来源的答案。 */ 2const generateNode = async (state) => { 3 // 核心逻辑:只合并实际存在的资料,避免 prompt 出现无意义空段落。 4 const context = [ 5 state.localContext, 6 state.webContext, 7 ] 8 .filter(Boolean) 9 .join("\n\n===== 网络补充 =====\n\n"); 10 11 const response = await model.invoke(` 12请优先依据给定上下文回答,不要编造。 13 14${context || "(没有可用上下文)"} 15 16用户问题:${state.question} 17 18要求: 191. 对网络信息保留引用编号和 URL。 202. 资料不足时明确说明无法确认,并指出缺失内容。 21`); 22 23 return { generation: String(response.content) }; 24}; 25
这里的模型不是事实来源,而是资料整理者。事实来自本地知识库和搜索结果;模型负责把多种来源组织成连贯答案。
只在本地不足时触发搜索
1/** 简单问题直接回答,其余问题先查本地知识库。 */ 2function afterRoute(state) { 3 return state.strategy === "simple" 4 ? "direct_answer" 5 : "local_retrieve"; 6} 7 8/** 根据资料评估结果选择生成答案或联网搜索。 */ 9function afterEvaluation(state) { 10 // 已经搜索过一次,就进入生成,避免无限联网循环。 11 if (state.webContext?.trim()) { 12 return "generate"; 13 } 14 15 return state.evaluation.enough 16 ? "generate" 17 : "web_search"; 18} 19 20const graph = new StateGraph(GraphState) 21 22 // 注册节点 23 .addNode("route_question", routeQuestionNode) 24 .addNode("direct_answer", directAnswerNode) 25 .addNode("local_retrieve", retrieveLocalNode) 26 .addNode("evaluate_local", evaluateNode) 27 .addNode("web_search", webSearchNode) 28 .addNode("generate", generateNode) 29 30 // 注册边 31 .addEdge(START, "route_question") 32 // 问题判断 33 .addConditionalEdges("route_question", afterRoute, { 34 direct_answer: "direct_answer", 35 local_retrieve: "local_retrieve", 36 }) 37 .addEdge("local_retrieve", "evaluate_local") 38 // 是否需要联网搜索,还是直接生成回答 39 .addConditionalEdges("evaluate_local", afterEvaluation, { 40 generate: "generate", 41 web_search: "web_search", 42 }) 43 .addEdge("web_search", "evaluate_local") 44 .addEdge("direct_answer", END) 45 .addEdge("generate", END) 46 .compile(); 47
注意,联网后会再次进入评估节点,但只要 webContext 已经存在,当前条件函数就固定进入生成。第二次评估可以记录“资料仍然缺什么”,却不会再次触发搜索。
这是一个有意设置的边界:避免模型因为总觉得资料不足而无限搜索。更严格的实现可以把二次评估的 missing 也放进生成 prompt,让最终答案准确说明哪些内容仍未确认。
总结
回顾上文,其实就是一直在改造同一条 RAG 链路:
- 基础检索:收到问题后,固定查询一次知识库。
- 查询路由:先判断是否需要检索,普通问题可以直接回答。
- 分步检索:把复杂问题拆成多个子问题,再逐个查找资料。
- 联网补充:本地资料不够时,再去网络补充信息。
随着流程一步步升级,大模型开始参与检索决策:判断要不要查、怎样拆解问题、当前资料够不够,以及是否需要继续检索或换一个资料来源。程序则负责保存状态、控制分支、限制次数,并保证流程能够停下来。
可以把 Agentic RAG 理解成一个会根据检索结果继续思考和调整的闭环:模型负责判断,程序负责控制;资料不够就继续找,方向不对就调整,满足条件后再生成答案。 LangGraph 的作用,就是把这个过程连接成一张可以执行、可以循环、也可以结束的图。
《Agentic RAG 入门:从固定检索到自主决策》 是转载文章,点击查看原文。