RAG 工程化实践:如何避免半成品文档进入在线召回
从“能检索”到“可生产”:一次 RAG 链路工程化优化实践
很多 RAG 项目刚开始做时,流程都差不多:
文档解析 ↓ 文本切分 ↓ 生成向量 ↓ 写入向量库 ↓ 用户提问时召回相关内容 ↓ 拼进 Prompt 交给大模型从功能上看,只要问题能召回内容、模型能回答,RAG 就算跑通了。
但真正接到生产业务里以后,会发现“能跑”和“稳定可用”之间还有很长一段距离。
我参与的这条 RAG 链路主要服务于风险信息监控场景,知识源里既有 PDF,也有信用信息查询结果、处罚数据、企业风险汇总等内容。原来的实现已经区分了离线建索引和在线检索,但这种区分更多停留在代码逻辑上,工程上并没有完全拆开。
最典型的问题是:在线查询仍然可能触发文档扫描、切分、向量生成和索引刷新。一旦文档解析较慢,或者 embedding 服务发生异常,用户的一次普通查询就可能被拖住。
更麻烦的是,原来的链路没有可靠的文档状态控制。一个 PDF 如果只处理了一半,部分 chunk 已经写入 Milvus,在线检索仍然可能提前召回这些不完整数据。
所以这次优化的重点,不是简单调一下topK,也不是换一个 embedding 模型,而是先把整条 RAG 链路的工程边界重新梳理清楚。
一、原来的 RAG 链路是怎么运行的
原来的流程可以分成两部分。
离线阶段负责:
扫描知识源目录 读取 PDF 提取文本 切分 chunk 生成 embedding 写入 Milvus在线阶段负责:
接收用户问题 生成 query embedding 从 Milvus 召回 topK 把命中的 chunk 拼进 Prompt 交给上层 Agent 使用乍看之下,在线和离线已经分开了。
但实际查询时,系统会先调用一次类似ensure_ready()的逻辑。如果发现知识源目录发生变化,或者索引状态不完整,就会在当前请求中触发刷新。
这意味着一次在线查询,背后可能顺带执行:
目录扫描 PDF 解析 文本切分 embedding 调用 向量写入一旦文档稍微多一些,查询耗时就会变得不可控。
所以原来的问题不是“没有离线阶段”,而是离线阶段没有真正从请求链路中拿出去。
二、最危险的问题:半成品文档也可能被召回
比查询慢更严重的是数据一致性问题。
假设一个 PDF 被切成 100 个 chunk,系统处理到第 40 个 chunk 时发生异常:
前 40 个 chunk 已经写入 Milvus 后 60 个 chunk 没有完成此时从向量库的角度看,这个文档已经“存在”了。
如果在线查询正好命中了前 40 个 chunk,系统就会把这些内容当成正常知识使用。
这会带来几种情况:
召回内容不完整 上下文前后断裂 关键事实缺失 模型根据局部内容做出错误判断风险监控场景对完整性要求比较高。
例如一条处罚信息,前半部分可能只有处罚对象和时间,真正的处罚原因、处罚金额、处理机关在后半部分。如果只召回了半成品 chunk,最终回答就可能产生明显偏差。
因此,这次优化里最重要的一个设计,就是引入文档状态机。
三、引入文档状态机,让半成品数据不可见
我把文档生命周期拆成了四个状态:
PROCESSING READY FAILED DELETED它们分别表示:
PROCESSING:文档正在解析、切分或写入向量库 READY:文档已经完整处理,可以参与在线检索 FAILED:文档处理失败,不能参与召回 DELETED:源文件已经删除,历史向量不再使用完整处理流程变成:
发现新文档 ↓ 状态更新为 PROCESSING ↓ 提取文本 ↓ 切分 chunk ↓ 生成 embedding ↓ 写入 Milvus ↓ 所有步骤成功 ↓ 状态更新为 READY如果中间任何一步失败:
文本提取失败 chunk 切分失败 embedding 调用失败 Milvus 写入失败文档都会被标记为FAILED。
在线查询时增加一层门控:
只有 READY 状态的文档才允许参与召回这样即使 Milvus 中已经写入了一部分向量,只要 PostgreSQL 里的文档状态还不是READY,这些半成品 chunk 就不会进入最终结果。
这个设计没有使用 PostgreSQL 和 Milvus 的分布式事务,而是采用:
最终一致性 + READY 状态门控实现成本低,但能有效控制脏数据。
四、为什么状态放在 PostgreSQL,而不是 Milvus
Milvus 很适合保存向量和检索字段,但文档状态本质上属于事务型元数据。
除了状态本身,后面通常还需要记录:
文件名称 文件路径 文件摘要 处理时间 失败原因 重试次数 文件签名 更新时间这类信息放在 PostgreSQL 更合适。
所以这次做了明确分工:
PostgreSQL:管理文档生命周期和处理状态 Milvus:负责 chunk 向量存储和相似度召回这种拆分还有一个好处:以后需要做失败重试、任务审计、处理历史查询时,不需要从向量库里反推文档状态。
五、把索引构建真正移出在线请求
状态机解决了半成品数据问题,但如果索引构建还在用户请求中执行,查询延迟仍然不可控。
所以第二个重点改造是:
在线请求只负责发现变化和调度刷新,不负责同步建索引。
优化后的在线请求只做两件事:
判断知识源是否发生变化 如果发生变化,提交一个后台 ingestion 任务真正的解析、切分、embedding 和向量写入都放到后台执行。
新的离线链路是:
扫描知识源 ↓ 识别新增、修改和删除文件 ↓ 提交后台 ingestion 任务 ↓ 更新文档状态 ↓ 文本提取 ↓ chunk 切分 ↓ 去重 ↓ 生成 embedding ↓ 批量写入 Milvus ↓ 状态切换为 READY这样目录发生变化时,当前查询不会等待新文档完整建完索引。
它仍然可以使用上一版已经READY的数据完成检索,新文档则在后台完成更新。
六、为什么先用单线程后台执行器
索引构建移到后台后,可以选择的方案很多:
线程池 消息队列 定时任务平台 独立 ingestion 服务当前知识源规模并不大,所以没有一开始就引入 MQ 或单独部署任务服务,而是先使用单线程后台执行器。
这样做主要是为了避免几个问题:
同一个目录被多个任务重复扫描 同一个文件被并发处理 文档状态被不同线程反复覆盖 Milvus 重复写入单线程方案的优点是实现简单、状态清晰,也比较容易排查。
这不是最终形态,但符合当前规模。
如果后面文档量增加,可以再演进成:
任务表 ↓ 消息队列 ↓ 多个 ingestion worker ↓ 按文档 ID 做并发隔离工程优化不一定要一步做到最复杂,先解决当前最真实的问题更重要。
七、知识源不能只支持 PDF
原来的 ingestion 主要围绕 PDF 设计,但风险监控业务的数据来源并不只有 PDF。
实际使用中常见的还有:
信用查询结果 JSON 处罚数据 JSON 企业风险 CSV 汇总说明 Markdown 普通 TXT 文档如果系统只支持 PDF,会带来两个问题。
第一,很多真实业务数据必须先人工转换成 PDF,增加了使用成本。
第二,评测样本很难直接复用。很多风险查询结果本身就是结构化 JSON,如果必须转换后才能入库,测试和验证都会变得很麻烦。
所以这次把 ingestion 扩展成了统一入口,支持:
PDF MD TXT JSON CSV不同格式分别解析,最后统一转成标准文本块,再进入后续的切分、向量化和入库流程。
这样做以后,信用中国、企查查、处罚汇总等真实样本可以直接进入知识库,不需要额外转换。
八、原来只有向量召回,效果还不够稳定
工程链路稳定以后,下一步才是检索质量。
原来的检索主要是:
query embedding ↓ Milvus 相似度搜索 ↓ 取 topK这种方式能用,但有几个明显问题。
1. 缺少阈值控制
即使召回结果的相关度都比较低,只要设置了topK=5,系统仍然会返回 5 条结果。
这会把无关内容拼进 Prompt,反而干扰模型判断。
2. overlap 会造成重复内容
文本切分时通常会设置 overlap,相邻 chunk 之间会有一部分重复。
如果多个相邻 chunk 同时进入 topK,最终 Prompt 里会出现大量重复信息,浪费上下文窗口。
3. 单纯向量相似度不一定符合业务需求
向量召回擅长语义相似,但风险业务里还经常依赖:
企业名称 统一社会信用代码 处罚机关 法规名称 具体时间这些关键词有时更适合词法匹配,而不是完全依赖向量距离。
因此,优化后的检索链路改成了多阶段处理。
九、优化后的检索流程
新的在线检索流程是:
用户 Query ↓ 生成 query embedding ↓ 从 Milvus 召回较大的候选集 ↓ 过滤非 READY 文档 ↓ 应用 metadata filter ↓ chunk 去重 ↓ 词法初排 ↓ 模型 rerank ↓ score threshold 截断 ↓ 返回最终 topK这里不再直接把 Milvus 返回的前几条内容塞进 Prompt,而是先召回更多候选,再逐步收口。
十、metadata filter 解决什么问题
风险信息查询经常带有明确条件,例如:
只查某一个企业 只查某一种风险类型 只查指定来源 只查某个时间范围如果完全依赖向量相似度,可能会召回语义接近、但来源错误的内容。
所以在候选召回后增加 metadata filter,例如:
source documentId fileName type publishDate authority举个例子,用户明确问“某企业在信用中国的处罚记录”,系统就可以先限制来源为信用中国,再进行排序,而不是让企查查、内部报告和其他来源一起竞争。
十一、chunk 去重不能只按 ID
由于 overlap 的存在,相邻 chunk 可能内容高度相似。
如果只按 chunk ID 去重,它们仍然会被认为是不同结果。
因此可以综合使用:
文档 ID chunk 序号 文本摘要 内容哈希 相似度来抑制重复内容。
比较简单的做法是先按文档聚合,再限制同一文档进入最终结果的 chunk 数量。
也可以对候选文本做归一化后计算内容哈希,过滤完全重复或高度重复的片段。
目标不是完全消除相邻内容,而是避免最终 Prompt 被同一段话重复占满。
十二、为什么增加词法初排
向量召回负责语义相似,词法初排负责保留关键字匹配优势。
例如用户问题里出现了统一社会信用代码,这种内容具有非常强的精确匹配特征。
如果某个 chunk 精确包含这个代码,即使向量分数不是最高,也应该提高排序。
所以可以给候选结果增加一部分词法得分,例如关注:
企业名称命中 信用代码命中 处罚机关命中 法规关键词命中 问题关键词覆盖率最终先基于:
向量分 + 词法分完成一次初排,再把较小的候选集交给模型重排。
这样既控制了模型调用成本,也提高了精确字段的权重。
十三、为什么暂时使用 LLM 做 rerank
重排通常有几种方案:
规则重排 专用 reranker 模型 大模型重排当前项目已经有稳定的大模型客户端,因此为了快速验证效果,先选择了 LLM JSON 重排。
做法是把用户问题和候选 chunk 一起交给模型,让模型为每个候选结果给出相关度评分,再根据评分重新排序。
这种方案的优点是:
接入速度快 对复杂语义判断效果较好 不需要额外部署模型缺点也比较明显:
调用成本更高 延迟高于本地 reranker JSON 输出可能不稳定因此它更适合当前阶段做效果验证,不一定是最终方案。
后面数据量和调用量上来以后,可以替换成专用 reranker 模型,把大模型重排作为兜底或者离线评测工具。
十四、重排失败时不能拖垮整个查询
RAG 的目标是提升回答质量,但不能因为重排服务失败,导致整个检索不可用。
所以这里做了降级:
LLM rerank 成功 ↓ 使用模型重排结果 LLM rerank 失败 ↓ 退回向量分 + 词法分排序重排只是增强能力,不应该成为单点依赖。
同样的思路也适用于其他非核心环节:
统计失败不影响查询 监控写入失败不影响召回 后台刷新失败不影响已有 READY 数据先保证主链路可用,再尽可能提升质量。
十五、score threshold 比固定 topK 更重要
固定topK容易产生一个问题:无论有没有相关内容,都必须返回固定数量的结果。
更合理的方式是:
先按相关度排序 再过滤低于 threshold 的结果 最后从剩余结果里取 topK这样可能出现:
返回 5 条 返回 2 条 甚至一条也不返回一条都不返回并不一定是坏事。
与其把明显不相关的内容塞给大模型,不如明确告诉上层:
当前知识库没有找到足够相关的信息这对降低幻觉反而更有帮助。
十六、没有评测,优化就只能靠感觉
RAG 调优很容易陷入一种状态:
改了 chunk size,感觉好了一点 加了 rerank,感觉更准了 调了 topK,好像回答更完整了但如果没有固定评测集,这些结论都不够可靠。
所以这次补了一套风险场景基线评测集,围绕真实业务问题构造:
用户问题 期望命中的文档 期望命中的来源 期望答案关键词并统计:
full_hit_rate answer_hit_rate source_hit_rate recall_at_1 recall_at_3 mrr_at_3其中:
Recall@1表示正确结果是否排在第一位Recall@3表示正确结果是否出现在前三位MRR@3会同时考虑正确结果有没有命中,以及命中位置是否靠前
有了这些指标以后,就可以对比:
纯向量召回 向量 + 词法 向量 + 词法 + rerank 不同 threshold 不同 chunk sizeRAG 优化才从“凭感觉”变成“有数据验证”。
十七、可观测性也要跟上
除了离线评测,运行期也需要有基本观测数据。
目前主要记录:
ingestion 当前状态 ingestion 失败原因 query 总数 query 命中数 hit rate rerank 调用次数 rerank 成功次数ingestion 状态可以包括:
queued running idle failed这样排查问题时,可以快速判断:
是文档还没处理完 是 ingestion 失败 是查询没有召回 还是 rerank 失败后发生了降级如果没有这些信息,RAG 很容易变成黑盒。
用户只知道“这次没回答出来”,但开发无法判断问题发生在哪个环节。
十八、优化后的完整链路
最终整条链路可以概括为:
知识源发生变化 ↓ 后台调度 ingestion ↓ 文档状态置为 PROCESSING ↓ 解析 PDF / JSON / CSV / MD / TXT ↓ 切分和去重 ↓ 生成 embedding ↓ 写入 Milvus ↓ 全部成功后状态置为 READY在线查询则是:
用户提问 ↓ 生成 query embedding ↓ 召回候选 chunk ↓ 过滤非 READY 文档 ↓ metadata filter ↓ chunk 去重 ↓ 词法初排 ↓ LLM rerank ↓ 失败则降级为规则排序 ↓ score threshold 截断 ↓ 返回最终 topK ↓ 注入 Prompt这时的 RAG 已经不再只是“向量库查询”,而是一条完整的检索工程链路。
十九、这次优化真正解决了什么
回头看,这次改造并不是单纯为了让召回分数更高,而是解决了几个更基础的问题。
1. 在线和离线真正解耦
文档解析和索引构建不再阻塞用户查询,在线请求耗时更加稳定。
2. 半成品数据不可见
通过PROCESSING / READY / FAILED / DELETED状态控制,只有完整处理完成的文档才会参与检索。
3. 检索结果更可控
从单纯向量 topK,升级为:
候选召回 metadata 过滤 去重 词法初排 模型重排 阈值截断4. 更贴近真实风险场景
知识源从只支持 PDF,扩展到 JSON、CSV、Markdown 和 TXT,可以直接使用更多真实业务数据。
5. 优化效果可以量化
通过 Recall、MRR、命中率和运行期统计,后续调整不再依赖主观感受。
二十、总结
这次 RAG 优化给我最大的体会是,RAG 的问题不一定首先出在模型或向量库。
很多时候,真正影响生产效果的是:
在线和离线没有解耦 文档状态不可控 半成品数据提前可见 异常没有降级 召回结果没有过滤 优化效果无法评测如果这些基础问题没有解决,即使换了更好的 embedding 模型,或者把topK调得更大,整体效果也不一定稳定。
所以这次优化的顺序是:
先把工程边界立住 再保证数据状态可控 然后提升检索质量 最后建立评测闭环当 RAG 具备了后台 ingestion、状态门控、检索重排、异常降级和评测指标之后,它才真正从一个“能演示的功能”,变成了一条可以继续演进的生产链路。
