RAG 文档版本与索引一致性
RAG 系统上线后,真正难维护的往往不是检索算法,而是“用户看到的答案究竟来自哪一版文档”。源文件已更新,向量库可能仍保留旧片段;重建任务只成功一半,检索结果就会混入两个版本;文件被删除后,缓存中的回答甚至还会继续引用它。
解决这类问题不能只靠定时全量重建。更可靠的做法是把源文档、解析产物、chunk、embedding 和可查询索引视为一条有版本的发布链路,并明确何时写入、何时可见、如何回滚。
一、先定义什么叫“同一版”
文档版本不应只用文件名或更新时间判断。文件可能原地覆盖,更新时间可能被同步工具改写,相同内容也可能重复上传。建议为每次摄取生成不可变的 document_version_id,并保存源内容哈希、解析器版本、分块策略版本和 embedding 模型标识。
| 标识 | 作用 | 是否不可变 |
|---|---|---|
document_id | 表示业务上的同一份文档 | 否,可持续存在 |
document_version_id | 表示一次确定的内容版本 | 是 |
content_hash | 判断源内容是否真的变化 | 是 |
pipeline_version | 定位解析、清洗和分块差异 | 是 |
index_generation | 表示一次可查询索引发布 | 是 |
关键是区分“文档版本”和“索引代次”。一份文档版本可以因分块算法升级而被重新索引;同一索引代次则应只包含每份文档被批准上线的一个版本。这样排查错误时,才能回答是源内容变了,还是处理流水线变了。
二、用不可变记录代替原地覆盖
最危险的实现是按 document_id 直接 upsert chunk。新版本有 80 个片段、旧版本有 100 个片段时,如果只覆盖前 80 个,剩余 20 个旧片段仍可被召回。即使先删后写,任务中途失败也会造成短暂缺失。
更稳妥的模型是追加新版本:新 chunk 全部写入新的 document_version_id,写入期间标记为 building,验证完成后才把文档的 active_version_id 原子切换到新版本。检索必须显式过滤活动版本,而不是默认查询整个 collection。
SELECT chunk_id, text, embedding
FROM rag_chunks
WHERE tenant_id = :tenant_id
AND index_generation = :active_generation
AND status = 'published';
不可变写入会多占一段时间的存储,但换来了可验证发布和快速回滚。旧版本可在保留窗口结束后异步清理,不需要把删除放进用户可见的切换事务。
三、把更新做成两阶段发布
一次完整更新可以拆为 build 与 publish 两个阶段。build 阶段下载源文件、计算哈希、解析、分块、生成 embedding,并写入隔离的 staging 代次。每一步都记录输入版本和产物数量;相同幂等键重试时,应复用成功结果或安全覆盖 staging 数据。
publish 阶段先做验收,再更新一个很小的“活动代次”指针。验收至少包括:源文档数是否符合预期、每份非空文档是否产生 chunk、失败率是否为零、抽样检索能否命中新内容,以及旧版本关键语句是否已经不可见。
不要按 chunk 逐个切换可见状态,那会制造一个较长的混合窗口。若向量数据库不支持事务,可维护独立的 generation 元数据,并让查询服务在一次配置刷新中切换过滤条件。
四、增量索引也要处理删除传播
增量任务通常善于发现新增和修改,却容易漏掉删除。每轮同步应给源侧对象做清单快照,比较“本轮存在集合”与“上一轮活动集合”。确认删除后,不只是移除向量,还要处理关键词索引、rerank 特征、引用预览和答案缓存。
删除应区分软删除与物理清理。先把文档标记为 tombstoned,让检索立即过滤;随后由后台任务清理各存储中的派生产物。对于权限撤销,不能等待批处理,应优先更新访问控制过滤条件,因为“搜不到”必须先于“存储已清空”。多租户与权限字段如何进入检索条件,可参考 RAG Metadata Filter 设计:从字段选型到多租户隔离。
五、缓存键必须包含知识版本
如果回答缓存只以用户问题作为 key,索引更新后仍会返回旧答案。缓存键至少应包含租户、权限摘要、检索配置和 index_generation。索引代次切换后,新请求自然落到新命名空间,无需扫描删除全部旧 key。
语义缓存也要遵循相同原则。即使两个问题语义相近,只要知识版本不同,就不能共享结果。旧缓存可以短期保留以支持回滚,但不能继续服务当前代次。通用缓存分层与失效边界可结合 LLM 应用缓存层设计 一起制定。
六、用对账任务发现静默漂移
发布成功不代表以后不会漂移。应定期从源系统、版本库与向量索引三个方向做对账:活动文档数、chunk 数、内容哈希、权限摘要和 generation 是否匹配。对账任务只读扫描,发现差异后生成修复任务,不要直接在扫描过程中边查边改。
建议监控几类指标:处于 building 状态过久的版本、没有 chunk 的活动文档、引用不存在文档的孤儿 chunk、源侧已删除但仍可检索的文档,以及不同查询节点使用不同 generation 的持续时间。告警应携带 document_id、版本号和任务 ID,才能快速复现。
七、回滚与故障演练要提前设计
只要旧代次仍在保留期内,回滚可以简化为把活动指针切回上一代,而不必重新计算 embedding。切回后还要同步恢复对应缓存命名空间,并确认查询节点都已刷新配置。
上线前至少演练三种故障:embedding 批次部分失败、发布指针切换后部分节点未更新、源文件删除事件重复投递。验收目标不是“任务最终能跑完”,而是故障期间不会混用版本、不会越权召回,并且重试不会制造重复数据。
RAG 质量评测还应固定测试集所依赖的索引代次,否则同一份评测结果无法复现。可以把 generation 写入每条 trace,并配合 RAG 系统评测框架搭建实战 验证更新前后的召回与答案变化。