RAG 答不准,先别急着换模型:给检索管道加上路由、拆解和循环检索

RAG 答不准,先别急着换模型:给检索管道加上路由、拆解和循环检索

拿到一个 RAG 项目,"回答不准"时最常见的反应是换更强的模型。但一份用《天龙八部》小说做语料的 LangGraph + Milvus 项目给出了另一个答案:问题往往不在模型,而在检索管道太"直线"------所有问题都检索一次,一次检索就想召回链式推理需要的全部证据。

本文把这个项目的四步演进(数据入库 → 朴素 RAG → 查询路由 → 多跳检索)拆给你看,包括多跳检索的四个核心机制,以及能从代码里直接指认的 3 个必错点。文内代码运行未验证 ,模型与 Milvus 地址均在 .env 配置。

一条测试题暴露的问题

项目里的测试题(src/rag-multihop.mjs:364)是这样的:

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

推理链:叶二娘 → 儿子虚竹 → 生父玄慈(少林方丈)。朴素 RAG 把整句丢去向量检索,一次召回很难同时覆盖三跳的证据。源码文件头的注释也写了改造动机:"直接把 query 向量化匹配不够准确"。

改进思路不是一次到位,而是逐层加决策环节。四个版本的对比如下------这张表也是全文的路线图:

版本 图结构 解决的问题 代价
朴素 RAG 线性 跑通闭环 简单问题浪费检索
+ 查询路由 分支 简单问题省资源 复杂问题仍一跳
+ 多跳检索 循环 链式推理召回 需轮数上限兜底
(规划)混合检索 + ReRank 召回增强 召回率与质量 引入 ES 与重排模型

第 0 步:入库时埋下的两个伏笔

数据入库脚本(src/ebook-writter.mjs)有两个容易被忽略、但后面全用得上的设计:

  1. 可读主键 :id 手工拼成 bookId_chapterNum_chunkIndex(如 1_111_23)。多跳检索多轮召回同一片段时,就靠这个 id 去重。
  2. IVF_FLAT 分桶索引 :IVF_FLAT + COSINE + nlist: 1024。源码注释:"把向量聚类分成很多小桶,检索时只扫最相关的几个桶,速度快;先分桶,只在目标桶里暴力比对。"

切分策略:EPubLoader 按章节加载后,RecursiveCharacterTextSplitter 切 500 字符、重叠 50------重叠是为了防止句子被切断后语义丢失。入库按章节流式进行,边切边向量化边插入,整本书不压内存。

朴素 RAG:两节点的最小闭环

LangGraph 最小形态是一条线性链(src/naive-rag.mjs:105-111):

js 复制代码
const graph = new StateGraph(GraphState)
  .addNode("retrieve", retrieveNode)
  .addNode("generate", generateNode)
  .addEdge(START, "retrieve")
  .addEdge("retrieve", "generate")
  .addEdge("generate", END)
  .compile()

状态用 Annotation.Root({ question, k, documents, generation }) 声明,节点返回状态增量。检索失败返回空数组而不是抛错,让下游能优雅降级。生成节点的 Prompt 里有条防幻觉指令值得抄走:"如果片段中没有相关信息,请如实告知用户。"

它的缺陷同样清楚:"1+1=?" 也要过一遍向量库,四大恶人那种题一遍又不够。 两头都不对,这就是后续改造的切入点。

路由:不是所有问题都值得检索

第一个决策环节:检索前让大模型"分诊"。关键在 zod 的枚举约束(src/rag-query-router.mjs:41-45):

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

z.enum 把输出锁死在两个值上,代码分支才可靠------这是结构化输出比"让模型返回 JSON 字符串"强的地方。分支用条件边跳转:

js 复制代码
.addConditionalEdges("route_question", decideNext, {
  direct_answer: "direct_answer",
  retrieve: "retrieve"
})

simple 直接流式回答,省下一次向量库往返;complex 才进 RAG 链路。项目笔记(a.md)的表述很直白:"所有问题都走检索"是浪费,"区分出简单问题,省资源"。

多跳检索:四个机制环环相扣

src/rag-multihop.mjs 是全文重点。

机制 1:拆解有纪律。 DecomposeSchema 约束输出 1~8 条子问题,Prompt 立了硬规矩:每条必须是可独立检索的完整问句 、禁止"他/她/此人"等指代、顺序符合推理链。禁指代的原因:子问题要单独向量化,代词没有语义锚点,"他的儿子是谁"单独拿去检索等于废掉。

机制 2:去重保优。 每轮只检索当前一条子问题,结果合并时用 Map 按 id 去重、保留高分版本(src/rag-multihop.mjs:174-186):

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

源码注释点破动机:重复片段"会浪费资源",还会被模型当成强调信号放大噪声。

机制 3:规划器不盲信模型。 plan_next_step 让模型输出 nextAction: retrieve | generate,但代码叠加硬规则强制收口(src/rag-multihop.mjs:279-281):

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

maxRetrievals 默认 8。LLM 负责建议,代码硬规则兜底------这是全文最值得带走的一个工程判断。

机制 4:循环靠条件边。 LangGraph 允许条件边指回已访问的节点:

js 复制代码
.addEdge("retrieve", "plan_next_step")
.addConditionalEdges("plan_next_step", afterPlan, {
  retrieve: "retrieve",
  generate: "generate"
})

拆解 → 逐条检索 → 规划器决策 → 继续检索或生成,多跳的"跳"就是这么转起来的。

从代码里直接能指认的 3 个必错点

以下问题静态阅读即可证实,运行未验证:

症状 位置 原因与修复
TOP_K is not defined rag-multihop.mjs:113 默认参数引用了别的文件才有的常量,本文件补 const TOP_K = 5
规划器永远走 generate rag-multihop.mjs:233-235 afterPlan 判错了字段:strategy 存的是路由结果 simple/complex,应判 state.plannedNext
异常无输出 rag-multihop.mjs:409 main().then(err => ...) 只在成功时执行,应改 .catch

外加一个隐患:查询侧配了 HNSW + ef: 64,建库却是 IVF_FLAT(应配 nprobe),参数与索引类型不匹配(rag-query-router.mjs:193-198)。检索前建议先跑一遍项目自带的 check-data.mjs------它取"含阿朱剧情"和"无关"两条正文,手写余弦公式验证问题向量的区分度,能提前暴露坏向量。

路线图:混合检索 + ReRank

a.md 笔记里还有一段未实现的规划,判断清晰:向量检索懂语义但关键词不敏感,ES 关键词检索精确但不懂同义改写,两路召回互补;混合召回冗余多、上下文有限、噪声加幻觉,所以需要"输入问题 + 文档、输出相关度分数"的 ReRank 小模型("体积小,推理快,成本极低")先过滤再精简。完整设想:问题改写 3-5 个角度 → 每个角度同时查 ES + Milvus → 合并去重 → ReRank 取前 N → 增强 Prompt。

带走一个判断

RAG 系统的升级,本质是在检索管道里插入决策环节:路由解决"该不该检索",拆解解决"检索什么",规划器解决"检索几轮",去重解决"结果怎么合并"。模型能力的边际收益,往往小于管道结构的一次合理改造。

如果要动手改造自己的项目,先做这个一分钟检查:数一数你的条件边函数返回的字段,和状态里实际存的是不是同一个------上面第 2 个必错点(strategy vs plannedNext)就是这类错,编译不报错、运行不报错,只有结果永远不对。

标签:LangGraph、RAG、Milvus、AI 编程

相关推荐
麦客奥德彪2 小时前
别把 Agent 写成一个大循环:读懂 DeepSeek Harness 的接口与运行时设计
agent·源码阅读
hpoenixf2 小时前
为什么我们把 Agent 的路由请求越做越简单?
agent
布列瑟农的星空3 小时前
2026年我们不再谈论运行时Harness
agent
武子康4 小时前
ESP32 唤醒成功,离线命令为什么没有执行?
人工智能·后端·agent
易知微EasyV数据可视化4 小时前
会插卡片的编辑器:在 Agent 输入框里混排文字与结构化内容
人工智能·经验分享·编辑器·agent·设计
小七-七牛开发者4 小时前
从 AWS 到 OpenRouter,拆解 Agent 模型路由的工程设计
llm·agent·aws·openrouter·codingagent
阿阿阿安5 小时前
Agent 智能体开发(三)Agent 自定义框架搭建
人工智能·ai·agent
怕浪猫5 小时前
腾讯又交作业了,开源版 WorkBuddy 太夯了!这回是只粉色小章鱼Octop!
javascript·面试·agent
路多辛5 小时前
全能 Go Agent 框架 covonaut v1.1.5 发布:改进检查点、Token用量与缓存
缓存·agent·ai编程