带权限的检索:索引是一台把权限压平的机器。
建索引这件事,是把受众各不相同的文档切成块、剥掉它们赖以继承访问规则的那个容器,再把碎片存进一个由你的智能体用单一服务凭据去查询的集合——于是检索悄悄成了你系统里最宽的一次读取,而这次读取是代表此刻正在跟机器人说话的那个人执行的。解法不是在答案上加过滤器:只要模型看见了,无论有没有写进回复,那都已经算披露;这意味着访问控制必须发生在检索之前,而你在建索引时拷贝进来的那份权限数据有多新鲜,就是你的撤销时延。
索引什么也继承不到,于是它默认成了一切的并集。
在源系统里,访问控制是结构性的。文档住在某个文件夹、某个站点、某个仓库、某个频道里;容器承载规则;规则由存储它的那个系统强制执行。这些性质在摄取的那一刻统统被丢掉了。落进向量库的是文本、一个嵌入,以及你的加载器碰巧拷过去的那点元数据——而一个 chunk 是没有文件夹的。
然后智能体用一枚凭据去查询它,因为客户端库就是这么设计的,也因为要配置的连接恰好只有一个。就在那一刻,你的检索层持有了每一位用户权限的超集,而挡在一个好奇的问题与那份薪酬表之间的,只剩下查询向量会不会碰巧落在它附近。这就是数据外泄与工具滥用里那个混淆代理的形状,只是它是从一条团队根本不会归类为权限边界的路径抵达的——因为大家把索引想成了一项搜索功能。
请注意这不是什么。这不是投毒与注入问题——检索到的文本作为不可信输入,在 RAG 管道安全里讲过,那是另一种失败、另一套解法。本页讲的是相反的方向:不是语料对你的智能体做了什么,而是你的智能体代表一个不该看见全部内容的人,拿这份语料做了什么。
一个问题就能判定你有没有这个毛病:如果用户 B 问了一个问题,而与之最匹配的 chunk 来自一份只有用户 A 能读的文档,是什么把它挡住了?如果答案说的是某个发生在检索之后的步骤——对结果的过滤、提示词里的一句叮嘱、模型的判断——那你有的不是访问控制,是一种偏好。
在模型看过之后再过滤,不算控制。
诱人的架构是:先大范围检索,再让下游某一步把用户不该看的东西剔掉。它在三个彼此独立的层面上失败,而任何一个单拎出来就已经够了。
- 披露已经发生了。进了上下文窗口的文本,就是系统代表这位用户处理过的文本;在任何一个不是你自己运行的服务商那里,它还是离开了你边界的文本。它有没有活到答案里并不相干——而且它无论如何都会影响答案,因为一个读过那份文档的模型,即便被要求不要引用,它的措辞、留有余地的程度与优先级排布都会不一样。
- 这种压制是概率性的。要求模型忽略它刚刚读到的东西,是一条与上下文里其他一切竞争的指令,而指令输掉这场竞争的频率,高到无法拿它当边界。通用原则——强制该在模型下方、而不是在模型里面——与范围受限凭证背后是同一条。
- 旁路把剩下的漏了出去。「我找到了 14 份相关文档,但只能给你看 2 份」,这句话披露了还有十一份存在,以及它们大致有多相关。引用计数、置信分数、延迟差异,以及一次真正的「什么都没匹配上」与一次被过滤掉的「没有我能给你看的」之间那点微妙对比,也都一样。「存在」本身就是信息,而在一桩并购、一份裁员方案或一场诉讼里,它往往就是信息的全部。
请就照这条规矩来办:只要模型看见了,就当作已披露,并把它记成一次访问。仅这一条假设,就会把架构逼进正确的形状,因为它让「大范围检索」从方便变成了昂贵。
可以强制的位置有三处,而取舍是召回率对存储。
访问控制必须发生在检索时或检索之前。可行的位置有三个,而多数生产系统最后会组合其中两个。
- 前置过滤的检索。查询带上用户的主体标识——组、角色、租户、密级——存储在排序之前,把候选限制在访问键集合与之相交的 chunk 上。这是默认答案,如今每一个正经的向量库都支持元数据过滤。代价是真实存在、却常常没人量的:近似最近邻索引是为无过滤遍历而建的,一个高选择性的过滤器要么让召回率下降——因为图上的游走不断落到随后就被丢弃的候选上——要么悄悄退化成暴力扫描。两者都表现为延迟或质量回退,而没人会把它归因到权限头上。请带着你的过滤器、在你真实的 ACL 分布上量召回率——索引选型上的后果在如何挑选向量数据库里。
- 分区索引。按租户、按密级、或按大类受众各建一个集合。简单、快,而且在你所分区的那道边界上是结构性安全的——跨租户泄漏于是变成一个接线错误,而不是一个过滤器错误。它扩展不到按人的粒度,而且会让「同时面向两类受众的文档」变成一个运维问题而不是一个建模问题。
- 延迟绑定的授权。先取候选,再在任何内容进入上下文之前,逐条去问源系统:这位用户可以读它吗?这是唯一严格正确的做法,因为源系统才是权威;它也是唯一能即时生效撤销的做法。代价是每个候选一次往返,而它在多数 API 上都很好批处理,在真正进到模型的那个 top-k 规模上完全负担得起。
值得当默认的组合是:在你绝不能搞错的粗边界上分区(租户),在你拷贝过来的主体标识上做前置过滤,再对活到提示词里的那寥寥几个 chunk 做延迟绑定。前两项让它快,第三项让它对。
你那份 ACL 拷贝是一份缓存,而它的陈旧程度就是你的撤销时延。
前置过滤依赖的是摄取时拷贝进索引的权限数据。那份拷贝是别人权威状态的一份缓存,而它会像每一份缓存那样变陈旧——只不过这里的失败不是一个错误答案,而是一次披露。
这道算术毫不留情。如果你每晚重建索引,一个周一上午被移出某项目的人,能一直检索到那个项目的文档,直到周二。如果你每周重建,那就是一周。没有人会写下「我们的撤销 SLA 是七天」,可每周重建一次就是这个意思,而这也是一次安全评审最终会从你嘴里问出来的数字。
有两个设计选择能把它压到几乎为零:
- 存权限的键,而不是解析后的成员名单。把 chunk 连同
acl: ["group:eng-payments", "user:1421"]一起索引,而在查询时从身份提供方解析发问者的组成员关系。于是组成员关系只在一个地方变更,并在下一次查询时立即生效;只有文档自身 ACL 的变更才需要动索引。仅这一个决定,就把你大部分撤销滞后变成了零。 - 把文档 ACL 的变更当成事件,而不是当成批处理。在源系统提供变更流的地方订阅它,就地更新受影响 chunk 的元数据。更新元数据不需要重新做嵌入,所以这很便宜——比重建索引与嵌入迁移里讨论的全量重建便宜得多,而全量重建你出于别的理由仍然需要。
另外要定下「删除」是什么意思。一份从源头被移除的文档,在有东西去索引里删掉它之前一直都在,而「智能体引用了一份已经不存在的文档」是那类事故里客气的版本;不客气的版本是:它引用了一份因故被删掉的文档。
派生产物什么权限也继承不到,而智能体在不停地造它们。
就算检索路径过滤得完美无缺,只要允许智能体把读到的东西写下来,就依然会漏。每一件派生产物都是内容的一份新拷贝,不带任何 ACL,而智能体是把造它们当家常便饭的:
- 智能体记忆。读过一份受限文档之后写下的摘要,随后被为另一位用户召回,这是一条带延时引信的外泄路径。记忆必须携带其来源的访问约束,或者被严格地按用户隔离——存储形态上的取舍在记忆存储里,删除方面的后果在针对智能体记忆的删除请求里。
- 缓存。以问题、而不是以提问者为键的语义缓存是其中最锋利的一种:用户 B 问了一个相似的问题,然后被飞快地端上了用户 A 那份获授权的答案。若要做缓存,主体集合就是键的一部分。
- 摘要、简报与报表。那份用智能体够得着的一切拼装出来的周报,取得了权限的并集,然后被发到一个邮件列表上。
- 追踪与评测固件。受限内容流进了你的可观测性后端与测试语料,而这两处通常每位工程师都读得到。这往往是整个系统里最大的一份无人管理的拷贝,而它是在审计中被发现的,不是被设计出来的。
- 多跳检索。一个先检索、再用检索到的东西去构造下一个查询的智能体,可以从一份它可以读的文档,走到一份它不可以读的文档上——强制必须坐在每一跳上,而不是只坐在第一跳。这个模式在智能体式检索里有描述;其安全后果是:「带过滤的 top-k」必须是检索工具本身的性质。
把它当作一套授权系统来测,因为它本来就是。
检索质量是用相关性指标来测的,而相关性指标对披露一个字也说不上。权限行为需要自己的一套测试,而且建起来很便宜:
- 以差分测试为核心。两个刻意拥有不同访问权的用户、同一个问题,跑在 CI 里。断言 A 拿到那份受限内容而 B 拿不到。这一种测试形状就能覆盖整整一类问题,而当有人加了一层缓存或一条新的检索路径时,它会大声地失败。
- 每个密级放一份哨兵文档。给每一个密级层塞一份含有独一无二、猜不出来的字符串的文档,并断言这个字符串永远不会出现在未获授权用户的上下文里——是上下文,而不只是答案。哨兵一旦冒头,含义毫不含糊,而这正是你想从一个警报里得到的东西。
- 对上下文窗口下断言,而不是对回复。回复是被过滤过的,上下文才是真相。你的测试台应当把拼装出来的上下文捕获下来,因为那才是真正要紧的那道边界。
- 撤销时效测试。把一个用户移出某个组,然后断言访问会在你声明的窗口内停止。如果还没人声明过一个窗口,这个测试就是逼出一个窗口的那股力。
- 把检索记成一次访问。每一个进入过某次上下文的 chunk,连同它是为谁拼装的。没有这份记录,你就回答不了事故之后唯一要紧的那个问题——谁看见了什么——而你一定会被问到,正如面向智能体的数据治理所讲的那样。
如果只做一件事,就把检查挪到检索前面去,并让身份跟着查询一起走。检索以发问者的主体标识执行,而绝不用一枚能读遍一切的服务凭据;索引里存的是权限键而不是解析后的成员名单,好让撤销在下一次查询就落地;而活到提示词里的那寥寥几个 chunk,要对源系统做一次延迟绑定的检查。然后写下让它保持为真的那两个测试:一个差分测试,同一个问题对两位用户返回不同且正确的结果;以及每个密级一条哨兵字符串,对拼装出来的上下文下断言。其余的一切——重排、切块策略、混合检索——都是质量活儿。而这一部分,缺陷不是一个更差的答案,是一次披露;两者的区别在于,后者没有人会给你提工单。