跳到主要内容

源文档更新从未同步到向量索引的那些 Embedding

· 阅读需 12 分钟
Tian Pan
Software Engineer

一名支持工程师在值班频道发了消息。一位客户粘贴了助手上周检索到的一个句子,合规团队回复说:我们已经不再这么说了。他们已经停用这句话四个月了。CMS 中的文档显示是正确的。但向量索引中的嵌入分块(chunk)仍然是旧的内容,且拥有极高的相似度得分,并在每次相关查询时被提供给模型。没人改过检索代码。没人改过模型。真相源(source-of-truth)变了,而索引却对此一无所知。

这是摄取流水线(ingestion pipeline)的一种失败模式:它最初是为“创建”而设计的,后来演变成了一个也要处理“更新”的系统,却没有人专门为“更新”进行设计。“创建时嵌入”的任务在每份文档首次写入的那天运行。一个季度后,CMS 团队发布了一个编辑端点,由另一个团队负责,他们将其接入了搜索、面向公众的渲染器和变更日志 feed —— 接入了每一个消费者,除了那个隐藏在不同名称下的派生数据集。数月过去,语料库发生了漂移。检索系统开始回答那些公司早已正式抛弃的问题,而唯一的信号来自于一位困惑的客户。

创建路径大张旗鼓,更新路径悄无声息

回顾过去,看看组织架构图,失败的原因显而易见。构建向量索引的团队同时也构建了摄取任务,然后就认为大功告成了。负责 CMS 的团队则根据不同的路线图发布功能。当产品中加入编辑功能时,编辑事件被分发给了该团队所知道的消费者:渲染页面、API 响应、分析计数器。向量索引不在列表中,因为它不在他们对 CMS 的心智模型里 —— 它被视为“AI 的东西”,由 AI 团队运行,针对的是 AI 团队曾经提取过的一次性快照。

从各自的职责范围来看,每个团队都是正确的。CMS 团队正在向所有已知的订阅者发布编辑。检索团队拥有一个可以工作的摄取任务。没有人站在交界处,而交界处正是新鲜度契约(freshness contract)必须存在的地方。这种 bug 最常见的形式并不是一个复杂的分布式系统问题;而是两个待办事项列表(backlog)中从未产生过那张名为“将 CMS 更新事件接入嵌入 worker”的工单。

“仅限创建”的摄取任务之所以能存在,是因为它对每份新文档的前几周都是有效的。检索在演示中看起来很棒。评估套件是根据构建索引时使用的同一个快照构建的,因此评估准则永远不会与检索出的内容产生分歧。真相源与索引之间的漂移在看似正常的仪表盘下不断累积。

向量索引是一个物化视图

对于这一层,正确的心智模型应该借用自数据库。向量索引是源语料库上的一个物化视图(materialized view)。每个物化视图都有两个核心问题:它是如何构建的,以及它是如何失效(invalidated)的。构建向量索引的团队通常只回答第一个问题 —— 分块(chunker)、嵌入(embedder)、写入存储 —— 并假设第二个问题会自动解决,因为 AI 工具链中没有任何东西提醒他们去思考这个问题。

一个永远不会失效的物化视图就是一个没有淘汰策略(eviction policy)的缓存。它提供的是它状态最好那一天的世界版本。每个数据库工程师都深知,你不应该将这样的东西发布到生产环境的读取路径中。但当同一个工程师看到相同的检索架构图时,却没能识别出其中的缓存,因为图上写的是“向量索引”,而缓存是隐性的。

定性很重要,因为它会改变工作内容。如果你将索引视为一个物化视图,你就不再将其视为一次性的摄取问题,而是将其视为一个持续派生的数据集,其与源系统的契约是新鲜度保证。持续派生的数据集需要变更 feed、分发、版本锚点和对账路径。它们需要负责的是源系统与视图之间的差异,而不仅仅是负责构建任务。

闭环的四种模式

以下四种模式(按投入成本递增排序)可以修复这一一致性问题。

从源系统到嵌入 worker 的变更数据捕获(CDC)feed。 CMS、工单系统、知识库 —— 无论语料库是什么 —— 都会为每一次创建、更新和删除发出事件。嵌入 worker 订阅这些事件。更新时,它会对受影响的文档重新分块,重新生成嵌入,并覆盖现有的向量。删除时,它会为向量添加墓碑标记(tombstone),使检索无法再找到它们。这是唯一能实现实时闭环的模式;其他一切都是失败后的回退方案。如果源系统不原生支持发送变更事件,你就需要针对其数据库构建 CDC —— 逻辑复制槽(logical replication slot)、基于触发器的发件箱表(outbox table)或 Debezium 风格的连接器。这类工作的形式与你的数据团队为数据仓库所做的工作完全一致。

在向量旁边记录每份文档的版本锚点。 将源系统的最后修改时间戳和内容哈希值与嵌入向量存储在一起。检索系统现在可以回答“这个分块是否仍与源系统保持一致?”的问题,而无需在热路径上查阅源系统。每晚运行的任务可以做这件事。仪表盘可以做这件事。新鲜度探测器也可以做这件事。如果没有版本锚点,索引就是不透明的 —— 你无法通过观察来区分新鲜分块和陈旧分块,你对新鲜度给出的任何答案都只是基于你记忆中上次运行摄取时间的猜测。

根据源系统的最后修改时钟测量新鲜度 SLO。 选择能够对应到用户可见损害的指标。索引内容的平均年龄并不重要;源系统更新与索引更新之间的延迟(lag)才重要。将其表述为:“99% 的源系统更新在 N 分钟内反映到索引中。”并为此设置告警。这个指标只有在你拥有版本锚点时才有意义,因为你需要两端的每文档时间戳来计算延迟。

对比源系统与索引的对账任务(Reconciliation job)。 即使有了 CDC,事件也可能丢失,worker 可能会在处理批次的中途崩溃,删除可能会丢失,模式迁移可能会漏掉一列。对账任务是定期审计,用于捕捉流式路径遗漏的内容。遍历源语料库,将每份文档的最后修改时间戳和哈希值与索引中记录的版本进行对比,将差异集排入 CDC 路径使用的同一个嵌入 worker 队列中。对账任务也是任何历史数据早于 CDC 连接器的源系统的回填(backfill)机制 —— 一旦有了对账机制,你就不再需要单独的一次性摄取任务,因为针对空索引的对账与针对陈旧索引的对账在代码路径上是一样的。

不依赖于用户反馈的检测机制

如果检索过时的第一个信号是客户将过时的报价贴回给客服,那么该系统对其最重大的故障模式就缺乏可观测性。行之有效的检测模式不应依赖于用户报告。

将新鲜度(Freshness)作为与检索延迟和回答质量同等的一等公民指标,可以在偏差累积之前就将其暴露出来。一个包含过时文档计数的绿色仪表盘不会在系统健康状况上说谎。告警阈值应反映语料库的特性:每周编辑一次的法律手册可以容忍数小时的滞后;而驱动报价的价格页面则不行。

对新索引内容的分布进行嵌入向量偏移检测(Embedding drift detection)可以捕捉到另一类故障——当源语料库在变化但索引却没有变化时,向量的分布将停止移动。一个对比本周与上周新向量分布的监控器会在摄取流程静默停止时触发告警,即使没有任何单个文档报错。

端到端探测(End-to-end probes)是团队真正能够维护的廉价版评估方案。挑选一小部分定期编辑的文档。每次编辑后,向检索层查询一个其答案取决于新措辞的问题。如果索引返回了旧的措辞,就发出告警。这是一个合成金丝雀(synthetic canary),而非评估标准(eval rubric),即使评估套件本身相对于当前产品已经过时,它也能捕捉到过时类故障。

为什么“定期全量重索引”行不通

大多数团队首先想到的退路是对整个语料库进行每晚或每周的批量重索引。安排这种任务令人安心且易于交付,它产生的故障模式类似于停机维护,比持续的偏移更容易理解。但随着规模的扩大,它也会产生问题。

全量重索引的成本与语料库大小成正比,而不是与变化率成正比。一个拥有百万文档、每天只有一百个文档变化的语料库,每晚都要支付百万级别的代价来捕捉这一百个变化。成本是实实在在的——嵌入(embeddings)并非免费,而且嵌入预算是生产级 RAG 系统中最大的开销项之一。流式架构(Streaming architectures)的成本与变化率成正比,通常比语料库全量处理的成本低一到三个数量级。

批量重索引存在流式处理所没有的部分更新故障模式。如果批处理在执行到一半时崩溃,索引会处于一种尴尬的状态:一半文档是版本 N,另一半是版本 N+1,且没有明显的界限。检索层无法区分它查询的是哪一半。批量重索引的安全模式是别名切换(alias swap)——完全构建一个新索引,针对基准查询集进行验证,然后原子性地切换,并保留旧索引以备回滚——但这增加了足够的运维负担,导致大多数团队最终交付的是不安全版本。

每晚一次的节奏还预设了一个索引在定义上就是错误的窗口。如果一个客户可见的文档在上午 9 点发生了变化,而重索引在凌晨 2 点才运行,那么检索结果在设计上就有 17 个小时是错误的。那个窗口对内部用户是不可见的,因为它发生在办公时间内,看起来索引“正在工作”。这不是一份新鲜度契约;这是对新鲜度的拒绝。

架构层面的觉醒

Bug 不在于摄取任务出错了。摄取任务完全按照它的设计执行:在创建时对每个文档进行嵌入。Bug 在于那个假设——认为“创建”是索引需要感知的唯一事件。源端发出了更新;消费端只注册了创建;这个鸿沟就是 Bug 所在。

一旦团队将向量索引视为物化视图(materialized view)而非摄取目标,对话就会发生变化。工作不再是“构建一个更好的摄取任务”;而是“维护源端与视图之间的一致性(invariant)”。这种所有权有其产物:变更提要(change feed)、版本固定(version pin)、新鲜度 SLO、对账差异(reconciliation diff)。它有告警和值班排班。它有一份你可以向其他团队出示的契约,当下一个团队向 CMS 添加新的事件类型时,因为版本固定会开始显示分歧,SLO 会触发告警,某人会收到传呼,而这种传呼在过去四个月系统发生静默偏移时是从来没有过的。

交付可靠检索的团队,是那些不再将向量存储描述为一个他们写入数据的数据库,而是将其描述为一个必须与记录系统(system of record)保持一致的缓存的团队。这种词汇的变化迫使你思考正确的问题:真相来源是什么,失效事件是什么,新鲜度 SLO 是多少,对账路径是什么。一个认为自己只是在运行摄取任务的团队永远不会问这些问题。而一个意识到自己是在运行派生数据集(derived dataset)的团队会回答所有这些问题。区别就在于:你的客户永远不需要再把过时的报价贴回给客服。

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