RAG 答不准,先别急着换模型:给检索管道加上路由、拆解和循环检索
拿到一个 RAG 项目,"回答不准"时最常见的反应是换更强的模型。但一份用《天龙八部》小说做语料的 LangGraph + Milvus 项目给出了另一个答案:问题往往不在模型,而在检索管道太"直线"------所有问题都检索一次,一次检索就想召回链式推理需要的全部证据。
本文把这个项目的四步演进(数据入库 → 朴素 RAG → 查询路由 → 多跳检索)拆给你看,包括多跳检索的四个核心机制,以及能从代码里直接指认的 3 个必错点。文内代码运行未验证 ,模型与 Milvus 地址均在 .env 配置。
一条测试题暴露的问题
项目里的测试题(src/rag-multihop.mjs:364)是这样的:
四大恶人排行第二的是谁?此人之子在身世揭晓前,其生父在武林中的公开身份是什么?
推理链:叶二娘 → 儿子虚竹 → 生父玄慈(少林方丈)。朴素 RAG 把整句丢去向量检索,一次召回很难同时覆盖三跳的证据。源码文件头的注释也写了改造动机:"直接把 query 向量化匹配不够准确"。
改进思路不是一次到位,而是逐层加决策环节。四个版本的对比如下------这张表也是全文的路线图:
| 版本 | 图结构 | 解决的问题 | 代价 |
|---|---|---|---|
| 朴素 RAG | 线性 | 跑通闭环 | 简单问题浪费检索 |
| + 查询路由 | 分支 | 简单问题省资源 | 复杂问题仍一跳 |
| + 多跳检索 | 循环 | 链式推理召回 | 需轮数上限兜底 |
| (规划)混合检索 + ReRank | 召回增强 | 召回率与质量 | 引入 ES 与重排模型 |
第 0 步:入库时埋下的两个伏笔
数据入库脚本(src/ebook-writter.mjs)有两个容易被忽略、但后面全用得上的设计:
- 可读主键 :
id手工拼成bookId_chapterNum_chunkIndex(如1_111_23)。多跳检索多轮召回同一片段时,就靠这个 id 去重。 - 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 编程