ACL-Aware RAG:无权读的 chunk 在哪一步被拦下

比较 ACL in the Index、Search then Check、Tenant Namespace 与 Policy-compiled Filter 四种带权限的 RAG 拓扑:权限判断发生在搜索前还是搜索后、读的是哪份状态、这份状态有多新,以及判断错了会泄露多远、怎么收住。

模型没有权限概念:进了 prompt 的 chunk 就可能出现在回答里。所以带权限的 RAG 只有一个问题要答清楚:这个用户无权读的 chunk,在哪一步被拦下? 展开是四问:判断发生在搜索之前还是之后、判断读的是哪份状态、这份状态有多新、判断错了会泄露多远。

想亲手走一遍四种拓扑、注入故障再恢复,打开互动 Lab:/system-design-lab/acl-aware-rag-architectures

有约束的设计问题

一个 AI 产品要接四类知识库:内部文件共享和 Wiki,文档在源系统里本来就有用户和组权限,每天变化不多;协作文档,用户随时分享和取消分享,嵌套组和分享链接很多,一次提问只需要几段内容;多租户 SaaS,每个客户公司只看自己的资料,绝不能出现跨租户内容;合规文档,谁能看什么区域、什么密级由规则决定,规则每周调,文档本身很少变。每一类该用哪种拓扑?

四种 topology signature

架构决策点State owner何时生效适合主要代价
ACL in the Index搜索里的安全过滤器索引里每个 chunk 旁的 ACL(从源系统同步)打分之前文档来自已有权限的系统结果只有上一次同步那么新;每个 chunk 都要带 ACL
Search then Check检索之后的授权检查authorization service(关系元组)检索之后、prompt 之前权限变化快、分享关系复杂、结果集小前 K 个候选可能被拒光;每次提问多一次往返;要多取
Tenant Namespace路由到一个 namespacetenant map(租户 → namespace)搜索触到任何数据之前租户之间从不共享文档的多租户 SaaS不能跨租户共享;租户解析错一次就是整体泄露
Policy-compiled Filter策略引擎编译出过滤器policy store(版本化规则)+ 带属性的 chunk打分之前按区域、密级、部门定规则,规则改得比文档勤策略引用的属性和索引属性会漂移;策略改错全员同时受影响

1. ACL in the Index:权限同步进索引,搜索前就过滤

同步任务按计划从源系统抄每份文档的访问控制列表(ACL),连同 chunk 一起写进索引:每个 chunk 旁边存着允许读它的用户和组。用户提问时,检索器先向身份提供方要这个人的全部 principal(用户 ID 加所有组 ID),把它们放进搜索的过滤条件;索引只对 ACL 里包含这些 principal 的 chunk 打分,不可读的 chunk 从头就不参与。

这是各家搜索服务的标准做法。Azure AI Search 把身份存在一个可过滤、不可返回的 Collection(Edm.String) 字段里,用 group_ids/any(g:search.in(g, 'group_id1, group_id2')) 做安全过滤;文档写得很直白:principal 在这里「只是过滤表达式里的一个字符串」,文档级授权靠的是「每一次查询都带上这个过滤器」。Amazon Kendra 在入库时随文档摄取 ACL,没有 ACL 的文档就是公开文档;查询时按用户 token 或用户 ID 加组过滤,并且明确说用户上下文过滤「不是对内容的认证或授权控制」,应用必须自己保证传进来的用户和组是认证过的。Google 的 AI Applications 要求在索引时给出 acl_info 里的读者 principal,访问控制必须在创建 data store 时就打开,之后不能改,每份文档最多 3,000 个读者。Elasticsearch 的 document level security 给角色绑一条查询,不匹配的文档永远不会返回,查询可以用 {{_user.username}} 或用户元数据模板化。

代价有两条。第一,结果只有上一次同步那么新:Azure 文档写明,源系统里的权限变化「只有在元数据同步到索引之后」才会反映在结果里,原生 ACL 支持也会有「一段时间差」。一个员工上午被移出「财务」组,同步每晚跑一次,那他一整天都能读到财务文档。第二,每个 chunk 都要带上 ACL:切块流水线如果只复制了正文和向量,Azure 的提醒是「没有这个投影,chunk 级引用不会被过滤」——整批 chunk 对所有人可见。修法:给同步定撤销窗口和 SLO,超时报警,撤销事件走一条即时生效的拒绝列表叠加在过滤器里;切块时把 ACL 投影到每一个 chunk 行,入库拒绝 ACL 为空的 chunk;principal 只从会话和身份提供方取,请求体里的组列表一律忽略。

2. Search then Check:先检索,再逐条问授权服务

索引里没有任何权限信息。检索器先按相关度取回前 K 个候选,再把它们所属的对象和调用者一起交给授权服务批量判断;授权服务从关系元组里查「这个人和这个对象之间有没有可读关系」,拒绝的候选被丢掉,只有幸存的 chunk 进入 prompt。

OpenFGA 把它叫做「search, then check」:先在数据库里过滤和排序,再调批量检查接口验证访问权限;推荐用在「搜索查询可以优化到只返回少量结果」的场景。它的好处是权限永远和授权服务一样新,不需要任何同步,嵌套组、分享链接这种关系模型也不用摊平进索引。Azure 的对比给出了代价:「在搜索流水线内部过滤,比把更大的结果集加载到应用里再裁剪要快,尤其在高查询量下」。

真正的坑在检索之后。前 K 个候选可能全被拒绝:用户问了一个只有高管文档才回答得了的问题,K 个候选一个都不能读,如果检索器不作声,模型会凭空作答,用户以为那是文档里的内容。修法是多取一些并按页继续检查,直到填满上下文或到达上限;到上限仍为空,用固定回复告诉用户「没有你能读的内容匹配」。另一个坑是授权服务不可达时 fail open:错误处理把「没有判定」当成「允许」,故障期间整个索引对所有人可见,而日志看起来一切正常。没有判定就没有候选,熔断期间返回没有可读内容并报警;用故障注入确认超时、错误、空响应三种情况都不放行。

3. Tenant Namespace:每个租户一个分区,查询只进一个

每个租户的 chunk 存在自己的 namespace 里,物理隔离。路由器从登录会话拿到租户,查租户表得到对应的 namespace,搜索只发向这一个分区;别的租户的数据根本不在被搜的范围里,所以不存在「过滤条件写错」这种漏法。

Pinecone 推荐的多租户方案就是每个租户一个 namespace:「每个 namespace 单独存储,所以 namespace 提供了租户之间数据的物理隔离」;下线一个租户就是删掉它的 namespace,「轻量且几乎即时」;查询成本只随被搜的那一个 namespace 的大小走。它明确不推荐用 metadata 过滤做租户隔离:「不管过滤条件如何,查询都会扫描整个 namespace」,大 namespace 增加延迟,大过滤器增加请求开销。

代价是共享和路由。租户之间不能共享文档,一份给 A 和 B 都看的政策文档要在两个 namespace 各存一份,更新时两份一起写,否则一边新一边旧。更危险的是租户解析:为了让内部工具「代查」,路由器加了一个请求体里的 tenant_id 覆盖,没校验会话——任何登录用户改一个字段,就能把搜索指向别的租户的 namespace,物理隔离一行代码就被绕过。租户只能从会话令牌里取,请求体和会话不一致就拒绝并记录;代查走单独的、有审计和审批的内部路径。租户内部还有用户级权限的话,在 namespace 里再叠加前两种之一。

4. Policy-compiled Filter:策略引擎把规则编译成过滤器

权限不是一份份名单,而是规则:「只能看自己区域的、密级不高于自己的文档」。策略引擎从策略仓库读当前版本的规则,代入调用者的属性做部分求值,剩下的条件就是一条过滤表达式,例如 region = eu AND clearance <= 2;检索器带着它去搜带属性标签的索引。规则改了不用重建索引,索引里也不用存任何用户名单,只要每个 chunk 带着区域、密级、部门这些属性。

OPA 的 Compile API 就是这个机制:「部分求值 Rego 查询并得到策略的简化版本」,把指定的项当作未知量,输出「查询为真的条件」,可以直接翻译成 SQL 的 WHERE 子句或 UCAST 条件交给数据存储执行。Amazon Bedrock Knowledge Bases 的 metadata 过滤提供了 equalsinnotIngreaterThanlistContains 这些算子和 andAll / orAll 组合,在检索时对文档 metadata 文件里的属性生效——编译出来的策略过滤器最后落到的就是这类算子。

代价是属性漂移和全员生效。新策略版本加了 clearance <= caller.clearance,但索引里的 chunk 从来没有 clearance 这个属性:引擎如果把缺失的条件悄悄丢掉,过滤器就退化成「全部可读」,所有用户同时看到不该看的密级内容。属性缺失时必须 fail closed,过滤器匹配不到任何 chunk 并报警;发布策略版本前先对照索引 schema 检查每个引用的属性。另一种是策略改错:把 region == caller.region 放宽成 region in [caller.region, global],而很多内部文档被误标成了 global,新版本一生效全员都能搜到。策略版本化,先在金丝雀租户上跑,观察「去掉的候选数」是否突然下降,再全量;出问题回滚到上一个版本。

四种拓扑共同的底线

  • 身份只来自会话。 principal、租户、属性都从认证过的会话取,永远不从问题或请求体里读;Kendra 的文档已经替你说了,过滤本身不是认证。
  • fail closed。 身份提供方、授权服务、策略仓库任何一个不可达,返回没有可读内容,而不是未过滤的内容。
  • 每个 chunk 继承文档的权限。 切块流水线要把 ACL 和属性复制到每一个 chunk 行。
  • 同步任务是安全控制。 最后一次成功同步太旧就报警,撤销窗口是一条要写进 SLO 的安全属性。
  • 日志要能回答泄露排查。 每次请求记下用到的 principal、跑了哪条过滤或检查、去掉了多少候选、哪些 chunk 进了 prompt;回答里的引用必须是用户能直接打开的文档。

故障与恢复

架构故障用户看到什么恢复
ACL in the Index用户被移出组,索引里的 chunk 还写着这个组撤销后到下一次同步之间仍能读到该组文档同步定撤销窗口和 SLO;即时拒绝列表叠加;把同步当安全控制运维
ACL in the Index切块流水线写出了没有 ACL 的 chunk整批文档对所有人可见ACL 投影到每个 chunk 行;入库拒绝 ACL 为空的 chunk;定期抽样对比源权限
ACL in the Index检索器用了请求体里的组列表任何人改请求即可以任意组身份搜索principal 只从会话和身份提供方取;过滤器构造放在服务端唯一入口并测试
Search then Check前 K 个候选全被拒绝模型凭空作答或空回答多取并翻页检查到上限;仍为空就固定回复「没有你能读的内容匹配」
Search then Check授权服务不可达,检索器 fail open故障期间整个索引对所有人可见没有判定就没有候选;熔断返回无可读内容并报警;故障注入测试
Tenant Namespace路由器信了请求体里的 tenant_id改一个字段即可读别的租户租户只从会话令牌取;不一致就拒绝并记录;代查走审计路径
Tenant Namespace共享文档只在一个 namespace 里更新两个租户看到不同版本每个 namespace 都写成功或整体重试;副本带版本戳并定期对比
Policy-compiled Filter策略引用了索引没有的属性过滤器退化成全部可读属性缺失 fail closed;发布前对照索引 schema 检查
Policy-compiled Filter一次策略修改放宽了全员访问所有区域用户同时搜到内部文档策略版本化 + 金丝雀 + 回滚;每个版本有覆盖关键文档的测试

怎么选

  • 文档来自已有权限的系统、权限每天变化不多:ACL in the Index,ACL 投影到每个 chunk,同步滞后写进 SLO。
  • 分享随时变、关系复杂、结果集小:Search then Check,多取、fail closed、候选拒光就如实说没有。
  • 多租户从不共享:Tenant Namespace,租户只从会话取,租户内的用户级权限再叠加前两种之一。
  • 按区域、密级定规则,规则改得比文档勤:Policy-compiled Filter,属性缺失 fail closed,策略版本先灰度。
  • 无论哪种,模型只能看到通过了判断的 chunk,回答里的引用必须是用户能直接打开的文档。

回到开头的四类知识库:文件共享和 Wiki 走 ACL in the Index;协作文档走 Search then Check;多租户 SaaS 走 Tenant Namespace;合规文档走 Policy-compiled Filter。

面试时这样回答

  1. 先复述约束:权限在哪里、变得多快、租户共不共享、是名单还是规则。
  2. 说判断路径:点名图上的边,例如「同步把 ACL 写进索引,过滤器在打分前生效」「取回候选后交给授权服务批量检查」「租户表选 namespace 只搜一个分区」「引擎把规则编译成过滤器」。
  3. 说权限状态存在哪:索引里的 ACL、授权服务、租户表,还是策略仓库。
  4. 说代价与一个故障:例如授权服务不可达时 fail open 会让整个索引对所有人可见,修法是没有判定就没有候选、熔断并报警。

一手证据