跳到主要内容

检索级联失效:文档删除如何毒害你的 RAG 流水线

· 阅读需 11 分钟
Tian Pan
Software Engineer

一个用户询问你的支持机器人退款期限何时结束。机器人带着愉快的自信给出了“60 天”的回答并附带了引用。然而,那个写着“60 天”的策略页面早在三个月前就从 CMS 中删除了。新策略是 14 天。直到有客户投诉,你的团队中才有人意识到机器人出错了。

这就是检索级联失效(retrieval cascade failure):文档已从事实源中消失,但其嵌入(embedding)仍留在索引中,在余弦相似度排名中依然靠前,不断为模型提供一个“幽灵”。RAG 流水线将向量索引视为源内容的缓存,但大多数团队在构建缓存时并没有构建失效机制。插入操作得到了所有的工程关注,而删除操作只得到了一个 TODO 注释。

这种故障很难被察觉,因为系统看起来是健康的。检索依然返回前 k 个结果。模型依然引用来源。来源 URL 甚至可能依然能解析为 404 或重定向。从外部看,唯一的信号是对于那些已经更新过的内容,回答会出现细微的错误。等人类捕捉到这一点时,错误的答案已经提供给了成千上万的用户。

为什么向量索引不会“忘记”

对大多数团队来说,第一个意外是:从向量数据库中删除一行很少是真正的硬删除。像 HNSW 这样基于图的索引——它驱动着 Weaviate、Qdrant、pgvector 的 HNSW 模式以及大多数其他生产系统——实际上无法在不破坏图连接性保证的情况下移除节点。所以它们并不直接移除,而是给节点打上墓碑(tombstone)标记并将其保留在图中。

系统会在查询时检查墓碑标记并从结果中过滤掉它们。只要检查了墓碑标记,这种方式就有效。此外,只要清理进程在索引降级之前运行,它也有效。Weaviate 的默认 cleanupIntervalSeconds 为 300,并且有一个记录在案的窗口期:在清理批处理完成之前,已删除的对象仍然可以被搜索到。对于更新频率较低的语料库,这个窗口期是无害的。但对于快速变动的知识库——如产品文档、工单历史、新闻源——这个窗口期正是“幽灵检索”存在的地方。

当清理进程确实运行时,它也有自己的病理特征。从 HNSW 图中移除一个节点会切断其入边和出边。其他节点会失去邻居。如果被删除的节点是通往图中某个区域的唯一路径,那么该区域就会变成孤儿节点(orphaned)——即使其数据完好无损,在搜索期间也无法触达。虽然存在修复操作(重新搜索替代邻居、重新链接图),但这些操作代价高昂,且不会在每次删除时运行。如果频繁变动而没有定期的全局重建,你的有效召回率(recall)会悄然萎缩。

HNSW 的研究文献将这些策略分为三类:逻辑删除(墓碑标记,最快,但会留下图降级隐患)、物理删除(剪断边,会产生孤儿节点)和全面重建(最准确,但在周二下午重新索引一个包含十亿向量的库可不是件轻松差事)。生产系统默认使用惰性删除,并寄希望于你会按计划触发重建。

级联效应:一个文档,多个分块,多次失效

单个源文档不会只产生一个嵌入,而是产生 N 个分块(chunks),每个分块都有自己的向量。现代的分块策略——滑动窗口、语义边界、重叠——会进一步增加这个 N。源文档的删除对应于索引中向量删除的扇出,而你漏掉的每一个分块都是一个“幽灵”。

这就是级联效应变得昂贵的地方。朴素的摄取流水线追踪文档,但索引存储的是分块。如果你的文档与分块的映射关系不可靠——例如,如果你在每次摄取时重新生成分块 ID,或者分块边界在运行之间发生了微调——你就无法在删除时可靠地列出“属于文档 X 的所有分块”。你删除了能找到的那些,而那些从你的追踪表中漂移出去的“孤儿”则会永久留在索引中。

编辑比删除更糟糕。当一个文档更新时,正确的行为是删除旧分块并插入新分块。如果旧分块无法被唯一确定地识别,你最终会导致新旧两个版本同时存在于索引中。检索可能会返回过时的分块、当前的分块或两者的混合——而模型没有信号来判断哪个才是权威版本。你“更新后”的知识库现在成了一堆未经整理的、包含每个文档所有版本的乱葬岗。

解决办法虽然乏味但很有效:在摄取时分配一个稳定的文档 ID 和稳定的分块 ID,将两者都作为元数据存储在每个向量上,并将文档与分块的映射持久化在你控制的辅助表(sidecar table)中。当源文档被删除时,你通过辅助表查询其对应的分块 ID,然后根据这些精确的 ID 对向量库执行删除。跳过辅助表,你就只能靠猜了。

使用 CDC 而非 Cron 进行源与索引的同步

许多团队使用每晚一次的批处理任务(Cron)来同步向量索引:读取所有文档、计算哈希值、与上一次运行进行比较、推送删除和更新。这在小规模下可行,但随着语料库的增长,它会以可预测的方式崩溃。任务耗时超过了两次运行之间的间隙;运行中途的失败会导致索引处于不一致状态;在运行间隔期间删除的文档会提供长达 24 小时的幽灵检索。

变更数据捕获(Change Data Capture, CDC)是生产环境的答案。你不再轮询源,而是订阅其变更日志——如 Postgres 逻辑复制、MongoDB 变更流、来自上游服务的 Kafka 主题——并实时对删除事件做出反应。像 Milvus-CDC 和 pgvector 的逻辑解码集成工具正是为了这种模式而存在的。失效一个已删除向量的延迟从“下一次批处理运行”降到了“秒级”。

CDC 还能给你 Cron 无法提供的东西:权威的顺序。如果一个文档在一分钟内被创建、更新并删除,变更流会按顺序看到这些事件。向量索引最终将不包含该文档的任何分块,这是正确的。而一个基于内容哈希的 Cron 任务可能会错过整个生命周期,并留下陈旧的分块,因为该文档仅存在于两个快照之间。

使用 CDC 的代价是你现在拥有了一个流式流水线,这意味着你得处理它带来的所有复杂性——精确一次语义(exactly-once semantics)、死信队列、回放工具,以及变更事件本身的模式迁移。对于高风险语料库(合规文档、支持知识库,以及任何错误答案会导致法律或安全风险的场景),这种运维开销是值得的。对于个人项目的聊天机器人,每晚批处理就足够了。请有意识地做出选择,而不是随大流。

检索时的防御措施

即使拥有完善的数据摄取管道 (ingestion pipeline),你仍 vision 需要在查询时建立一道防线。有两种模式值得从第一天起就接入。

针对事实来源 (source of truth) 进行存在性检查。 每个检索到的块都带有文档 ID。在将前 k 个块传递给模型之前,验证每个 ID 在源系统中是否依然有效。这相当于每个块增加了一次查找——通常是针对你维护的新鲜度表进行哈希表关联 (hash-map join)——它可以捕捉到数据摄取管道遗漏的任何内容。其代价是一次往返;其收益是不会有“幽灵数据”到达模型。对于足够小的新鲜度表,你可以将其保存在内存中,这样查找几乎是无开销的。

元数据中的最后验证时间戳。 在每个块上存储 ingested_atlast_verified_at。检索层会过滤掉 last_verified_at 早于可配置阈值的块。后台任务会更积极地重新验证最近检索到的块(即那些有流量的块)。这为你提供了平滑降级的能力:如果你的数据摄取管道在周末崩溃了,检索层会开始排除陈旧的块,而不是自信地提供它们。这种做法的权衡在于你现在暴露了一个“悬崖”——一个 24 小时前还完全正常的块突然无法被检索到了——因此请按文档类型设置阈值,而不是全局统一设置。

第三种模式,衰减加权评分 (decay-weighted scoring),值得一提,但更容易出错。其核心思想是将余弦相似度乘以一个新鲜度衰减因子,从而让较旧的块排名降低。如果处理得当,这可以在不隐藏旧但仍有效的內容的情况下,让新鲜内容浮现出来。如果处理不当,它会产生永久性的新鲜度偏见,导致上周二更新的文档总是胜过第一天就正确且无需修改的文档。如果你要上线此功能,请务必配合固定查询集的离线评估,否则你将无法察觉何时为了追求新鲜度而损害了检索质量。

周一该做什么

如果你正在运行一个生产环境中的 RAG 系统,且从未审计过删除行为,这里有一个最简单有效的初步步骤:挑选一批最近删除的源文档样本(例如,过去一周内的 50 份),针对每一份文档,检查其任何块是否仍出现在针对合理查询的前 50 个检索结果中。如果答案是“是的,经常出现”,那么你现在就面临着幽灵嵌入 (ghost-embedding) 的问题。

完整的解决方案是多层级的:稳定的文档和块 ID、Sidecar 映射表、基于 CDC 的同步、检索时的存在性检查,以及吸收墓碑记录 (tombstones) 留下的图降级 (graph degradation) 的定期全量重建计划。每一层都可以在一个迭代周期 (sprint) 内完成。加在一起大概需要一个季度。部署这些方案的团队将不再收到“机器人自信地引用了已删除的政策”这类工单,虽然没人追踪这项指标,但它是最重要的指标。

请将你的向量索引视为源语料库的派生视图,而不是语料库本身。不失效的缓存不是缓存——它们是缓慢、昂贵的事实来源,且与真实的情况不符。RAG 管道总是在悄无声息地发生偏移,因为其失效模式永远是自信的错误回答,而绝非显式的报错。构建失效逻辑时要像构建检索逻辑一样用心,否则就只能接受“大部分时间答案还行”将是你所交付系统的上限。

References:Let's stay in touch and Follow me for more thoughts and updates