Agentic RAG 入门:从固定检索到自主决策

作者:copyer_xyf日期:2026/7/20

普通 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

基础检索的优点是简单、可预测、容易调试。对于“用户一定是在查询知识库”的场景,它可能已经够用。

但如果系统同时接收普通问题和知识库问题,固定检索就显得浪费。下一步需要让流程先判断“这次到底要不要查”。

查询路由:不是所有问题都需要检索

查询路由在基础检索前增加一个决策节点:

这里的 simplecomplex 不是在判断语言难度,而是在判断是否需要外部知识:

  • 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 入门:从固定检索到自主决策》 是转载文章,点击查看原文


相关推荐


AEO 答案引擎优化:从“被搜索”到“被引用”的 AI 营销下一站
A亨日记2026/7/11

一、AI 正在改写“搜索”的默认行为 过去二十年,用户打开百度、Google 的第一件事是输入关键词,然后在十条蓝色链接里选择。但今天,越来越多的人直接向 DeepSeek、豆包、Kimi、ChatGPT 提问,并期待得到一个“直接可用”的答案。 这意味着企业的营销目标正在发生根本转移: SEO 时代:争取搜索结果页的前十排名;GEO 时代:争取被生成式引擎在回答中主动引用;AIO 时代:用结构化数据与反馈闭环持续优化 AI 对企业的“认知”;AEO 时代:直接为答案引擎生产“可被引用的高置信度


解决方案十八-企业级发音评测技术使用
Liu202605172026/7/3

在人工智能与教育深度融合的今天,语音评测技术(Speech Evaluation)已成为语言学习、在线教育、智能客服等领域的核心基础设施。无论是英语四六级口语考试、普通话水平测试,还是儿童语言启蒙应用,都离不开稳定、精准的语音评测能力。 然而,对于许多前端开发者而言,语音评测似乎总隔着一层“黑盒”——如何从前端采集音频?如何与云端AI服务建立稳定连接?如何处理流式数据并实时展示评测结果?这些问题往往让人望而却步。 本文将带你从零开始,基于Vue.js和WebSocket协议,构建一个完整的语


Claude Code上手指南:安装、部署、接入大模型
小此方2026/6/25

◆ 博主名称: 晓此方-CSDN博客 大家好,欢迎来到晓此方的博客。 ⭐️现代AI系列个人专栏: 【别传】AI应用与开发 ⭐️ “当AI能力被内化,智能就不再是成本,而是生产力。”——李彦宏 概要&序論    Hello,大家好,我是此方。 本文将带大家从零开始,完整走通 Claude Code 的安装、配置与部署流程。 一,让你的 Claude Code 动起来 1.1 安装前置软件 1


K-Means 聚类的目标函数:簇内误差平方和
F_D_Z2026/6/16

1. 什么是 K-Means? K-Means 是一种无监督、迭代式的聚类算法: 给定数据集 {x₁, x₂, …, xₙ} 与预设簇数 K,算法把样本划分为 K 个不相交的簇 C₁, C₂, …, Cₖ,使得同一簇内样本尽可能相似,不同簇间样本尽可能远离。 核心思想: > “让簇内‘抱团’,让簇间‘疏远’。” 2. 目标函数 J:簇内误差平方和(WCSS) K-Means 用几何距离衡量相似性,目标函数 J 定义为: J=∑k=1K∑x∈Ck∥x−μk∥2 J = \sum_{k=1}^{K


STM32F4 TIM定时器HAL库源码完全解析
冉卓电子2026/6/9

一、前言    在STM32嵌入式开发中,TIM定时器是使用频率最高、功能最丰富的外设之一,几乎所有嵌入式项目都会用到定时中断、PWM输出、输入捕获、编码器解码等功能,而这些功能的底层支撑都是TIM外设。   很多开发者只会调用HAL库现成函数,却不懂底层实现逻辑,遇到定时器卡死、PWM波形异常、捕获数据不准、DMA传输失效等问题时无从排查。   本文基于STM32F4xx HAL库 tim.c 完整源码,保姆级拆解TIM定时器全套底层架构、功能模块、核心函数、回调机制与使用规范,带你从源


【GaussDB】会话里出现大量idle in transaction状态的问题排查
DarkAthena2026/6/1

【GaussDB】会话里出现大量idle in transaction状态的问题排查 背景 客户DBA给应用开发人员宣讲GaussDB数据库的idle in transaction会话的监控告警后,开发人员自查,发现在开发环境中存在大量的idle in transaction会话,一时恐慌,于是来找DBA帮忙排查是不是应用代码哪里写得有问题。 数据库内核版本是 506.0 SPC0500 集中式。 检查应用 应用软件使用的spring框架接mybaitis,连接使用基于DBCP修改的连


Milvus 实战:当 RAG 遇上向量数据库,从"玩具 Demo"到"生产可用的"那一步
Lee川2026/5/25

Milvus 实战:当 RAG 遇上向量数据库,从"玩具 Demo"到"生产可用的"那一步 前面的文章讲过,RAG 的第四步是"向量存储"。在原型阶段,一个 MemoryVectorStore 就能跑通全链路。但问题是:程序一关,所有向量灰飞烟灭;数据一多,内存直接爆掉;更别提多用户隔离、权限控制、分布式检索这些生产环境的刚需。 MemoryVectorStore 是 Demo 的终点,却是向量数据库的起点。 本文通过一段与 Milvus(Zilliz Cloud)交互的真实代码,拆解向量数据库


RAG 系列(五):Embedding 模型——语义理解的核心
冬奇Lab2026/5/3

为什么换个 Embedding 模型,检索效果天差地别? 前面四篇文章,我们搞定了 Pipeline 搭建、参数调优和分块策略。但有一个问题一直没细说: 你的文档被切成 Chunk 之后,是怎么变成向量的? 这个过程叫 Embedding(嵌入),它把人类可读的文本变成计算机可算的向量。Embedding 模型的选择,直接决定了: "苹果"和"iPhone"能不能被识别为相关 "数据库连接池耗尽"和"Too many connections"能不能被匹配到一起 中文成语、专业术语、缩写能不


Python全栈项目实战:自建高效多媒体处理工具
天天进步20152026/4/24

在数字化时代,视频剪辑、格式转换、音频提取等需求已成为日常。虽然市面上有很多成熟的工具,但作为开发者,**亲手构建一个属于自己的“全栈多媒体处理平台”**不仅能深度掌握 Python 生态,还能解决隐私安全和批量化定制的痛点。 本博文将带你梳理一个 Python 全栈多媒体处理工具的核心设计与实现方案。 一、 项目核心功能 一个实用的多媒体工具至少应具备以下“硬核”功能: 视频处理:格式转换(MP4/WebM/AVI)、视频抽帧、添加水印、调整分辨率。 音频处理:音频提取、格式压


Frida 源码编译全流程:自己动手编译 frida-server
CYRUS_STUDIO2026/4/15

版权归作者所有,如有转发,请注明文章出处:cyrus-studio.github.io/blog/ 下载 Frida 源码 Frida 源码:github.com/frida/frida 官方文档:frida.re/docs/buildi… 下载源码 git clone https://github.com/frida/frida.git 安装相关依赖: sudo apt-get install build-essential git lib32stdc++-9-dev \ libc

首页编辑器站点地图

本站内容在 CC BY-SA 4.0 协议下发布

Copyright © 2026 聚合阅读