从「单路检索」到「企业级 RAG」:多问题改写 + 混合多路检索 + 重排精筛
朴素 RAG 只会拿用户原话向量检索一遍,碰到口语化表达、同义换说法、含精确编号的问题就会漏召回。本篇以一份「生活笔记」个人知识库为例,讲透企业级 RAG 的三大升级:LLM 把问题改写成 3 条多角度问句、每条问句分别走 Elasticsearch 关键词检索与 Milvus 语义检索再合并去重、用 Rerank 交叉编码模型精筛出与原始问题最相关的 3 篇文档喂给大模型。每环节配最小可运行代码,最后用 LangGraph 把整条流水线串起来。适合已掌握基础 RAG、想理解检索质量优化与生产级架构的学习者。
Leo
2026.09.03 · Updated 2026.09.21
从「单路检索」到「企业级 RAG」:多问题改写 + 混合多路检索 + 重排精筛
开场白:一条直流水线,为什么一上真实业务就露馅
我们学过的朴素 RAG,长这样一条直线:
用户问题向量化 → 相似度检索 Top-K → 片段拼进 Prompt → 生成回答
它实现简单、跑 demo 很顺。可一旦面对真人随口问出来的问题,检索常常捞不回真正有用的笔记。设想一个个人知识库里存了几条生活备忘:
| 库存的笔记 | 内容大意 |
|---|---|
| 路由器断流排查 | 先重启光猫再重启路由、信道改 36、升级固件…… |
| 滤芯更换记录 | 机身序列 SN-MILO-77821、配件订单号 PO-20250409-K9、下次换前置 PP 棉 |
| 龟苓膏粉冲泡 | 双钱牌粉一包兑常温凉水先搅匀再小火搅拌,别用开水冲否则结块 |
| 排骨煲汤 | 排骨冷水焯水、砂锅小火一小时、起锅前再调味 |
用户有三种真实的问法,恰好各踩一种坑:
- 口语化,跟库存标题对不上:「家里无线老是断断续续的咋整啊」——库里的笔记叫《路由器偶尔断流排查笔记》,关键词基本不重合,但语义高度相关。
- 换了个说法 / 俗称:「那个黑凉粉粉怎么冲不结块」——库里写的是「龟苓膏粉」,搜「黑凉粉」纯关键词匹配不到。
- 咬文嚼字的精确编号:「PO-20250409-K9 滤芯订单去哪了」——这种精确订单号恰恰是向量库的软肋,语义相似不等于字面命中。
任何一条「单路」都救不回来:
| 单路方案 | 1 口语化问题 | 2 俗称问题 | 3 精确编号问题 |
|---|---|---|---|
| 关键词检索(ES) | ❌ 词面不重叠 | ❌ 同名异称搜不到 | ✅ 编号字面命中 |
| 语义检索(向量库) | ✅ 语义相近 | ✅ 意思相近 | ❌ 编号语义容易漂 |
所以要上的是——改写 + 混合 + 重排,也就是本文要讲的完整架构。一句话总纲先摆在这:
企业级 RAG = 先用大模型把一句口语问题改写成 N 条多角度问句,让每条问句分别同时过关键词检索和语义检索(多路召回,求全),把各路结果合并去重后,再用一个重排模型按「和原始问题有多相关」精筛出最 Top 的几篇(精排,求准),最后才交给大模型作答。
一句话概括这条流水线:
问题改写(1 → 3条) → 每条分别 [ES 关键词 ∥ Milvus 语义] → 合并去重 → Rerank 精筛 Top3 → LLM 作答
下面把每一环拆开讲,每一环都给你最小可运行的代码和「要点」清单。文中知识库就以这份「生活笔记」为例,索引字段为 note_title / note_body,配上标签、心情等元数据。
第一章 为什么一个问句不够:多问题改写(Query Augmentation)
一句话说明
多问题改写,是让大模型把用户那一句问题,改写/扩写成交互独立、角度各异的 3 条检索问句。 它不是改着玩,而是提高召回命中率的信号放大手段:一句问得含糊,多句把各种可能问法都覆盖到,任何一路、任何一句命中,文档就被捞回来了。
为什么需要:一个问句的表达,天生「押不中」
用户问「黑凉粉咋冲不结块」,这句话拿去检索,只有一种写法、一个角度。可知识库可能是按「龟苓膏粉」「冲调比例」「结块原因」分别记的——单一句子要同时命中这些不同的措辞,概率很低。改写的目的,是替用户把他的意图翻译成多种能被检索到的说法:换个词、换个角度、补个限定,撞中库的概率就大大提高了。
这和「多跳拆解」不是一回事:多跳是把一个大问题拆成有先后依赖的子问题一条条查;改写是同一意图的多路问法,无先后,拿来并行检索,本质是召回增强。
怎么做:用结构化输出,强制返回恰好 3 条
要点是两条:条数固定(多路并行,条数太多浪费算力、太少覆盖不够),以及保留专名(订单号、型号、品牌这种字面信息绝不能改丢,重排和关键词检索都靠它)。
import * as z from "zod";
import { ChatPromptTemplate } from "@langchain/core/prompts";
// ① 用 schema 把输出「钉死」在恰好 3 条
const Schema = z.object({
queries: z.array(z.string())
.length(3)
.describe("恰好 3 条中文检索问句:不同角度改写或扩写;保留订单号、型号等专名;不要编造"),
});
const prompt = ChatPromptTemplate.fromMessages([
["system",
"用户会给出一句中文问题。请另写恰好 3 条检索用问句(原意一致、角度尽量不同)," +
"便于搜索引擎和向量库分别召回;专有名词、型号、订单号必须原样保留。"],
["human", "{query}"],
]);
// ② withStructuredOutput:LLM 直接输出符合 schema 的 JSON
export async function augmentQuery(chatModel, query) {
const chain = prompt.pipe(chatModel.withStructuredOutput(Schema));
const { queries } = await chain.invoke({ query });
return queries;
}
跑一句真实的问题,改写结果长这样(示意):
query: 家里无线老是断断续续的咋整啊
[1] 家里 wifi 老是断断续续怎么办
[2] 路由器经常掉线断流,有什么排查方法
[3] 无线网络信号不稳定,怎么解决
提示:真实工程里建议做一个「保底」——万一 LLM 返回的条数不足 3,就用原句补齐,保证后面并行检索永远有 3 条可跑,不至于崩。
要点清单:
- 条数写死在 schema 里(
.length(3)),让多路检索有稳定的扇出。 - Prompt 强调「不同角度」:改写说法、换提问角度、加限定词,别生成三条几乎一样的句子。
- 专名必须原样保留:订单号、品牌、型号等字面信息是关键词检索的命根子,改写歪了反而帮倒忙。
- 本质是召回增强:用 N 个问法去「撒网」,任何一句命中即召回。
第二章 双引擎并行:ES 关键词路 + Milvus 语义路(多路召回)
一句话说明
混合检索(Hybrid Search),是把「关键词精确匹配」和「语义近似匹配」两套完全互补的检索器拼起来用:对改写出的每一条问句,都同时去 ES 和向量库各查一次,目标是多路召回、先把可能相关的都捞回来。这一刻要「求全」,宁多勿漏。
为什么需要:两条路谁也不能替代谁
回看开头那张对照表——关键词擅长字面、语义擅长意思,恰好互补:
| 维度 | Elasticsearch(关键词路) | Milvus(语义路) |
|---|---|---|
| 匹配原理 | 分词后查倒排索引,词必须出现 | 文本 → 向量,按语义距离找相近 |
| 擅长 | 精确实体、型号、订单号、专名 | 口语化、同义改写、模糊表达 |
| 短处 | 词面不重合就漏 | 精确编号、术语容易向量化「漂」 |
| 中文前置 | 需要 IK 分词 | 需要好的 embedding 模型 |
关键词路我们已经在 ES 那篇吃透了:它靠倒排索引和 IK 分词。这里直接用在中文笔记上——标题给更高权重,正文次之,用 multi_match 在多个字段一起搜:
// 关键词路:一条问句 → ES multi_match(note_title 权重翻倍)
const res = await es.search({
index: "life_notes",
size: kEach, // 每条问句取 kEach 条
query: {
multi_match: {
query: q, // 这一条问句
fields: ["note_title^2", "note_body"], // 标题命中得分翻倍
type: "best_fields",
analyzer: "ik_smart", // 检索侧用 ik_smart,中文切词
},
},
});
语义路走向量库:把每条问句交给 embedding 模型转成向量,再到 Milvus 里做最近邻检索。铁律:检索时用的 embedding 模型必须和建库时是同一个,否则两边的向量空间对不上,检索结果全乱:
// 语义路:一条问句 → Milvus 相似度检索(内部会先向量化这条问句)
const docs = await milvus.similaritySearch(q, kEach);
怎么做:把改写出的每一条问句都「两路双跑」
到这里组合起来——检索串 = 原始问题 + 3 条改写,共 4 条;这 4 条每一条都要去 ES 查一遍、去向量库查一遍。四句 × 两路 = 8 次检索,全部并行发出去:
// 要拿去检索的问句:原始问题在前,后接 LLM 改写的 3 条
const retrievalQueries = [query, ...augmentedQueries].filter(Boolean);
const n = Math.max(1, retrievalQueries.length);
// 总预算 15 条/库,均摊到每条问句上,保证至少 2 条兜底
const kEach = Math.max(2, Math.ceil(15 / n));
// 每一条问句都同时打 ES 和 Milvus
const [esBatches, mvBatches] = await Promise.all([
Promise.all(retrievalQueries.map((q) => searchEs(q, kEach))), // ES 关键词
Promise.all(retrievalQueries.map((q) => milvus.similaritySearch(q, kEach))), // 语义
]);
const esHits = esBatches.flat(); // 4 句的 ES 结果拍平
const mvHits = mvBatches.flat(); // 4 句的向量结果拍平
要点清单:
- 「每路总预算」比「每句预算」更合理:ES 总召回 15 条、向量总召回 15 条,均摊到 4 句上(每句约 4 条),避免某一句独吞全部名额。
- 并行用
Promise.all:4 句 × 2 路互相独立,串行会白白多等几倍的检索时间。 - 多字段加权:标题
^2权重翻倍,命中标题的笔记排更前——和真人「标题最像的优先」直觉一致。 - 中文检索记得
analyzer: "ik_smart":没有 IK,黑凉粉会被拆错甚至拆不开。
第三章 合并去重:把两套库的结果合成一份
一句话说明
合并去重,是把 ES 和向量库各自召回的结果倒进同一个池子,按文档 id 去掉重复,只保留首次出现的那份。 两路常常会召回同一篇笔记(它既命中关键词又语义相近),不合并去重,同一篇会以不同顺序、不同分数出现在 Prompt 里,既浪费 token 又干扰模型判断。
怎么做:以 id 为唯一去重键
两套系统里同一篇笔记,靠同一个 id 对上。关键决策:只用 id 去重,不做正文相似去重——因为正文只差一个字可能根本不是同一篇;id 才是稳定的实体标识。保留首次出现的顺序(一般 ES 在前),重排阶段会重新洗牌,所以这里的先后不重要:
function merge(esDocs, mvDocs) {
const seen = new Set();
const out = [];
for (const doc of [...(esDocs ?? []), ...(mvDocs ?? [])]) {
if (!doc?.pageContent) continue; // 空正文直接丢
const id = doc.metadata?.id;
if (!id || seen.has(id)) continue; // 无 id / 已见过 → 跳过
seen.add(id);
out.push(doc);
}
return out;
}
小坑提醒:ES 的
_id和向量库的id必须在建库时就约定一致(同一份笔记两处都用life_01这种),合并才能对上。这也是为什么 seed 脚本里两边写入的是同一条数据源。
要点清单:
- 去重键 = 文档 id,不是正文、不是向量相似度。
- 保留首次出现,先到先得;顺序问题交给后面的重排,不用在这里纠结。
- 空正文直接丢弃,避免把垃圾片段带进 Prompt。
第四章 重排精筛:召回求全,重排求准
一句话说明
Rerank,是用一个交叉编码(cross-encoder)的排序模型,把合并后的一批候选文档逐条和「用户原始问题」比对,算出真正相关度并重新排序,最后只取最相关的 Top N 篇。 前面多路召回是「广撒网求全」,这里要「精准打捞求准」——因为向量库和 ES 各自算出来的「分数」不能跨库直接比较,而且粗召回时是按各自问句算的相关,未必贴合用户原始问题。
为什么需要:两路分数不能比,粗排角度也不对
两个问题,任何一个都能把粗召回结果直接喂给 LLM 搞砸:
- 分数不可比:ES 的
_score是 BM25 相关度,向量库返回的是余弦/L2 距离——单位都不同,没法放一起排大小。 - 按问句排的,不是按原问题排的:改写出的第 2 条问句召回的文档,是按「跟第 2 条像不像」排的,可用户真正关心的是第 0 条原始问题。
Rerank 模型一次性拿到 [query + document] 整段做深度交叉编码,直接输出「这篇和这个 query 有多相关」的分数——所有候选在同一把尺子下度量,也统一回到原始问题这把尺子。于是多路召回的分歧被一笔抹平。
怎么做:一个最简 Rerank 适配器
市面上成熟的 rerank 模型(如阿里的 qwen3-rerank)走 HTTP,输入 query + documents,返回每篇的 index 和分数。用 LangChain 的 BaseDocumentCompressor 包一层,就能让它像普通「文档压缩器」一样被调用——这是为了后续能直接接进检索器生态:
import { BaseDocumentCompressor } from "@langchain/core/retrievers/document_compressors";
export class DashScopeRerank extends BaseDocumentCompressor {
constructor({ apiKey, model = "qwen3-rerank", topN = 3, baseUrl }) {
super();
Object.assign(this, { apiKey, model, topN, baseUrl });
}
async compressDocuments(documents, query, _callbacks) {
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: { top_n: this.topN, return_documents: false },
}),
});
const json = await res.json();
if (!res.ok) throw new Error(`rerank ${res.status}: ${JSON.stringify(json)}`);
// 服务端只回 index + score,按 index 把原 Document 取回来
return (json.output?.results ?? []).map((r) => documents[r.index]);
}
}
调用处放在合并之后、作答之前,只留最相关的 3 篇:
const reranker = new DashScopeRerank({ apiKey, model: "qwen3-rerank", topN: 3 });
const topDocuments = await reranker.compressDocuments(mergedDocs, originalQuery);
// topDocuments:和【原始问题】最相关的 3 篇,去重、跨库、按统一尺度排好了
要点清单:
- 关键参数
top_n:企业落地一般取 3~5 篇——太少信息不足,太多模型上下文被无关片段稀释。 - 用
return_documents: false只回 index,省流量;反正原文Document我们手里就有,按index取回即可。 - 喂给重排的是用户原始问题,不是改写句——重排要贴近用户本意。
- 重排不可省:它是把「多路、多句、两把尺子」的杂乱候选收敛成「一份干净 Top-N」的唯一环节。
第五章 拼 Prompt 作答 + 空检索兜底
一句话说明
最后把重排出的 Top-N 篇拼成「检索片段」上下文,连同用户原始问题一起交给大模型生成回答,并明确约束:只根据片段作答、片段没有的就说没有——这是整个 RAG 防幻觉的最后一关。
const ANSWER_PROMPT = ChatPromptTemplate.fromMessages([
["system",
"你是阅读用户「生活笔记」知识库并作答的助手。规则:只根据下方检索片段推断答案;" +
"片段里没有的信息不要编造;若片段不足以回答,明确说「笔记里未提到」。"],
["human", "用户问题:{query}\n\n检索片段:\n{context}"],
]);
const context = topDocuments
.map((d, i) => `[${i + 1}] id=${d.metadata.id}\n${d.pageContent}`)
.join("\n\n---\n\n");
const answer = await ANSWER_PROMPT.pipe(chatModel).invoke({
query: originalQuery,
context,
});
兜底分支:如果重排后一篇都没留下(知识库里真没有),不要硬着头皮答,换个「无上下文」的分支,让模型坦诚说明并引导用户换个说法:
if (!topDocuments.length) {
return await NO_CONTEXT_PROMPT.pipe(chatModel)
.invoke({ query: originalQuery });
}
要点清单:
- 回答基于片段,把「检索到的原文」和「问题」一起给模型,禁止脑补库外信息。
- 每个片段带编号与来源 id,模型可以「引用」,也更方便将来做溯源/引文展示。
- 空检索要有兜底话术,宁可承认查不到,也不能编一个「好像有」。
第六章 总装配:用 LangGraph 把六块积木串成一条流水线
一句话说明
每一环都是独立的函数,最后要有一个编排器把它们按正确顺序跑起来,还要支持「两路并行检索、到合并节点汇合」这种非直线结构。LangGraph 的节点/边正好天然表达这一点。
为什么用 LangGraph:因为这条流水线不是一条直线
流水线里有明显的分叉与汇合:改写完成后,ES 检索与向量检索并行执行,二者都完成后才到合并。普通顺序代码也能用 Promise.all 模拟,但 LangGraph 把「哪些步骤可并行、谁等谁」显式画成了图,可观测、可中断、可局部替换(比如把某一检索器换成工具调用),是生产级编排的更稳形态。
怎么做:六个节点 + 三条「非平凡」边
节点就是前面几章的函数,各自只改自己负责的那块共享状态:
import { Annotation, END, START, StateGraph } from "@langchain/langgraph";
// 共享状态表:每个节点往里写自己的增量字段
const State = Annotation.Root({
query: Annotation(), // 用户原始问题(全程不变)
queries: Annotation(), // 改写出的 3 条
esHits: Annotation(), // ES 关键词召回
milvusHits: Annotation(), // 向量召回
merged: Annotation(), // 合并去重后
topDocuments: Annotation(), // 重排后 Top3
answer: Annotation(), // 最终回答
});
// ① 改写:query → queries
async function augmentNode(state) {
return { queries: await augmentQuery(chatModel, state.query) };
}
// ② 关键词召回节点(内部对 4 条问句并行打 ES)
async function esRecallNode(state) {
const docs = await searchAllEs(state.query, state.queries);
return { esHits: dedupeById(docs) };
}
// ③ 语义召回节点(内部对 4 条问句并行打 Milvus)
async function milvusRecallNode(state) {
const docs = await searchAllMilvus(state.query, state.queries);
return { milvusHits: dedupeById(docs) };
}
// ④ 合并去重
async function mergeNode(state) {
return { merged: merge(state.esHits, state.milvusHits) };
}
// ⑤ 重排精筛(按原始问题)
async function rerankNode(state) {
return { topDocuments: await reranker.compressDocuments(state.merged, state.query) };
}
// ⑥ 作答(含空检索兜底)
async function answerNode(state) {
return { answer: await generate(state.query, state.topDocuments) };
}
装配成图的关键是那几条「不直走」的边:
new StateGraph(State)
.addNode("augment", augmentNode)
.addNode("es_recall", esRecallNode)
.addNode("milvus_recall", milvusRecallNode)
.addNode("merge", mergeNode)
.addNode("rerank", rerankNode)
.addNode("answer", answerNode)
.addEdge(START, "augment")
// ★ 从同一节点拉出两条边 → 两个召回节点并行执行
.addEdge("augment", "es_recall")
.addEdge("augment", "milvus_recall")
// ★ 两条边汇合到 merge:两路都跑完才继续
.addEdge(["es_recall", "milvus_recall"], "merge")
.addEdge("merge", "rerank")
.addEdge("rerank", "answer")
.addEdge("answer", END)
.compile();
这张图跑起来的状态流,每一步都能通过打印 state 观察到,方便调试到具体是哪一路召回出了问题:
要点清单:
- 并行不是靠代码,而是靠「两条出边 + 两条入边」:从
augment拉两条边到两个召回节点是并行扇出;两个召回节点都指向merge是汇合屏障。 - 每个节点只返回自己负责的字段,LangGraph 会自动合并进共享状态,互不覆盖。
- 中间状态都可观测可打印:
esHits/milvusHits/topDocuments任一环节不对,打印出来就知道问题出在召回、去重还是重排。
总结:怎么选、怎么用,一张表看懂
把朴素 RAG 和这套企业级 RAG 摆一起,升级点一目了然:
| 维度 | 朴素 RAG | 企业级 RAG(本文) |
|---|---|---|
| 检索前 | 直接用用户原话 | 改写 3 条多角度问句(+原句共 4 句) |
| 检索器 | 单一向量库 | 关键词(ES) ∥ 语义(向量库) 双路并行 |
| 召回目标 | Top-K 直接用 | 每路每句多召回,宁多勿漏 |
| 结果合并 | —— | 按 id 合并去重 |
| 精排 | 无(粗排分数直接进 Prompt) | Rerank 交叉编码重排,取与原始问题最相关的 Top3 |
| 分数可比性 | 单库自洽 | 跨库由 Rerank 统一度量 |
| 抗口语/同义/编号 | 部分 | 三类问题各有对应环节兜住 |
各环节关键调参(本文这套工程的落地值,供参考起步):
| 环节 | 参数 | 取值 | 理由 |
|---|---|---|---|
| 改写 | 问句条数 | 3 | 覆盖与开销的平衡 |
| 召回 | 每库总预算 K | 15 | 给重排留足候选 |
| 召回 | 每问句 kEach | ceil(K / 问句数),保底 2 | 均摊名额,防独吞 |
| 重排 | top_n | 3 | 信息量 vs 上下文稀释的平衡 |
回到开头那三句「真人问法」:口语化问题靠语义路捞回《路由器断流》,俗称问题靠改写出的多角度问句撞中「冲泡比例」,精确编号靠关键词路 + 改写保留专名精准命中《滤芯记录》——三个漏点各有对应环节堵住,最后统一交给 Rerank 按你真正想问的那句排好序,再让大模型基于片段作答。
到这里,RAG 就正式从「一条直线」进化成 多问题改写 + 混合多路检索 + 合并去重 + 重排精筛 + 受约束生成 的完整架构——这也是企业级落地时真正能打的形态。每一环都不难,难的是把它们按顺序、按并发的结构正确拼起来;而拼起来这件事,正是 LangGraph 的用武之地。
Leo
BloggerIndependent developer / Blogger and the maintainer of the original blog “大道至简”. Migrating years of posts and shiyu from WordPress to Next.js.
Reader comments
COMMENTS · 0Leave a comment