RAG 的核心流程:离线建库与在线推理
很多人把 RAG 理解成「向量库 + 大模型」,效果不好就换更强的模型。但一条链路上有十几个环节,模型只占两三个。这篇把离线建库和在线推理两条链路完整拆开,并说明为什么排障要按这两半切开。
很多人对 RAG 的第一印象是「把文档塞进向量库,然后让大模型去查」。所以效果不好的时候,第一反应是换个更强的嵌入模型,或者换个参数更大的 LLM。
但一条真实的 RAG 链路上有十几个环节,模型只占其中两三个。更常见的情况是:答案之所以不对,是因为那段文字压根没被正确解析出来、或者被切碎了——这种情况下换什么模型都救不回来。
要看清这件事,最有效的做法是把链路拆成离线和在线两半。它们的目标、节奏、失败方式都不一样。
一、两条链路,两种节奏
【离线:构建可检索的知识底座】 批量跑,可以慢,可以重跑
文档源(PDF / HTML / 数据库 / API / 工单)
↓ ① 数据接入与清洗 去页眉页脚、去乱码、去重复内容
↓ ② 切块 Chunking 按结构或语义切,保留标题 / 章节 / 表格
↓ ③ 向量化 Embedding 领域适配的嵌入模型把 chunk 转成向量
↓ ④ 索引构建 向量库 + BM25 全文索引 + 元数据字段
↓ ⑤ 可选增强 摘要索引、父子块索引、知识图谱、同义词表
知识底座
【在线:一次请求的完整链路】 按毫秒计,每一步延迟都会叠加
用户问题
↓ ① 查询理解 意图识别、纠错、改写、同义词扩展、多跳分解
↓ ② 多路召回 向量检索 + BM25 + 元数据过滤 + 图谱 / API
↓ ③ 融合与重排 RRF 融合 → Cross-Encoder 或 LLM 重排 → Top-N
↓ ④ 上下文组装 按相关性、多样性、新鲜度、权限拼装,控 token 预算
↓ ⑤ 生成与引用 LLM 基于上下文作答,强制标注来源
↓ ⑥ 后处理与监控 答案校验、敏感信息过滤、埋点、bad case 回流
答案
这两半的节奏是反的。离线是批量的、可以慢、可以整体重跑;在线是实时的、每一步的耗时都要算进用户体感里。
| 离线阶段 | 在线阶段 | |
|---|---|---|
| 触发方式 | 定时 / 事件驱动 | 每次用户提问 |
| 延迟敏感度 | 几乎不敏感 | 每 100ms 都能被感知 |
| 主要成本 | 存储、Embedding 计算 | LLM token、检索耗时 |
| 主要失败方式 | 解析丢内容、切碎、脏数据入库 | 召回偏、排序差、模型不听话 |
| 出问题怎么修 | 重跑离线任务 | 调参数 / 改 Prompt,越快越好 |
| 回答的问题 | 能不能找到 | 找得准不准、答得忠不忠实 |
一句话总结:离线决定「能不能找到」,在线决定「找得准不准、答得忠不忠实」。
这句话不只是概括,它还是个排障工具——如果一个问题的答案在知识库里根本不存在,那么在线环节上无论怎么调 TopK、怎么加 Rerank,都是在做无用功。
二、离线阶段:五步里最容易出事的三步
1. 数据接入与清洗
数据源通常是异构的:PDF、HTML、数据库表、内部 API、历史工单。抽取文本之后要做的基础清理包括去页眉页脚、去导航栏、修掉编码乱码、合并跨页断行、去掉完全重复的段落。
这一步看起来像体力活,但它是唯一一个「不做就必然出问题」的环节。扫描版 PDF 的 OCR 错误、双栏排版被读成一行、页脚页码混进正文,这些脏数据会一路带到切块、向量化和检索里,而且在后面的任何一个环节都看不出来。
2. 切块:决定检索粒度的一步
按结构或语义切分,尽量保留标题层级、章节归属、表格结构这些信息。工程上最常见的组合是递归切块 + 10%~20% 重叠。
这一步的产出不只是文本片段,还有每个片段的元数据——标题、所属章节、文档类型、生效时间、来源。元数据不是装饰,它是后面做过滤和路由的依据。
3. 向量化与索引构建
用嵌入模型把每个 chunk 转成向量,写进向量库(Milvus、Qdrant、pgvector 等)。这里有一个容易忽略的点:
向量索引只是索引的一种,不是全部。
生产环境的索引通常是三件套一起建:
| 索引类型 | 承载什么查询 | 典型实现 |
|---|---|---|
| 向量索引 | 语义相似的模糊匹配 | Milvus HNSW + COSINE / IP |
| 全文索引 | 专有名词、型号、编号的精确匹配 | BM25(Tantivy / Sparse-BM25 / ES) |
| 标量字段 | 时间、来源、权限、类型的过滤 | 向量库的标量索引或 partition |
只建向量索引的系统,在遇到「H20 这个型号的吸力是多少」这类问题时召不回来——因为嵌入模型对专有实体并不敏感。这不是模型不够强,是索引用错了工具。
4. 可选增强
不是必需,但在特定场景下收益明显:摘要索引(先检索摘要再定位原文)、父子块索引(小块检索、大块生成)、知识图谱(多跳关系)、同义词表(领域黑话)。
三、在线阶段:六步各自的职责
1. 查询理解
用户的原话往往不能直接拿去检索。常见的处理有:纠错、意图识别(判断问题属于哪一类业务域)、指代消解(把「它多少钱」里的「它」还原成具体商品)、同义词扩展、以及多跳问题的子查询分解。
多轮对话里这一步尤其关键。用户说「那它呢」,如果不结合历史改写成完整的独立查询,检索必然跑偏。
2. 多路召回
同时跑几路检索,拿一个候选集回来:
- 向量检索:抓语义相似,擅长「噪音小」和「静音」这种同义表达
- 关键词检索 BM25:抓专有名词、编号、术语,擅长精确匹配
- 元数据过滤:先用文档类型、时间、业务模块缩小范围
- 图谱 / API 召回:结构化关系和实时数据
多路的目的是互补。任何单路都有系统性盲区,而这些盲区恰好是另一路的强项。
3. 融合与重排
多路结果的分数往往不可直接比较——余弦相似度在 0~1 之间集中分布,BM25 的分数没有上界。硬加权求和会被量纲带偏。
工程上更稳的做法是 RRF(Reciprocal Rank Fusion),只用排名不用原始分数:
score(d) = Σ 1 / (k + rank_i(d))
i∈各路结果
k 一般取 60,两路各自排第 1 的文档会得到相同贡献,不用调权重。
融合之后是重排:用一个 Cross-Encoder 或者 LLM 对候选项逐条打分,把真正相关的排到最前面。向量检索是双塔结构、查询和文档没有交互,区分度天然不够;重排模型有交互,准确率高但慢,所以只能作用在少量候选上。
4. 上下文组装
按相关性、多样性、时间新鲜度、权限把片段拼进 Prompt,同时控制 token 预算。
这一步有两个反直觉的细节:片段不是越多越好(精排后通常只留 5~8 条,多了注意力会被稀释),以及位置很重要(模型对开头和结尾的利用最好,最相关的片段应该放开头)。
5. 生成与引用
LLM 基于上下文作答,并且强制标注来源编号。这一招同时干两件事:逼模型去看资料,以及出问题时能溯源。
同时要设计好「不知道」这个出口——资料不够时明确拒答或交由人工处理,而不是硬答。
6. 后处理与监控
答案校验(关键数字是否在上下文里出现过)、敏感信息过滤、记录检索命中、延迟、Token 消耗,形成 bad case 回流。
四、把一次请求完整走一遍
假设用户问的是「2000 块以内、家里养猫、有没有防缠绕的洗地机」。
用户原始 query
「2000 块以内、家里养猫、有没有防缠绕的洗地机」
↓
① 查询理解
意图 = 商品知识类(不是售后政策类) → 决定后续路由到哪个知识分区
指代 / 省略补全:无
扩展子查询:「防缠绕 滚刷」「养宠 毛发 清理」「2000 元以内 洗地机」
↓
② 多路召回
向量 A(topK=50):「防缠绕」→ 命中滚刷结构、宠物毛发相关段落
向量 B(topK=50):「2000 元以内」→ 命中价格区间相关段落
BM25 (topK=50):「防缠绕」这个专有卖点词 → 精确命中商品参数表
元数据过滤:source_type = 商品参数,status = 生效中
↓
③ 融合与重排
RRF 合并三路 → 约 100 条候选(有重叠)
Cross-Encoder 重排 → 取 Top 5~8
注意:此时「2000 元」这个条件不能只靠语义匹配,价格要另行校验
↓
④ 上下文组装
5 条片段 + 编号 [1]~[5] + 来源标题
最相关的一条放在最前面
token 预算校验:总长控制在阈值内,超了就压缩低分片段
↓
⑤ 生成与引用
系统提示:只基于给定资料回答;每句话标注来源编号;
资料中没有的直接说不知道,不要推测
若涉及实时价格 → 走工具调用查,不靠知识库里的旧价格
↓
⑥ 后处理与监控
抽取答案里的型号和数字 → 检查是否都在上下文里出现过 → 对不上就打回
记录:命中片段、各段相似度、排名、Token 消耗、各环节耗时
这个例子里有两处值得单独拎出来说。
第一处:价格不能进知识库。 「2000 块以内」表面上是个检索条件,但价格是秒级变化的数据。把它写进向量库,检索到的可能是昨天的价格,而用户下单时看到的是今天的——这中间的差值会直接变成投诉。静态知识和实时数据分流是整条链路里最关键的一个架构决策:变化慢的(政策、参数、说明)进知识库,变化快的(价格、库存、订单状态)一律走实时接口。
第二处:「生效中」这个过滤条件应该在检索阶段生效,而不是事后。 如果知识库里同时存在三个版本的活动规则,而检索只按语义相似度排序,很容易把已经下线的旧版本排在前面——旧版本和新版本的措辞往往高度相似,嵌入模型区分不出来。所以元数据里必须带生效时间,并且在检索时就作为过滤条件下推。
五、离线链路怎么更新
知识库不是建一次就完事。文档会改版、活动会下线、商品会换代,离线链路的更新策略直接决定了「答出来的政策是哪个版本」。
常见的三种更新方式:
| 方式 | 做法 | 优点 | 代价 |
|---|---|---|---|
| 全量重建 | 定期清空重建整个索引 | 实现最简单,不会有一致性问题 | 耗时长,重建期间要么停服要么双写 |
| 增量更新 | 按文档 ID 覆盖写,带 version 字段 | 快,资源占用低 | 需要处理删除和版本冲突 |
| 双写切换 | 先写新集合,校验通过后切流量 | 无停机,可回滚 | 需要两倍存储和一套切换逻辑 |
无论选哪种,有两件事必须做:
- 记数据来源和同步时间。 出了问题要能回答「这条内容是什么时候、从哪个源同步过来的」。没有这个字段,「为什么答了旧政策」这类问题是查不出来的。
- 处理删除。 文档下线了但向量还在,是增量更新里最常见的坑——旧的 chunk 会持续被召回,而且因为它语义上确实相关,很难被发现。
另外,向量库的写入和原始文本的存储通常是分开的(向量库存向量和检索字段,关系型库存 chunk 原文和元数据)。这两边一旦不一致,就会出现「检索命中了但取不到原文」的情况。以哪一边为准要事先定清楚,并配一个定时对账任务把缺的补上。
六、一次请求的耗时都花在哪
在线链路的延迟是累加的,所以分段埋点是排障的前提。下面是各环节的典型量级(具体数值取决于部署方式和模型,这里只给相对比例):
| 环节 | 典型量级 | 可变性 |
|---|---|---|
| 查询理解(含 LLM 改写) | 100~500 ms | 高,取决于是否调用 LLM |
| 向量检索 topK=50 | 5~30 ms | 低,取决于索引和过滤条件 |
| BM25 检索 | 5~20 ms | 低 |
| RRF 融合 | < 5 ms | 极低,纯计算 |
| Rerank 50 → 5 | 50~300 ms | 中,取决于模型和批量策略 |
| LLM 首 token | 300~1500 ms | 高 |
| LLM 完整输出 | 1~10 s | 极高 |
从这张表能读出一个结论:优化检索环节对总延迟的贡献很小,用户体感慢基本都是 LLM 生成的锅。所以「回答太慢」这个反馈,不该无脑去优化向量库。
七、最常见的四个误区
| 误区 | 为什么错 |
|---|---|
| RAG = 向量库 + 大模型 | 漏掉了清洗、切块、融合、重排、评测,而这些恰恰是效果的主要来源 |
| 效果不好就换更强的模型 | 换模型是最贵的一步,而且往往不是根因;数据层的问题换模型救不回来 |
| TopK 越大越好 | 上下文变长会带来成本上涨、延迟变高,还会让模型忽略中间信息 |
| 只用向量检索就够 | 对专有名词、型号、编号的精确匹配召回很弱,需要 BM25 互补 |
小结
- RAG 分成离线建库和在线推理两条链路,前者决定能不能找到,后者决定找得准不准、答得忠不忠实
- 离线五步:数据接入与清洗 → 切块 → 向量化 → 索引构建 → 可选增强
- 在线六步:查询理解 → 多路召回 → 融合与重排 → 上下文组装 → 生成与引用 → 后处理与监控
- 索引不是只有向量一种,向量 + BM25 + 标量字段是标准配置
- 多路分数不可直接相加,用 RRF 这类只依赖排名的融合方式最稳
- 排障时要先分清是离线的问题还是在线的问题——知识库里没有的东西,在线怎么调都调不出来
把这条链路完整走通一遍,你会发现真正难的不是「接一个大模型」,而是让送进模型的那几段文字恰好是对的。
留下点什么
评论基于 GitHub Discussions(giscus)。在项目根目录配置.env.local填入 NEXT_PUBLIC_GISCUS_* 四个变量后自动开启。