Agentic RAG:用 LangGraph 构建会路由、会纠错、会收敛的闭环 RAG

很多团队第一次做 RAG,思路都差不多:

  1. 把文档切分。
  2. 用 Embedding 模型转向量。
  3. 写入向量数据库。
  4. 查询时做一次 similaritySearch
  5. 把召回片段拼进 Prompt。
  6. 让大模型输出答案。

这套流程能不能跑通?当然能。

但真正上线以后,问题也会很快暴露出来。

  • 有些问题根本不需要检索,系统却仍然查库,白白增加延迟和成本。
  • 有些问题不是单跳事实,而是链式关系推理,一次召回拿不到完整证据。
  • 有些问题本地知识库根本没覆盖,系统既不会承认不知道,也不会主动补外部来源。
  • 有些问题虽然召回了文档,但上下文并不足以支撑最终答案,模型还是会"像知道一样"回答。

所以,RAG 真正的难点,从来不是"能不能把文档喂给模型",而是:系统要不要查、查几次、查完够不够、什么时候该停下来。

我看完 advanced-rag 这份项目之后,最值得展开讲的并不是它用了 LangGraph,也不是它把 Milvus、Qwen、Bocha API 接起来了,而是它已经把一个很关键的工程判断做出来了:

Agentic RAG 的核心,不是让模型替你多搜一次,而是让模型在检索前、检索中、检索后都参与决策,同时又被明确的状态机和停止条件约束住。

这句话基本就是全文主线。后面你会看到,这个项目虽然只是一个以《天龙八部》为知识域的示例,但它已经把 Agentic RAG 最关键的闭环骨架搭出来了。


先把问题讲清楚:Agentic RAG 到底比普通 RAG 多了什么

很多文章一上来就说 Agentic RAG 是"更智能的 RAG",这句话几乎没信息量。

更准确一点的说法应该是:

  • 普通 RAG 的控制流主要写在代码里,模型只负责最终生成。
  • Agentic RAG 的控制流有一部分前移给模型,模型不仅回答,还参与判断下一步动作。

这两者的差别,不在于有没有向量检索,而在于模型是否被允许影响流程本身

我们可以先对比一下。

维度 普通 RAG Agentic RAG
检索是否触发 通常固定触发 由模型或规则判断
检索次数 通常 1 次 可能多次
查询形式 原问题直接检索 可拆解、改写、分步检索
是否评估上下文充分性 通常没有 可以显式评估
外部信息补充 通常没有 可以作为兜底路径
停止条件 代码隐含 显式定义
失败模式 查不到、答偏、硬编 分支更多,但更可控

从工程上看,Agentic RAG 并不是"模型更强"这么简单,而是把 RAG 从一个线性流水线 改造成一个带分支和反馈的闭环系统

这也是为什么 Agentic RAG 天然更适合用图来表达,而不是继续在一个 ask() 函数里不断 if/else。


这份 advanced-rag 项目到底实现了什么

先看目录,项目非常克制,只有四个核心脚本:

文件 能力阶段 解决的问题
src/naive-rag.mjs 基线版 先跑通最小 RAG
src/rag-query-router.mjs 路由版 不是所有问题都该进知识库
src/rag-multihop.mjs 多跳版 复杂问题需要多轮检索和收敛
src/rag-webfallback.mjs 兜底版 本地知识不够时补外部来源

如果把它们放在一起看,你会发现这不是四个互不相关的 demo,而是一条很清晰的演化路径:

flowchart LR A[naive-rag
检索后直接生成] --> B[rag-query-router
先判断要不要检索] --> C[rag-multihop
复杂问题拆解并多轮检索] --> D[rag-webfallback
评估不足时联网补充]

这条演化路径本身就很有代表性。因为大多数团队的 RAG 演进,基本都会经历这四步:

  1. 先把召回和生成串起来。
  2. 发现很多查询不该进检索链路。
  3. 发现单跳检索答不动复杂问题。
  4. 发现本地知识库覆盖率永远不可能 100%。

而 Agentic RAG,本质上就是在这四个问题上逐步前移决策权。


技术栈不是重点,但理解它们的位置很重要

当前源码里的技术栈并不复杂,但每个组件的位置必须分清:

  • @langchain/openai:接聊天模型和嵌入模型。
  • @langchain/langgraph:用状态图组织整个闭环流程。
  • @langchain/community/vectorstores/milvus:把 Milvus 包装成可直接调用的向量检索接口。
  • zod:约束模型输出结构,避免控制流失真。
  • Bocha Web Search API:在本地知识不够时提供外部信息补充。

从架构关系看,大概是这样:

flowchart TD A[用户问题] --> B[LangGraph StateGraph] B --> C[Chat Model
qwen-plus] B --> D[Embedding Model
text-embedding-v3] D --> E[Milvus Vector Store] B --> F[Bocha Web Search] C --> G[路由 / 拆解 / 评估 / 规划 / 生成] E --> G F --> G

这里有一个特别容易被忽略的点:在这个项目里,大模型不只是回答器,更是流程控制器。

它负责五种角色:

  • 路由器:判断这个问题该不该进入检索链路。
  • 拆解器:把复杂问题拆成可检索的子问题。
  • 规划器:判断还要不要继续检索。
  • 评估器:判断当前上下文是否足够回答。
  • 生成器:最终把证据组织成答案。

这也是为什么我说 Agentic RAG 的重点,不在"多搜一次",而在"多一次明确的决策"。


为什么这里用 LangGraph,而不是直接写一个 ReAct Agent

这是一个很值得明确讲清楚的工程判断。

很多人一看到"Agentic",第一反应就是 ReAct、Tool Calling、多 Agent 协作。但这份项目没有走那条路,而是选择了 LangGraph + 有限状态图。这其实非常合理。

因为当前问题并不是开放世界任务规划,而是一个边界明确的检索闭环:

  • 工具种类有限:本地检索、联网搜索、生成回答。
  • 决策节点有限:路由、拆解、评估、规划。
  • 成功条件明确:回答问题,或者明确承认上下文不足。

对于这种任务,图式工作流往往比自由形态 Agent 更稳,原因有三点。

第一,可预测

ReAct Agent 适合探索式任务,但在 RAG 场景里,流程如果太开放,反而容易引入不可控的中间动作,比如反复改写查询、反复尝试同类工具、输出不符合预期的结构。

第二,可约束

LangGraph 的条件边、共享状态和显式终止节点,天然适合表达"查几轮就必须停""没有下一条子问题就收口生成"这种硬约束。

第三,可观测

RAG 系统最怕的是最后答案出了问题,但你不知道问题是出在路由、召回、评估还是生成。图式流程的每一步都是一个节点,天然比把所有动作揉进一个大 Prompt 更容易排障。

换句话说,这份项目并不是"Agent 不够智能",而是它故意选择了更工程化的 Agentic 路径


先看基线版:naive-rag.mjs 为什么只能算起点

基本流程

src/naive-rag.mjs 的结构非常直接:

flowchart LR A[用户问题] --> B[Milvus 相似度检索] --> C[拼接上下文] --> D[LLM 生成答案]

图上只有两个节点:retrievegenerate

核心状态也很简单:

js 复制代码
const GraphState = Annotation.Root({
  question: Annotation,
  k: Annotation,
  documents: Annotation,
  generation: Annotation,
})

这个版本的好处是:

  • 上手快。
  • 依赖少。
  • 很适合验证向量库和模型链路是否跑通。

但它的问题也同样明显:

  • 默认所有问题都必须检索。
  • 默认召回一次就够。
  • 默认只要召回了几段文本,就能支撑最终回答。

为什么说它只是"能跑"的 demo

看这段生成逻辑:

js 复制代码
const context = state.documents
  .map(
    (item, i) => `[片段 ${i + 1}]
章节: 第 ${item.chapter_num} 章
内容: ${item.content}`
  )
  .join("\n\n━━━━━\n\n")

这段代码在 demo 里没问题,但在真实业务里会马上遇到两个工程限制。

第一,上下文污染

只要召回里混入几个相关度一般的片段,模型就可能把不重要的信息当成线索。

第二,没有失败中间态

系统只有"查完然后答"这一条路,没有"先想一想要不要查""查完判断够不够""不够再补证据"这些中间决策层。

所以,普通 RAG 的本质问题不是"检索不行",而是"它把检索当成必选项,把生成当成默认出口"。

从源码结构看,naive-rag 其实只做了三件事

如果把 src/naive-rag.mjs 再拆细一点,你会发现它真正完成的工作非常聚焦:

  1. 连接 Milvus,并约定好集合、字段、索引参数。
  2. 用原问题直接做一次向量相似度检索。
  3. 把召回片段拼成上下文,交给模型流式生成。

也就是说,这一版不是"完整的问答系统",而是一个最小查询闭环

它证明的是三件事已经打通:

  • 问题能够变成检索请求。
  • 检索结果能够变成可消费的上下文。
  • 上下文能够被模型组织成最终答案。

从教学角度看,这样的基线版非常有价值。因为后面所有 Agentic 能力,都是建立在这个最小闭环已经成立的前提上。

检索函数为什么值得单独看

最基础的检索函数其实已经暴露了很多后续演进方向:

js 复制代码
async function retrieveRelevantContent(question, k = TOP_K) {
  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 ?? "未知",
  }))
}

这里最重要的点不是 similaritySearchWithScore 本身,而是作者没有把返回值直接当成一段纯文本,而是先规整成了包含 scoreidchapter_numindex 的对象。

这说明一个很重要的工程意识:
检索结果从第一天开始就应该是"证据对象",而不是"字符串列表"。

因为只要系统继续往前演进,这些字段都会变得有用:

  • id 可以用于去重、溯源和证据回放。
  • score 可以作为排序和诊断信号。
  • chapter_numindex 可以帮助你理解召回结果在原始语料里的位置关系。

很多团队做第一版 RAG 时喜欢尽快跑通,于是把召回结果很早就压平成字符串,后面一旦要加引用、去重、证据展示,就要重新补结构。当前这份 demo 在这一点上其实已经踩对了方向。

为什么"相似度高"仍然可能答不准

朴素 RAG 最大的误区就是把"召回相关文档"和"可以稳定回答问题"当成一回事。

但这两者之间至少隔着两层差距:

第一层差距是证据完整性

一个高分片段可能和问题很像,但只覆盖了答案的一半。

第二层差距是证据组合关系

有时问题需要两个片段分别提供不同事实,单看每一个都不完整,合在一起才够。

向量相似度更擅长解决的是"相关不相关",而不是"够不够答"。这也是为什么很多团队会出现这样一种表象:

  • 检索日志看起来很正常。
  • 召回片段也都不算离题。
  • 但最终答案就是不稳定。

本质原因往往不是"检索坏了",而是系统根本没有显式判断当前证据是否足以支撑回答

为什么这层没有真正的"拒答控制"

naive-rag.mjs 在生成 Prompt 里虽然写了"如果片段中没有相关信息,请如实告知用户",但这件事仍然是交给最终生成节点顺手处理的。

这会带来一个经典问题:

生成模型天然倾向于完成任务,而不是主动收缩任务。

只要上下文里出现一些似是而非的相关文本,它就可能尝试把这些片段拼成一个看起来流畅的答案。

所以你会得到一种很典型的坏结果:

  • 语气很自信
  • 用词很自然
  • 结构也很完整
  • 但证据其实不够

这就是为什么后面一定要把"是否足够回答"从生成器里拆出来,做成独立节点。
不是因为生成模型不聪明,而是因为控制流判断不应该寄托在生成模型的自觉上。


第一层升级:rag-query-router.mjs 先解决"不是所有问题都该查"

为什么入口路由是必要的

很多团队做 RAG 时,会默认所有请求都先进知识库。这在开发初期很自然,因为架构简单,路径统一。

但一旦系统里同时存在以下问题:

  • 常识问答
  • 定义解释
  • 通用写作类任务
  • 需要企业内部事实支撑的任务

你就不能再把所有查询强行塞给向量库了。

原因很简单。检索不是免费的。它会引入:

  • 一次 Embedding / query encode
  • 一次向量数据库查询
  • 一次上下文拼装
  • 更多 token 消耗
  • 更长响应时间

如果一个"什么是向量数据库"这样的问题也要去 Milvus 里召回一轮《天龙八部》片段,那就不是智能,而是机械。

源码里是怎么做路由的

核心代码非常简洁:

js 复制代码
const RouteSchema = z.object({
  strategy: z.enum(["simple", "complex"]),
  reason: z.string(),
})

const router = llm.withStructuredOutput(RouteSchema)
const route = await router.invoke(`
你是问答路由器。请判断用户问题是否需要外部检索。

规则:
- simple: 常识问答、简短定义、无需特定小说细节即可回答。
- complex: 需要《天龙八部》具体情节、人物关系、章节事实、原文细节或证据支持。
`)

这里最关键的不是 Prompt 内容,而是两点设计:

第一,结构化输出

路由节点不是"生成一段分析",而是只允许输出 simplecomplex。这是控制流节点最需要的能力。

第二,附带 reason
reason 不影响分支本身,但它在排障时很有价值。你可以知道模型为什么把这个问题判成 complex,后续做误路由分析会轻松很多。

为什么这一步看似简单,实则很重要

因为 Agentic RAG 的第一步,不是多加工具,而是先减少不必要的工具调用

很多人对"智能系统"的理解是路径越复杂越高级,但实际工程里恰恰相反:如果一个问题本来直接答就够,那最优路径就是别查。

所以路由层的价值,本质上是:

  • 降低成本
  • 缩短延迟
  • 提前把简单问题挡在复杂闭环之外

这是一个很朴素,但很值得先做的优化。

路由真正难的地方,不是二分类,而是边界判断

入口路由看起来只是把问题判成 simplecomplex,但真实挑战不在极端 case,而在灰区。

比如:

  • "什么是向量数据库?"
    很明显应走 simple
  • "Milvus 的 HNSW 和 IVF 有什么差异?"
    既像常识解释,又像需要知识支撑的专业问题。
  • "公司内部文档里对于 HNSW 参数怎么建议设置?"
    这显然依赖内部事实,应走 complex

也就是说,路由器真正要判断的不是"这个问题能不能被模型说出点东西",而是:

  • 这个问题是否依赖特定外部事实。
  • 这个问题是否需要可核对证据。
  • 如果直接回答,出错代价高不高。

从这个角度看,路由层本质上并不是"语义分类器",而是一个成本和风险分配器

为什么先用模型路由,而不是纯规则

很多人看到二分类,第一反应是写规则,比如:

  • 出现"谁""哪一章""发生在何处"就走检索。
  • 出现"什么是""为什么"就直接回答。

规则不是不能用,但只靠规则很快会遇到局限。

一方面,用户表达方式太多,同一意图的表述极其丰富。

另一方面,很多问题表面形式和真实需求并不一致。一个看似定义类的问题,也可能依赖内部文档;一个带有专业术语的问题,也可能只是泛化解释。

所以先让模型理解问题的"知识依赖程度",通常比只看字面关键词更稳。

更成熟的生产做法往往是:

  • 先用便宜规则处理强模式场景。
  • 灰区交给模型做语义路由。
  • 对高风险问题采用保守策略,宁可多进检索,也不要误判为直答。

当前 demo 只展示了模型路由这一层,但它已经把最重要的思路表达出来了:
问题进入系统后,不应该默认只有一条路径。

为什么 simple / complex 只是一个教学上刚好的切分

当前脚本的二分法足以说明原则,但不代表生产里只需要两条路。

更常见的扩展方式可能是:

  • direct:直接回答
  • local_rag:只查本地知识库
  • multi_hop:需要拆解并多轮检索
  • web_fallback:本地不足时允许外部补偿
  • special_tool:走 SQL、代码搜索、工单系统等专用工具

你会发现,一旦路由器出现,系统后续的整个架构空间就打开了。

所以 rag-query-router.mjs 的价值不只是"省一次检索",而是它第一次明确告诉系统:查询处理应该是分流的,而不是一刀切的。


第二层升级:rag-multihop.mjs 真正进入闭环阶段

如果说查询路由只是做了一次"是否进入检索"的前置判断,那么 rag-multihop.mjs 才是真正把 RAG 从单步流程改造成闭环系统的开始。

它解决的核心问题是:

复杂问题不是"查得更准"就能解决的,很多时候它需要的是"分步查证"。

这类问题通常有几个特征:

  • 问题里带有跨实体关系。
  • 问题里隐含前置事实。
  • 问题答案需要按顺序确认多个中间节点。
  • 原始问题本身并不适合直接作为检索句。

项目里的示例问题就是典型多跳问题:

《天龙八部》中「四大恶人」排行第二的是谁?此人之子在身世揭晓前,其生父在武林中的公开身份是什么?

这个问题如果直接拿去做一次向量检索,往往会出现两种结果:

  • 要么召回一些包含"四大恶人"的片段,但没覆盖"其生父公开身份"。
  • 要么召回"段誉""镇南王"等片段,但脱离了"排行第二是谁"的前置链路。

所以系统需要的不是"更大的 topK",而是先把问题拆开。

1. 状态定义:闭环从状态开始

rag-multihop.mjsGraphState 已经明显比基线版复杂:

js 复制代码
const GraphState = Annotation.Root({
  question: Annotation,
  k: Annotation,
  strategy: Annotation,
  routeReason: Annotation,
  subQuestions: Annotation,
  nextSubIdx: Annotation,
  documents: Annotation,
  currentQuery: Annotation,
  retrievalCount: Annotation,
  maxRetrievals: Annotation,
  plannedNext: Annotation,
  generation: Annotation,
})

如果你只从"字段数量变多了"去看,会低估它的意义。真正重要的是,这些字段对应了一个完整闭环里必须被显式管理的状态:

  • subQuestions:当前规划出的检索序列。
  • nextSubIdx:下一轮到底检索哪一个子问题。
  • documents:不是一轮召回,而是累计证据池。
  • retrievalCount:当前已经走了几轮。
  • maxRetrievals:系统允许自治到什么程度。
  • plannedNext:规划器给出的下一步动作。

当这些状态开始被显式维护时,系统就不再是"一个函数里连续调用几个接口",而是一台真正有中间态的执行机。

2. 子问题拆解:不要让模型自由发挥式改 query

多跳系统里最容易做坏的一件事,就是让模型每轮都自由生成新的检索句。这样做表面上灵活,实际很容易导致:

  • 查询漂移
  • 重复搜索
  • 跳过前置事实
  • 难以复盘

这份代码采用的是更稳的做法:先一次性生成有序子问题列表

js 复制代码
const DecomposeSchema = z.object({
  sub_questions: z.array(z.string()).min(1).max(8),
  reason: z.string(),
})

Prompt 里也写得很细:

  • 每条子问题必须是可独立检索的完整中文问句。
  • 禁止使用"他/她/此人"这种跨句指代。
  • 顺序必须符合推理链。
  • 不能只把原题原样复制回去。

这里体现的是非常明确的工程意识:拆解器的职责不是思考最终答案,而是产出稳定、可执行、可检索的中间查询。

这是 Agentic 系统里一个特别重要的角色分离。你越早把"负责生成答案"和"负责生成检索动作"的能力拆开,后面越不容易失控。

为什么 Prompt 里要强制"禁止代词"

这一条在很多人眼里可能只是 Prompt 修辞,但它其实非常关键。

拆解器被要求输出的是"可直接送入检索系统的子问题",而不是对话里给人看的自然延续句。

向量库并不知道"此人""他""她"分别指向谁,它接收到的只是一个字符串,然后基于这个字符串去做相似度匹配。

所以:

  • "此人的儿子是谁?" 对人类来说上下文充分。
  • "叶二娘之子是谁?" 对检索系统来说才是真正可执行。

这背后反映的是一个很重要的工程原则:

中间查询必须为检索系统而写,而不是为对话系统而写。

很多多跳系统效果差,不是因为模型不会拆,而是它拆出来的子问题仍然保留了对话式省略,导致下一步检索无法稳定命中。

子问题顺序为什么比"拆成几条"更重要

多跳系统里,顺序本身就是信息。

以示例问题为例,一个更合理的拆解大致会是:

  1. 四大恶人排行第二的是谁?
  2. 叶二娘之子是谁?
  3. 虚竹身世揭晓前,其生父在武林中的公开身份是什么?

这个顺序本质上是在按实体依赖链 推进。

如果顺序反过来,系统第一轮就可能先跑去召回"玄慈""少林方丈"相关内容,而不是先锁定"叶二娘"这个关键中间实体。证据会变得更散,后续上下文也更容易跑偏。

所以多跳拆解不是"把大问题拆成多个小问题"这么简单,而是要把原始问题转成一个按依赖关系排序的检索程序

规划器到底在判断什么

planNextStepNode 最容易被误解成"又一个回答模型",但它真正判断的不是答案本身,而是:

  • 当前证据池是否已经覆盖核心事实。
  • 剩余未检索子问题是否仍然有补充价值。
  • 继续检索带来的收益,是否大于继续扩张上下文的代价。

也就是说,它的职责不是"思考答案",而是"判断证据闭环是否已经形成"。

这是一个非常关键的角色边界。

如果规划器也开始追求"直接猜答案",它就会和生成器职责重叠;而一旦职责重叠,系统要么会提前收口,要么会过度拖延。

当前脚本用 nextAction: retrieve | generate 这种非常窄的输出空间,把规划器钉死在"下一步动作判断"上,这恰恰是它稳定的原因。

为什么 mergeUnique 是稳定器,不是附属函数

多跳系统里的文档集合并不是静态上下文,而是一块不断增长的工作记忆。

如果没有一个稳定的合并策略,证据池会以非常快的速度失控。

mergeUnique 的价值体现在三个层面:

  • 防止同一片段在不同轮次重复进入上下文。
  • 保留高质量版本,避免弱命中覆盖强命中。
  • 让证据池始终以一个稳定顺序被后续节点消费。

如果要继续生产化,我通常还会在它之上再补两层:

  1. 语义级去重,而不只是 ID 去重。
  2. 给每条证据附上"来自哪一轮、哪一个子问题"的 provenance 信息。

第二点尤其重要。因为一旦最终答案有问题,你会非常想知道:

错误证据到底是第一轮带进来的,还是第三轮带进来的;是拆解器带偏了,还是检索器带偏了。

如果拆解错了,多跳系统会怎么坏

多跳闭环最大的风险,不是没有能力,而是能力很强但方向错了

一旦子问题拆错,系统会以一种非常自洽的方式一路走偏。

常见坏法有四种:

  • 欠拆解:本来需要三步,但只拆成一步,最终退化回单跳 RAG。
  • 过拆解:本来两步够了,却拆成五六步,导致上下文越来越散。
  • 错实体:第一跳实体识别就错,后续所有检索都围绕错误对象展开。
  • 错顺序:子问题本身没错,但前后依赖被打乱,证据无法有效累积。

这也是为什么多跳系统一定要保留中间状态和日志。

只看最终答案,你很难区分到底是"召回不准"还是"拆解走偏"。

多跳层真正解决的,不是"多检索",而是"把推理需求翻译成检索序列"

这是我认为很多教程没有讲透的地方。

用户问题通常是推理型表达,而向量库更擅长处理事实型检索表达。

多跳层真正做的,就是把前者翻译成后者,把一个人类式提问,变成一条适合检索系统执行的序列化程序。

所以这层的本质,不是检索次数增加了,而是:

  • 查询语义被重写了
  • 检索顺序被显式化了
  • 证据累积过程被程序化了

这才是 rag-multihop.mjs 真正的深度所在。

3. 检索节点:多轮检索最怕的不是查不到,而是证据池越来越脏

检索节点本身并不复杂,核心还是 similaritySearchWithScore。难点在于,多轮检索时怎么处理新旧结果。

源码用了一段很关键的函数:

js 复制代码
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)
  )
}

这段代码非常值得讲,因为它恰好对应多跳 RAG 的两个典型失败模式。

第一个失败模式是覆盖式检索

如果每一轮新召回都直接替换旧结果,那系统根本没有"累计证据"的能力,最后只剩最后一轮局部上下文。

第二个失败模式是重复式膨胀

如果你把每一轮结果简单拼接,Prompt 很快就会堆满重复片段,token 成本上升,噪声也上升。

mergeUnique 的策略很朴素,但很有效:

  • 用文档 ID 去重。
  • 冲突时保留更优版本。
  • 最后统一按分数排序。

这就是闭环系统里很典型的一类"小函数决定大行为"。它不是 flashy 的 AI 技巧,但没有它,多跳基本很难稳定。

4. 规划节点:允许模型决定下一步,但不能把系统完全交给模型

多跳闭环里最关键的部分,其实不是"拆得好不好",而是"什么时候该停"。

源码里 planNextStepNode 做的是这件事:把当前子问题序列、已检索文档摘要、剩余未检索条数、轮数上限都交给模型,然后让它判断下一步是继续 retrieve 还是直接 generate

这里最值得注意的地方不是模型判断本身,而是后面的硬性覆盖逻辑:

js 复制代码
let finalNext = nextAction
if (state.retrievalCount >= state.maxRetrievals) finalNext = "generate"
if (remaining <= 0) finalNext = "generate"

这行代码非常重要。

因为它体现了一个成熟 Agent 系统的基本原则:

模型可以决定策略,但不能决定系统的安全边界。

也就是说:

  • 模型负责"智能性"
  • 代码负责"收敛性"

如果没有这一层硬覆盖,多跳系统很容易从"会规划"退化成"会反复犹豫"。在 demo 里可能只是多调用几次模型,在生产里则意味着:

  • token 成本不可控
  • 响应延迟不可控
  • 重复召回越来越多
  • 用户体验越来越差

5. 多跳图结构:这已经不是简单 if/else,而是可追踪的闭环

整个图结构是这样:

flowchart TD A[route_question] -->|simple| B[direct_answer] A -->|complex| C[decompose_question] C --> D[retrieve] D --> E[plan_next_step] E -->|retrieve| D E -->|generate| F[generate]

这张图背后真正要表达的是:复杂问题不再被视为"查一次然后答",而是被视为"先规划检索链,再在约束内逐步收敛"。

6. 用一次真实问题,看状态是怎么流动的

如果只看代码,很多人还是会觉得抽象。我们用项目里的示例问题走一遍:

《天龙八部》中「四大恶人」排行第二的是谁?此人之子在身世揭晓前,其生父在武林中的公开身份是什么?

一个合理的内部流转,通常会像这样:

阶段 关键状态变化 系统在做什么
路由 strategy=complex 判断这不是常识题,必须检索
拆解 subQuestions=[...] 先把问题拆成几个可检索子问题
检索 1 currentQuery=四大恶人排行第二是谁 找到叶二娘
检索 2 currentQuery=叶二娘之子是谁/其生父是谁 找到虚竹、玄慈相关片段
规划 plannedNext=generate 证据已够,停止检索
生成 generation=... 基于累计上下文组织最终答案

你会发现,真正重要的不是"模型会不会一下答出答案",而是系统是否能把一个不适合直接召回的复杂问题,拆成一条稳定的证据链。

这就是多跳 RAG 的核心工程价值。


第三层升级:rag-webfallback.mjs 解决"本地知识不够怎么办"

多跳解决的是"问题复杂",但还有一类问题跟复杂不复杂没关系,而是本地知识边界不够

项目里的示例问题就是这样:

请回答《天龙八部》小说里"雁门关事件"的主谋是谁,并说明其儿子的最终结局;另外请补充:在《天龙八部》2013 版电视剧中,这段"雁门关事件"主要出现在哪几集?请给出可核对的来源链接。

这个问题天然分成两半:

  • 小说里的"雁门关事件"主谋与人物结局,本地知识库大概率有。
  • 2013 版电视剧对应集数,这是外部信息,本地小说库不一定有。

如果系统只会"查本地然后答",就会在后半段暴露。

1. 这份实现的思路非常实用:先本地,再评估,再联网

流程不是"查不到就联网",而是:

  1. 本地检索。
  2. 判断当前上下文是否足够回答。
  3. 如果不够,让模型给出补充搜索意图。
  4. 联网检索一次。
  5. 再评估一次。
  6. 收口生成。

图结构如下:

flowchart TD A[route_question] -->|simple| B[direct_answer] A -->|complex| C[local_retrieve] C --> D[evaluate_local] D -->|enough=true| E[generate] D -->|enough=false| F[web_search] F --> D

这条链路比"本地没结果就去查网"成熟得多。因为真实问题往往不是"完全没召回",而是"召回了一部分,但还不足够"。

2. 为什么"信息充分性评估"是这个版本最值钱的部分

EvaluateSchema 是整份脚本里最值得借鉴的设计之一:

js 复制代码
const EvaluateSchema = z.object({
  enough: z.boolean(),
  missing: z.array(z.string()).max(6),
  reason: z.string(),
  web_query: z.string().optional(),
})

它比很多 RAG 系统只看"有没有结果"成熟得多,因为它显式表达了四件事:

  • enough:当前上下文够不够回答。
  • missing:如果不够,具体缺什么。
  • reason:为什么判断不够。
  • web_query:如果要联网,建议怎么查。

这其实是在把"答不出来"从一种模糊感受,转成一个可执行的状态。

这是非常典型的工程思路:
不要只让模型说"我觉得不够",而要让它明确缺了什么、下一步该补什么。

3. 为什么联网查询句不应该直接复用原问题

源码里这个细节也很对:

js 复制代码
const query = (parsed.web_query ?? "").trim() || state.question

也就是说,系统优先使用评估器给出的 web_query,只有在它没给出来时,才回退到原问题。

这是因为原问题往往混合了本地知识和外部知识,直接拿整句上网查,容易出现两个问题:

  • 查询过长,包含无关条件,召回杂。
  • 外部搜索引擎对"复合问题"的理解不一定稳定。

让评估器先把"缺失的外部信息"抽出来,再组织成更适合联网搜索的句子,通常效果会更好。

4. 这份实现为什么只补一次网,而不是无限搜

源码里的 afterEvaluateLocal 有一个很重要的设计:

js 复制代码
if (state.webContext && String(state.webContext).trim()) {
  return "generate"
}

这意味着当前实现的策略是:

  • 本地检索一次
  • 如不足,联网补一次
  • 再次评估后直接收口

它不是无限循环式的"直到搜到满意为止"。

为什么这反而是个好设计?

因为对于 demo 和大多数业务系统来说,联网兜底的目标不是做一个开放域搜索 Agent,而是做一个有限补偿机制。一旦你把联网检索变成默认可反复触发的路径,系统就会面临新的问题:

  • 搜索结果噪声越来越多
  • 可信度越来越难判定
  • 成本和时延显著上升
  • 来源管理和引用变得复杂

所以这份实现做的是"补一刀",而不是"把整个系统变成搜索型 Agent"。这非常符合工程上的最小增强原则。

本地知识不足,和本地检索失败,不是一回事

这是 Web 兜底层最值得先建立的认知。

一个问题最终答不出来,至少可能有三种原因:

  • 本地库里有答案,但检索没召回出来。
  • 本地库里有部分答案,但不足以覆盖整个问题。
  • 本地库里真的没有答案。

这三种情况的下一步策略并不一样。

第一种更应该优化召回;第二种适合做补充;第三种则要么外搜,要么明确承认边界。

当前脚本通过评估器把"当前上下文是否足够"独立出来,至少避免了一个常见误区:
不是一答不出就把所有锅都甩给检索。

为什么"够不够答"不能只看召回分数

很多人会想,既然 Milvus 已经返回了 score,能不能直接设阈值:

  • 分数高就生成答案
  • 分数低就去联网

这在局部上可以作为参考,但不能代替充分性评估。

原因在于,相似度分数回答的是"这段文本和问题像不像",而不是"这批文本加起来能不能完成任务"。

例如:

  • 一个高分片段可能只覆盖了问题前半句。
  • 两个中等分数片段组合起来,反而已经足够完整。
  • 当前缺的不是相似文本,而是一个本地库根本没有的外部事实。

所以 EvaluateSchema 的价值就在于,它不是看单条文档,而是站在整批证据集合的视角判断任务是否可完成。

联网补充后最危险的问题,是来源污染

一旦系统引入 Web Search,它处理的就不再是单一来源证据,而是多来源融合。

在这个 demo 里,本地来源是小说知识库,外部来源是网页摘要。

如果模型不清楚区分两类来源,很容易发生两种污染:

  • 把网页摘要当成与本地知识同等可信的一手事实。
  • 把本地语料和网页描述混写成一个看似统一、实则混杂的答案。

所以真正生产化时,Web 兜底不仅仅是"再查一下网",而是要认真处理:

  • 来源标签
  • 来源可信度
  • 来源冲突
  • 引用展示

当前脚本在生成要求里已经强调引用 引用: n / URL,这是一个很好的开始,但还远远不够。

如果要进一步演进,最好把"本地证据"和"外部证据"分别组织成不同上下文块,并在最终答案里显式区分。

为什么这个版本还不能算开放域问答系统

虽然当前版本已经支持联网兜底,但它本质上仍然是一个本地知识优先、外部搜索补偿一次的受控系统。

它还没有做这些事情:

  • 多轮 Web 搜索和追问
  • 外部页面正文抓取与清洗
  • 外部来源之间的一致性校验
  • 网页事实与本地事实冲突时的仲裁

所以这层真正解决的是"本地知识边界上的缺口",而不是"开放网络上的可靠事实问答"。

这个边界一定要讲清楚,否则很容易高估系统能力。


这份项目最值得借鉴的,不是某个 Prompt,而是三种约束方式

如果你只从表面看,这份代码像是在不断加 LLM 调用。但如果你仔细看,会发现它真正有价值的是三类"约束"。

约束一:结构约束

路由、拆解、规划、评估全部使用了 zod + withStructuredOutput

这不是形式主义,而是把"模型说的话"变成"系统能消费的契约"。

在 Agentic RAG 里,只要一个节点的输出要影响下一步控制流,就应该尽量结构化。

约束二:状态约束

所有关键中间态都被放进 GraphState,而不是靠临时变量到处传。

这意味着:

  • 每个节点只处理它该处理的字段。
  • 条件边依赖的是状态,不是上下文猜测。
  • 你可以清楚知道某一轮系统为什么走到了这个分支。

约束三:退出约束

不管是 maxRetrievals,还是 Web 兜底只补一次,本质上都在回答一个问题:

这个系统最晚什么时候必须停下来?

这是很多 Agent 系统容易忽视,但实际上最重要的设计题。

因为不能停的系统,再聪明也不适合上线。


从源码视角看,这其实已经是一个"小型决策系统"

如果把四个脚本合在一起,你会发现这份项目其实已经覆盖了一个查询型 Agent 系统的核心决策面:

  • 查询是否需要外部知识
  • 如果需要,是单跳还是多跳
  • 多跳时是否还要继续
  • 本地证据是否足够
  • 不足时是否需要外部补充

换句话说,这已经不再是"检索增强生成",而是"检索增强决策,再由决策驱动生成"。

这是一个特别值得建立的认知。因为很多人做 RAG 做久了,会默认问题出在 Embedding、向量库、Chunking 上。它们当然重要,但一旦系统规模上去,控制流质量 往往会比单次召回质量更早成为瓶颈。

如果把四个脚本重新抽象成一张统一的总图,当前项目其实实现的是这样一套查询决策闭环:

flowchart TD A[用户问题] --> B{是否需要外部知识} B -- 否 --> C[直接回答] B -- 是 --> D{是否为复杂推理} D -- 否 --> E[单轮本地检索] D -- 是 --> F[拆解为有序子问题] F --> G[多轮本地检索] G --> H{证据是否足够} E --> H H -- 是 --> I[基于证据生成答案] H -- 否 --> J[生成联网查询意图] J --> K[Web Search 补充] K --> L[再次评估] L --> I

这张总图很能说明问题:

RAG 在这里已经不是单一模块,而是一台有分流、有反馈、有停止条件的查询决策机。

一旦你这样理解系统,后面的优化重点就会发生变化:

  • 你不会只盯着召回率。
  • 你会开始关注哪些决策其实不该发生。
  • 你会开始关注哪些问题应该更早停止。
  • 你会开始关注哪些问题应该明确承认边界,而不是继续尝试。

关键参数不要只记值,更要知道它们在控制什么

当前项目里几个核心参数如下:

参数 当前值 它控制的不是表面含义,而是
temperature 0 决策节点输出稳定性
k 58 每轮证据池扩张速度
dimensions 1024 向量 schema 一致性
metric_type COSINE 相似度语义
index_type HNSW 检索性能与召回折中
ef 64 搜索阶段的召回精度/时延平衡
maxRetrievals 8 多跳闭环的最大自治半径
count 8 联网补充的信息密度

这里有几个点值得单独展开。

temperature=0 为什么在这里是对的

很多人对温度的理解还停留在"低温更准确,高温更有创造力"。在生成式写作里这没问题,但在当前项目里,大模型大量承担的是控制流职责,不是文案创作职责。

当模型负责路由、评估、规划时,稳定比灵感重要得多。

否则你会遇到很糟糕的现象:

  • 同一个问题一会儿被判 simple,一会儿被判 complex。
  • 同一批上下文一会儿说够,一会儿说不够。
  • 同一组子问题一会儿继续检索,一会儿直接收口。

所以控制节点低温,几乎是默认正确的选择。

k 不是越大越好

很多 RAG 初学者会把答不准简单归因于"召回太少",然后不断把 topK 调大。

但在多跳和闭环系统里,k 变大不只是"多一点信息",它还意味着:

  • 每轮候选上下文更长
  • 噪声片段更容易混进来
  • 后续评估器看到的信息更杂
  • 最终生成更难聚焦

所以 k 的本质不是"多查几条",而是"每一轮给证据池加多大口径的流量"。

maxRetrievals 决定的是系统人格

这个参数很少在教程里被认真讨论,但它其实非常重要。

如果 maxRetrievals 太小,系统会显得保守,复杂问题容易在证据不足时提前生成。

如果 maxRetrievals 太大,系统又会显得犹豫,可能不停地试图补更多证据,导致延迟升高、上下文膨胀。

所以它的本质不是"技术参数",而是一个产品取舍参数:

你希望系统更快停下,还是更愿意多查几轮再回答?

HNSW 参数为什么不能只抄不懂

当前项目里 Milvus 的关键索引参数是:

js 复制代码
indexCreateOptions: {
  metric_type: "COSINE",
  index_type: "HNSW",
  params: { M: 16, efConstruction: 200 },
  search_params: { ef: 64 },
}

很多教程把这类参数写进去就结束了,但如果你想真正理解系统为什么"有时准、有时不准",这些参数必须知道大概控制什么。

可以简单理解成:

  • M 影响图索引的连边数量,决定图结构的稠密程度。
  • efConstruction 影响建索引时的搜索范围,通常越大索引质量越好,但构建更慢。
  • ef 影响查询时探索候选的广度,通常越大召回更稳,但延迟更高。

为什么这对 Agentic RAG 更重要?因为闭环系统会放大底层召回的任何不稳定性。

在单跳 RAG 里,第一轮召回稍微漏一点,最终答案可能只是偏一点。

但在多跳 RAG 里,如果第一轮就漏掉了关键实体,后面整条子问题链都可能建立在不完整证据上,误差会一层层传递下去。

所以多跳和闭环系统对底层检索稳定性的要求,通常比单步 RAG 更高。


如果你真的要把它跑起来,需要注意什么

这部分很多文章会一笔带过,但实际落地时很关键。

当前项目是一个查询侧 demo,而不是全量 RAG 工程。它有几个前置假设:

  1. Milvus 已经部署,并监听在 localhost:19530
  2. 已存在名为 ebook_collection 的集合。
  3. 该集合里已经写入了与 text-embedding-v31024 维向量一致的数据。
  4. .env 中已经配置好 OPENAI_API_KEYOPENAI_BASE_URL,如果跑 Web 兜底还需要 BOCHA_API_KEY

也就是说,这个项目当前重点在查询编排 ,不在知识摄取

这其实是件好事。因为很多人写 RAG 教程时喜欢把"切分、写库、检索、生成、评估"全部塞在一篇文章里,最后导致每部分都讲不深。这份代码反而比较聚焦,它主要展示的是:

查询来了以后,系统应该如何思考、如何分支、如何收口。

这正是 Agentic RAG 最值得讲透的那一部分。


这份示例离真正生产可用,还差哪几层

当前实现已经有很好的骨架,但如果要走向生产,至少还需要补下面几层。

1. 混合检索,而不是纯向量检索

当前所有本地检索都走 similaritySearchWithScore。这对于语义类问题没问题,但对以下场景并不稳:

  • 精确人名、术语、编号
  • 法规条款号
  • 代码符号名
  • 带版本号的技术文档

企业场景里,向量检索通常需要和关键词检索、BM25 或倒排索引结合。否则"语义接近但事实不准"的召回会明显增多。

2. 重排层

现在系统是"召回完直接用",没有独立 reranker。

一旦知识库变大、文本更杂,重排通常是提升最终答复质量的关键层。

因为很多时候问题不是"没召回",而是"相关文档在结果里,但排序不够靠前"。

3. 引用与证据粒度管理

Web 兜底版已经开始强调引用来源,这个方向是对的。但真正生产化时还要更进一步:

  • 给每条证据分配稳定引用 ID
  • 区分本地知识和外部来源
  • 在最终答案里保留可回溯锚点

否则一旦用户质疑答案,你很难快速定位到底是哪个来源出了问题。

4. 评测体系

闭环系统不能只看最终答案好不好,还要看中间动作对不对。

你至少要能评估:

  • 路由命中率
  • 多跳拆解质量
  • 检索轮数分布
  • 信息充分性评估准确率
  • Web 兜底触发率
  • 最终答案引用完整性

如果没有这些指标,后续优化很容易沦为凭感觉改 Prompt。

5. 可观测性和回放能力

当前代码已经会打印日志,这对 demo 足够,但对生产还不够。

真正落地时,你最好把下面这些信息结构化记录下来:

  • 原始问题
  • 路由结果
  • 每轮子问题
  • 每轮召回摘要
  • 规划决策
  • 评估结果
  • 是否触发 Web
  • 最终答案

这样你才能做真实的 bad case 复盘。


一次完整请求,从用户输入到答案生成,到底怎么流转

为了把整个系统讲透,我们把它抽象成一个更完整的时序图:

sequenceDiagram participant U as User participant G as LangGraph participant L as LLM participant M as Milvus participant W as Web Search U->>G: 提交问题 G->>L: 路由判断 alt simple L-->>G: direct_answer G->>L: 直接生成 L-->>G: answer G-->>U: 返回答案 else complex G->>L: 拆解子问题 L-->>G: sub_questions loop 多轮检索 G->>M: 检索当前子问题 M-->>G: docs G->>L: 判断继续检索还是生成 L-->>G: retrieve / generate end opt 本地上下文不足 G->>L: 评估缺失信息 L-->>G: web_query G->>W: 联网搜索 W-->>G: web_context end G->>L: 基于累计证据生成最终答案 L-->>G: answer G-->>U: 返回答案 end

这张图有两个地方特别值得注意。

第一,用户只看到一个问题和一个答案,但系统内部可能已经经历了多轮决策

第二,整个流程里最贵的并不一定是最终生成,而往往是中间决策的叠加

这也是为什么 Agentic RAG 的设计必须尽量节制,不能看到一个问题就把所有节点都跑一遍。


把一次多跳请求按源码真正跑一遍

前面已经从概念上解释了 rag-multihop.mjs 的闭环逻辑,但如果你想真正吃透这份代码,最有效的方式还是把一次请求按节点顺序跑完。

我们就用源码里的问题:

《天龙八部》中「四大恶人」排行第二的是谁?此人之子在身世揭晓前,其生父在武林中的公开身份是什么?

这一问的价值很高,因为它同时具备三个特点:

  • 不是通用常识题。
  • 不能用一次直接检索稳定拿到完整答案。
  • 最终答案依赖一个中间实体链:四大恶人第二名 -> 叶二娘 -> 其子虚竹 -> 虚竹生父玄慈 -> 公开身份是少林方丈。

也就是说,这不是"查一个事实",而是"沿着一个人物关系链逐步锁定最终事实"。

第 0 步:状态初始化不是样板代码,而是执行边界

graph.invoke(...) 之前,源码会给初始状态填一整套字段:

js 复制代码
const result = await graph.invoke({
  question,
  k: Number.isFinite(k) ? k : 5,
  strategy: "",
  routeReason: "",
  subQuestions: [],
  nextSubIdx: 0,
  documents: [],
  currentQuery: "",
  retrievalCount: 0,
  maxRetrievals: 8,
  plannedNext: "",
  generation: "",
})

很多人看这类初始化对象会觉得啰嗦,但这里其实已经把系统行为边界写死了。

比如:

  • subQuestions: [] 表示系统必须先经过拆解节点,不能凭空进入多轮检索。
  • nextSubIdx: 0 表示第一轮永远从第一个子问题开始,而不是随意跳步。
  • retrievalCount: 0maxRetrievals: 8 共同定义了闭环的最大自治范围。
  • documents: [] 明确说明证据池从空开始累积,而不是隐式继承上一次运行痕迹。

换句话说,初始化状态不是为了"让代码能跑",而是为了告诉整个图:
这次执行从哪里开始、允许走多远、哪些字段必须被谁来填充。

第 1 步:routeQuestionNode 决定是不是值得进入复杂链路

源码里的路由节点本质上做了一件事:

判断这个问题到底是"直接回答类",还是"需要知识链路支撑的复杂问题"。

js 复制代码
const router = llm.withStructuredOutput(RouteSchema)
const route = await router.invoke(`
你是问答路由器。请判断用户问题是否需要外部检索。

规则:
- simple: 常识问答、简短定义、无需特定小说细节即可回答。
- complex: 需要《天龙八部》具体情节、人物关系、章节事实、原文细节或证据支持。
`)

对于这个问题,路由节点几乎一定会返回:

  • strategy = complex
  • reason = 需要小说人物关系与具体事实支撑

为什么这里不会误判成 simple

因为它至少有三个强信号:

  1. 明确依赖小说内部人物关系。
  2. 问的是"公开身份"这种细节事实,不是开放式解释。
  3. 问题存在前置推理链,不能靠一般常识补足。

这一层的意义不是把答案提前猜出来,而是明确:
后面必须进入检索链路,否则系统没有可靠的证据来源。

第 2 步:decomposeQuestionNode 把原问题翻译成检索序列

多跳层真正的第一步不是检索,而是翻译。

源码要求模型输出 sub_questions

js 复制代码
const DecomposeSchema = z.object({
  sub_questions: z.array(z.string()).min(1).max(8),
  reason: z.string(),
})

对于这个示例问题,一个合理拆解大概率会接近下面这样:

  1. 《天龙八部》中四大恶人排行第二的是谁?
  2. 叶二娘之子是谁?
  3. 虚竹身世揭晓前,其生父在武林中的公开身份是什么?

这里最值得注意的,不是"它拆成了三条",而是它完成了一次关键的语义变换:

  • 用户原问题是推理型表达。
  • 子问题序列是检索型表达。

这一步本质上是在把"人类提问方式"翻译成"知识系统可执行方式"。

如果这一步失败,后面即使检索器和生成器都很强,系统也依然会偏。因为检索器只能回答"你拿什么来查",不能替你纠正"你应该先查什么"。

第 3 步:第一次 retrieveNode 的目标不是直接拿答案,而是锁定关键实体

第一轮检索通常围绕第一个子问题:

《天龙八部》中四大恶人排行第二的是谁?

源码里检索逻辑本身并不花哨:

js 复制代码
const newDocs = await retrieveRelevantContent(q, state.k)
const merged = mergeUnique(state.documents ?? [], newDocs)

但从语义上看,第一轮检索非常关键。

它要完成的不是回答整个原问题,而是确认链路上的第一个关键中间实体:叶二娘

这是多跳系统和单跳系统最大的认知差异之一:

  • 单跳系统希望"一次召回尽量拿到最终答案"。
  • 多跳系统更关注"当前轮是否成功推进到下一个关键事实节点"。

也就是说,多跳检索每一轮都像是在解一道分步证明题,而不是一次性做完全部推理。

第 4 步:planNextStepNode 判断的不是"我会不会答",而是"这轮证据是否足以进入下一阶段"

当第一轮检索结束后,规划器开始接管。

它看到的不是原始问题 alone,而是:

  • 原始问题
  • 全部子问题
  • 已经检索过哪些子问题
  • 还剩哪些子问题
  • 当前证据池摘要
  • 已经用了几轮机会

它再给出一个很克制的判断:

  • 继续检索 retrieve
  • 或者直接进入生成 generate

这里的关键是,第一轮之后它大概率不会急着生成。

因为即使系统已经知道"排行第二的是叶二娘",它仍然没有足够证据回答后半句"其子生父的公开身份是什么"。

所以规划器做出的,其实是一个非常工程化的判断:

当前证据池是否已经覆盖了原问题的所有关键缺口?

如果答案是否定的,系统就继续,而不是因为"已经有点像答案了"就提前收口。

第 5 步:第二轮 retrieveNode 是在补"链路证据",不是补"背景信息"

第二轮检索通常会围绕:

  • 叶二娘之子是谁?
  • 或进一步锁定虚竹与其生父信息。

这一步有一个很重要的区别:

它不是在继续堆积背景材料,而是在补上前一轮留下的结构性空白。

这和很多回答型系统的常见坏习惯正好相反。

很多系统一旦觉得信息不够,会不断召回更多"相关但泛"的片段,结果上下文越来越长,但关键缺口始终没被填上。

多跳系统如果设计正确,每一轮新增证据都应该和某个明确缺口对应。

这也是为什么源码在规划器里同时维护:

  • subQuestions
  • nextSubIdx
  • retrievalCount

它不是在盲目扩上下文,而是在按缺口推进。

第 6 步:mergeUnique 在这条链路里起的作用,比表面看起来更大

当第二轮、第三轮结果开始回流时,证据池会出现两个常见现象:

  • 某些关键片段被不同子问题重复命中。
  • 某些信息片段之间高度相似,但来自不同位置。

这时如果没有合并和重排,后果会很明显:

  • 上下文里充满重复句子。
  • 关键片段和次要片段混杂。
  • 生成模型注意力被噪声吸走。

mergeUnique 在这里本质上扮演的是"工作记忆整理器"的角色。

它不是让系统"更聪明",而是防止系统在多轮中变得越来越乱。

从工程上讲,这类函数往往决定系统上限:

  • 没有它,系统越跑越脏。
  • 有了它,系统才有机会逐轮收敛。

第 7 步:当 plannedNext=generate 时,说明形成的不是"完整语句",而是"完整证据闭环"

很多人会误以为规划器输出 generate,表示模型已经"想到了答案"。其实更准确的理解应该是:

  • 需要的关键实体已经被锁定。
  • 前后依赖关系已经补齐。
  • 当前继续检索的边际收益已经不高。

也就是说,generate 的真正含义不是"我知道答案了",而是:

证据集已经足够支持答案生成器工作了。

这背后其实是一个很成熟的职责分离:

  • 检索和规划链负责把问题还原成足够可靠的证据状态。
  • 生成链负责把证据状态翻译成用户可读答案。

如果这两步混在一起,系统很容易一边查一边脑补,最后把"我猜大概是这样"误当成"证据已经足够"。

第 8 步:generateNode 才第一次真正关心"怎么对用户说"

到了生成节点,系统终于开始从"证据组织"切换到"用户表达"。

源码会把累计 documents 拼成上下文:

js 复制代码
const context = state.documents
  .map(
    (item, i) => `[片段 ${i + 1}]
章节: 第 ${item.chapter_num} 章
内容: ${item.content}`
  )
  .join("\n\n━━━━━\n\n")

这里有个很关键的角色转变:

  • 前面的节点关心的是流程、约束、缺口、下一步。
  • 到了这里,系统才关心语言组织和答案呈现。

这也是为什么一个成熟的 Agentic RAG 往往不该让生成模型去兼任太多中间判断。

它最擅长的阶段,仍然是"拿到足够证据以后,把它说清楚"。

如果把这次执行过程压缩成一张状态表,长这样

节点 输入关注点 输出的真正价值
route_question 这个问题是否依赖知识库事实 决定是否进入复杂链路
decompose_question 原问题的推理结构 产出可执行检索序列
retrieve 当前子问题 为证据池补一个结构性缺口
plan_next_step 当前证据池 + 剩余问题 判断是否继续收集证据
generate 已收敛证据池 把证据翻译成最终答案

你会发现,这张表其实已经非常接近一个查询型工作流引擎的设计说明。

这也从另一个角度说明:rag-multihop.mjs 的真正价值,不是"多查几次",而是把一次复杂问答拆成了清晰的、可验证的执行阶段。

再看 rag-webfallback,多出的只是"外部证据补偿层"

如果把同样的 walkthrough 套到 rag-webfallback.mjs 上,你会发现它不是另起一套架构,而是在多跳思路之外,补了一层"外部证据补偿":

  • 本地检索先跑一遍。
  • 评估器判断当前证据能否完成任务。
  • 如果不能,生成一个更适合联网的外部查询意图。
  • 用 Web Search 补一批不同来源证据。
  • 再次评估后收口生成。

这说明整个项目的设计并不是"每个脚本都是新系统",而是在复用同一个原则:

先收集当前最便宜、最可信的证据;如果不够,再进入更贵、更开放的路径。

这条原则在企业系统里非常重要。因为成本更高、噪声更大的路径,通常都不应该成为默认首选。


常见误区:很多"看起来合理"的做法,其实不是默认最优解

误区一:问题答不准,就把 topK 调大

这通常只是在增加噪声。

如果问题本质上需要多跳推理,topK=20 也不一定比"拆成两个可检索子问题,各查 topK=5"更好。

误区二:Agentic RAG 就是多 Agent

不对。

Agentic RAG 的本质是"模型参与流程决策",不要求必须有多个 Agent 进程。当前项目就是单图多角色,依然是非常典型的 Agentic RAG。

误区三:只要有召回,就应该生成答案

不对。

很多错误答案都不是来自"零召回",而是来自"半截证据 + 模型脑补"。

误区四:联网兜底越积极越好

也不对。

联网兜底带来的是补充信息,但同时也引入噪声、时效问题、来源可信度问题和更高延迟。它应该是兜底路径,而不是默认路径。

误区五:Prompt 写得越长,闭环就越稳定

不一定。

Agentic RAG 稳不稳定,更多取决于:

  • 状态有没有设计清楚
  • 分支有没有收紧
  • 输出有没有结构化
  • 停止条件有没有定义

Prompt 很重要,但它不是唯一变量。


如果让我把这份 demo 继续演进,我会怎么做

如果目标是做一版更接近企业应用的 Agentic RAG,我会按下面顺序演进,而不是一上来就堆更多工具。

第一步:把查询侧配置化

把下面这些东西从代码里抽出来:

  • 模型名
  • topK
  • 最大检索轮数
  • Web 兜底开关
  • Prompt 模板

这样系统才具备实验和调优的基础。

第二步:补混合检索与重排

这是提升召回质量最实用的一步,通常比"再加一个 Agent"更直接有效。

第三步:引入引用粒度和证据对象

不要只把 content 拼成字符串,最好维护结构化证据对象,比如:

  • 来源类型
  • 文档 ID
  • 章节 / 标题
  • URL
  • score
  • 摘要片段

这样后续生成阶段才能更稳定地引用来源。

第四步:做最小评测集

至少准备三类问题:

  • simple:本不该触发复杂检索的问题
  • multihop:需要链式检索的问题
  • webfallback:本地知识不足的问题

只要这三类问题不能持续稳定通过,后续再做更复杂的自治都意义不大。

第五步:再考虑更自由的工具调用

很多团队的顺序恰好反过来,一上来就做 ReAct、多 Tool、多 Agent,最后把系统搞得很炫,但极难调试。

我的判断正好相反:
先把有限状态图做稳,再考虑更自由的智能体能力。

如果把这份 demo 改造成生产级 Agentic RAG,我会怎么重构边界

前面那一节讲的是"先做什么",这一节讲的是"代码层面怎么重构"。

因为 demo 和生产系统最大的区别,往往不是多几个功能,而是边界是否被拆清楚。

如果让我接手这份代码往生产化演进,我不会先加更多节点,而会先把下面几个边界明确下来。

1. 把"证据"从字符串升级成一等公民

当前 demo 已经开始把召回结果写成对象,但在生产里,我会把它进一步抽成统一 Evidence 结构,比如:

js 复制代码
const Evidence = {
  id: "",
  sourceType: "local" | "web",
  sourceId: "",
  title: "",
  location: "",
  score: 0,
  content: "",
  query: "",
  retrievalRound: 0,
}

为什么这一步这么重要?因为生产系统里,后面几乎所有能力都依赖证据对象:

  • 去重依赖它
  • 重排依赖它
  • 引用依赖它
  • 回放依赖它
  • 评测依赖它
  • 人工审查也依赖它

如果证据没有统一结构,你的系统就很难既能回答问题,又能解释自己为什么这么回答。

2. 把检索器从"Milvus 调用"抽成稳定接口

当前代码里本地检索和 Web Search 都直接写在脚本里,这对 demo 合理,但生产里最好抽成统一接口。

比如:

js 复制代码
class Retriever {
  async retrieve(query, options) {}
}

class LocalVectorRetriever extends Retriever {}
class HybridRetriever extends Retriever {}
class WebFallbackRetriever extends Retriever {}

这样做的意义不是"为了抽象而抽象",而是让系统后面能平滑做这些事:

  • 从纯向量检索升级成混合检索
  • 增加重排层
  • 对不同知识域使用不同召回策略
  • 对同一问题在不同环境走不同检索实现

也就是说,你真正想稳定的不是 "Milvus API",而是"系统如何拿回一批可比较、可排序的证据"。

3. 把评估器从 Prompt 逻辑升级成策略层

当前评估器已经很有启发性,但生产里我通常不会让它只是一个"自由节点",而会把它变成更明确的策略层:

  • 哪些问题允许 Web 兜底。
  • 哪些问题本地不足时必须拒答。
  • 哪些问题需要更保守的证据阈值。
  • 哪些问题必须引用来源,否则不允许输出结论。

这意味着评估器不只是"一个模型调用",而是要和业务策略绑定。

比如在企业内部场景里:

  • 制度类问答可能必须只看本地知识,不允许外网补充。
  • 技术趋势类问题可以允许 Web 兜底。
  • 合规类问题必须保守,信息不足就明确拒答。

所以评估器的本质,不只是"判断够不够",还是"决定允许系统做到哪一步"。

4. 把 GraphState 从演示状态升级成可审计状态

当前 GraphState 已经很有价值,但如果进入生产,我会再补几类字段:

  • traceId
  • startTime
  • latencyBreakdown
  • decisionLog
  • evidences
  • finalCitations
  • riskFlags

这样做的原因很直接:

线上系统的问题不是"看不懂代码",而是"问题已经发生时,你还能不能复盘出它是怎么发生的"。

对于 Agentic RAG 来说,状态不是内部实现细节,而是最重要的审计材料。

5. 把"最终答案"拆成答案体和证据体

很多 demo 最后只返回一段字符串,这在演示里没问题,但在生产里太弱了。

我更倾向返回这种结构:

js 复制代码
{
  answer: "...",
  citations: [...],
  confidence: "high",
  unresolved: [...],
  route: "multi_hop_with_web",
}

这样你才能在前端做更多事情:

  • 点击查看引用来源
  • 折叠展开证据
  • 高亮不确定部分
  • 记录用户反馈时带上路线信息

也就是说,生产级 Agentic RAG 的输出,不应该只是"会说话",还应该是"会交代出处和边界"。

6. 把图上的每个节点都变成可观测事件

当前 demo 用 console.log 记录节点执行过程,这对本地调试很有帮助。

但生产里更好的做法是把每个节点视为一个事件:

  • route_decided
  • question_decomposed
  • retrieval_round_finished
  • next_step_planned
  • context_evaluated
  • web_fallback_triggered
  • final_answer_generated

一旦事件化,你就可以做很多后续能力:

  • 可视化链路回放
  • 统计每类问题平均检索轮数
  • 排查哪些问题最容易触发 Web
  • 分析哪个节点最容易出错

这类能力在 demo 里看不出价值,但到了线上会极其重要。

7. 真正的生产架构,通常长这样

如果把生产版的核心模块画出来,我会更倾向于类似下面的结构:

flowchart TD A[Query Orchestrator] --> B[Router] --> C[Planner] --> D[Retriever Layer] --> E[Evaluator] --> F[Answer Generator] D --> D1[Local Vector Retriever] D --> D2[Keyword or BM25 Retriever] D --> D3[Reranker] D --> D4[Web Fallback Retriever] A --> G[Trace & Metrics] A --> H[Policy Guardrails] F --> I[Citations & Confidence]

这张图和 demo 最大的差别不在于"更多模块",而在于每一块的职责更清晰了:

  • Router 决定路径
  • Planner 决定下一步动作
  • Retriever Layer 负责拿证据
  • Evaluator 负责判断证据是否足够
  • Answer Generator 只负责把证据说清楚

一旦职责清晰,系统就更容易迭代。

否则你会不断往一个大 Prompt 或一个大函数里堆能力,最后谁都改不动。

8. 最后才考虑让它变得"更像 Agent"

这点我想刻意强调。

很多团队一提生产级 Agentic RAG,就会立刻想到:

  • 多 Agent 协作
  • 自动追问
  • 动态选工具
  • 反思与自我纠错

这些方向不是没价值,但前提是当前这份有限状态闭环已经足够稳。

否则你会出现一种很常见的情况:

  • 能力表面上越来越丰富
  • 但每个问题都要走很多不必要的分支
  • 调试成本越来越高
  • 系统行为越来越不可预测

所以在工程实践里,我更认同这样的顺序:

  1. 先把有限状态图做稳。
  2. 再把证据结构、评测、可观测性补齐。
  3. 最后才考虑更自由的自治能力。

这是一个看起来保守、但实际上最容易走到可上线结果的路径。


评测与 bad case 分析:怎么验证 routedecomposeretrieveevaluate 这四层真的有效

讲 Agentic RAG,如果只讲流程设计,不讲怎么验证,文章其实只完成了一半。

因为这类系统最容易出现一种错觉:

  • 你看日志,节点都跑了。
  • 你看答案,也不是完全离谱。
  • 你看链路,还挺像那么回事。

但只要没有分层评测,你就很难回答下面这些关键问题:

  • 路由到底有没有把本该直答的问题错误送进复杂链路?
  • 拆解到底是在帮检索,还是在制造更多噪声?
  • 多轮检索到底有没有提高证据覆盖率,还是只是让上下文变长?
  • 评估器判断"够不够答"到底可靠不可靠,还是只是在重复模型偏见?

所以这一节的重点不是"评测很重要"这种空话,而是把四层能力分别拆开,讲清楚应该测什么、怎么看 bad case、怎么知道问题到底出在哪一层

为什么不能只看最终答案对不对

这是 Agentic RAG 和普通 RAG 在评测上的最大差异。

普通 RAG 至少在很多场景下还能用"最终答案准确率"粗暴判断系统是否提升。

但 Agentic RAG 一旦有路由、拆解、规划、评估这些中间控制层,就会出现一种现象:

  • 最终答案错了,但错因可能完全不同。

同样是一条错误答案,它可能来自:

  • route 错了,本该走复杂链路却被直答。
  • decompose 错了,子问题一开始就拆偏了。
  • retrieve 错了,拆得没问题,但没召回关键证据。
  • evaluate 错了,证据明明不够,却提前判断为 enough。

如果你只看"最终答对率",这些错因会被混成一团。

最后你只能凭感觉改 Prompt,或者盲目换模型,完全不知道系统真正的瓶颈在哪里。

所以对于 Agentic RAG,更合理的验证方法不是"只看结果",而是:

把最终回答拆回中间决策,逐层判断每一层是否把后续步骤送到了更好的状态。

也就是说,每一层的评测目标都不一样:

  • route 评测的是分流是否合理。
  • decompose 评测的是检索程序是否被正确生成。
  • retrieve 评测的是关键证据是否真的被找回来。
  • evaluate 评测的是系统对当前证据充分性的判断是否可靠。

先准备一套"可分层评测"的数据,而不是只有问答对

如果你手里只有这种数据:

json 复制代码
{
  "question": "阿朱的结局是什么?",
  "answer": "阿朱最终死在萧峰误伤之下。"
}

那它更适合测最终问答,不足以测 Agentic 链路。

更适合分层评测的数据结构,至少应该长这样:

json 复制代码
{
  "id": "case_multihop_001",
  "question": "《天龙八部》中四大恶人排行第二的是谁?此人之子在身世揭晓前,其生父在武林中的公开身份是什么?",
  "expected_route": "complex",
  "expected_subquestions": [
    "《天龙八部》中四大恶人排行第二的是谁?",
    "叶二娘之子是谁?",
    "虚竹身世揭晓前,其生父在武林中的公开身份是什么?"
  ],
  "required_evidence_ids": ["doc_102", "doc_311", "doc_876"],
  "expected_enough_without_web": true,
  "expected_answer_facts": [
    "四大恶人排行第二的是叶二娘",
    "其子是虚竹",
    "虚竹生父的公开身份是少林方丈玄慈"
  ]
}

这类标注看起来更重,但价值非常大。

因为它把一条问题的"正确最终答案"拆成了几块可验证中间目标:

  • 正确路径应该是什么。
  • 子问题应该怎么拆。
  • 哪些证据必须出现。
  • 不联网时是否应该已经足够。
  • 最终答案至少应覆盖哪些关键事实。

只要这些字段齐全,你就能定位失败层,而不是只知道"这题没答好"。

评测集本身也要分桶,不要把所有题混在一起

我通常会把评测集至少切成四类:

类型 作用 应重点观察什么
simple_direct 验证路由保守性 是否误进复杂链路
single_hop_local 验证本地单跳检索 是否能低成本回答
multi_hop_local 验证拆解与多轮检索 子问题顺序、证据累积
local_insufficient_web 验证充分性评估与 Web 兜底 是否在该补网时补网

如果不分桶,你会很难解释系统指标。

比如一个路由器把几乎所有问题都判成 complex,在一些数据集上甚至可能让最终答对率看起来不低,因为复杂链路"兜住了"很多题。但从系统效率和成本角度看,这其实是失败的。

所以分桶评测的意义,是让你看到:

  • 它在哪类问题上保守过头。
  • 它在哪类问题上过度自信。
  • 它在哪类问题上明明应该多走几步却停太早。

一套实用的分层评测流程

如果让我给这类系统设计一个最小可落地的评测流程,我会用下面这条链:

flowchart LR A[评测问题集] --> B[运行完整 Agentic 链路] --> C[保存 trace 与中间状态] --> D[逐层打分 route/decompose/retrieve/evaluate] --> E[聚类失败样本] --> F[定位责任层] --> G[只改一层后回归验证]

这里最关键的不是"打分",而是 保存中间 trace

没有中间状态,你连 bad case 是怎么产生的都不知道;没有责任层定位,你就只能胡子眉毛一把抓地乱改。


第一层:怎么评 route

route 看起来最简单,但其实很容易被"最终结果还不错"掩盖问题。

route 的核心指标不是准确率,而是错分成本

路由的错误至少有两种:

  • 误判为 simple:本来需要知识支撑,却被直接回答。
  • 误判为 complex:本来可以直答,却被送进检索链路。

这两种错的代价完全不一样。

  • 前者通常会直接伤害答案正确性。
  • 后者更多伤害的是时延、成本和系统复杂度。

所以评 route 时,不能只看 overall accuracy。更应该看类似下面的分布:

真实类型 预测类型 风险
simple -> simple 正确 成本最低
simple -> complex 过度保守 性能浪费
complex -> simple 高风险误判 可能直接答错
complex -> complex 正确 路径合理

如果你只能优先优化一种错误,那几乎总是应该先压低 complex -> simple 这类错分。

route 的 bad case 长什么样

最常见的坏例子有三类:

第一类,伪常识题

问题表面像定义题,实际上依赖内部知识或证据。

例如:

  • "Milvus 的 HNSW 参数一般怎么设置?"

如果你的系统面对的是通用技术博客,这可以直答;

但如果面对的是公司内部知识库,这很可能应该检索,因为它问的是你们自己的配置约定。

第二类,伪知识题

问题里带实体词,但其实只是在问泛化概念。

例如:

  • "《天龙八部》为什么适合做 RAG 示例?"

它包含特定名词,但未必需要知识库检索。

第三类,混合题

前半句可以直答,后半句需要事实支撑。

例如:

  • "什么是向量数据库?顺便结合公司知识库说明为什么我们选了 Milvus。"

这种问题如果只做 simple / complex 二分,就会显得粗糙。它提示你路由空间可能要进一步扩展。

路由层怎么修

如果 route 经常错,不要第一反应就重写 Prompt。先看是以下哪类问题:

  • 定义边界本身没讲清楚。
  • 训练/示例题分布太偏。
  • 二分类标签不够表达真实业务。
  • 高风险问题缺保守规则兜底。

很多时候,路由问题不是模型不行,而是标签体系本身过于粗糙


第二层:怎么评 decompose

decompose 是多跳链路里最容易"看起来像对,实际上没用"的一层。

因为子问题只要写得顺口,读起来往往都像那么回事。但对检索系统来说,它是否真的有效,要看四个维度。

维度一:完整性

拆解后的子问题是否覆盖了原问题所需的全部关键缺口。

例如原问题需要回答三个事实:

  • 谁是四大恶人排行第二。
  • 此人之子是谁。
  • 其生父公开身份是什么。

如果拆解只覆盖了前两步,那它在语言上仍然"像一个合理拆解",但从任务角度已经失败。

维度二:顺序正确性

子问题顺序是否符合事实依赖链。

顺序错了,哪怕每条子问题单独都没错,整体执行起来也会变差。

因为你会先检索一个尚未锁定实体的问题,导致召回空间过大。

维度三:可检索性

子问题是否真的适合作为检索输入,而不是只是"给人看起来通顺"。

这也是为什么前面强调:

  • 少用代词
  • 尽量显式实体
  • 避免过度抽象表达

"此人的儿子是谁"是通顺中文,但不是好检索句。

维度四:最小性

拆解不是越多越好。

如果一个单跳问题被拆成五六个小问题,说明系统在过拆解,它会显著增加后续成本和噪声。

所以我通常会给 decompose 定一个简单 rubric:

分项 问题
完整性 是否漏掉关键事实
顺序性 是否按依赖链排序
可检索性 是否适合直接进入检索
最小性 是否存在明显过拆解

decompose 的典型 bad case

最常见的坏法有五种:

  • 漏拆:只拆出第一跳,后续关键事实没被显式化。
  • 过拆:把一个本可单跳解决的问题拆成很多碎问题。
  • 指代不清:子问题依赖上文,脱离上下文后不可检索。
  • 顺序错:先问结果,再问前置实体。
  • 引入新假设:子问题里偷偷加入原问题并没有给出的前提。

最后这一类特别危险,因为它会让后面的检索和生成都建立在错误前提上,但表面看起来仍然是"系统在认真思考"。

拆解层怎么修

如果 decompose 质量不稳定,我通常不会先调大模型,而会先做三件事:

  1. 在 Prompt 里强化"禁止代词、要求显式实体、要求依赖顺序"。
  2. 加少量高质量 few-shot,让模型看到什么叫"可检索子问题"。
  3. 在后处理阶段加轻量校验,比如空字符串过滤、重复子问题去除、过长子问题截断。

你会发现,拆解层很多问题并不需要更聪明的模型,而需要更清晰的执行约束。


第三层:怎么评 retrieve

retrieve 是最容易被低估的一层。

因为很多团队会以为"检索就是 Milvus 的事",但在 Agentic RAG 里,检索质量不仅决定当前轮能不能推进,还会影响后续的规划和评估。

retrieve 不应该只看 Hit@K

Hit@K 很有用,但不够。

对于多跳和闭环系统,我更关心这些指标:

  • Evidence Hit@K:必须证据是否至少命中一条。
  • Evidence Recall@K:所有关键证据命中了多少。
  • Coverage by Round:每一轮是否补上了新的关键缺口。
  • Duplicate Ratio:重复片段占比是否过高。
  • Noise Ratio:明显无关片段比例是否过高。

这里尤其重要的是 Coverage by Round

因为多跳系统的目标不是"每轮都召回一些看起来相关的东西",而是"每轮都尽量新增对原问题有价值的证据"。

retrieve 的 bad case 长什么样

典型坏法有四种:

  • 没命中关键证据:这是最直接的问题。
  • 命中了,但排位太低:说明召回和排序仍然有优化空间。
  • 命中了很多重复片段:会污染上下文。
  • 命中了很多泛相关片段:看起来相关,但对回答没有推进作用。

第四类是最隐蔽的。

因为从日志上看,系统"并没有完全跑偏",但每一轮实际上都在消费 token 却没有真正推进证据闭环。

多跳检索特别要看"边际收益"

这是单跳系统常常忽略,但多跳系统必须关心的指标。

假设系统连跑三轮检索,你要问的不是"总共找回多少片段",而是:

  • 第 1 轮比空状态补了多少关键事实?
  • 第 2 轮比第 1 轮又补了什么?
  • 第 3 轮是不是只是在重复前两轮的内容?

如果第三轮几乎没有新增关键证据,却仍然被规划器触发,就说明问题可能不在检索器,而在规划器没有意识到"继续查的边际收益已经很低"。

所以 bad case 定位时,一定要把 retrieveplan_next_step 联合看。


第四层:怎么评 evaluate

evaluate 是闭环里最像"大脑"的一层,因为它决定系统是在当前证据上收口,还是承认不足、继续补充。

但也正因为如此,它很容易成为一个"看起来聪明,实际上难验证"的黑盒。

要把它评好,必须明确:它到底在输出什么。

当前 demo 里的 EvaluateSchema 是:

js 复制代码
const EvaluateSchema = z.object({
  enough: z.boolean(),
  missing: z.array(z.string()).max(6),
  reason: z.string(),
  web_query: z.string().optional(),
})

这意味着 evaluate 其实同时承担了四个可测能力:

  • 是否判断当前上下文足够
  • 是否能指出真正缺失点
  • 是否能解释判断原因
  • 是否能生成有效的外部补偿查询

evaluate 最重要的指标不是准确率,而是错误方向

和路由一样,评估器也有不同类型的错误:

  • False Enough:明明证据不够,却说 enough。
  • False Not-Enough:明明证据已经够,却说不够。

这两者的代价也不同。

  • False Enough 会直接导致幻觉或缺证据回答。
  • False Not-Enough 会增加成本和时延,甚至误触发 Web Search。

在大多数生产场景里,前者通常更危险。

所以如果你只能优先优化一种错误,通常优先压低"证据不够却误判 enough"。

evaluate 的 bad case 通常不是"完全错",而是"缺口识别错位"

常见坏法有四类:

  • 误判为 enough:系统过早收口。
  • 误判为 not enough:系统不必要地继续走成本更高路径。
  • missing 写得太泛:比如只说"缺少更多信息",但没有指出具体缺口。
  • web_query 不可执行:比如直接复述原题,或者生成太泛、太长的外部查询句。

其中第三、第四类特别值得注意。

因为它们不会立刻让系统完全崩掉,但会让后续 Web Search 的收益非常差,最终表现成"系统看似在补信息,实际上补得不准"。

怎么判断 missing 是不是有用

一个非常实用的标准是:

missing 能不能被拿来直接驱动下一步动作。

如果 missing 只是写:

  • "信息不足"
  • "缺少更多背景"

那它对系统几乎没有帮助。

但如果它写的是:

  • "缺少 2013 版电视剧中雁门关事件对应集数信息"
  • "缺少可核对的外部链接来源"

那它就真的把缺口结构化了。

也就是说,评估器不是在写评论,而是在写下一步行动的输入。

怎么判断 web_query 是不是有效

最简单的方法不是人工看"顺不顺",而是看它是否满足三个条件:

  • 比原问题更聚焦于缺失信息。
  • 保留了关键实体。
  • 真正提升了外部检索结果质量。

生产里可以直接做 A/B:

  • 用原问题做外搜
  • web_query 做外搜
  • 比较结果覆盖率、相关性和最终答案质量

如果 web_query 长期不能优于直接用原问题,那说明评估器在"补偿查询重写"上并没有真正创造价值。


分层 bad case 复盘时,最怕"错层修复"

这是实际工程里特别常见的问题。

比如最终答案错了,团队第一反应可能是:

  • 调 Prompt
  • 换模型
  • 把 topK 调大

但如果真实问题出在 route 误判,你去调生成 Prompt 基本不会解决核心矛盾。

同样,如果真实问题出在 decompose 顺序错了,你把 retrievek 从 5 调到 12,往往只会把更多噪声带进来。

所以复盘 bad case 时,我会强制按下面顺序排:

  1. 路由对没对?
  2. 拆解对没对?
  3. 检索有没有拿回关键证据?
  4. 评估有没有正确判断是否足够?
  5. 最后才看生成表达是否有误。

这个顺序看起来机械,但能强行避免"哪里都想改一点,最后哪里都没修好"。

一套实用的 bad case 记录模板

为了让复盘不流于口头讨论,我建议每个 bad case 至少记录这些字段:

json 复制代码
{
  "case_id": "case_multihop_014",
  "question": "...",
  "expected_route": "complex",
  "actual_route": "simple",
  "expected_subquestions": ["...", "..."],
  "actual_subquestions": [],
  "required_evidence_ids": ["doc_12", "doc_98"],
  "retrieved_evidence_ids": [],
  "expected_enough": false,
  "actual_enough": true,
  "final_answer_ok": false,
  "root_cause_layer": "route",
  "fix_hypothesis": "增加复杂事实题 few-shot,并对高风险实体问题启用保守路由"
}

这个模板的价值是:

它强迫你给出"责任层"和"修复假设",而不是只记录"这题答错了"。

我更推荐的评测策略:先看链路质量,再看最终答案质量

如果只能给一个实践建议,我会这么做:

第一阶段先看中间层:

  • 路由是否合理
  • 拆解是否可执行
  • 检索是否补足关键证据
  • 评估是否可靠

第二阶段再看最终答案:

  • 事实是否覆盖
  • 引用是否完整
  • 表达是否清晰

为什么顺序要这样?因为最终答案质量往往是多层问题叠加后的结果。

如果中间链路本身就不稳,你直接追最终答案,只会在生成阶段做很多无效优化。

最后给一个非常实用的判断标准

怎么知道这套 Agentic RAG 是不是真的在变好?

不是看它是否"更像人",也不是看链路是否"更复杂",而是看下面四件事是否同时发生:

  • 本不该检索的问题,越来越少误入复杂链路。
  • 需要多跳的问题,越来越能通过子问题链稳定拿回关键证据。
  • 本地证据不足的问题,越来越能被准确识别,而不是硬答。
  • 系统每增加一层自治,bad case 反而越来越容易定位,而不是越来越难解释。

如果这四件事都成立,那说明你的 Agentic RAG 不是只在"加功能",而是在真正变得可控、可验、可迭代。


回到开头:这篇文章最想让你建立的认知是什么

如果你只记住一句话,我希望是这句:

Agentic RAG 的价值,不是让模型更像人,而是让 RAG 系统从固定流水线升级成一个有决策、有反馈、有边界的闭环。

这份 advanced-rag 项目最值得学习的地方,也恰恰在这里:

  • 它没有把所有能力揉成一个大 Prompt。
  • 它没有让模型无限自由地调用工具。
  • 它没有把"智能"理解成"流程越发散越好"。

相反,它做的是更工程化、也更难得的一件事:

  • 让模型在关键节点参与判断。
  • 用结构化输出把判断收窄。
  • 用共享状态把流程串起来。
  • 用停止条件把闭环收住。

这也是为什么我会说,这份代码虽然只是一个小说问答示例,但它的价值并不在小说本身,而在它已经把 Agentic RAG 的骨架搭得很清楚了。

当你自己的 RAG 开始出现这些问题时:

  • 查得很多,但不够准
  • 答得很多,但不够稳
  • 看起来有上下文,但还是会编
  • 一遇到复杂问题就失真

你真正该补的,往往不是再换一个更大的模型,而是像这份项目这样,先把中间那条"决策链"补完整。


总结

传统 RAG 的强项是简单、直接、容易起步,但它默认了一件现实里并不成立的事:所有问题都适合同一条检索路径。

advanced-rag 这份项目做的事情,可以概括成三步:

  1. 用查询路由解决"不是所有问题都该检索"。
  2. 用多跳闭环解决"复杂问题不能只检索一次"。
  3. 用充分性评估和 Web 兜底解决"本地知识并不总是够用"。

一旦你把这三层补上,RAG 就不再是"检索 + 生成"的单线流程,而变成了一个会判断、会补证据、会在边界内停下来的闭环系统

这,才是 Agentic RAG 真正值得投入的地方。

相关推荐
折哥的程序人生 · 物流技术专研2 小时前
《Java 100 天进阶之路》第23篇:缓冲区数据结构 ByteBuffer
java·开发语言·数据结构·后端·面试·求职招聘
还是鼠鼠2 小时前
AI掘金头条新闻系统 (Toutiao News)-获取新闻分类
后端·python·mysql·fastapi·web
赢乐2 小时前
大模型学习笔记:LangChain核心组件-记忆(memory)
数据库·langchain·长短时记忆网络·长期记忆·短期记忆·智能体agent·记忆(memory)
超梦dasgg2 小时前
Spring Security 原理 + 生产环境认证授权实战
java·后端·spring
东方小月2 小时前
Claude Code Skill 完全指南:一个 markdown 文件,就是一个专家分身
前端·后端
DianSan_ERP2 小时前
抖店订单接口中消费者信息加密解密机制与安全履约全解析
前端·网络·数据库·后端·安全·团队开发·运维开发
冬奇Lab3 小时前
RAG 系列(十八):Conversational RAG——多轮对话中的代词陷阱
人工智能·llm
紫洋葱_popo3 小时前
一文吃透 LangChain 流式输出:同步、异步、LCEL 链式穿透全解析
后端