面向中级开发者|读完就能照着改|代码全部来自真实跑通的《天龙八部》问答项目
写在前面
先说结论:RAG 的瓶颈从来不在向量库,而在"判断"二字。
大部分人的第一个 RAG 长这样:用户提问 → 向量检索 topK → 拼进 prompt → 大模型生成。这套流程能跑,demo 效果也还行,但只要上了真实业务(公司内部文档、客服知识库、小说问答),你会发现它处处漏风:
- 用户问"1+1 等于几",你也老老实实去查向量库,白白烧掉 token 和检索延迟;
- 用户问"阿朱是怎么死的",检索回来的片段可能只讲了半截,模型就开始一本正经地编;
- 用户问"四大恶人排行第二的是谁?他儿子在身世揭晓前,生父在武林中的公开身份是什么?",一次检索根本答不了,得先查 A 再查 B;
- 用户问"高血糖的人能吃什么",纯语义检索把"低血糖注意事项"也召回来了,因为 embedding 眼里这俩太像;
- 本地知识库里根本没有的 2013 版电视剧分集信息,模型不知道就说"不知道"——或者更糟,直接瞎编。
这篇就是把上面这些坑,一个个用 LangGraph 补上,最终得到一个可路由、可多跳、可评估、可联网兜底、可混合召回重排的 Agentic RAG 闭环。
文章里的每一段代码,都对应仓库里一个真实文件:
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 是"回路"。管道只有单向流动,回路才有反馈和修正。
所以我们接下来做四件事,一件比一件"重":
- 让它判断要不要检索(Router);
- 让它规划多步、多轮检索(Decompose + Plan);
- 让它评估自己答不答得上来,答不上就联网(Evaluate + Web Fallback);
- 让它多路召回再用 ReRank 精筛(Hybrid Retrieval)。
二、把《天龙八部》灌进 Milvus:数据底座
在谈架构之前,先把"喂料"这步打牢。ebook-writter.mjs 干的就是这件事,三个关键决策值得抄。
1. EPUB 按章节加载,再二次切分
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 设计成幂等键
id: `${bookId}_${chapterNum}_${chunkIndex}`
bookId_chapterNum_chunkIndex 这种结构化 ID 有三个好处:
- 重跑脚本不会重复入库(虽然这里是 insert 而非 upsert,但至少能人工判断);
- 检索结果能直接溯源到"第几章第几段";
- 后面做混检时,ES 和 Milvus 可以用同一个 ID 去重——这一点是后面第 7 节的关键。
3. 索引:IVF_FLAT 与 HNSW 怎么选
代码里一开始用 IVF_FLAT,后面全改成 HNSW,这不是随手改的:
// 早期:分桶 + 桶内暴力比对
{ 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:一个全局共享的"白板"
const GraphState = Annotation.Root({
question: Annotation, // 用户问题
k: Annotation, // 检索数量
documents: Annotation, // 检索到的文档
generation: Annotation, // 生成结果
});
Annotation.Root 定义的是图的共享状态。每个节点不用互相传参,读 state、返回一个"局部更新"就行,LangGraph 会自动 merge 进去。这是它比手写 if-else 链式调用更清晰的地方。
Node:纯函数式的处理单元
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:决定下一步去哪
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"稳得多
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 };
};
三个要点:
withStructuredOutput+ zod:把输出约束在枚举上,模型只能吐simple/complex,不用写正则去 parse;reason字段:让模型"先说理由再下结论",分类准确率明显更高,而且日志里能直接看到它为什么这么判,排查起来很快;- prompt 里给判断标准,而不是给例子:"需要具体情节/人物关系/原文细节"这种边界描述,比塞十个 few-shot 更省 token。
条件边就是"分流器"
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,向量会被"平均"成一个四不像的语义中心,两个实体都召不准。
先拆解,再按顺序检索
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 条:禁止指代。
因为子问题是独立去向量库检索的,一旦出现"他是谁的儿子",检索器根本不知道"他"是谁。所以要求模型把人物名、事件名写全。这个约束本质上是把"多轮对话里的指代消解"提前到了拆解阶段。
一轮检索 = 处理一个子问题
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,
};
};
这里有三个工程细节,都是踩过坑才写得出来的:
- 去重不是"可有可无"。同一个角色在多章反复出现,多轮检索必然召回重复片段。重复片段会让模型产生"这条信息被强调了"的错觉,答案反而跑偏;同时也白烧了上下文 token。
- 保留高分、丢掉低分,而不是简单"后来者覆盖"——同一个 chunk 在不同子问题下相似度不同,留高分的那份更有说服力。
nextSubIdx存在 state 里,用"索引游标"驱动循环,比在节点内部写 while 循环更符合 LangGraph 的"图 + 状态"模型(也方便断点续跑)。
让 LLM 决定"还要不要继续查"
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 这条回边,就是"闭环"的形状。
源码里几个必须修的坑(这段很真实)
这部分我不藏,直接列出来,你抄的时候顺手改掉:
afterPlan读错了字段
// 现在这样写,循环永远不会发生
function afterPlan(state) {
return state.strategy === "retrieve" ? "retrieve" : "generate";
}
strategy 只有 simple / complex 两个值,永远不等于 "retrieve",所以每次都直接去 generate,多跳退化成单跳。应该读 plannedNext:
const afterPlan = (state) => state.plannedNext === "retrieve" ? "retrieve" : "generate";
-
planNextStepNode里state.subQuestion少了个 s,导致remaining恒为 0,护栏直接把流程踢去生成。 -
retrieveNode里state.retrieveRound + 1,但Annotation.Root里根本没声明retrieveRound,第一次运行时是undefined + 1 = NaN。要么在 State 里补声明,要么用(state.retrieveRound ?? 0) + 1。 -
decompose_question之后的route_question节点里把retrievalContent: 0写成了初始化,其实是想初始化retrieveRound——典型的字段名打错,跑起来不报错但逻辑错。 -
direct_answer在 multihop 里连到了generate,等于直答完又跑一次 RAG prompt(此时 documents 为空),答案会被覆盖或重复输出。正确做法是direct_answer → END。
这几处说明一件事:Agentic RAG 的复杂度不在"调 API",在"状态字段的一致性"。State 一旦字段对不上,图还能跑,但跑的是错的东西。建议把 State 定义和每个节点 return 的字段做成一张对照表,或者干脆上 TypeScript。
六、第三刀:自我评估 + 联网兜底,给 Agent 装上刹车
rag-webfallback.mjs 解决的是最让人头疼的问题:本地没有,模型硬编。
评估器:让模型给自己打分
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 换掉)。
这就是"先评估,再补料,再评估"的循环,比"一次性全塞给模型"靠谱得多。
联网兜底:博查 Web Search
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 里最精妙的一行(你笔记里也记下了):
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 的自主权越大,护栏就得越硬。
生成节点的"不许编"约束
const stream = await llm.stream(`你是一个严谨的中文问答助手。
优先依据上下文回答,不要编造。
上下文(本地知识库 + 可选联网补充): ${context || "(空)"}
用户问题: ${state.question}
回答要求:
1. 如果上下文足够,给出清晰、可核对的回答,需要时引用 [n]/URL 或小说片段来支撑。
2. 如果上下文仍不满足已确定关键事实,明确说明"不确定/无法从上下文确认",并说明缺失点。
3. 不要输出表情符号。
`);
"引用来源"这个要求不止为了好看:
- 对用户:可核对,信任度立刻上去;
- 对开发者:出问题能直接定位是哪条召回错了。
这段代码里同样有几个坑
state.webContext这个字段根本不存在,State 里声明的是webContent。afterEvaluateLocal里判断的应该是webContent,否则护栏失效、无限循环。这是最危险的一个 bug。bochawebSearch里json在声明之前就被console.log(json, ...)用了(let json还在下面),会直接ReferenceError。- 判空逻辑写反了:
if (webpages.length) return "未找到相关结果";—— 有结果反而返回"未找到"。 body.count用的是count变量,但函数参数叫k,count未定义会抛错。retrieveNode写了一半(const localContent = (retrieveDocs ?? [])没有后续),明显是仓库里的半成品,抄的时候要补全成:
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 };
};
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、两个引擎:
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 条不同角度的检索句:
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 同时打出去:
.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 那一路:
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 那一路:
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 条防止召回太稀。
合并去重是这一节的"命门":
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,所以能直接插到任何文档压缩/重排位置:
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 价格"是相对大模型打分而言的量级感受)。
所以整条链路的分工非常清晰:
召回(宽) → 向量 + 关键词 + 多查询改写 提升 recall(别漏)
重排(窄) → cross-encoder 打分, topN=3 提升 precision(别脏)
生成(精) → 只把 topN 塞进 prompt 降噪、省 token、抗幻觉
金句四:召回决定下限,重排决定上限。
生成层:有上下文 / 无上下文两套 prompt
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__(["__start__"]) --> retrieve
retrieve --> generate
generate --> __end__(["__end__"])
② 加路由(query-router)
graph TD
__start__(["__start__"]) --> route_question
route_question -.->|simple| direct_answer
route_question -.->|complex| retrieve
direct_answer --> __end__(["__end__"])
retrieve --> rag_generate
rag_generate --> __end__(["__end__"])
③ 加多跳(multihop)
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
direct_answer --> generate
generate --> __end__(["__end__"])
④ 加评估 + 联网兜底(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的参数名在版本间有差异(primaryFieldvsprimaryKeyField),装错版本会静默失效。
多跳
-
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 |
如果你只记住三件事,就记这三件:
- 加节点,别改流程——LangGraph 的价值是让架构能"长出来";
- 一切循环都要有硬护栏——模型负责"想不想停",代码负责"必须停";
- 召回要宽,重排要窄,生成要精——这是 RAG 的物理定律。
代码都在仓库里,改完第九节那几个坑,就可以拿去接你自己的知识库了。下一步可以做的事:把五张图合成一张,接上 LangSmith 看每一跳花了多少钱,然后你会对"Agentic"这三个字有完全不一样的理解。