Agentic RAG(上):别让所有问题都走检索——查询路由与 Web 兜底

Agentic RAG(上):别让所有问题都走检索------查询路由与 Web 兜底

我最早写的 RAG(Retrieval-Augmented Generation,检索增强生成)流程很固定:用户提问,向量库检索,把文档塞进提示词,再让模型回答。这个流程能工作,但有一个明显浪费:无论用户问"1+1 等于几"还是问小说里的具体人物关系,都要走一遍 Embedding、向量检索和生成。

更麻烦的是,检索结果不够时,流程仍然会硬着头皮生成。模型并不知道本地知识库缺了什么,很容易把"没有证据"变成"听起来合理"。

于是我把线性 RAG 逐步改造成一个 Agentic RAG:先判断是否需要检索,再评估本地上下文是否充分,不够时只做一次网络搜索,最后基于可见上下文回答。

下面按这条演进路线拆解实现。外部 Milvus、模型和搜索 API 需要自行配置,文中同时修正几个开发过程中遇到的变量名和条件判断问题。

1. Naive RAG 的问题不在"简单",而在"无条件"

最初的 Naive RAG 只有两个节点:retrieve 完成检索,随后固定进入 generate,再结束流程。

它的优势是稳定、容易理解。只要知识库和问题都适合向量检索,这种线性流程反而比复杂 Agent 更好。

问题在于它没有选择权:

  • 简单常识也产生一次检索成本;
  • 本地文档为空时仍进入生成;
  • 检索内容是否相关没有评估;
  • 本地知识库之外的时效信息无法补充。

所以我没有直接删除 Naive RAG,而是把它保留为基线,再增加"路由"和"评估"两个决策点。

2. 用结构化路由区分 simple 与 complex

路由节点不应该返回一段自然语言让程序猜,而应该返回固定枚举。这里使用 Zod:

js 复制代码
import { z } from "zod";

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

async function routeQuestionNode(state) {
  const router = llm.withStructuredOutput(RouteSchema);

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

- simple:常识、简短定义,不依赖知识库具体事实
- complex:需要小说情节、人物关系、章节事实或证据

用户问题:${state.question}
`);

  return {
    strategy: route.strategy,
    routeReason: route.reason,
  };
}

图通过条件边选择下一步:

js 复制代码
const afterRoute = (state) =>
  state.strategy === "simple"
    ? "direct_answer"
    : "local_retrieve";

graph.addConditionalEdges("route_question", afterRoute, {
  direct_answer: "direct_answer",
  local_retrieve: "local_retrieve",
});

方案 A 是关键词规则,例如包含书名就检索;方案 B 是让模型结构化分类。A 便宜且确定,适合规则清楚的业务;B 能处理更多自然语言变体,但会增加一次模型调用,也可能误判。

实际系统可以组合:先用确定规则处理明显场景,其余再交给模型路由。

3. 本地检索后,不要立刻生成

原项目用 Milvus 做向量检索,并保存内容、章节和分数:

js 复制代码
async function retrieveRelevantContent(question, k = 5) {
  const docsWithScores =
    await vectorStore.similaritySearchWithScore(question, k);

  return docsWithScores.map(([doc, score]) => ({
    score,
    content: doc.pageContent,
    id: doc.metadata?.id ?? "unknown",
    bookId: doc.metadata?.book_id ?? "unknown",
    chapter: doc.metadata?.chapter_num ?? "unknown",
  }));
}

async function retrieveLocalNode(state) {
  const retrievedDocs =
    await retrieveRelevantContent(state.question, state.k);

  return {
    retrievedDocs,
    localContext: retrievedDocs
      .map((document) => document.content)
      .join("\n\n"),
  };
}

我曾把状态字段写成 retrievedDocs,节点却返回 retrieveDocs,导致后续节点读取不到结果。这里统一使用 retrievedDocs。

检索完成后,我增加一个信息充分性评估节点:

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

async function evaluateNode(state) {
  const evaluator = llm.withStructuredOutput(EvaluateSchema);

  return {
    evaluation: await evaluator.invoke(`
判断上下文是否足以回答问题。

问题:
${state.question}

本地上下文:
${state.localContext || "(空)"}

如果不足:
1. 列出缺失信息;
2. 给出一条适合网络搜索的中文查询。
`),
  };
}

这里直接保存对象,不再"先 JSON.stringify,条件边再 JSON.parse"。图状态本来就能传对象,没有必要把类型信息丢掉再恢复。

4. Web fallback 只能兜底,不能变成无限循环

如果本地上下文不足,流程进入 Web fallback(网络兜底)。搜索节点优先使用评估器生成的查询:

js 复制代码
async function webSearchNode(state) {
  const query =
    state.evaluation.webQuery?.trim() || state.question;

  const webContext = await searchProvider.search({
    query,
    count: 8,
  });

  return {
    webContext,
    webSearched: true,
  };
}

这个版本还踩过三个明确的问题:

  1. 搜索请求的 catch 没有接收 error,却读取 error.message;
  2. 原判断是"有网页结果时返回未找到",条件写反;
  3. 搜索后再次进入评估,如果没有护栏,可能在"评估不足---继续搜索"之间循环。

一个清晰的路由函数应该显式限制搜索次数:

js 复制代码
function afterEvaluation(state) {
  if (state.evaluation?.enough) {
    return "generate";
  }

  if (!state.webSearched) {
    return "web_search";
  }

  // 已经联网一次,即使仍不足也结束检索,
  // 由生成节点明确说明缺失信息。
  return "generate";
}

方案 A 是不断搜索直到评估器说够;方案 B 是限制为一次或固定次数。A 看似更"智能",实际可能产生不可控的延迟、费用和循环。没有可靠停止条件时,我选择 B。

5. 生成节点要允许回答"不知道"

本地知识库和网络结果都可能不够。最终提示词不能只说"请回答",还要允许拒绝编造:

js 复制代码
async function generateNode(state) {
  const context = [
    state.localContext,
    state.webContext,
  ].filter(Boolean).join("\n\n=== 网络补充 ===\n\n");

  const response = await llm.invoke(`
你是严谨的中文问答助手。

规则:
1. 优先依据上下文回答;
2. 不要补写上下文中没有的事实;
3. 信息不足时明确说"无法从当前上下文确认";
4. 列出仍然缺失的信息。

上下文:
${context || "(空)"}

问题:
${state.question}
`);

  return { generation: response.content };
}

"不知道"不是失败,而是检索系统的重要输出。如果业务强制每次给出确定答案,评估和兜底最终都会被生成节点抵消。

6. 完整图应该长什么样

整理后的流程如下:

js 复制代码
const graph = new StateGraph(GraphState)
  .addNode("route_question", routeQuestionNode)
  .addNode("direct_answer", directAnswerNode)
  .addNode("local_retrieve", retrieveLocalNode)
  .addNode("evaluate", evaluateNode)
  .addNode("web_search", webSearchNode)
  .addNode("generate", generateNode)
  .addEdge(START, "route_question")
  .addConditionalEdges("route_question", afterRoute, {
    direct_answer: "direct_answer",
    local_retrieve: "local_retrieve",
  })
  .addEdge("local_retrieve", "evaluate")
  .addConditionalEdges("evaluate", afterEvaluation, {
    generate: "generate",
    web_search: "web_search",
  })
  .addEdge("web_search", "evaluate")
  .addEdge("direct_answer", END)
  .addEdge("generate", END)
  .compile();

这个图比 Naive RAG 多了两次模型决策,不一定更快、更便宜。它适合问题类型差异大、知识库可能不完整、错误答案代价较高的场景。如果所有请求都明确是内部文档查询,固定检索链可能更合适。

7. 还缺哪些生产级能力

当前学习实现已经表达了闭环思路,但距离生产还有几项:

  • 给路由与评估保存可观察的理由和耗时;
  • 对搜索 API 设置超时、错误分类和有限重试;
  • 对网络内容做来源白名单、去重和提示注入防护;
  • 给检索文档保留稳定引用,生成答案能追溯来源;
  • 对 k、相似度阈值和搜索次数做配置,而不是散落在代码里;
  • 用固定评测集比较 Naive RAG 与 Agentic RAG,而不是凭主观感觉。

目前还没有固定评测集,因此本文不提供准确率或延迟数字。

结尾

从线性 RAG 升级到 Agentic RAG,我认为最关键的不是"多调用几次模型",而是增加了三个明确决策:

  1. 这个问题是否需要检索;
  2. 当前检索结果是否足够;
  3. 兜底已经尝试后,是否应该停止并承认信息不足。

可复用的取舍是:能用固定流程解决,就不要为了"Agentic"增加决策成本;需要动态路由时,让模型输出结构化枚举;任何循环都要有硬护栏;生成节点必须允许"不知道"。

下一篇继续处理另一个线性 RAG 的弱点:一个问题需要先查 A,再根据 A 的结果查 B 时,怎样做多跳问题分解和迭代检索。

相关推荐
敲代码的小小酥4 小时前
Agent 运行机制全景:从工具调用到沙箱隔离
ai·agent
鱼饼Y4 小时前
AI Agent 驾驭指南
agent·ai编程
BlackStar_L4 小时前
第五章 Coding Agent 与通用 Agent
大模型·llm·agent
Blockbuater_drug4 小时前
protein-design-mcp 裸机实战:41 个蛋白设计工具大合集Agent部署与实测
agent·mcp·proteinmpnn·rfdiffusion·esmfold·boltz-2
用户79457223954134 小时前
当卖 token 的遇上修高速公路的:AI 周期的真空期在哪
人工智能·agent
程序员老赵4 小时前
Docker 部署 OpenViking:轻松搭建 AI Agent 上下文数据库平台
docker·开源·agent
费曼学习法4 小时前
无人值守AI内容矩阵实战(2):我给 Agent 装了个「声称检测器」,专治它谎报已发布
llm·agent·ai编程
AI小白Lin5 小时前
我的 Agent 迁移"成功"了:每道门都在册,没有一道能跑
架构·llm·agent
lucas_AI5 小时前
给 Coding Agent 塞文档前先看这篇:什么时候是救命稻草,什么时候帮倒忙
llm·agent