Skip to main content

权限过滤要在检索前

向量检索回答的问题是"哪些内容和这个问题最像"。它不回答"这些内容允许当前用户看吗"。这两个问题在数学上毫无关系——相似度高的片段,完全可能是该用户无权查看的。

所以权限过滤不是一个"安全加固项",它是检索链路的一部分。把它放在生成阶段、放在提示词里、或者放在前端展示时再过滤,都等于没做。

认证会话tenant + ACL scope前过滤后过滤前过滤(推荐)受限候选集只对有权文档做相似度结果全部有权不会漏,也不需要多取后过滤(只能当兜底)全量检索 Top-K无权文档也进了候选剔除后可能不足需 over-fetch 补量过滤条件来自已认证会话,绝不能来自请求参数或模型输出
图:前过滤缩小候选空间,后过滤只能在召回后补救

1. 越权的三种形态​

1.1 跨租户​

最严重也最容易测出来的一种。A 公司的员工问了一个很普通的问题,召回的却是 B 公司的文档。多租户共用一套索引时,只要某个查询路径漏了 tenant_id 过滤,就会发生。

这类事故的特点是:它不会报错,回答看起来也很正常,只是引用的内容不属于提问的人。用户可能根本发现不了,直到有人在回答里看到一份自己不该知道的报价单。

1.2 跨权限​

同一个租户内部,不同角色能看到的东西不同。实习生和 HR 总监对着同一个索引问同一个问题,应当拿到不同的候选集,而不只是不同的措辞。

如果两人的检索结果完全一样、只是最后生成的语气不同,这个系统就是"权限装饰"的,不是"权限感知"的。

1.3 跨时间​

权限会变。有人从项目里移出去、文档密级上调、离职员工的历史文档要收回。索引里的 ACL 元数据如果不同步,过期权限就还在生效——陈旧权限就是泄漏。

这一条最容易被忽略,因为它的故障窗口是"曾经有权、现在无权",而大部分测试用例只覆盖"一直有权"和"一直无权"。

2. 前过滤与后过滤​

2.1 前过滤:先缩小候选空间​

在算相似度之前,用结构化条件把候选集限制在当前用户有权访问的范围内。无权文档从头到尾就不是候选。

-- pgvector:过滤条件和向量距离写在同一个查询里
SELECT id, content, embedding <=> $1 AS distance
FROM chunks
WHERE tenant_id = $2
AND acl_groups && $3 -- 数组交集:用户所属组
AND (valid_until IS NULL OR valid_until > now())
ORDER BY embedding <=> $1
LIMIT 10;

前过滤的好处不只是安全:候选集小了,检索也更快,排序质量也更稳定(排序只在有权范围内竞争)。

需要注意索引的配合。见《HNSW 还是 IVFFlat》里提到的"过滤后返回不足"问题——HNSW 在有强过滤条件时可能提前停止遍历,pgvector 0.8 起的 iterative_scan 就是为这个场景准备的。

2.2 后过滤:只能当兜底​

先全量召回,再逐条问授权服务,剔除无权项。

它的问题不是安全(剔除后确实不泄漏给模型),而是召回坍缩:语义最相关的 20 条如果全属于高密级、被悉数剔除,那么即使库里还有大量该用户有权访问的相关文档,最终也只剩空上下文,模型只能拒答。

单靠后过滤时,必须 over-fetch(比如取 3–5 倍候选再过滤),但这又带来两个新问题:一是提问量放大,二是"取多少才够"没有通用答案,得按你的权限分布实测。

2.3 组合起来用​

比较稳的结构是分两层:

层次处理什么手段为什么在这一层
粗粒度租户、密级、有效期前过滤(元数据)分布稳定、可索引、能显著缩小候选
细粒度用户级、资源关系、动态规则后过滤(查授权服务)规则复杂、可能超出元数据能表达的范围
兜底缓存、日志、导出独立校验这些路径常常绕过检索服务

粗粒度前置把候选压到合理规模,细粒度后置处理元数据表达不了的规则——比如"只有文档的参与人可见"这类需要实时计算的关系。

3. 过滤条件只能来自已认证会话​

这一条值得单独说,因为它是实现层面最容易写错的地方。

tenant_id 不能来自请求参数。 如果接口形如 POST /search { tenantId, query },那么权限就交给了调用方。正确做法是服务端从已认证会话里解析出租户与组,客户端没有机会指定。

也不能来自模型输出。 让 Agent 自己决定查哪个租户、用哪个过滤条件,等于把访问控制交给了一个会被提示词注入影响的组件。检索服务要自己拿身份,不接受上游传来的"建议范围"。

// 反例:过滤条件来自入参
async function search(req) {
return vectorStore.query(req.body.query, {
filter: { tenant_id: req.body.tenantId }, // 客户端说什么就是什么
})
}

// 正例:从会话解析,调用方无法影响
async function search(req) {
const scope = await resolveAccessScope(req.session.userId)
if (!scope) throw new PermissionError('cannot verify access')
return vectorStore.query(req.body.query, {
filter: { tenant_id: scope.tenantId, acl_groups: { $in: scope.groups } },
})
}

3.1 元数据怎么设计​

入库时每个切片要带上的字段,建议至少包含:

  • tenant_id —— 租户边界,必备
  • acl_groups —— 允许访问的用户组列表
  • doc_id / chunk_index —— 便于按文档整体失效(见《知识库的重灌与切换》)
  • classification —— 密级,用于粗粒度前过滤和审计
  • valid_from / valid_until —— 有效期,处理"曾经有权现在无权"

这些字段是硬约束,不是可选的增强。写入管线里任何一个分支忘了打标,该批数据就会在检索时要么全部不可见、要么全部可见(取决于过滤是 AND 还是 OR)——后者更危险。所以入库环节要有校验:没有 ACL 元数据的切片直接拒绝写入。

4. 权限的新鲜度​

静态写进元数据的 ACL 会过期。三个手段按强度递增:

定期同步。 从源系统拉取权限变更,更新索引元数据。简单,但存在窗口期——窗口内被撤权的人仍能检索到。窗口多长,取决于数据敏感度。

事件驱动。 源系统发出权限变更事件,检索侧消费并更新。窗口短得多,但需要源系统配合,且要处理乱序和丢消息(用带版本号的更新,丢弃旧版本)。

查询时实时校验。 最高敏感度的数据(比如 HR、法务)在检索后逐条问源系统"这个人现在还能看吗"。最慢,但窗口为零。

实践中可以按密级分档:普通文档定期同步 + 事件驱动,高密文档加一道实时校验。

4.1 缓存必须带权限范围​

这条出现过太多次事故:语义缓存或结果缓存的 key 只用了查询文本,于是 A 用户缓存的答案被 B 用户命中。

缓存 key 至少要有 tenant_id 和权限范围的摘要。更保守的做法是——对高密内容不做跨用户缓存,只做同用户内的重复查询缓存。命中率会低一些,但边界清晰。

5. 失败要 fail-closed​

权限服务的调用会失败。这时候的正确行为是拒绝查询,而不是"先查了再说"。

let scope
try {
scope = await resolveAccessScope(userId)
} catch (err) {
// 无法确认身份范围 → 拒绝,而不是降级为无过滤查询
logger.error('access resolution failed', { userId, err })
throw new PermissionError('cannot verify access')
}

fail-open 的实现写起来更省事(异常时跳过过滤),而且大部分时候也确实"只是权限服务抖了一下"。但它的失败模式是:权限服务挂掉的那一分钟,全员可见全库。这个代价不对称——拒绝一次查询是可用性损失,放行一次是数据事故。

同理,元数据缺失的切片要按"无权"处理,而不是按"有权"处理。

6. 上线前该验证的四件事​

  1. 无权限文档不能召回。 用一个明确无权的账号,问一个能精确命中该文档的问题,返回应当为空或不含该文档。
  2. 跨租户查询返回空。 用租户 A 的会话查租户 B 独有的关键词。
  3. 权限变更能传播。 现场撤掉某个用户的权限,测多久之后检索结果开始变化,把这个数字记下来,它就是你系统的权限窗口。
  4. 检索日志能解释过滤。 每条检索都要能回答"用了哪些过滤条件、授权结果是什么"。出事之后能不能复盘,全看这个。

第四条常被当成可选,但它决定了前三条出问题时你是花十分钟定位还是花三天。

7. 和溯源的关系​

权限过滤解决的是"不该看的进不来",引用溯源解决的是"进来的有没有被正确引用"。两者互补,不能互相替代:过滤做对了,上下文里全是用户有权看的内容,但模型仍可能把 A 文档的内容说成来自 B 文档——那是《答案要能追溯到出处》要处理的问题。

反过来,溯源做得再好,如果一条无权片段已经进了上下文,边界就已经失守了。所以顺序是固定的:先过滤,再生成,最后溯源校验。