1. RAG 为什么要拆成两条链路
上一篇我们把 RAG 概括成了“先查资料,再回答”。这个概括没有问题,但真正开始实现时,还需要把时间关系考虑进去:资料什么时候处理,用户的问题什么时候到达?
以公司员工手册问答助手为例。员工手册不会随着每一次提问重新读取。管理员可能在晚上上传新版本,由后台任务负责解析文件、切块、生成向量并写入索引;第二天员工提问时,在线服务只需要在已经准备好的索引中查找资料。
如果每次提问都从 PDF 开始处理,系统不仅会变慢,还会反复消耗 Embedding 和生成模型的调用额度。于是,RAG 通常会被拆成两条相对独立的链路:索引链路负责把原始资料整理成可检索的数据,查询链路负责在用户提问时找出相关资料,再生成答案。
换个角度看,索引链路回答的是“知识库里应该保存什么”,查询链路回答的是“这一次问题应该取出什么”。两条链路分工明确,系统才不需要把后台处理和在线问答挤在同一个请求里。
2. 两条链路如何配合
这张图把一次资料入库和一次用户提问放在同一个视角里。上方是通常在后台执行的索引链路,下方是用户请求到达后执行的查询链路。
我们先把图中的四个对象分开看。最前面是 PDF、Markdown、网页、Notion 页面或数据库记录等原始资料;Loader 读取资料后,会得到统一的 Document;长文档经过语义切分后变成较小的 Chunk;Chunk 再经过 Embedding 得到向量,并和来源、版本、权限等信息一起组成向量记录。
用户提问时,系统不会直接在 PDF 文件里寻找答案,而是先把问题转换成查询向量,再从向量记录中找候选 Chunk。经过筛选的 Chunk 被组装成上下文,最后交给模型结合用户问题生成回答。
后续文章需要分别讲文档处理、向量检索和检索质量,原因就在这里。资料在进入索引前被截断,和资料已经正确入库但查询范围错误,是两类不同的问题,排查时也应当沿着不同的链路进行。
3. 索引链路
索引链路也叫 Ingestion Pipeline。它通常由文件上传、后台管理操作、消息队列或定时任务触发。因为处理过程可能涉及文件解析和批量向量化,所以不应该在用户查询接口中同步完成。
在实现中,一份资料进入索引后,会依次经过这些处理:
1原始资料2-> 读取与解析3-> 清理与标准化4-> 文档切块5-> 生成 Embedding6-> 写入向量索引7-> 发布可查询版本
这些步骤各自负责不同的事情,后面的步骤不能可靠地弥补前面丢失的信息。例如 PDF 解析时丢了表头,Embedding 模型只能对残缺文本进行编码;向量检索拿到了错误的 Chunk,生成模型也很难凭空恢复原始表格的结构。
3.1 读取原始资料
原始资料可能来自 PDF、Markdown、网页、Notion、对象存储或数据库。Loader 负责读取这些来源,并尽量把它们转换成统一的 Document。它的任务是完成资料接入,不是在这一阶段理解问题或生成答案。
在 LangChain 中,Document 会把正文和来源信息放在一起:
01import { Document } from '@langchain/core/documents'0203const document = new Document({04id: 'leave-policy:v3:page-4',05pageContent: '连续请假三天及以上,需要直属负责人审批。',06metadata: {07sourceId: 'leave-policy',08sourceVersion: 3,09page: 4,10department: 'all',11visibility: 'employee',12},13})
pageContent 是后续清理、切块和 Embedding 真正要处理的正文,metadata 则保存来源、版本、权限和定位信息。id 可以省略,但生产系统最好提供稳定标识,因为更新、删除和引用都需要知道这段内容究竟来自哪里。
不同文件格式的解析质量差异很大。普通 Markdown 往往能够保留标题层级,PDF 可能出现页眉页脚重复、双栏顺序错乱和跨页表格断裂,网页还可能把导航菜单和正文混在一起。因此,文档加载不能只看程序是否返回了字符串,还要检查返回的文本是否仍然保留了原文关系。
3.2 清理、标准化与切块
解析后的文本通常不能直接生成向量。页眉、页脚、导航菜单、重复版权声明和 OCR 乱码会占用索引空间,还可能在检索时反复被召回;但清理时也不能把编号、标题层级、表格表头和否定词一起删掉。
例如“请假不超过三天无需部门负责人审批”中的“不”如果在 OCR 清理时丢失,后面的检索和生成都可能得到相反结论。
清理完成后,才进入文档切块。切块不是机械地凑一个固定字符数,而是尽量让标题、正文、表格字段和适用条件保留在同一个语义范围内,使 Chunk 脱离原文位置后仍然能够被理解。
这是影响检索精度最直接的环节。后续的文档处理与切块会继续讲文件解析、清理规则、切分参数和质量检查。这里先记住一个判断:如果 Chunk 本身缺少必要上下文,换更大的模型或更贵的向量数据库也无法稳定修复。
3.3 生成 Embedding
每个 Chunk 会被 Embedding 模型转换成固定维度的向量。向量不是摘要,也不是可以直接展示给用户的文字,而是供系统计算语义相似度的数字表示。
用于建立索引的模型必须与查询时使用的模型处在同一个语义空间,向量维度也必须匹配。假设索引使用 text-embedding-3-small 建立,后来查询侧换成另一个维度或语义空间不同的模型,即使接口没有立即报错,两个向量也没有可靠的可比性。
所以,模型名称、模型版本、向量维度和切分流程版本都应该记录在索引配置中。Embedding 模型升级通常意味着一次索引迁移,不能只改一行环境变量就认为迁移完成。
3.4 写入向量索引
向量记录通常至少包含以下信息:
| 数据 | 作用 |
|---|---|
| Chunk ID | 唯一定位一段文本 |
| 向量 values | 参与相似度搜索 |
sourceId | 找回原始资料 |
sourceVersion | 区分不同版本 |
tenantId、权限字段 | 限制可检索范围 |
| 页码、章节和位置 | 展示引用并支持追溯 |
是否把完整正文放进向量库,需要根据容量、访问方式和数据治理要求决定。比较稳妥的做法是让业务数据库或对象存储保存权威正文,向量库保存向量、过滤字段和正文定位 ID。检索命中后,再根据 chunkId 批量读取正文。这样编辑、审计和删除会更容易保持一致。
小型 Demo 可以直接把文本放在向量记录的 metadata 中,但需要明确这是一种简化方案。真正上线前,应该先确定哪一份数据是 Source of Truth,避免向量库里的旧文本和业务数据库里的正文逐渐产生差异。
4. 查询链路
用户提问后,查询链路需要在较短时间内完成查询整理、权限过滤、候选召回、重排、上下文组装和生成。把问题转成向量只是其中一步,真正影响答案质量的还有检索范围、候选筛选和上下文组织。
1用户问题2-> 查询整理3-> 确定权限与业务范围4-> 召回候选 Chunk5-> 重排、去重与补充上下文6-> 生成带来源的回答
4.1 整理查询
用户的问题可能不完整。第一轮问“退款政策是什么”,第二轮只说“超过 30 天呢”,如果直接拿第二句话检索,系统很容易失去“退款”这个主题。
查询改写可以结合最近对话,把第二句话整理成“购买超过 30 天后如何申请退款”。改写后的文本只用于检索,最终回答仍然要面对用户的原始问题,这样既能补足检索所需的上下文,也不会让用户看到一段脱离对话的生硬问题。
除了补全多轮对话,查询整理还可能拆分多个子问题、提取时间范围、识别产品版本,或者保留错误码等精确词。简单而完整的问题不必强行改写,直接检索反而更稳定。
4.2 先限定检索范围
权限、租户、部门、产品版本和时间范围应尽量在检索前确定。员工只能搜索自己有权查看的制度,使用产品 v2 的用户也不应该拿到 v1 的配置说明。
不能先做全库检索,再把无权结果删掉。这样做会浪费 Top-K 名额,还可能让敏感内容进入日志或重排模型。更合理的顺序是先确定用户允许访问的搜索空间,再在这个范围内选择候选结果。
4.3 召回候选资料
召回阶段首先要避免漏掉正确资料,所以通常会先取比最终上下文更多的候选。例如最终只给模型 4 个 Chunk,可以先召回 20 个,再进行权限确认、去重和重排。
召回方式可以是向量检索、关键词检索、SQL 查询,也可以多路并行。专有名词、制度编号和错误码更适合关键词,换一种说法的自然语言问题更适合向量检索。至于什么时候使用混合召回、查询改写和重排,会在后续的检索质量文章中继续展开。
4.4 重排与上下文组装
第一轮召回强调覆盖率,重排则要判断正确资料能否排到前面。完成重排后,还需要去掉高度重复的 Chunk,补回必要的标题或父级内容,再根据上下文预算选择最终资料。
上下文不是越多越好。把十几段相似制度全部塞给模型,可能让真正关键的例外条件被淹没;上下文过长还会增加 Token 成本和延迟。更合适的目标是足够且相关,而不是把所有检索结果都交给模型。
4.5 生成与引用
传给模型的上下文应当带有稳定的来源编号,例如 [leave-policy:v3:p4]。Prompt 需要明确要求模型只依据提供的资料回答,资料不足时说明不足,并在答案中保留引用。
保留引用有两个实际作用:用户可以据此核对答案,开发者也可以在问题发生后追溯模型使用了哪个 Chunk、哪个文档版本以及哪一页内容。
5. 链路之间的数据契约
索引链路和查询链路可以独立部署、独立扩容,但两边处理的数据必须遵守同一套契约。具体来说,至少包括四项:
| 契约 | 索引侧 | 查询侧 |
|---|---|---|
| Embedding 模型 | 记录模型和版本 | 使用同一语义空间 |
| 向量维度 | 创建对应维度的索引 | 查询向量维度一致 |
| metadata | 写入租户、版本和权限字段 | 使用同名字段过滤 |
| 文档版本 | 新版本写入并切换状态 | 只检索 active 或 ready 版本 |
例如,新文档已经上传,搜索结果却仍然是旧内容。这时问题不一定出在向量数据库,也可能是后台索引还在处理中、旧版本没有停用,或者查询服务仍然读取旧的索引别名。
6. 用状态管理索引发布
索引任务应该有明确的状态。仅仅通过“索引里有没有几条向量”来判断处理是否完成,很容易把半成品当成可查询版本:
1type SourceStatus =2| 'pending'3| 'parsing'4| 'embedding'5| 'ready'6| 'failed'7| 'archived'
只有 ready 版本可以进入线上查询。更新文档时,先让新版本完整建立索引并通过抽样验证,再原子切换 active 版本,最后异步清理旧向量。这样切换期间不会出现一半新数据、一半旧数据的状态。
这里的“原子切换”不一定要求向量数据库本身支持事务。可以通过版本字段、索引别名,或者一张记录当前 active 版本的配置表来实现。关键在于,同一次查询只能看到一个确定的版本。
7. 稳定 ID 与更新删除
很多演示使用随机 UUID 作为 Chunk ID。第一次运行时看不出问题,但文档重新切分后,每个 Chunk 都会得到新的 ID,旧向量便无法和新向量对应,删除、缓存和引用都会变得困难。
生产系统更适合根据业务身份生成稳定 ID:
01type ChunkIdentity = {02tenantId: string03sourceId: string04sourceVersion: number05chunkIndex: number06contentHash: string07}0809function buildChunkId(input: ChunkIdentity) {10return [11input.tenantId,12input.sourceId,13input.sourceVersion,14input.chunkIndex,15input.contentHash.slice(0, 12),16].join(':')17}
sourceId 用于定位原文,sourceVersion 区分文档版本,chunkIndex 保留顺序,contentHash 帮助判断内容是否真的发生变化。文档删除时,也能根据 sourceId 找到相关向量并清理。
稳定 ID 并不意味着切分参数永远不能改。修改切分算法时,可以同时提升 pipelineVersion,建立一套新索引,验证完成后再切换。不要在同一个线上版本中混入两套无法比较的处理结果。
8. 文档更新时会发生什么
我们继续看请假制度这个例子。v3 规定“连续请假三天及以上需要直属负责人审批”,v4 改成“两天及以上”。
如果系统只把 v4 写入,却没有删除或停用 v3,用户提问“两天年假是否需要审批”时,就可能同时召回两个版本。模型看到冲突内容后,可能选择旧规则,也可能把两条规则拼在一起。
比较稳妥的更新过程可以分成以下几步:
- 创建
leave-policy:v4,状态为pending; - 解析、清理并按新版本生成 Chunk;
- 使用当前 Embedding 模型建立索引;
- 用固定问题集验证“两天”“三天”“半天年假”等问题;
- 将 v4 切换为
ready,同时把 v3 标记为archived; - 查询侧通过 metadata 只允许
ready版本; - 异步删除 v3 向量,并保留必要的审计记录。
这部分往往比 similaritySearch() 更能体现真实项目经验。面试官问“知识库文档更新后如何保证不查到旧内容”,完整答案应该包含版本、状态、索引切换和删除同步,而不是只说重新生成 Embedding。
9. 按链路排查问题
索引链路和查询链路的故障表现不同。遇到问题时,先判断故障发生在哪条链路,排查范围会小很多。
| 现象 | 优先检查 |
|---|---|
| 新文档完全搜不到 | 索引任务状态、Embedding 写入、版本过滤 |
| 只命中旧制度 | active 版本、旧向量清理、查询缓存 |
| 不同用户搜到彼此资料 | namespace、tenantId、权限过滤位置 |
| 答案引用页码错误 | Chunk 与 source/page 映射 |
| 检索内容正确但答案编造 | 上下文组装、Prompt、生成模型 |
| 相似内容很多但关键例外没进上下文 | 重排、去重、Chunk 粒度 |
监控也应该分开设计。索引链路关注处理成功率、任务积压、失败重试和版本延迟;查询链路关注检索延迟、候选数量、命中率、无答案比例和生成质量。两类指标分开后,才能判断系统变慢究竟发生在后台处理,还是发生在在线请求。
一次查询至少应该能够追溯到原始问题、改写后的问题、过滤条件、Embedding 模型、索引版本、候选 Chunk、分数和最终选中的 Chunk。普通日志不要直接记录完整的私有文档和用户问题;需要查看详细内容时,应当通过有权限控制的追踪系统完成。
10. 总结
RAG 不是一个同步函数,而是由索引链路和查询链路共同组成的一套系统。
索引链路把原始资料整理成可查、可更新、可删除的知识索引;查询链路根据当前问题和权限范围召回资料,再完成重排、上下文组装和生成。两条链路需要通过 Embedding 模型、向量维度、metadata 和文档版本保持一致。
接下来我们继续看索引链路中经常被低估的部分:文档加载、解析、清理、标准化和切块。很多检索精度问题,在生成向量之前就已经发生了。