RAG 系统上线后,文档更新是常态。但很多团队会忽略一个问题:向量索引的更新并不等同于知识库的更新。

你往文档库里修改了一段产品说明,重新走一遍 embedding,把新向量写入向量数据库,旧向量却还留在索引里。检索时,排在最前面的可能是旧版本内容。除非你把文档 ID 设计得足够好,或者每次更新都全量重建索引,否则这个问题早晚会出现。

本文不绑定具体产品,而是从维护 RAG 系统的通用工程路径出发,讨论增量更新后旧索引残留的清理策略。

为什么增量更新会留下旧索引

常见 RAG 增量更新流程是:

  1. 文档发生变化
  2. 对变更内容做切分
  3. 调用 embedding 模型生成新向量
  4. 将新向量写入向量数据库

问题出在第 4 步。如果向量数据库按照 doc_id + chunk_id 写入,却没有删除旧的 chunk_id,那么同一段内容在索引里会同时存在新旧两个版本。

更隐蔽的情况是文档切分逻辑变化。比如你从按段落切分改成按 500 token 固定窗口切分,chunk 的边界全部变了,旧 chunk 没有对应 ID 可以覆盖,只能靠清理。

三类常见策略:全量重建、直接覆盖、版本过滤

1. 全量重建

全量重建是逻辑上最简单的方案。把整个知识库重新切分、重新 embedding、重新写入一个新的 collection 或 namespace,然后切换检索入口。

优点是实现成本低,不需要处理单条删除逻辑。缺点是文档量大时耗时明显,且重建期间可能面临检索服务切换的窗口。如果系统允许低频更新(例如每天一次),这个方案足够用。

2. 直接覆盖写入(upsert)

部分向量数据库(如 Milvus、Qdrant 等)提供 upsert 能力,相同向量 ID 只会保留最新写入的向量,具体以产品文档为准。

这个方案的前提是:你必须为每个 chunk 设计稳定的、可预测的 ID。如果 ID 由 doc_id + chunk_index 组成,那么文档内部内容顺序变化时,chunk 对齐会错位。新增一个段落导致后续 chunk 索引整体后移时,upsert 不会删除错位的旧 chunk。

所以,upsert 适合文档结构稳定、切分结果可预测的场景。它不是万能方案。

3. 按版本号或时间戳过滤

这是工程上更值得构建的方案:给每个文档(或每个 chunk)打上版本号或更新时间字段,检索时通过元数据过滤只查最新版本。

这个方案的优点是不需要立刻物理删除旧向量,业务上先保证结果正确。旧向量可以通过异步任务延迟清理。

三类方案可以组合使用。这里给出一个推荐的分层思路:

  • 写入侧:doc_id + version + chunk_id 作为向量唯一标识
  • 检索侧:元数据过滤 version = {最新版本}
  • 后台侧:定期删除 version < {当前版本} 的旧向量

按版本号清理的落地路径

要落地这套机制,需要关注几个关键点。

1. 文档版本表

维护一张文档版本表,记录每个 doc_id 当前生效的版本号。更新文档时,版本号递增。这个表可以放在关系型数据库里,也可以放在 Redis 等 KV 存储中。它的作用是让检索服务和清理任务都能快速得知:某个文档现在应该指向哪个向量版本。

2. 写入新版本向量

文档更新后,先生成新的 chunk 列表,给每个 chunk 写入向量,并记录 doc_idversionchunk_id 这三个字段。

注意:此时不要立刻删除旧版本。因为新向量可能还在异步写入中,如果先删旧再写新,中间会有一段检索空窗期。更稳妥的顺序是:先写新,再删旧。

3. 检索时按版本过滤

查询向量数据库时,把 doc_id → version 的映射注入过滤条件。

不同向量数据库的过滤语法不同,但概念是统一的。如果使用 FAISS,原生 Index 的基础检索接口通常不直接支持按元数据过滤,需自行维护映射或使用更高层封装。例如,你可以在检索返回的 doc_id 列表上做一次后置过滤,或者考虑使用支持元数据过滤的索引类型或向更高层的检索服务迁移。

如果使用 Milvus、Elasticsearch 这类支持结构化字段过滤的向量检索能力,做法会直接一些:查询时附加一个表达式,例如 version = 3,同时过滤掉 doc_status = deprecated 的条目。

这里真正值得关注的是:过滤条件必须设计在查询路径上,而不是只靠清理任务保证。否则清理任务未执行时,系统依然会返回旧数据。

4. 删除旧向量

当新向量确认写入成功、检索切换完成后,可以删除旧版本向量。删除操作可以利用向量 ID 前缀或范围过滤完成。

关键点是删除任务要支持断点续跑。你可以记录每次删除的 doc_id 游标,也可以按版本号批量删除。注意控制批量删除的粒度,避免一次性删除大量向量导致数据库负载过高。

定时任务设计

清理旧向量适合做成独立的定时任务,而不是嵌在每次文档更新的同步路径里。

一个可行的做法是:

  • 任务每分钟或每五分钟扫描一次文档版本表,找出已更新到新版本且未完成清理的 doc_id
  • 对每个 doc_id,先确认新版本向量已写入,再删除旧版本向量
  • 删除完成后,在文档版本表中记录清理状态
  • 如果清理失败,保留状态,下一次任务继续重试

这种设计能避免两个问题:一是更新接口中同步删除带来的请求延迟;二是删除失败后无法自动恢复。

需要特别留意的边界情况

1. 文档回滚

如果文档从 v3 回滚到 v2,而 v2 的向量可能已经被删除,这时需要从原始文档重新生成向量,而不是直接复用旧向量。方案上,要么保留最近 N 个版本的向量,要么在回滚时触发一次补建任务。

2. 切分逻辑变更

修改切分参数后,同一个文档的 chunk 数量和边界都会变化。此时 upsert 无法正确处理,必须走完整的新版本写入 + 旧版本清理流程。

3. 多租户隔离

如果你的 RAG 系统服务多个租户,删除操作要始终携带租户过滤条件。这个条件同样应该出现在写入、检索和清理全链路,避免租户间的数据串扰和误删。

4. 删除不是即时生效的

在许多向量数据库中,删除操作标记的向量不会立刻从底层索引文件里物理消失,检索时会被过滤掉,但磁盘空间可能暂时没有释放。如果系统对存储成本敏感,需要定期做索引压缩或重建。

清理方案的对比判断

策略 适用场景 主要风险
全量重建 文档总量可控、更新频率低 耗时、存在切换窗口
upsert 覆盖 文档结构稳定、chunk 边界可预测 切分逻辑变更时无法覆盖旧 chunk
版本过滤 + 异步删除 更新频繁、需要快速恢复正确性 需要维护版本表和清理任务

对大多数维护 RAG 系统的团队而言,版本过滤 + 异步删除是更稳妥的长期方案,尤其是文档更新频繁或切分逻辑会调整的场景。

如果资料量不大、更新频率极低,全量重建反而更省事,值得优先考虑。

总结

增量更新后旧索引残留问题,本质上是:

  • 向量写入时没有考虑旧数据的生命周期
  • 检索时没有附加版本过滤
  • 删除任务没有独立设计

一个完整的解决方案应该同时覆盖写入、检索和清理三个环节。单独增加删除任务而不在检索侧过滤,系统依然存在返回旧数据的窗口。反过来,只加版本过滤而不做异步清理,索引会慢慢膨胀。

从工程角度看,先保证检索结果正确,再考虑存储空间回收,是更合理的实施顺序。具体到某个向量数据库的删除语法、过滤索引的支持程度,还需要结合你实际使用的产品文档做二次验证。