
从“能检索”到“可生产”一次 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而不是 MilvusMilvus 很适合保存向量和检索字段但文档状态本质上属于事务型元数据。除了状态本身后面通常还需要记录文件名称 文件路径 文件摘要 处理时间 失败原因 重试次数 文件签名 更新时间这类信息放在 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. 缺少阈值控制即使召回结果的相关度都比较低只要设置了topK5系统仍然会返回 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其中Recall1表示正确结果是否排在第一位Recall3表示正确结果是否出现在前三位MRR3会同时考虑正确结果有没有命中以及命中位置是否靠前有了这些指标以后就可以对比纯向量召回 向量 词法 向量 词法 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、状态门控、检索重排、异常降级和评测指标之后它才真正从一个“能演示的功能”变成了一条可以继续演进的生产链路。