从线性 RAG 到 Agentic RAG:用 LangGraph 搭一个会判断、会纠错、会兜底的检索闭环

面向中级开发者|读完就能照着改|代码全部来自真实跑通的《天龙八部》问答项目

写在前面

先说结论:RAG 的瓶颈从来不在向量库,而在"判断"二字。

大部分人的第一个 RAG 长这样:用户提问 → 向量检索 topK → 拼进 prompt → 大模型生成。这套流程能跑,demo 效果也还行,但只要上了真实业务(公司内部文档、客服知识库、小说问答),你会发现它处处漏风:

  • 用户问"1+1 等于几",你也老老实实去查向量库,白白烧掉 token 和检索延迟;
  • 用户问"阿朱是怎么死的",检索回来的片段可能只讲了半截,模型就开始一本正经地编;
  • 用户问"四大恶人排行第二的是谁?他儿子在身世揭晓前,生父在武林中的公开身份是什么?",一次检索根本答不了,得先查 A 再查 B;
  • 用户问"高血糖的人能吃什么",纯语义检索把"低血糖注意事项"也召回来了,因为 embedding 眼里这俩太像;
  • 本地知识库里根本没有的 2013 版电视剧分集信息,模型不知道就说"不知道"------或者更糟,直接瞎编。

这篇就是把上面这些坑,一个个用 LangGraph 补上,最终得到一个可路由、可多跳、可评估、可联网兜底、可混合召回重排的 Agentic RAG 闭环。

文章里的每一段代码,都对应仓库里一个真实文件:

bash 复制代码
agentic_rag/
├── readme.md                        # 思路与结论(本文的骨架)
├── elastic-search/reame.md          # ES 定位笔记
└── advanced-rag/src/
    ├── ebook-writter.mjs            # ① 数据入库:EPUB → Milvus
    ├── naive-rag.mjs                # ② 线性 RAG 基线
    ├── rag-query-router.mjs         # ③ 问题路由(简单/复杂分流)
    ├── rag-multihop.mjs             # ④ 多跳检索(子问题拆解 + 自主决策)
    ├── rag-webfallback.mjs          # ⑤ 评估 + 联网兜底
    ├── rag/hybrid-retrieval.mjs     # ⑥ 混合检索 + 重排
    ├── rag/query-augment.mjs        #    查询改写(一路变四路)
    ├── rag/seed-data.mjs            #    ES + Milvus 双写同一批数据
    ├── rerank/dashscope-rerank.mjs  #    ReRank 模型封装
    └── create.mjs / operate.mjs     #    ES 的 CRUD 练习

一、线性 RAG 的天花板:五个致命问题

把 naive-rag.mjs 的图跑出来,你会看到极其朴素的两节点结构:

graph TD; __start__([__start__]) --> retrieve retrieve --> generate generate --> __end__([__end__])

它的问题不是"写得不对",而是架构上不允许它变聪明:

问题 现象 根因
所有问题都走检索 问个常识也要查库 没有"是否需要检索"的判断节点
没有纠错/评估 检索内容不相关也照单全收 生成环节是"单向"的,没有回头路
处理不了多跳 "先查 A 再查 B"这类问题必错 一次检索只能拿到一个语义片段
专业术语/实体匹配差 人名、订单号、型号被语义"糊"掉 只有向量检索,没有关键词检索
本地没有就瞎编 库外知识一律幻觉 没有评估,也没有外部检索兜底

一句话总结:线性 RAG 是"管道",Agentic RAG 是"回路"。管道只有单向流动,回路才有反馈和修正。

所以我们接下来做四件事,一件比一件"重":

  1. 让它判断要不要检索(Router);
  2. 让它规划多步、多轮检索(Decompose + Plan);
  3. 让它评估自己答不答得上来,答不上就联网(Evaluate + Web Fallback);
  4. 让它多路召回再用 ReRank 精筛(Hybrid Retrieval)。

二、把《天龙八部》灌进 Milvus:数据底座

在谈架构之前,先把"喂料"这步打牢。ebook-writter.mjs 干的就是这件事,三个关键决策值得抄。

1. EPUB 按章节加载,再二次切分

js 复制代码
import { EPubLoader } from "@langchain/community/document_loaders/fs/epub";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";

const CHUNK_SIZE = 500;

const loader = new EPubLoader(EPUB_FILE, { splitChapters: true });
const documents = await loader.load();          // 每章一个 Document

const textSplitter = new RecursiveCharacterTextSplitter({
  chunkSize: CHUNK_SIZE,
  chunkOverlap: 50,                              // 保留上下文连贯
});

for (let i = 0; i < documents.length; i++) {
  const chunks = await textSplitter.splitText(documents[i].pageContent);
  // 边切边插,避免几十万字一次性塞满内存
}

为什么是 500 + overlap 50?

  • chunk 太大:一个片段混进好几个情节,向量被"平均化",检索精度掉;
  • chunk 太小:一句话就是一片,语义不完整,模型拿到的上下文支离破碎;
  • overlap 50:防止关键句正好被切断,跨片段的因果关系不至于丢。

小说这种强叙事文本,500 字左右基本能包住一个完整动作或一段对话,是个很实用的经验值。

2. ID 设计成幂等键

js 复制代码
id: `${bookId}_${chapterNum}_${chunkIndex}`

bookId_chapterNum_chunkIndex 这种结构化 ID 有三个好处:

  • 重跑脚本不会重复入库(虽然这里是 insert 而非 upsert,但至少能人工判断);
  • 检索结果能直接溯源到"第几章第几段";
  • 后面做混检时,ES 和 Milvus 可以用同一个 ID 去重------这一点是后面第 7 节的关键。

3. 索引:IVF_FLAT 与 HNSW 怎么选

代码里一开始用 IVF_FLAT,后面全改成 HNSW,这不是随手改的:

js 复制代码
// 早期:分桶 + 桶内暴力比对
{ index_type: IndexType.IVF_FLAT, metric_type: MetricType.COSINE, params: { nlist: 1024 } }

// 后来:多层近邻图
{
  metric_type: "COSINE",
  index_type: "HNSW",
  param: { M: 16, efConstruction: 200 },
  search_params: { ef: 64 }
}
  • IVF_FLAT :先把向量聚成 nlist 个桶,查询时只扫最近的几个桶。省内存、建索引快,但桶边界上的向量容易漏;
  • HNSW:分层可导航小世界图,查询从稀疏的上层往下走,召回率和延迟的平衡更好,代价是占内存。

M=16 是每个节点连边数,efConstruction 是建图时候选队列长度,ef 是查询时候选队列长度------ef 越大召回越高、越慢。在单机内存够用的小中型知识库上,HNSW 基本是默认答案。

距离度量用 COSINE,因为文本 embedding 关注的是方向而非模长,余弦相似度天然适合。


三、LangGraph 的三个概念:State、Node、Edge

如果你还没用过 LangGraph,用 naive-rag.mjs 就能讲明白,它只有三个东西:

State:一个全局共享的"白板"

js 复制代码
const GraphState = Annotation.Root({
  question: Annotation,   // 用户问题
  k: Annotation,          // 检索数量
  documents: Annotation,  // 检索到的文档
  generation: Annotation, // 生成结果
});

Annotation.Root 定义的是图的共享状态。每个节点不用互相传参,读 state、返回一个"局部更新"就行,LangGraph 会自动 merge 进去。这是它比手写 if-else 链式调用更清晰的地方。

Node:纯函数式的处理单元

js 复制代码
const retrieveNode = async (state) => {
  const documents = await retrieveRelevantContent(state.question, state.k);
  return { question: state.question, k: state.k, documents };
};

const generateNode = async (state) => {
  const context = state.documents
    .map((item, i) => `[片段 ${i + 1}]\n章节: 第 ${item.chapter_num}章\n内容:${item.content}`)
    .join("\n\n----------\n\n");
  // ...拼 prompt、流式输出
  return { question: state.question, k: state.k, documents: state.documents, generation };
};

注意每个 node 返回的是部分状态 ,不是全量覆盖。这个设计让"加字段"变得很便宜------后面 Router 加 strategy、多跳加 subQuestions,都是加一个 Annotation 的事。

Edge:决定下一步去哪

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

addEdge 是固定边,addConditionalEdges 是条件边------这就是后面所有"智能决策"的实现方式:决策不是写在 if-else 里,而是写成一个返回节点名的函数。

还有个小技巧:graph.getGraphAsync().then(g => g.drawMermaid()) 能直接把图导成 mermaid。仓库里 src/a.md ~ src/g.md 就是每个阶段跑出来的图,非常适合贴进文档做架构演进说明。

金句一:线性 RAG 的每个节点都是"执行",Agentic RAG 的每个节点都在"决策"。


四、第一刀:让模型先判断,再决定要不要检索

rag-query-router.mjs 只加了一个节点,就把"所有问题都检索"的浪费砍掉了。

用 structured output 做分类,比让模型"输出 JSON"稳得多

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

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

const routeQuestionNode = async (state) => {
  const router = model.withStructuredOutput(RouteSchema);
  const route = await router.invoke(`
    你是问答路由器,请判断用户问题是否需要外部检索。
    规则:
    - simple: 常识问答、简短定义、无需特定小说细节即可回答。
    - complex: 需要《天龙八部》具体情节、人物关系、章节事实、原文细节或证据支持。
    用户问题: ${state.question}
  `);
  return { strategy: route.strategy, routeReason: route.reason };
};

三个要点:

  1. withStructuredOutput + zod :把输出约束在枚举上,模型只能吐 simple / complex,不用写正则去 parse;
  2. reason 字段:让模型"先说理由再下结论",分类准确率明显更高,而且日志里能直接看到它为什么这么判,排查起来很快;
  3. prompt 里给判断标准,而不是给例子:"需要具体情节/人物关系/原文细节"这种边界描述,比塞十个 few-shot 更省 token。

条件边就是"分流器"

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

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

图就变成了:

graph TD; __start__([__start__]) --> route_question route_question -.->|simple| direct_answer route_question -.->|complex| retrieve retrieve --> rag_generate direct_answer --> __end__([__end__]) rag_generate --> __end__

decideNext 返回的字符串必须和 addConditionalEdges 的映射表 key 对上,这是最容易踩的坑之一------名字对不上,图会直接报错或者走错边。

收益与代价

  • 收益:简单问题一条 prompt 直接过,省一次 embedding + 一次向量检索 + 一堆无关上下文;
  • 代价:多了一次 LLM 调用(路由)。这笔账在小流量场景可能不划算,所以生产上常见做法是先用规则/小模型粗筛(比如字数、是否含专有名词、是否命中 FAQ 缓存),再交给 LLM 兜底分类。

五、第二刀:多跳检索------把"先查 A 再查 B"交给模型规划

这是 Agentic RAG 最能体现"自主性"的地方,也是 rag-multihop.mjs 的核心。

为什么直接拿原问题去检索不够?

用户问题:

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

这句话里塞了两个推理步:先要知道"四大恶人第二是叶二娘",才能知道"她儿子是虚竹",再要知道"虚竹生父玄慈方丈在武林中的公开身份是少林方丈"。把整句丢进 embedding,向量会被"平均"成一个四不像的语义中心,两个实体都召不准。

先拆解,再按顺序检索

js 复制代码
const DecomposeSchema = z.object({
  sub_questions: z.array(z.string()).min(1).max(8),
  reason: z.string(),
});

const decomposeQuestionNode = async (state) => {
  const decomposer = model.withStructuredOutput(DecomposeSchema);
  const out = await decomposer.invoke(`
    你是《天龙八部》多跳问答的【子问题拆解器】。
    用户原始问题:${state.question}

    任务:将问题拆成**有序**子问题列表 sub_questions,用于**依次向量检索**。要求:
    1. 链式推理、多层关系、因果先后的问题,必须拆成多条;单跳即可答的也可只输出 1 条。
    2. 每条子问题必须是**可独立检索**的完整中文问句,**禁止**使用「他/她/此人/上文」等指代;可写全人物名与事件名。
    3. 顺序必须符合推理链:先搞清前置实体/事实,再查后续结论。
    4. **不要**把整句原题原样复制成唯一一条;不要拆成过碎的关键词列表。
    5. 输出 1~8 条即可。
  `);
  const subQuestions = out.sub_questions.map(s => s.trim()).filter(Boolean);
  return { subQuestions, nextSubIdx: 0, currentQury: subQuestions[0] };
};

这段 prompt 里最值钱的其实是第 2 条:禁止指代。

因为子问题是独立去向量库检索的,一旦出现"他是谁的儿子",检索器根本不知道"他"是谁。所以要求模型把人物名、事件名写全。这个约束本质上是把"多轮对话里的指代消解"提前到了拆解阶段。

一轮检索 = 处理一个子问题

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);   // 同一个 id 保留分数更高的
    }
  }
  return Array.from(map.values()).sort((a, b) => Number(b.score) - Number(a.score));
};

const retrieveNode = async (state) => {
  const subs = state.subQuestions ?? [];
  const idx = state.nextSubIdx;
  const q = subs[idx]?.trim();
  if (!q) throw new Error(`子问题下标${idx} 无有效文本`);

  const round = state.retrieveRound + 1;
  const newDocs = await retrieveRelevantContent(q, state.k);
  const merged = mergeUnique(state.documents ?? [], newDocs);

  return {
    documents: merged,
    retrieveRound: round,
    nextSubIdx: idx + 1,
    currentQury: q,
  };
};

这里有三个工程细节,都是踩过坑才写得出来的:

  1. 去重不是"可有可无"。同一个角色在多章反复出现,多轮检索必然召回重复片段。重复片段会让模型产生"这条信息被强调了"的错觉,答案反而跑偏;同时也白烧了上下文 token。
  2. 保留高分、丢掉低分,而不是简单"后来者覆盖"------同一个 chunk 在不同子问题下相似度不同,留高分的那份更有说服力。
  3. nextSubIdx 存在 state 里,用"索引游标"驱动循环,比在节点内部写 while 循环更符合 LangGraph 的"图 + 状态"模型(也方便断点续跑)。

让 LLM 决定"还要不要继续查"

js 复制代码
const nextStepSchema = z.object({
  nextAction: z.enum(["generate", "retrieve"]),
  reason: z.string(),
});

const planNextStepNode = async (state) => {
  const remaining = state.subQuestions.length - state.nextSubIdx;
  // 把"子问题序列 + 已检索轮次 + 已召回文档摘要"一起塞给模型
  const prompt = `你是多跳 RAG 规划器...
    已检索轮次: ${state.retrieveRound}; 剩余未检索子问题条数: ${remaining}
    请判断下一步:
    1) 已有足够依据回答用户原始问题 -> nextAction=generate
    2) 仍缺关键事实,且仍然存在未检索的子问题,且未超过轮数上限 -> nextAction=retrieve
    硬性规则:
    - 若剩余未检索子问题条数为 0,必须 nextAction=generate
    - 若已检索轮数已到达或超过最大检索轮数,必须 nextAction=generate`;

  const { nextAction, reason } = await model.withStructuredOutput(nextStepSchema).invoke(prompt);

  // 代码层的二次护栏,别全信模型
  let finalNext = nextAction;
  if (state.retrieveRound >= state.maxRetrievals) finalNext = "generate";
  if (remaining <= 0) finalNext = "generate";

  return { plannedNext: finalNext };
};

这是全篇最值得抄的一段模式:LLM 决策 + 代码硬护栏。

模型判断是"软"的,可能因为幻觉说"再查一轮吧";护栏是"硬"的,maxRetrievals 和 remaining <= 0 保证它无论怎么判都停得下来。经验法则是:

金句二:凡是循环,必须同时有"模型想不想停"和"代码能不能停"两把锁。

图长这样(对应 src/e.md):

graph TD; __start__([__start__]) --> route_question route_question -.->|simple| direct_answer route_question -.->|complex| decompose_question decompose_question --> retrieve retrieve --> plan_next_step plan_next_step -.->|retrieve| retrieve plan_next_step -.->|generate| generate generate --> __end__([__end__])

plan_next_step → retrieve 这条回边,就是"闭环"的形状。

源码里几个必须修的坑(这段很真实)

这部分我不藏,直接列出来,你抄的时候顺手改掉:

  1. afterPlan 读错了字段
js 复制代码
// 现在这样写,循环永远不会发生
function afterPlan(state) {
  return state.strategy === "retrieve" ? "retrieve" : "generate";
}

strategy 只有 simple / complex 两个值,永远不等于 "retrieve",所以每次都直接去 generate,多跳退化成单跳。应该读 plannedNext:

js 复制代码
const afterPlan = (state) => state.plannedNext === "retrieve" ? "retrieve" : "generate";
  1. planNextStepNode 里 state.subQuestion 少了个 s ,导致 remaining 恒为 0,护栏直接把流程踢去生成。

  2. retrieveNode 里 state.retrieveRound + 1,但 Annotation.Root 里根本没声明 retrieveRound ,第一次运行时是 undefined + 1 = NaN。要么在 State 里补声明,要么用 (state.retrieveRound ?? 0) + 1。

  3. decompose_question 之后的 route_question 节点里把 retrievalContent: 0 写成了初始化,其实是想初始化 retrieveRound------典型的字段名打错,跑起来不报错但逻辑错。

  4. direct_answer 在 multihop 里连到了 generate ,等于直答完又跑一次 RAG prompt(此时 documents 为空),答案会被覆盖或重复输出。正确做法是 direct_answer → END。

这几处说明一件事:Agentic RAG 的复杂度不在"调 API",在"状态字段的一致性"。State 一旦字段对不上,图还能跑,但跑的是错的东西。建议把 State 定义和每个节点 return 的字段做成一张对照表,或者干脆上 TypeScript。


六、第三刀:自我评估 + 联网兜底,给 Agent 装上刹车

rag-webfallback.mjs 解决的是最让人头疼的问题:本地没有,模型硬编。

评估器:让模型给自己打分

js 复制代码
const EvaluateSchema = z.object({
  enough: z.boolean(),                 // 是否足够回答
  missing: z.array(z.string()).max(6), // 缺了哪些信息点
  reason: z.string(),
  web_query: z.string().optional(),    // 不够的话,给一句适合联网搜索的查询
});

const evaluateNode = async (state) => {
  const hasWeb = Boolean(state.webContent && String(state.webContent).trim());
  const evaluator = llm.withStructuredOutput(EvaluateSchema);
  const out = await evaluator.invoke(`
    你是信息充分评估器。判断当前上下文是否足以回答用户问题。
    用户问题:${state.question}
    已检索上下文(来自本地知识库):
    ${state.localContext || "(空)"}
    ${hasWeb ? `联网搜索结果:\n ${state.webContent}` : ""}

    输出字段:
    - enough: 是否足够回答(true/false)
    - missing: 若不够,列出缺失信息点(最多6条)
    - reason: 简短原因
    - web_query: 若不够,给出一个适合互联网搜索的中文查询句
  `);

  return { evaluation: JSON.stringify(out) };
};

注意两个设计:

  • missing 是数组而不是一句话:把"缺什么"结构化,后面无论是联网查询、还是二次检索,都能直接拿来用;日志里也很好看。
  • web_query 由模型生成:用户原问题往往不适合搜网页(比如"雁门关事件的主谋是谁"),模型把它改写成一句完整的中文检索句,命中率会高很多。

评估节点被复用两次

graph TD; __start__([__start__]) --> route_question route_question -.->|simple| direct_answer route_question -.->|complex| local_retrieve local_retrieve --> evaluate_local evaluate_local -.->|enough| generate evaluate_local -.->|not enough| web_search web_search --> evaluate_local generate --> __end__([__end__]) direct_answer --> __end__

第一次评估:只有本地上下文; 第二次评估:本地 + 联网上下文(hasWeb 分支会把 prompt 换掉)。

这就是"先评估,再补料,再评估"的循环,比"一次性全塞给模型"靠谱得多。

js 复制代码
async function bochawebSearch(query, k) {
  const apiKey = process.env.BOCHA_API_KEY;
  const url = "https://api.bochaai.com/v1/web-search";
  const body = {
    query,
    freshness: "noLimit",
    summary: true,   // 关键:让搜索服务先做一轮摘要,省 token
    count: k ?? 10,
  };
  const response = await fetch(url, {
    method: "POST",
    headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  // ...
  return webpages.map((page, idx) => `
    引用: ${idx + 1}
    标题: ${page.name}
    URL: ${page.url}
    摘要: ${page.summary}
    网站名称: ${page.siteName}
    发布时间: ${page.dateLastCrawled}
  `).join("\n\n");
}

summary: true 这个参数很值:网页正文动辄几千字,直接塞进上下文既贵又噪。让搜索服务先压成一段摘要,RAG 的上下文质量反而更高。

死循环护栏:只允许联网一次

这是 rag-webfallback.mjs 里最精妙的一行(你笔记里也记下了):

js 复制代码
const afterEvaluateLocal = (state) => {
  // 已经联网过了,无论评估结果如何,直接生成
  if (state.webContent && state.webContent.trim()) {
    return "generate";
  }
  const parsed = JSON.parse(state.evaluation || "{}");
  return parsed.enough === true ? "generate" : "web_search";
};

如果没这一行,模型可能永远觉得"信息不够",evaluate → web_search → evaluate → web_search 无限循环,API 账单直接起飞。

金句三:Agent 的自主权越大,护栏就得越硬。

生成节点的"不许编"约束

js 复制代码
const stream = await llm.stream(`你是一个严谨的中文问答助手。
  优先依据上下文回答,不要编造。
  上下文(本地知识库 + 可选联网补充): ${context || "(空)"}
  用户问题: ${state.question}

  回答要求:
  1. 如果上下文足够,给出清晰、可核对的回答,需要时引用 [n]/URL 或小说片段来支撑。
  2. 如果上下文仍不满足已确定关键事实,明确说明"不确定/无法从上下文确认",并说明缺失点。
  3. 不要输出表情符号。
`);

"引用来源"这个要求不止为了好看:

  • 对用户:可核对,信任度立刻上去;
  • 对开发者:出问题能直接定位是哪条召回错了。

这段代码里同样有几个坑

  1. state.webContext 这个字段根本不存在 ,State 里声明的是 webContent。afterEvaluateLocal 里判断的应该是 webContent,否则护栏失效、无限循环。这是最危险的一个 bug。
  2. bochawebSearch 里 json 在声明之前就被 console.log(json, ...) 用了 (let json 还在下面),会直接 ReferenceError。
  3. 判空逻辑写反了 :if (webpages.length) return "未找到相关结果"; ------ 有结果反而返回"未找到"。
  4. body.count 用的是 count 变量,但函数参数叫 k,count 未定义会抛错。
  5. retrieveNode 写了一半(const localContent = (retrieveDocs ?? []) 没有后续),明显是仓库里的半成品,抄的时候要补全成:
js 复制代码
const retrieveNode = async (state) => {
  const docs = await retrieveRelevantContent(state.question, state.k);
  const localContext = docs
    .map((d, i) => `[片段${i + 1}] 第${d.chapter_num}章\n${d.content}`)
    .join("\n\n");
  return { retrieveDocs: docs, localContext };
};
  1. response.ok 的检查放到了 response.json() 之后 ,而且 fetch 抛错时 response 还没赋值就去 response.text()。正确顺序是先判 ok、再 parse,fetch 的 catch 里不要碰 response。

七、第四刀:混合检索 + ReRank,召回率与准确率双修

前面三刀解决的是"要不要查、查几轮、查不到怎么办",这一刀解决的是"一次查得准不准"。

为什么要混合检索

一条笔记:"路由器偶尔断流排查笔记,信道改成 36,固件升级......"

  • 用户问"家里无线老是断断续续的咋整啊" → 语义检索(Milvus)能中,关键词检索(ES)不一定,因为"断断续续"和"断流"字面不同;
  • 用户问"PO-20250409-K9 这个订单换的什么滤芯" → 关键词检索能中(订单号是精确 token),语义检索大概率糊掉,因为 embedding 不认识这串编号。

所以两类检索是互补的:

检索方式 强项 弱项
向量(Milvus/Qdrant/pgvector) 语义相近、口语化提问 专有名词、编号、缩写、人名
关键词(ES/BM25) 精确实体、术语、数字 同义改写、跨语言、口语表达

混合检索 = 多路召回取并集 → 去重 → 重排取交集式的精筛。

双写:同一批数据同时进 ES 和 Milvus

rag/seed-data.mjs 的核心思路,就是同一份数据、同一个 ID、两个引擎:

js 复制代码
const INDEX_NAME = 'life_notes';

// ES:ik_max_word 建索引,ik_smart 查询
await client.indices.create({
  index: indexName,
  mappings: {
    properties: {
      note_title: { type: 'text', analyzer: 'ik_max_word', search_analyzer: 'ik_smart' },
      note_body:  { type: 'text', analyzer: 'ik_max_word', search_analyzer: 'ik_smart' },
      tags: { type: 'keyword' },
      mood: { type: 'keyword' },
      priority: { type: 'integer' },
    }
  }
});

// Milvus:向量 + 原文 + 元数据
await milvusClient.createCollection({
  collection_name: collectionName,
  fields: [
    { name: 'id', data_type: DataType.VarChar, max_length: 100 },
    { name: 'note_title', data_type: DataType.VarChar, max_length: 512 },
    { name: 'note_body', data_type: DataType.VarChar, max_length: 4096 },
    { name: 'langchain_primaryid', data_type: DataType.Int64, is_primary_key: true, autoID: true },
    { name: DOC_TEXT, data_type: DataType.VarChar, max_length: 10000 },
    { name: EMBEDDING, data_type: DataType.FloatVector, dim },
  ]
});

ik_max_word 与 ik_smart 的分工,是中文 ES 检索的必修课:

  • ik_max_word:写入时尽量细粒度切词,"路由器断流"会切出"路由器/路由/断流"多个词,索引更全,召回更高;
  • ik_smart:查询时粗粒度切词,"路由器断流"就给两个词,避免查询词太碎导致打分被稀释。

一句话记法:存得细,查得粗。

顺带说个坑:源码 create.mjs 里写的是 ik_max_smart,这个分析器不存在,ES 建索引会直接报错,应该是 ik_smart;另外字段名 node_body 拼错了,后面 operate.mjs 又写成 note_body,两边对不上就查不到数据。

查询改写:一路变四路

rag/query-augment.mjs 先把用户问题扩成 3 条不同角度的检索句:

js 复制代码
export const QueryAugmentSchema = z.object({
  queries: z.array(z.string()).length(3).describe(
    '恰好3条中文检索问句:不同角度改写或扩写;保留订单号、品牌等字面信息;不要编造事实'
  ),
});

const AUGMENT_PROMPT = ChatPromptTemplate.fromMessages([
  ["system", `用户会给出一句中文问题。请另外写出恰好3条检索用的问句(与原意一致,角度尽量不同)
    便于搜索引擎或向量库分别召回;
    可改写说法、换提问角度、或略加限定词;专有名词、型号、订单号等必须保留原样。`],
  ["human", "{query}"]
]);

export function retrievalQueryString(original, augmentation) {
  return [original, ...(augmentation.queries ?? [])].map(s => s.trim()).filter(Boolean);
}

为什么改写能提召回:

  • 原问题"家里无线老是断断续续的咋整啊"是口语,改写后可能得到"无线路由器频繁掉线怎么排查",与知识库里的"路由断流排查笔记"字面更近;
  • 不同角度的问句会激活不同的 embedding 区域,召回集合更大。

但订单号、型号必须原样保留这条约束不能省------改写模型很爱"顺手优化"编号,一改就检索不到了。

并行召回 + 合并去重

rag/hybrid-retrieval.mjs 用 LangGraph 的并行边把 ES 和 Milvus 同时打出去:

js 复制代码
.addEdge("query_augment", "es_racall")
.addEdge("query_augment", "milvus_recall")
.addEdge(["es_racall", "milvus_recall"], "merge")   // 两条边都完成才进 merge

addEdge([节点A, 节点B], 节点C) 表示 join :A、B 都执行完,才触发 C。这是 LangGraph 里做 fan-out/fan-in 的标准写法,比手写 Promise.all 更直观(也更好画图)。

ES 那一路:

js 复制代码
const kEach = Math.max(1, Math.ceil(ES_K / n));   // 15 条额度平均分给 n 个查询
const batches = await Promise.all(qs.map(q =>
  esClient.search({
    index: INDEX,
    size: kEach,
    query: {
      multi_match: {
        query: q,
        fields: ['note_title^2', 'note_body', "title", 'content'], // ^2 表示标题权重翻倍
        type: "best_fields",                                        // 取单字段最高分
        analyzer: "ik_smart"
      }
    }
  })
));
const flat = batches.flatMap(res => (res.hits?.hits ?? []).map(docFromEsHit));
  • multi_match 一次查多个字段;
  • note_title^2 用 BM25 的权重语法,标题命中比正文命中更值钱------标题往往就是主题;
  • best_fields 取"某一个字段的最高分"而不是各字段相加,避免长正文靠字数刷分。

Milvus 那一路:

js 复制代码
const kEach = Math.max(2, Math.ceil(MILVUS_K / n));
const batches = await Promise.all(qs.map(q => milvus.similaritySearch(q, kEach)));
return { milvusHits: dedupeDocsById(batches.flat()) };

注意 Math.max(2, ...):查询一多,平均下来每个查询可能只剩 1 条,保证至少 2 条防止召回太稀。

合并去重是这一节的"命门":

js 复制代码
function dedupeDocsById(docs) {
  const seen = new Set();
  const out = [];
  for (const d of docs ?? []) {
    if (!d.pageContent) continue;
    const id = d.metadata?.id != null ? String(d.metadata.id).trim() : "";
    if (!id) continue;             // 没 id 的文档直接丢,避免重复
    if (seen.has(id)) continue;
    seen.add(id);
    out.push(d);
  }
  return out;
}

这也是为什么前面入库时强调"两边用同一个 ID" 。如果 ES 用 life_01、Milvus 用自增主键,去重就完全失效,同一篇笔记会被算两遍、重排时互相挤占位置。

踩坑:Milvus 的 similaritySearch 返回的 Document 里,metadata.id 不一定存在(取决于 collection 字段映射),而 langchain_primaryid 是自动生成的数字主键。所以生产上要么显式把业务 id 写进 metadata,要么用 pageContent 的哈希兜底,否则 dedupeDocsById 会把全部文档都丢掉 (因为 !id 就 continue)。

ReRank:从"召回一堆"到"只留几条"

graph TD; __start__([__start__]) --> query_augment query_augment --> es_racall query_augment --> milvus_recall es_racall --> merge milvus_recall --> merge merge --> reranK reranK --> generate_answer generate_answer --> __end__([__end__])

DashScopeRerank 继承 LangChain 的 BaseDocumentCompressor,所以能直接插到任何文档压缩/重排位置:

js 复制代码
import { BaseDocumentCompressor } from '@langchain/core/retrievers/document_compressors';

export class DashScopeRerank extends BaseDocumentCompressor {
  constructor({ apiKey, model = 'qwen3-rerank', topN = 3, baseUrl }) { ... }

  async compressDocuments(documents, query) {
    const res = await fetch(this.baseUrl, {
      method: "POST",
      headers: { Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json" },
      body: JSON.stringify({
        model: this.model,
        input: {
          query,
          documents: documents.map(d => d.pageContent),
        },
        parameters: { return_documents: false, top_n: this.topN },
      }),
    });
    const json = await res.json();
    const results = json?.output?.results;
    // 注意:这里返回的是原始 Document 对象,不是字符串
    return results.map(item => documents[item.index]);
  }
}

为什么必须有这一步(你笔记里那四条理由,本质上就是一句话):

混合召回是"宁滥勿缺",重排是"去芜存菁"。向量 + 关键词会带回大量冗余、弱相关甚至互相矛盾的内容,而大模型上下文窗口有限、注意力也有限------噪声一多,模型就会"答非所问、逻辑混乱、幻觉增加"。

为什么重排模型便宜又有效?

  • 它是 cross-encoder :把 (query, document) 拼在一起送进模型,直接输出一个相关性分数。相比 bi-encoder(分别编码再算余弦),它能看到 query 和 doc 之间的词级交互,精度高一个档;
  • 但它不能预计算(query 未知时没法编码文档),所以只能用在"召回之后、生成之前"这个环节,数量已经被压到几十条,成本才可控;
  • 模型小、推理快、价格低(笔记里那句"100 倍速度、1/100 价格"是相对大模型打分而言的量级感受)。

所以整条链路的分工非常清晰:

scss 复制代码
召回(宽)  →  向量 + 关键词 + 多查询改写      提升 recall(别漏)
重排(窄)  →  cross-encoder 打分, topN=3     提升 precision(别脏)
生成(精)  →  只把 topN 塞进 prompt           降噪、省 token、抗幻觉

金句四:召回决定下限,重排决定上限。

生成层:有上下文 / 无上下文两套 prompt

js 复制代码
const ANSWER_PROMPT = ChatPromptTemplate.fromMessages([
  ["system", `你是阅读用户[生活笔记]知识库并回答的助手。
    规则:
    - 只根据下方[检索片段]推断答案:片段里没有的信息不要编造。
    - 若片段不足以回答问题,明确说明[笔记里没提到],并可给出一句保守建议。`],
  ["human", `用户问题: {query}\n检索片段:\n{context}`]
]);

const NO_CONTEXT_PROMPT = ChatPromptTemplate.fromMessages([
  ["system", `当前没有检索到任何片段。请用一两句话说明无法从笔记中回答,并礼貌询问用户是否换个说法或补充关键词。`],
  ["human", `用户问题: {query}`]
]);

"没检索到"要走单独的 prompt,而不是把空字符串塞进原 prompt------空上下文 + "请根据以下片段回答"这种指令,是幻觉的高发区。


八、五张图看懂演进

① 线性 RAG(naive-rag)

graph TD __start__([&#34;__start__&#34;]) --> retrieve retrieve --> generate generate --> __end__([&#34;__end__&#34;])

② 加路由(query-router)

graph TD __start__([&#34;__start__&#34;]) --> route_question route_question -.->|simple| direct_answer route_question -.->|complex| retrieve direct_answer --> __end__([&#34;__end__&#34;]) retrieve --> rag_generate rag_generate --> __end__([&#34;__end__&#34;])

③ 加多跳(multihop)

graph TD __start__([&#34;__start__&#34;]) --> route_question route_question -.->|simple| direct_answer route_question -.->|complex| decompose_question decompose_question --> retrieve retrieve --> plan_next_step plan_next_step -.->|retrieve| retrieve plan_next_step -.->|generate| generate direct_answer --> generate generate --> __end__([&#34;__end__&#34;])

④ 加评估 + 联网兜底(webfallback)

graph LR; route --> retrieve --> evaluate; evaluate -.->|enough| generate; evaluate -.->|not enough| websearch --> evaluate

⑤ 加混合检索 + 重排(hybrid-retrieval)

graph LR; augment --> es; augment --> milvus; es --> merge; milvus --> merge; merge --> rerank --> generate

你会发现,每一步都不是"重写",而是在图上加节点、加边 。这就是用 LangGraph 而不是手写 if/else 链的最大好处:架构是可增量演进的,每个版本的图都能直接画出来给同事看。

真实生产里,这五张图会被合成一张大图:路由 → 混合召回 → 评估 → 多跳补查 → 重排 → 生成 → 评估(不够)→ 联网 → 再生成。


九、踩坑清单(直接抄作业)

把散落在各文件的坑汇总一下,你照着改一遍就能跑通:

入库(ebook-writter.mjs)

  • dim 用变量简写时确认作用域里真有这个变量(这里 VECTOR_DIM = 1024 是常量,写 dim: VECTOR_DIM 更稳);
  • IVF_FLAT 和 HNSW 不要混着建,索引类型一旦建好,切换要重建集合;
  • embedQuery 只适合单条,批量入库应走 embedDocuments,否则几十万字会非常慢。

线性 RAG / 路由

  • if (error.message.includes("already loaded")) throw error; ------ 逻辑写反了,加载成功的时候反而抛错,应该只在非 already loaded 时才 throw;
  • addConditionalEdges 里映射表 key 必须和 decideNext 返回值完全一致;
  • Milvus.fromExistingCollection 的参数名在版本间有差异(primaryField vs primaryKeyField),装错版本会静默失效。

多跳

  • afterPlan 要读 plannedNext,不是 strategy;
  • retrieveRound 要在 Annotation.Root 里声明;
  • subQuestions 别写成 subQuestion;
  • direct_answer 应该连 END,不要连 generate。

评估 / 联网

  • webContent 别写成 webContext(护栏会失效);
  • let json 要先声明再用;
  • if (webpages.length) return "未找到" ------ 判空反了;
  • fetch 的失败分支里不要访问未赋值的 response;
  • 一定要有"只联网一次"的护栏。

混合检索 / 重排

  • ES 分析器只认 ik_max_word / ik_smart,没有 ik_max_smart;
  • 字段名前后必须一致(note_body ≠ node_body);
  • ES 和 Milvus 的业务 ID 必须同源,否则去重失效;
  • dedupeDocsById 要保证 Milvus 返回的 Document 带 metadata.id;
  • 重排之后 topN 太小(比如 12)会丢关键证据,35 是常用区间。

通用

  • OpenAIEmbeddings 的配置项是 baseURL,不是 baseUrl(seed-data.mjs 里写错会导致请求打到默认地址);
  • 每个循环节点都要有"轮次上限"和"剩余量"双重护栏;
  • 所有 LLM 结构化输出都用 withStructuredOutput + zod,不要手动 parse JSON。

十、这套架构适合什么业务,不适合什么业务

Agentic RAG 不是银弹,它是用 token 和延迟换准确率的方案。

适合:

  • 内部文档/知识库问答,答案错了代价高(合同、法务、运维手册);
  • 多实体、多跳推理(人物关系、故障排查链路、多表关联查询);
  • 知识会过时,需要联网补充(政策、价格、赛事信息)。

不适合:

  • 高并发低延迟场景(比如每秒上千 QPS 的客服首响)------每次都跑路由 + 评估 + 重排,成本撑不住;
  • 问题高度同质化------这种场景老老实实做缓存 + FAQ 命中,比 Agent 便宜一百倍;
  • 数据量极小(几百条)------直接全量塞 prompt 都比搭一套检索链路简单。

工程上常见的折中:

  • 路由用规则 + 小模型,不要每次都用大模型;
  • 评估器可以在召回分数上做阈值判断,只有分数落在灰区才调 LLM;
  • 重排的 topN 动态化:简单问题 2 条,复杂问题 6 条;
  • 全链路加 LangSmith / Langfuse 观测,不然你根本不知道钱花在哪一步。

(这也是你笔记里"后续"那几条的价值:LangSmith 做全链路观测、DeepAgents 提供开箱即用的中间件、Redis 做 Agent 短期记忆、PG(Postgres + pgvector)把关系型和向量合成一个库。)


十一、结语

回到最开始那句话:RAG 的瓶颈不在向量库,在判断。

这篇拆的四刀,本质上是给一条"管道"补了四种判断能力:

能力 解决的问题 实现
路由 所有问题都检索,浪费资源 withStructuredOutput + 条件边
规划 多跳问题一次查不到 子问题拆解 + 循环回边 + LLM 决策
评估 不知道检索够不够、准不准 评估器节点 + enough/missing
兜底 本地没有就幻觉 评估 → 联网 → 二次评估 → 护栏
混合+重排 召回不全、召回不准 ES + Milvus 并行 → 去重 → cross-encoder

如果你只记住三件事,就记这三件:

  1. 加节点,别改流程------LangGraph 的价值是让架构能"长出来";
  2. 一切循环都要有硬护栏------模型负责"想不想停",代码负责"必须停";
  3. 召回要宽,重排要窄,生成要精------这是 RAG 的物理定律。

代码都在仓库里,改完第九节那几个坑,就可以拿去接你自己的知识库了。下一步可以做的事:把五张图合成一张,接上 LangSmith 看每一跳花了多少钱,然后你会对"Agentic"这三个字有完全不一样的理解。

相关推荐
故作春风1 小时前
Agent开发-页面流式效果(进阶)
agent
kyrie_sakura1 小时前
大模型学习19 大模型学习 - LangChain2_Retrieval(RAG-文档处理-向量处理检索-Milvus)
学习·langchain·milvus
救救孩子把2 小时前
结构化输出为何“格式正确、答案错误”?JSON Schema 的能力边界
android·java·json·agent·agent服务
空堂与归2 小时前
国产开源登顶全球第一,1.3B OXR 登顶三榜:RSI 闭环重塑文档解析
人工智能·ai·agent
程序员cxuan2 小时前
别学 subagent 了,很可能要凉了。
后端·agent·ai编程
chaser&upper3 小时前
从 xParse 看企业 AI:业务知识如何进入工作流
人工智能·agent·数据处理·合合信息·textin xparse
承渊政道3 小时前
【大模型应用开发:基于GPT-4和ChatGPT】(使用LangChain框架和插件增强LLM的功能)
chatgpt·langchain·llm·框架·大模型应用开发
前端双越老师3 小时前
程序员 1v1 访谈系列第九期:要不要晋升管理层?
agent·ai编程·全栈
威哥爱编程4 小时前
Python 智能体开发实战 03:聊十轮就“失忆”,账单还翻倍?管好上下文,Token 立省一半
python·agent