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

0 阅读10分钟

上一篇[《# 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检索片段数量调用入口
strategysimplecomplex路由节点
routeReason模型给出的简短分类理由路由节点
documents检索结果检索节点
generation最终答案直接回答或生成节点

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

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

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

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

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

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

import { z } from "zod";

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

const router = model.withStructuredOutput(RouteSchema);

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

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

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

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

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

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

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

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

条件边使用源码中的 decideNext

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

它是一个普通 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 条件边文档

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

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

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

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

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

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

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

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

  process.stdout.write("\n");
  return { 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 字段取得答案。

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

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

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

原脚本在 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 中的 strategyrouteReason
谁选择下一条边?decideNextaddConditionalEdges()
谁写最终答案?directAnswerNodegenerateNode
哪些能力沿用上一篇?建库、Embedding、Milvus、检索和基于资料生成

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