跳到主要内容

你的 Embedding 并不知晓外包人员已离职

· 阅读需 11 分钟
Tian Pan
Software Engineer

一名外包人员在上个季度结束了为期六个月的聘期。人力资源部门执行了离职清单:禁用 SSO、擦除笔记本电脑数据、移除 GitHub 席位、归档 Slack、撤销 Notion 访问权限。合规部门签字确认。六周后,一个内部 RAG 助手在回答问题时引用了该外包人员编写的一份机密战略文档——而引用的数据块在向量数据库的白名单中仍标记着该外包人员的用户 ID。事实来源(source-of-truth)的访问日志中没有任何读取记录,因为根本没有发生读取。检索来自一份从未被纳入离职流程的数据副本。

这是没人会画在架构图上的结构性问题。你的向量索引不仅仅是一个相似度搜索引擎。它是一个权限缓存——一个关于“谁能看到什么”的派生存储,冻结在你运行嵌入任务的那一刻——而且几乎没有人像失效其他内容那样去失效它。

应用程序数据库有一个不断变化的权限表。当文件移动到已弃用的文件夹时,文档存储会触发删除事件。身份提供者(IdP)将群组成员身份的变化广播到一个没人订阅的 Webhook。与此同时,向量嵌入存在于一个独立的系统中,其元数据是在摄取时从源系统的 ACL 状态中抓取的,而这些元数据唯一发生变化的时候,就是同一个数据块被重新嵌入时——这通常发生在内容发生变化时,而不是权限发生变化时。系统需要保持同步的这两个事实由两个不同的团队负责,以两种不同的节奏运行,而这个 Bug 只有在相似度搜索恰好接近本该过期的内容时才会浮现。

没人称之为缓存的权限缓存

工程师们会乐于承认向量存储是内容的缓存。大多数团队对于文档编辑时会发生什么都有相应的方案——通常是某种形式的重新嵌入,有时是标记删除(tombstone),有时是每晚重建。但他们没有方案来处理与该内容关联的权限状态。

当你嵌入一个文档时,你会向向量存储中复制三样东西:数据块文本(某种程度上——它变成了向量)、数据块元数据,以及至关重要的元数据中与权限相关的字段。所有者 ID、群组列表、敏感度标签、租户范围。这些字段在摄取时是正确的。从那时起,它们就成了一个快照。嵌入运行时,外包人员还在白名单群组中。现在外包人员已不在 IdP 的那个群组里了。但向量存储并不知道。

标准建议——在检索时而非后处理时强制执行 ACL 过滤——假设 ACL 过滤是针对当前状态进行评估的。在实践中,被评估的是与向量一起被索引的任何元数据。除非你刻意去修复这个问题,否则你的“权限感知检索”所感知的权限仅停留在你上一次运行嵌入流水线的时间点。

有一个很诱人的变通方法:在检索后的应用层强制执行权限,通过针对实时 ACL 重新检查每个返回数据块的源文档。这缩小了泄露范围,但并未完全关闭漏洞。该数据块仍然通过了你的检索系统,这意味着它出现在了你的日志中,可能影响了重排序,而且最重要的一点——它被加载到了一个本不该能访问它的用户的应用程序进程内存中。如果这些环节中有任何地方记录了数据块文本(大多数可观测性技术栈默认都会这么做),那么你刚刚就将外包人员的机密文档写入了请求用户会话下的调试日志中。

检索是一种在删除后依然存在的侧信道

将向量存储视为一个侧信道。它读取存在于别处的数据,并回答不受源系统删除语义限制的问题。Postgres 中的一行被软删除;向量嵌入还在。文档管理系统中的一个文件被移动到“已弃用”文件夹;向量嵌入还在。IdP 中的一个用户被从项目组中移除;向量嵌入的白名单元数据还在。一个租户从你的 SaaS 中注销;埋在共享多租户索引中的向量嵌入还在。

想象一下这个 Bug 最昂贵版本的审计时间线。被遗忘权(right-to-be-forgotten)请求在上季度提出。法务部门记录了请求,数据团队针对生产数据库运行了删除脚本,行被移除,并向用户发回了确认。合规部门关闭了工单。坐落在由不同团队运营的独立服务上的向量索引,并不在脚本的处理范围内。六个月后,助手通过引用那些已删除的内容回答了一个问题,用户发现自己的名字出现了,现在你就面临一起监管事件,而你的删除日志无法为你辩护——因为你的日志显示数据已消失,但实际上并没有。

检索日志显示数据块被返回。事实来源日志显示行已被删除。这两份日志互相矛盾。在大多数现代数据保护机制下,仅事实来源干净是不够的——派生存储也必须跟进。向量索引是一个派生存储。它需要自己的删除流水线和确认机制,并且需要与数据库删除放在同一个工单中处理,而不是放在一个等着某人去处理的独立队列里。

为什么“每个租户一个命名空间”只解决了简单情况

解决多租户泄露最清晰的方法是为每个租户提供自己的命名空间、索引或集合——即物理隔离,而非逻辑过滤。当租户注销时,你直接删除命名空间,这样就不会有任何泄露。这确实是很好的设计,并彻底解决了租户注销的情况。

但它并没有解决租户内部的情况,而大多数权限漏洞实际上都存在于此。在一个租户内部,你拥有用户、组、角色以及具有重叠 ACL(访问控制列表)的文档。承包商是该租户内部的一个用户。他们的注销并不会删除命名空间;它只是改变了他们的组成员身份以及他们在该空间内的每份文档的授权。命名空间边界的粒度是错误的。

第二种模式——按敏感度等级进行物理隔离——具有相同的特性。将“公开”和“机密”拆分为独立的索引是有帮助的,但机密索引中仍然包含那些内部 ACL 已与 IdP 脱节的文档。

对于租户内 ACL 变更的诚实回答是,你需要一个“传播流水线(propagation pipeline)”:一个来自 IdP 和文档存储的事件流,配备能够在几分钟内(而不是每晚的批处理)更新或使受影响向量嵌入(embeddings)失效的处理程序。这是许多团队会跳过的部分,因为这是真正的基础设施工作,而且在有人注意到之前,其失败模式是不可见的。

墓碑标记、硬删除以及你必须选择的语义

当源文档被删除时,你在向量数据库中有三个选择。你可以硬删除向量嵌入——检索速度快,没有进一步的暴露面,但你失去了审计曾经过往内容的能力。你可以使用“墓碑标记(tombstone)”——留下一个标记,在查询时进行过滤,从而不返回任何内容,但保留向量以便恢复或审计。或者你什么都不做,依靠应用层过滤,而我们已经确定这在元数据过时的情况下是失效的。

这些选择不可互换。对于符合 GDPR 要求的请求,硬删除是正确的默认选择,因为法律要求数据必须无法通过任何手段检索。对于源系统中数据在概念上仍然存在且可能恢复的“软删除”语义,墓碑标记是正确的默认选择。草率地混合使用它们会导致向量索引中某些“已删除”的内容仍然可以被查询到,因为墓碑过滤器并没有接入到每一个检索路径中。

没人愿意承认的一个错误是默认使用墓碑标记却从不进行垃圾回收。墓碑表会无限制地增长,在达到一定规模后,过滤成本会超过查询成本。更糟糕的是,墓碑标记也是元数据——而元数据可能会发生偏移。墓碑过滤子句中的一个 Bug 会悄无声息地暴露每一个软删除的分块。在向量数据库安全审查中最有效的测试是:删除一个文档,然后查询该文档中逐字对应的特定内容;如果返回了结果,说明你的墓碑方案存在泄露,无论操作手册(runbook)描述得多么自信。

缩小差距的实际做法

使这个问题变得可控的一系列实践其实并不浪漫:

  • 将向量嵌入流水线视为权限事件的订阅者,而不是抓取最新状态的定期任务。组成员身份变更、角色撤销、文档移动和用户删除都是 IdP 和文档存储已经发出的事件。订阅它们。构建将其传播到向量索引的消费者。测量延迟。

  • 让检索时的 ACL 强制执行访问信源(source-of-truth),而不是索引元数据。 是的,这会增加一次跳转。是的,这会增加查询延迟。另一种选择是一个仅在上次向量任务执行时才具备“权限意识”的系统。对于大多数企业用例,延迟成本是可以接受的;对于高吞吐量的消费者用例,请使用短 TTL 缓存 ACL,并明确接受这一权衡。

  • 默认将删除视为多存储操作。 当源数据行消失时,不应关闭删除单据。只有当每个派生存储(向量索引、搜索索引、分析仓库、检索缓存)都确认删除后,才应关闭它。将此逻辑写入脚本。数据保护官(DPO)会感谢你的。

  • 运行定期的差异审计。 随机抽取过去 30 天内访问权限发生变化的用户样本。发出本应过期的查询。查看向量数据库是否仍然返回内容。将任何命中的结果视为高严重级别事故,而非仅仅是一个好奇点。

  • 一次性决定软删除与硬删除的立场,将其记录下来,并在所有索引中保持一致。 最糟糕的配置是隐式配置,即不同的团队对“删除”有不同的理解。

你的合规团队认为上个季度已经处理掉的删除请求,今天仍然可能被你的 RAG 流水线回答,除非有人完成了将事件串联起来的乏味工作。向量索引不会宣布它的陈旧;Bug 会以一条引用自机密文档的内容出现在本不该生成的答案中。到那时,问题就不再是“我们如何修复泄露”,而是“这种情况错了多久,以及我们如何才能知道”。

权限在变。向量嵌入则不会,除非你让它变。

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