企业 RAG 的权限感知检索工程 2026
企业 RAG 的权限泄露不是过滤 bug 而是架构缺陷:必须把 ACL 下推进 ANN 检索内部、在分块阶段对齐权限边界、按权限域分片缓存,并用权限退化比与红队回归把正确性锁进 CI。
约 27 分钟阅读7,974 字9 次阅读博主

企业 RAG 的权限泄露不是过滤 bug 而是架构缺陷:必须把 ACL 下推进 ANN 检索内部、在分块阶段对齐权限边界、按权限域分片缓存,并用权限退化比与红队回归把正确性锁进 CI。

在企业内部部署 RAG 应用时,第一版 demo 往往在两周内跑通:把 Confluence、SharePoint、Google Drive、代码仓库的文档灌进向量库,接上一个 chat 界面,检索 top-k 拼进 prompt,模型开始回答问题。演示效果惊人。然后安全评审进场,问了一个问题:销售实习生问"公司下一季度的裁员名单在哪个文档里",这套系统会怎么回答?
这个问题让绝大多数 RAG 原型当场失效。向量库不知道"谁能看什么",它只知道"哪段文本与查询语义最近"。一旦企业知识库里混入了 HR 文档、财务预测、法务合同、未公开的战略计划,检索器就成了一台绕过所有既有权限系统的信息泄露泵——它不需要黑客攻击,只需要一个语义相近的问题。
本文讨论的是这条链路上最被低估的工程问题:权限感知检索(permission-aware retrieval)。它不是"在返回结果后过滤一下"这么简单,而是牵动分块策略、索引结构、检索算法、缓存设计、评估体系的一次系统性重构。我们会给出一个形式化模型、四种主流实现路径的定量对比、以及一套可落地的工程清单。
先给出最直观的错误做法,因为它在生产环境里极其普遍。
朴素方案是:正常检索 top-k,拿到 k 个 chunk 后,对每个 chunk 查一次权限系统,把用户无权访问的剔除,剩下的拼进 prompt。伪代码大约是这样:
def naive_retrieve(query, user, k=10):
hits = vector_db.search(embed(query), top_k=k)
allowed = [h for h in hits if acl.can_read(user, h.doc_id)]
return allowed # 可能只剩 0-2 条
这段代码有三个独立的致命缺陷。
第一,召回崩塌(recall collapse)。 设用户对全库文档的可见率为 ,则 top-k 中期望仅有 条可用。当 (一个普通员工在大型企业知识库里的典型可见比例), 时期望只剩 1 条。也就是说,一个权限受限的用户会系统性地拿到比管理员差 10 倍的答案质量。更糟的是这种退化是静默的:系统不会报错,只会给出一个由 1 条弱相关证据支撑的、听起来很自信的幻觉。
第二,侧信道泄露(side-channel leakage)。 即使内容没有泄露,元信息也会泄露。如果系统提示"找到 8 条相关结果,其中 7 条你无权访问",攻击者可以通过构造探测性查询来枚举受限文档的存在与主题分布。经典的攻击序列是二分搜索式的:从宽泛查询开始,逐步收窄关键词,观察"被过滤条数"的变化曲线,从而在完全不读到内容的前提下重建出受限文档的语义指纹。
第三,延迟放大。 每个 chunk 触发一次 ACL 查询, 时就是 50 次 RPC。即使每次 5ms,串行执行也是 250ms,而且这些查询发生在检索之后、生成之前,处于关键路径正中央,无法与其他工作重叠。
正确的做法是把权限下推到检索层,让检索器从一开始就只在用户可见的子空间里搜索。这句话说起来简单,实现起来牵扯到向量索引的底层结构,下面逐层展开。
我们需要一个足够精确的模型来讨论正确性,同时又不至于过度抽象。
设文档集合 ,每个文档被切分为若干 chunk,chunk 全集为 ,映射 给出归属关系。设主体(principal)集合为 ,包含用户与用户组。权限关系由布尔函数给出:
对用户 ,定义其可见子空间:
给定查询 与相似度函数 ,权限感知检索的目标是:
注意这个定义与朴素方案的关键区别:top-k 是在 上取的,而不是在 上取完再过滤。前者保证了无论 多小,都能返回 条真实可用的最佳结果;后者只能返回 条。
在此基础上可以定义三条工程必须满足的性质。
性质 1(正确性 / soundness):。返回的每一条都必须是用户有权看的。这是安全底线,任何违反都是数据泄露事故。
性质 2(完备性 / completeness):。只要用户可见文档足够多,就必须返回满 k 条。这是质量底线,违反它意味着受限用户拿到降级答案。
性质 3(无差别性 / indistinguishability):对任意两个用户 ,系统的响应延迟分布、返回条数、错误信息不应泄露 的信息。这是侧信道底线。
三条性质中,性质 1 最容易实现(过滤即可),性质 2 需要索引层支持,性质 3 最难且最常被忽略。工程上的绝大多数争论,本质都是在这三者与延迟/成本之间做权衡。
一个有用的补充概念是权限基数(permission cardinality):,即系统中实际存在多少种不同的可见子空间。如果一家公司有 5000 名员工但权限只有 12 种角色组合,那么权限基数是 12 而非 5000,这个数字直接决定了后面第五节讨论的分区索引策略是否可行。实践中笔者观察到的规律是:以角色为主的组织,权限基数通常在 到 ;一旦引入项目级、客户级的细粒度共享,基数会跳到 以上,两种情况的最优架构完全不同。
这是当前最主流的实现,几乎所有向量数据库都提供原生支持。做法是把 ACL 编码成 chunk 的元数据字段,检索时带上过滤条件。
在 Qdrant 里的典型形态:
from qdrant_client import models
def acl_search(client, query_vec, user_groups, k=10):
return client.search(
collection_name="kb",
query_vector=query_vec,
query_filter=models.Filter(
should=[
models.FieldCondition(
key="allowed_groups",
match=models.MatchAny(any=list(user_groups)),
)
]
),
limit=k,
# 关键参数:让 HNSW 遍历更多候选以补偿过滤造成的图稀疏
search_params=models.SearchParams(hnsw_ef=256),
)
这里的核心是 allowed_groups 字段:写入时把该文档的 ACL 展开成组 ID 列表,检索时用用户所属组做交集判断。pgvector 的等价写法是把过滤条件写进 WHERE 子句,配合 GIN 索引加速;Milvus 提供 expr 表达式;Weaviate 走 where 过滤器。
关键陷阱:过滤与 ANN 图遍历的交互。 HNSW 的搜索过程是在近邻图上做贪心游走,如果大量节点被过滤条件排除,游走会频繁陷入"邻居全被过滤"的死角,导致提前终止、召回率断崖式下降。这个现象在过滤选择率低于 5% 时尤为明显:实测中同一份数据,无过滤时 recall@10 = 0.95,加上选择率 2% 的过滤后,若不调 ef 参数,recall 可能掉到 0.4 以下。
主流数据库对此有三类应对策略。一是放大 ef:把 hnsw_ef 调到 的 20-50 倍,用更多计算换召回,代价是延迟线性上升。二是过滤感知图构建:Qdrant 的 payload index 会在过滤选择率极低时自动切换到暴力扫描子集,避开图退化问题。三是混合执行计划:pgvector 0.7+ 的迭代扫描(iterative scan)会在过滤后结果不足时继续扫描更多候选,这在语义上更接近性质 2。
元数据预过滤的适用边界:当权限模型可以用"组 ID 列表的交集非空"表达时,这条路径是最优解——实现简单、无额外服务、延迟可控。它的失效场景是继承式权限(文件夹权限向下传递,深度可达 10 层以上)与动态权限(基于时间、地理位置、审批状态的条件访问),因为这些无法静态展开成有限的组列表。
第二条路径是为每个权限域建立独立的索引分区,检索时只查用户能访问的那些分区,然后做结果归并。
def partitioned_search(query_vec, user, k=10):
partitions = resolve_partitions(user) # 用户可访问的分区列表
per_partition_k = k # 每个分区都取满 k
candidates = []
for p in partitions:
candidates += index[p].search(query_vec, top_k=per_partition_k)
# 全局重排:同一度量空间下直接按分数归并
candidates.sort(key=lambda c: -c.score)
return candidates[:k]
正确性来自结构而非过滤:用户根本无法向自己无权访问的分区发起查询,性质 1 由架构保证,不依赖过滤逻辑的正确性。这是分区方案最大的安全优势——权限 bug 的爆炸半径从"全库泄露"缩小到"分区划分错误"。
归并的正确性要求所有分区共享同一嵌入模型与同一度量空间。 这听起来是废话,但在渐进式迁移场景里极易违反:团队升级了嵌入模型,只对新分区重建了索引,旧分区仍是老模型,此时跨分区的分数不可比,归并结果实际上是随机的。防御手段是把模型指纹(模型名 + 版本 + 维度 + 归一化方式的哈希)写进分区元数据,归并前断言一致。
成本模型:设分区数为 ,用户平均可访问 个分区,则单次查询的计算量约为 次独立 ANN 搜索。当 较小(用户只属于少数几个域)时,总代价可能低于单一大索引,因为每个分区更小、图更浅。但当 增大到几十, 次 RPC 的固定开销就会主导延迟。经验分界线在 :低于此值分区方案通常更快,高于此值元数据过滤更优。
分区方案的真正代价是运维复杂度。 一份文档被共享给新的团队,可能需要在另一个分区新增副本,于是同一 chunk 在多个分区中重复存储,写放大随共享范围线性增长。删除时必须保证所有副本一致清除,否则出现"已撤销权限但仍可检索"的幽灵文档。实践中这套逻辑需要一个独立的 reconciliation 作业周期性扫描 ACL 系统与索引的差异,把它当作最终一致性系统来运维,而不是假设写入是原子的。
第三条路径来自数据库领域的行级安全(RLS),核心思想是把权限判定编译成可下推的谓词,并用密码学手段把谓词绑定到请求上。
具体做法:认证阶段签发一个短时效的能力令牌,其中包含用户可访问的权限域集合(或其压缩表示)。检索服务验证令牌签名后,直接把令牌里的域集合作为过滤谓词下推给向量库,全程不再回查 ACL 系统。
import hmac, hashlib, json, time
def issue_capability(user_id, domains, secret, ttl=300):
payload = {"sub": user_id, "dom": sorted(domains), "exp": int(time.time()) + ttl}
raw = json.dumps(payload, separators=(",", ":")).encode()
sig = hmac.new(secret, raw, hashlib.sha256).hexdigest()
return raw, sig
def verify_and_extract(raw, sig, secret):
expect = hmac.new(secret, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expect, sig):
raise PermissionError("capability signature mismatch")
payload = json.loads(raw)
if payload["exp"] < time.time():
raise PermissionError("capability expired")
return set(payload["dom"])
优势是把 ACL 查询从每查询关键路径移到了每会话一次。 对高 QPS 的检索服务,这能砍掉大部分尾延迟——ACL 系统通常是一个集中式服务,在流量高峰时是最先饱和的组件。
代价是撤销延迟(revocation lag)。 令牌签发后在 TTL 内一直有效,如果此间用户权限被撤销,仍能用旧令牌检索到已无权访问的内容。这是安全性与性能之间的硬权衡,缓解手段有三类:缩短 TTL(5 分钟是常见折中)、维护撤销列表(对高敏感文档做二次校验)、按敏感度分级(普通文档信令牌,机密文档强制回源校验)。
当权限域数量巨大时,令牌会膨胀。 一个属于 3000 个项目的用户,其域集合无法塞进 HTTP header。此工程上的解法是布隆过滤器压缩:令牌里携带一个 8KB 的布隆过滤器而非完整列表,检索时用它做第一层筛选,假阳性(约 1%)由后置精确校验兜底。注意这个组合必须保留精确校验层——布隆过滤器只保证"不漏"(无假阴性),不保证"不错",单靠它会违反性质 1。
前三条路径都在检索侧做文章,但有一类问题只能在分块阶段解决:权限边界与语义边界不一致。
考虑一份季度经营报告,前 20 页是全员可见的业务概况,第 21 页开始是仅高管可见的人员规划。如果按固定窗口切分,必然产生跨越第 20/21 页边界的 chunk——这个 chunk 该赋予什么权限?
赋予宽松权限则泄露机密内容;赋予严格权限则让全员可见的段落对普通员工不可检索。两者都是错的,唯一正确的做法是在分块前先按权限边界切分,权限边界优先于语义边界:
def acl_aware_chunk(doc, chunk_size=800, overlap=100):
chunks = []
# 第一步:按 ACL 边界硬切,绝不跨越
for segment in split_by_acl_boundary(doc):
# 第二步:段内按语义/长度切,overlap 只在段内滑动
chunks += semantic_split(
segment.text, size=chunk_size, overlap=overlap,
acl=segment.acl, # 整段继承同一 ACL
)
return chunks
关键细节是 overlap 不得跨越 ACL 边界。滑动窗口的重叠区是最隐蔽的泄露通道:一个宽松权限的 chunk 若包含来自机密段落的 100 字 overlap,就等于把机密内容复制到了低权限索引里。审计这类问题的方法是构造一个不变量测试:对每个 chunk,验证其文本区间完全落在单一 ACL 段的字符区间内。
第二类分块期问题是表格与结构化内容的行级权限。一张包含全体员工薪资的表格,HR 可见全部,部门经理只可见本部门行。把整表作为一个 chunk 意味着只能给最严格的权限。正确做法是按行拆分为独立 chunk,每行携带自己的 ACL,同时把表头作为共享上下文冗余到每个行 chunk 中——牺牲一些存储换取行级权限的可表达性。
第三类是引用与摘要的传染性。如果系统为长文档生成摘要 chunk 以提升检索效果,摘要必然混合了文档各部分的信息,其权限应当取所有来源段落 ACL 的交集(最严格),而不是并集。这条规则违反起来毫无痛感——摘要生成流水线通常与 ACL 系统完全解耦——但后果是把机密信息塞进了一个宽松权限的 chunk。工程上应当把"派生内容的 ACL = 来源 ACL 的交集"写成流水线的强制断言。
把四条路径放在同一组维度下对比,可以给出相对清晰的选型逻辑。
| 维度 | A 元数据过滤 | B 分区索引 | C 能力令牌 | D 分块对齐 |
|---|---|---|---|---|
| 性质 1 正确性 | 依赖过滤逻辑 | 结构保证 | 依赖签名校验 | 结构保证 |
| 性质 2 完备性 | 需调 ef / 迭代扫描 | 天然满足 | 同 A | 不适用(正交) |
| 性质 3 无差别性 | 需额外设计 | 较好 | 较好 | 不适用(正交) |
| 实现复杂度 | 低 | 高 | 中 | 中 |
| 写放大 | 无 | 随共享范围增长 | 无 | 轻微(表头冗余) |
| 撤销延迟 | 无(实时) | 取决于同步作业 | TTL 窗口 | 无 |
| 适用权限基数 | – | – | 任意(需布隆压缩) | 任意 |
需要强调的是D 与 A/B/C 正交:无论选择哪种检索侧方案,分块阶段的权限对齐都是必需的,它解决的是另一个维度的问题。真实系统几乎总是组合方案。
一个可用的选型决策流程:
ef 调优与迭代扫描保证性质 2。关于性质 3 的具体实现,这里给出几条经验规则。响应中不要透露被过滤的条数,只返回可见结果。不要因为权限不足而返回不同的错误码——统一返回"未找到相关内容"。延迟应做常量化处理:如果 不同导致查询耗时差异明显,考虑对响应做时间填充(padding)到固定分位。日志与 trace 里不要记录被过滤掉的 doc_id,否则可观测性平台就成了新的泄露面——这一条在实践中被违反的频率最高,因为为了调试召回问题,工程师会本能地把候选集全量打进 trace。
缓存必须按权限域分片。 语义缓存是 RAG 降本的标准手段,但一个未按用户权限隔离的缓存是最直接的泄露路径:用户 A 的查询结果被缓存,用户 B 提出语义相近的问题命中缓存,直接读到 A 的受限内容。正确的缓存键必须包含权限域指纹:
def cache_key(query_vec_hash, user_domains):
dom_fp = hashlib.sha256(
",".join(sorted(user_domains)).encode()
).hexdigest()[:16]
return f"rag:{query_vec_hash}:{dom_fp}"
代价是缓存命中率随权限基数上升而下降。若权限基数是 ,按域分片几乎等于关闭缓存。此时的折中方案是只缓存公开域内容(所有人可见的那部分文档单独建索引并缓存),受限内容不缓存——这抓住了大部分流量,因为企业知识库里通常 60-80% 的检索命中的是公开文档。
评估集必须按权限分层。 单一评估集会掩盖受限用户的质量退化。最小可用做法是构造三组评估数据:管理员视角(全库可见)、典型员工视角()、外部协作者视角(),分别测 recall@k 与答案质量。核心指标是权限退化比:
在正确实现性质 2 的系统里,PDR 应接近 1.0(因为 top-k 是在 内取满的);朴素后过滤方案的 PDR 会退化到接近 。这个单一数字是判断权限下推是否真正生效的最灵敏探针,建议直接接入 CI,设置回归阈值。
必须有泄露回归测试。 每次索引重建、嵌入模型升级、分块策略调整后,都要跑一遍"红队查询集":一批专门设计来命中受限文档的查询,断言低权限身份下返回结果为空或不含受限 doc_id。这类测试的构造方法是从受限文档中抽取独特短语,用其作为查询——如果系统正确,这些查询在低权限身份下必须一无所获。把它做成每次部署的门禁,比任何事后审计都有效。
权限变更的索引同步。 ACL 变更事件(用户离职、项目结束、文档重新分类)必须驱动索引更新。这里的架构选择是:把 ACL 存为 chunk 元数据(路径 A)意味着每次权限变更要更新所有相关 chunk,一份被 500 人共享的文档权限调整可能触发数万次元数据写入;把 ACL 存为间接引用(chunk 只存 acl_group_id,实际成员关系在外部服务)则变更成本恒定,但每次检索需要一次组成员展开。高变更频率场景应选后者。
如果你正在给企业客户交付 RAG 应用,以下清单可以直接作为设计评审的检查项。
架构层。 明确权限模型的形态:是扁平的组列表,还是继承式的树,还是带条件的动态策略?这决定了路径 A 是否可行。统计权限基数, 量级可考虑分区, 以上必须走元数据过滤或令牌压缩。确认权限的权威来源唯一——ACL 不应在向量库里被二次定义,向量库里的权限元数据必须是权威系统的投影,且有 reconciliation 作业保证收敛。
索引层。 分块前先按 ACL 边界硬切,overlap 严格不跨界,并把这条写成不变量测试。派生内容(摘要、假设性问题、知识图谱三元组)的 ACL 取来源交集。为过滤字段建立合适的索引(GIN / payload index),并压测低选择率下的召回率,把 ef 调到实测 recall@10 ≥ 0.9 的水位。
检索层。 权限过滤必须下推到 ANN 搜索内部,禁止先搜后滤。启用迭代扫描或过滤感知搜索以保证性质 2。响应不透露被过滤条数,错误信息与延迟对不同权限用户不可区分。
缓存层。 缓存键包含权限域指纹;或只对公开域启用缓存。任何跨用户复用的中间结果(重排序模型的缓存、query 改写缓存)都要同等审计。
评估层。 建立分层评估集,把 PDR 作为核心指标接入 CI。维护红队查询集,每次索引变更后跑泄露回归。在可观测性平台里剥离被过滤的 doc_id,避免 trace 成为泄露面。
组织层。 这一条最容易被工程团队忽略:权限感知检索的正确性无法只靠工程验证,必须让安全团队参与权限模型的设计评审,并把"新增数据源接入"设为需要安全签核的流程节点。绝大多数生产泄露事故的根因不是代码 bug,而是某个团队自行接入了一个新的文档源,而这个源的权限语义与既有模型不兼容。
最后一点判断标准:如果你无法在五分钟内回答"某个特定用户对某个特定 chunk 是否可见,以及为什么",那么这套系统的权限模型就还不具备可审计性,这比任何单点的技术选型都更值得优先修复。
一句话摘要:企业 RAG 应用的权限泄露不是过滤 bug,而是架构缺陷——必须把 ACL 下推进 ANN 检索内部、在分块阶段对齐权限边界、按权限域分片缓存,并用权限退化比(PDR)与红队回归测试把正确性锁进 CI。
Conversation
0 条