从固定流程到问题路由:让 LangGraph RAG 按需检索

文章来源声明: 原文作者:绿色蟑螂; 来源站点:掘金; 原文链接:https://juejin.cn/post/7687902696659566634; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

适合已搭好 LangGraph RAG 的开发者参考:用一次模型分类换取按需检索,能减少简单问题的检索开销,但需权衡成本与失败处理,并补齐直接回答节点。

上一篇\[《# LangChain 到 LangGraph: RAG 知识库改造》\]把问答流程拆成了两个节点:
START → retrieve → generate → END

retrieve 读取问题并检索资料,generate 根据资料生成答案,节点之间通过 State 传递数据。这个版本已经能查询《天龙八部》知识库,但所有问题都会进入检索。

用户问“阿朱是怎么死的”,检索小说合理;用户问“1+1 等于几”,再去小说里搜索就没有必要。本篇沿着 rag-query-router.mjs 的代码,在原图前面增加一次判断:这个问题需要小说资料,还是可以直接回答?

建库、Milvus 集合和原来的检索生成节点继续复用。新增内容集中在三个地方:路由节点、直接回答节点、条件边。下文给出源码讲解和必要修正片段,原脚本未修改,也未实际调用模型或数据库验证。

图 1 对比升级前后的结构。蓝色部分是本篇新增能力,灰色部分沿用上一篇;这张图展示调用图后的问答流程,数据库初始化仍在图外。

image.png

image.png图中的菱形代表条件边的选择逻辑,decideNext 是路由函数,不是通过 addNode() 注册的独立节点。

此前“检索后进入生成”是固定顺序。现在入口先走 route_question,根据它写入的策略,选择其中一条分支。本例的一次执行只选择一条分支。

代码中的 simplecomplex 是两个策略标签。判断标准是“回答是否依赖特定小说资料”,不能只看题目长短或计算难度。

问题期望策略原因
1+1 等于几?`simple`无需小说资料
JavaScript 数组的 map 有什么作用?`simple`通用技术知识
阿朱是怎么死的?`complex`需要核对小说情节
请引用原文说明阿朱与萧峰的关系`complex`需要原文依据

即使模型记得小说情节,只要知识库产品要求回答有资料依据,也应进入检索。这里选择的是回答策略,而不只是测试模型“会不会”。

这套二分类只覆盖示例约定的范围。“今天北京的天气”虽然也需要外部资料,却无法通过小说知识库得到答案。因此,路由提示词里的“外部检索”应具体理解为本例的小说检索;面向更多领域时,需要另行定义支持范围和其他去向。

沿用上一篇的 State,在原有四个字段上新增两个路由字段:

字段用途写入位置
`question`用户问题调用入口
`k`检索片段数量调用入口
`strategy``simple` 或 `complex`路由节点
`routeReason`模型给出的简短分类理由路由节点
`documents`检索结果检索节点
`generation`最终答案直接回答或生成节点

为了清楚展示每个字段,教学代码使用显式的 Annotation() 调用,并在执行入口统一传入初始值:

<span>import</span> { <span>Annotation</span>, <span>StateGraph</span>, <span>START</span>, <span>END</span> } <span>from</span> <span>"@langchain/langgraph"</span>;

<span>const</span> <span>GraphState</span> = <span>Annotation</span>.<span>Root</span>({
  <span>question</span>: <span>Annotation</span>(),
  <span>k</span>: <span>Annotation</span>(),
  <span>strategy</span>: <span>Annotation</span>(),
  <span>routeReason</span>: <span>Annotation</span>(),
  <span>documents</span>: <span>Annotation</span>(),
  <span>generation</span>: <span>Annotation</span>(),
});

这些字段按最新更新值覆盖。两个回答节点都写入 generation,调用方因此不需要分别处理两种答案字段。routeReason 便于查看分类依据,但它只是模型输出的解释,不能作为分类正确的证明。

接下来,让模型返回一个程序可以直接读取的路由结果。

如果只让模型输出“我觉得需要检索”,程序还得从自然语言中猜测结论。源码使用 Zod 描述输出结构,再交给 withStructuredOutput()

<span>import</span> { z } <span>from</span> <span>"zod"</span>;

<span>const</span> <span>RouteSchema</span> = z.<span>object</span>({
  <span>strategy</span>: z.<span>enum</span>([<span>"simple"</span>, <span>"complex"</span>]),
  <span>reason</span>: z.<span>string</span>(),
});

<span>const</span> router = model.<span>withStructuredOutput</span>(<span>RouteSchema</span>);

strategy 只能是两个约定值之一,reason 是字符串。withStructuredOutput() 让模型调用按指定结构返回并解析结果;使用 Zod 时还会进行结构校验。具体实现方式受模型服务支持能力影响,需要确认所用服务与配置兼容。LangChain 结构化输出文档

对于“阿朱是怎么死的”,期望结果类似:

<span>{</span>
  <span>"strategy"</span><span>:</span> <span>"complex"</span><span>,</span>
  <span>"reason"</span><span>:</span> <span>"需要依据小说中的具体情节回答。"</span>
<span>}</span>

这是输出形状示意,并非运行日志。结构正确与判断正确是两件事:模型可能返回合法的 simple,却把一个需要证据的问题分错类。

路由节点负责调用这个结构化模型,并把结果写入 State。下面将原提示词中的“任务关系”改为“人物关系”,同时把检索对象说明得更具体:

<span>const</span> <span>routeQuestionNode</span> = <span>async</span> (<span>state</span>) => {
  <span>const</span> route = <span>await</span> router.<span>invoke</span>([
    {
      <span>role</span>: <span>"system"</span>,
      <span>content</span>: <span>`判断问题是否需要查询《天龙八部》知识库。
simple:通用常识、简单定义或通用编程问题,无需小说资料。
complex:涉及小说具体情节、人物关系、章节事实或原文证据。
涉及小说事实时,即使你记得答案,也选择 complex。
只做分类,并给出简短理由。`</span>,
    },
    { <span>role</span>: <span>"user"</span>, <span>content</span>: state.<span>question</span> },
  ]);

  <span>console</span>.<span>log</span>(<span>`路由:<span>${route.strategy}</span>;原因:<span>${route.reason}</span>`</span>);
  <span>return</span> {
    <span>strategy</span>: route.<span>strategy</span>,
    <span>routeReason</span>: route.<span>reason</span>,
  };
};

questionk 没有变化,节点不必重复返回它们。这里也没有执行检索:路由节点只产生分类结果,下一步去哪由条件边决定。

条件边使用源码中的 decideNext

<span>const</span> <span>decideNext</span> = (<span>state</span>) =>
  state.<span>strategy</span> === <span>"simple"</span> ? <span>"direct_answer"</span> : <span>"retrieve"</span>;

它是一个普通 JavaScript 函数,不会再次调用模型。图 2 按时间顺序展示“模型分类”如何变成“程序选择路径”,其中 complex 是示意结果:

sequenceDiagram
    participant G as LangGraph
    participant R as routeQuestionNode
    participant M as 结构化模型
    participant S as 本次图状态 State
    participant D as decideNext

    G->>R: 执行节点,传入当前状态
    R->>M: 问题与分类规则
    M-->>R: strategy 为 complex,并给出 reason
    R-->>G: 返回 strategy、routeReason 更新
    G->>S: 合并节点返回的更新
    G->>D: 传入更新后的状态
    D-->>G: 返回 retrieve
    Note over G,D: 此处只运行 JavaScript,不再调用模型
    G->>G: 根据映射调度 retrieve 节点

顺序很重要:先保存路由节点的状态更新,再执行 decideNext,所以它能读到本轮刚得到的策略。

addConditionalEdges() 把这个选择逻辑接到路由节点后面。它的第三个参数将返回的标识映射为目标节点名。LangGraph 条件边文档

<span>const</span> graph = <span>new</span> <span>StateGraph</span>(<span>GraphState</span>)
  .<span>addNode</span>(<span>"route_question"</span>, routeQuestionNode)
  .<span>addNode</span>(<span>"direct_answer"</span>, directAnswerNode)
  .<span>addNode</span>(<span>"retrieve"</span>, retrieveNode)
  .<span>addNode</span>(<span>"generate"</span>, generateNode)
  .<span>addEdge</span>(<span>START</span>, <span>"route_question"</span>)
  .<span>addConditionalEdges</span>(<span>"route_question"</span>, decideNext, {
    <span>direct_answer</span>: <span>"direct_answer"</span>,
    <span>retrieve</span>: <span>"retrieve"</span>,
  })
  .<span>addEdge</span>(<span>"retrieve"</span>, <span>"generate"</span>)
  .<span>addEdge</span>(<span>"generate"</span>, <span>END</span>)
  .<span>addEdge</span>(<span>"direct_answer"</span>, <span>END</span>)
  .<span>compile</span>();

这段构图代码放在所有节点定义之后。addEdge() 表达固定连接;addConditionalEdges() 表达根据状态选择连接。本例已经用条件边连接了路由节点,不需要再给它添加一条无条件指向检索的边,否则会额外安排检索。

读到这里,可以记住这组分工:模型给出策略,路由节点保存策略,条件函数返回分支标识,LangGraph 按连接关系执行目标节点。

另一条新增路径是 direct_answer。它读取原始问题,不使用小说片段,直接生成回答。

源码中的这个节点尚未完成:它取得了 model.stream() 返回的流,却没有遍历流,也没有返回状态更新。因此,不能据此认为简单问题已经能完整输出并保存答案。可以将该函数替换为:

<span>const</span> <span>directAnswerNode</span> = <span>async</span> (<span>state</span>) => {
  <span>console</span>.<span>log</span>(<span>"---DIRECT-ANSWER---"</span>);
  <span>let</span> generation = <span>""</span>;

  <span>const</span> stream = <span>await</span> model.<span>stream</span>([
    { <span>role</span>: <span>"system"</span>, <span>content</span>: <span>"请用中文直接回答问题。"</span> },
    { <span>role</span>: <span>"user"</span>, <span>content</span>: state.<span>question</span> },
  ]);

  <span>for</span> <span>await</span> (<span>const</span> chunk <span>of</span> stream) {
    <span>const</span> text = <span>typeof</span> chunk.<span>content</span> === <span>"string"</span> ? chunk.<span>content</span> : <span>""</span>;
    generation += text;
    process.<span>stdout</span>.<span>write</span>(text);
  }

  process.<span>stdout</span>.<span>write</span>(<span>"\n"</span>);
  <span>return</span> { generation };
};

这里有两个动作:process.stdout.write() 把内容显示在终端,return { generation } 把完整答案交回图。只打印而不返回,最终状态拿不到答案;只返回而不打印,则不会在终端逐块展示。

complex 分支继续使用上一篇的 retrieveNodegenerateNode。它们分别执行相似度检索,以及用“问题+小说片段”生成回答。路由改造不要求重新切分电子书,也不要求重新建立向量库。

为了看清两条分支,可以逐条跟踪状态。以下假设分类符合预期,并且直接回答节点已经补齐:

问题route\_question 之后执行路径最终 documents
1+1 等于几?`strategy="simple"``direct_answer → END`保持初始空数组
阿朱是怎么死的?`strategy="complex"``retrieve → generate → END`本次检索结果

两条路径最后都会填充 generation。只有小说问答路径拥有检索证据;直接回答路径使用的是模型本身的能力,不能把它的答案当成“已核对知识库”的结果。

图 3 展示两条路径怎样更新同一套状态字段。它画的是一次执行中的状态变化;两个分支不会同时执行。蓝色框只列出该步骤更新的字段,未列出的字段继续保留。

image.png

沿左侧路径复习:documents 始终是初始空数组。沿右侧路径复习:检索先写入 documents,生成再读取它。无论哪条路径,调用方最后都从同一个 generation 字段取得答案。

按源码的初始化方式调用图:

<span>const</span> result = <span>await</span> graph.<span>invoke</span>({
  <span>question</span>: <span>"阿朱是怎么死的?"</span>,
  <span>k</span>: <span>5</span>,
  <span>strategy</span>: <span>""</span>,
  <span>routeReason</span>: <span>""</span>,
  <span>documents</span>: [],
  <span>generation</span>: <span>""</span>,
});

<span>console</span>.<span>log</span>({
  <span>strategy</span>: result.<span>strategy</span>,
  <span>reason</span>: result.<span>routeReason</span>,
  <span>documentCount</span>: result.<span>documents</span>.<span>length</span>,
  <span>answer</span>: result.<span>generation</span>,
});

原脚本在 main() 中把问题写死为“阿朱是怎么死的”。测试其他问题时,需要修改这个变量;当前代码没有读取命令行问题参数。沿用上一篇的依赖和模型配置,在项目目录运行:

cd D:\workspace\ysh_ai\ai\agent\agentic_rag\advanced-rag
node .\src\rag-query-router.mjs

运行前需要已有的小说集合,并确认模型支持本例的结构化输出。上一篇提到的 Embedding 和索引一致性也继续适用:本文件已经指定 dimensions: 1024,但仍使用 HNSW、ef 搜索配置,应核对它们与实际 Milvus 索引是否匹配。

这里还有一个容易忽略的区别:简单问题跳过了检索节点,但原脚本仍然会在 graph.invoke() 之前连接 Milvus、加载集合。所以按照当前写法,Milvus 不可用时,即使问题是“1+1”,也可能还没进入路由就失败。要让直接回答路径摆脱数据库依赖,需要把初始化延后到检索分支,并缓存已建立的连接;这属于后续实现改进。

路由也不保证每次都更快、更省。按一次成功执行、忽略重试来计数:

执行方案路由模型请求回答模型请求问题向量化与相似度检索
上一篇固定 RAG0 次1 次1 次
本篇 simple 路径1 次1 次0 次
本篇 complex 路径1 次1 次1 次

simple 路径省去了问题向量化、检索和小说上下文,但新增了一次分类请求;complex 路径则在原有流程前额外分类一次。实际是否节省成本和时间,取决于问题分布、模型选择、上下文长度以及服务延迟。

验证时,分别输入常识问题、小说事实问题和要求引用原文的问题,观察三个结果:strategy 是否符合预期,实际进入了哪个节点,generation 是否有内容。可以在 retrieveNode 开头添加 console.log("---RETRIEVE---"),配合路由和直接回答日志核对路径。单看 documents.length === 0 不足以证明跳过了检索,因为检索也可能返回空结果。

源码还有两处失败路径需要理解:结构化输出解析失败时,路由调用会抛错,不会自动变成某个策略;检索函数捕获异常后返回 [],则会混淆“没有资料”和“数据库出错”。这些情况应明确记录并单独处理。decideNext 中的默认 retrieve 分支无法接住路由节点已经抛出的异常。

日后复习,可以对照这张表定位新增代码:

复习问题对应代码与答案
谁定义可选策略?`RouteSchema` 中的 `z.enum()`
谁判断问题属于哪类?`routeQuestionNode` 调用结构化模型
谁把策略交给后续步骤?State 中的 `strategy`、`routeReason`
谁选择下一条边?`decideNext` 与 `addConditionalEdges()`
谁写最终答案?`directAnswerNode` 或 `generateNode`
哪些能力沿用上一篇?建库、Embedding、Milvus、检索和基于资料生成

本次增加了一次由模型参与的路径选择,所有可选节点和连接仍由开发者预先定义。小说分支依旧只检索一次、生成一次。如果一个问题需要先查到某个人物,再根据这个人物继续查另一段资料,这张图还没有表达这样的连续检索过程。后续文章可以从这个具体缺口继续演进。