权限过滤要在检索前
向量检索回答的问题是"哪些内容和这个问题最像"。它不回答"这些内容允许当前用户看吗"。这两个问题在数学上毫无关系——相似度高的片段,完全可能是该用户无权查看的。
所以权限过滤不是一个"安全加固项",它是检索链路的一部分。把它放在生成阶段、放在提示词里、或者放在前端展示时再过滤,都等于没做。
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. 上线前该验证的四件事
- 无权限文档不能召回。 用一个明确无权的账号,问一个能精确命中该文档的问题,返回应当为空或不含该文档。
- 跨租户查询返回空。 用租户 A 的会话查租户 B 独有的关键词。
- 权限变更能传播。 现场撤掉某个用户的权限,测多久之后检索结果开始变化,把这个数字记下来,它就是你系统的权限窗口。
- 检索日志能解释过滤。 每条检索都要能回答"用了哪些过滤条件、授权结果是什么"。出事之后能不能复盘,全看这个。
第四条常被当成可选,但它决定了前三条出问题时你是花十分钟定位还是花三天。
7. 和溯源的关系
权限过滤解决的是"不该看的进不来",引用溯源解决的是"进来的有没有被正确引用"。两者互补,不能互相替代:过滤做对了,上下文里全是用户有权看的内容,但模型仍可能把 A 文档的内容说成来自 B 文档——那是《答案要能追溯到出处》要处理的问题。
反过来,溯源做得再好,如果一条无权片段已经进了上下文,边界就已经失守了。所以顺序是固定的:先过滤,再生成,最后溯源校验。