跳到主要内容

RAG 读后写竞争:当你的向量索引引用了一个已不存在的文档

· 阅读需 11 分钟
Tian Pan
Software Engineer

一个用户在 14:32:07 向你的助手提问。你的检索器在 14:32:08 触发,从政策手册中提取了五个分块。模型思考了几秒钟,起草了回复,并在 14:32:12 流式传回了一个答案,自信地引用了第 4.3 节——而管理员在 14:32:10 刚刚删除了这一节,因为它有误。用户读到了一段来自已不存在文档的权威引用,甚至还附带了一个返回 404 的可点击链接。

你的技术栈中没有任何环节报错。检索器返回了有效的命中结果。模型生成了流利、有据可查的文字。引用的分块 ID 在检索发生时确实存在。然而,根据任何合理的定义,这个答案都是一个幻觉——并不是因为模型胡编乱造,而是因为在它观察世界与开口表达的间隙,底层数据已经发生了变化。

这就是 RAG 的“写后读竞争”(read-after-write race),而大多数生产级流水线对此毫无防备。

你从未设计过的一致性模型

每个 RAG 流水线都默认假设了一个一致性模型——对于刚刚编辑过文档的用户来说是“写后读一致性”(read-your-writes),对于单个答案生成步骤来说是“单调读”(monotonic reads),以及检索与生成之间的“快照隔离”(snapshot isolation)。但这些假设都没有被强制执行。它们只是因为在开发环境中,回答所需的四秒钟内什么都不会改变,而被偶然继承下来的。

生产环境不是开发环境。文档会不断地被编辑、删除、重新授权和更替。向量索引是索引时根据这些文档计算出的嵌入(embeddings)缓存,就像所有缓存一样,一旦数据源发生变化而没有通知它,它就会面临一致性问题。你检索器背后的 HNSW 图并不知道它刚刚返回的分块在 90 秒前刚从 Postgres 中删除。对这些分块进行打分的重排序器(reranker)也不知道其中一个分块已经被修改成了与以前完全相反的意思。生成引用的模型同样对此一无所知,因为它得到的唯一信号就是分块文本——没有一致性令牌,没有版本号,没有读取时间戳,没有任何东西能让它识别出“这是过时的”。

如果你曾从事过数据库工作,你会立刻意识到这一点。你的 RAG 流水线本质上是跨两个系统(向量索引和事实源)的分布式读取,既没有隔离保证,也没有检测系统分歧的协议。它通常能正常工作的原因与最终一致性缓存通常能工作的原因相同:不一致的窗口很小,大多数读取不会恰好落入其中。但对于安全相关或合规相关的系统来说,“通常”是最糟糕的 SLO 目标。

没人称之为竞争条件的三种失败模式

这种竞争以三种不同的形式出现,而不理解其根本原因的人往往会将它们归类为不同的 Bug。

已删除分块引用(The deleted-chunk citation)。 在请求处理过程中,一个文档从源语料库中被移除了。向量索引尚未收到通知——大多数生产环境使用延迟删除或批量索引更新,因为每次删除都重新构建 HNSW 的成本太高——因此检索器依然愉快地返回了该分块。用户看到了一段公司已明确选择删除的内容引用。在受监管的场景下(如撤回的医疗指南、召回的财务文件、移除的政策),这不只是一个“过时缓存”问题;这是你的 AI 在替公司说出公司已正式否认的话。标准的事故审查反应通常是责怪模型产生幻觉,但这掩盖了索引管道中真正的 Bug。

已编辑分块的反转(The edited-chunk inversion)。 比删除更糟糕的是:文档仍然存在,但其内容在检索和生成之间被编辑了。模型上下文中的分块写着“我们提供 30 天退款窗口”,而磁盘上的分块现在写着“我们不提供退款”。模型写出了一个礼貌、引用充分的答案,承诺了 30 天退款。六周后,当退款请求开始引用 AI 的承诺时,你的客户支持团队才发现了这个变化。这里没有可检测到的幻觉;模型忠实地根据检索到的上下文生成了答案,而该上下文在检索瞬间是准确的。Bug 在于该上下文在生成瞬间已不再准确,而你的流水线完全没有“瞬间”这个概念。

过时权限泄露(The stale-permission leak)。 这是让安全架构师夜不能寐的情况。用户对某个文档的访问权限在 09:00 被撤销,但权限更改直到 09:15 的下一次同步才会传播到向量索引的元数据中。在 09:00 到 09:15 之间,该用户运行的每一次搜索都可能返回他们已无权访问的文档分块。检索器根据本身已过时的元数据进行过滤,结果过滤通过了。系统中没有记录访问拒绝,因为访问检查使用的是缓存的权限,而缓存依然显示“允许”。正如一位从业者所言,来自第三方数据源的权限 API 通常不提供 webhook,因此重新索引权限始终是一个轮询间隔问题——而轮询间隔就是你的风险暴露窗口。

这三个失败在 Jira 待办事项中看起来互不相关,但它们其实是流水线不同部分触发的同一个竞争条件。

架构选择

将检索视为数据库读取——带有在银行系统中你绝不会接受但在聊天机器人中却泰然处之的隔离性预期——开启了一系列架构应对方案,按你对“真相源”作为快速键值查找的信任程度排序。

检索时的快照隔离。 将每次检索固定在一个逻辑时间戳上。检索器返回的不只是分块(chunks),而是带版本的分块——chunk_id 加上计算嵌入(embedding)时的修订号。模型被告知:“此回答基于 T0 时刻。”如果你引用,引用的就是那个版本。如果文档稍后被编辑,引用仍然指向模型实际看到的那个版本。这需要在存储层建立一套版本管理方案(每个分块在每次修订时都需要一个不可变的标识符),并增加一个 UI 提示,说明“此回答基于 <时间戳> 的文档”。如果你的分块已经存储在类似 CDC 驱动的事件日志中,实现起来就很便宜;但如果你的“真相源”是一个没有历史 API 的 Confluence 空间,那就会非常痛苦。

引用前重新验证。 在模型发出引用之前——或者在响应离开响应缓冲区之前——根据 ID 重新获取分块,并确认三件事:该分块仍然存在、其内容哈希与检索到的匹配、用户仍然拥有访问权限。如果任何检查失败,则丢弃该引用,并使用剩余的上下文重新提示模型,或者显示一条“此回答已部分过期,正在重新生成”的消息。这成本更高(每次引用多一次往返),但它是唯一能为刚编辑完原始源的用户提供“读己之所写”(read-your-writes)语义的选择。这也是唯一能关闭过期权限泄露的方案,因为访问检查发生在输出时,而不是检索时。

事务性检索。 对于高风险领域——医疗指导、法律建议、金融咨询、合规报告——走得更远:如果源数据在处理期间发生了变化,则让请求失败。这相当于数据库行上的乐观并发控制。用户的重试将针对最新的状态进行。你会损失一些吞吐量,但你获得了一种能力,可以声明“绝不会针对过期数据生成任何回答”。对于那些由于引用已删除文档而导致错误回答被视为监管事件的领域,这是值得的权衡。

流式重新索引。 以上任何方案都不能替代缩小不一致窗口这一根本手段。2026 年代的流式 RAG 架构对真相源数据库使用变更数据捕获(CDC),使嵌入延迟达到亚秒级(即文档更新与新嵌入被索引之间的延迟)。竞态条件依然存在,但窗口从分钟级缩小到秒级,这极大降低了任何给定请求落入该窗口的概率。CDC 并不是一种一致性模型——它只是让你的一致性系统更快达到最终一致(eventually-consistent)的一种方式。

捕捉此问题的评估方法

这个 bug 无处不在的原因是没人在评估(eval)中测试它。标准的 RAG 评估套件测量检索精度、生成忠实度和引用准确性——所有这些都是针对在评估运行期间保持静止的静态语料库。这些指标都无法捕捉到竞态条件,因为测试框架中根本没有竞争。

能捕捉到它的评估在结构上是不同的。你需要构建语料库在检索和生成之间发生变动的测试用例。设置很简单:捕获一个真实查询,复制源文档,并设置一个定时器,在检索器返回之后、模型完成流式输出之前注入变动(删除分块、修改分块内容使其断言相反的观点,或撤销测试用户的访问权限)。然后检查答案。

从这种评估中得出的指标不是你当前仪表盘上的那些。过期引用率 (Stale citation rate) ——在输出时引用已不存在的分块的回答比例。反转引用率 (Inverted citation rate) ——引用的分块当前内容与模型所用内容相矛盾的回答比例。权限泄露率 (Permission leak rate) ——引用的分块是请求用户当前无法检索到的回答比例。这些指标不会出现在你能买到的任何 RAG 基准测试中,因为基准测试是建立在冻结的语料库之上的,而 bug 只存在于动态之中。

如果你的团队对“让我们测量过期引用率”的第一反应是“但我们没有办法在请求期间更改语料库”,这就是一个信号,说明你的评估框架是在“真相源的更改是别人的问题”这一假设下设计的。它们不是别人的问题,它们就是问题所在。

架构上的认知

向量索引就是一个缓存。每个缓存都需要一套一致性协议 (coherence protocol)。你的缓存保存的是自然语言文本的嵌入,而不是缓存的 SQL 行,这一事实并没有改变工程本质——它只是让不一致性在下游用户面前隐形了,因为其失效模式是“模型针对一件不再属实的事情写了一段流利的段落”,而不是简单的“页面 500 错误”。

在 2026 年交付可靠 RAG 的团队已经内化了这一点。他们为分块设定版本。他们在引用中体现版本。对于高风险路径,他们在输出前重新验证。他们将“请求中变动”评估作为 CI 的一部分,而不是作为事故后的复盘。他们将嵌入延迟视为带有告警的 SLO,而不是没人看的图表。他们还有一套删除协议,即使牺牲延迟,也会将撤回的内容同步传播到索引中。

那些不这样做的团队将继续提交写着“模型对已删除文档产生了幻觉”的工单,并推送根本不可能奏效的提示词工程 (prompt-engineering) 修复方案,因为 bug 并不在提示词里。Bug 在于世界是并发的,而你的流水线却假装它不是。

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