一个内部知识库的检索接了三类用户:全员、部门、项目组。上线后一次抽查发现:项目组的用户搜到了"别的项目"的会议纪要——因为检索时只按"关键词"召回,而"权限"是在"生成答案之后"才检查的(检查的是"用户有没有权限看这个知识库",而不是"这段内容")。
RAG 的权限问题与"传统系统"不同:它不是"能不能访问一个页面",而是"能不能看到一段文本"——这个"分块"的单位决定了难度:一个分块可能有 500 字,而其中 50 字是敏感的——于是"整块放行"会泄漏,"整块拦截"会丢信息。
而"一段文本"来自"分块",分块可能混合了不同权限的内容。
1. 三个"过滤位置"
| 位置 | 效果 |
|---|---|
| 检索前过滤(query 里带权限条件) | 最安全 |
| 检索后过滤(召回后丢弃无权限的) | 安全但有代价 |
| 生成后过滤(答案产出后检查) | 已泄漏 |
第三项是"开头那个事故"的形态:**内容已经进了模型上下文,模型已经"看过"**第三项的"判据"很硬:只要"内容进入上下文",就必须假设"它可能被用上"——于是"输出侧拦截"只能降低"明显泄漏",不能作为"权限控制"。
——即使最终答案被拦,也无法保证"没有间接泄漏"(如"根据资料,某项目存在风险")。
2. 权限模型的三层
| 层 | 说明 |
|---|---|
| 文档级 | 整篇文档的可见范围 |
| 分块级 | 分块继承文档权限(最常见) |
| 字段级 | 段落/表格里的敏感字段(最细) |
第二项是"实践中的默认":分块时把"权限标签"复制到每个分块,于是"检索时能按标签过滤"。
第三项的"成本"在"识别":规则(如"含'薪酬'的段落")会漏,"模型识别"会有误判——所以它通常只在"极敏感"的语料上做,且要"人工复核"。
第三项在"同一文档里有敏感段落"时必要:分块时"识别敏感段"并降级它的可见范围——而它需要"能识别敏感内容"(规则或模型)。
// 分块的权限元数据(检索时作为过滤条件)
chunk:
id: c-00123
doc_id: d-0042
text: "..."
acl: # 谁能看这一块
visibility: project # public | dept | project | private
project_id: P-77
owner_dept: D-12
sensitivity: internal # 用于"字段级"降级
3. "检索前过滤"的三种实现
| 实现 | 说明 |
|---|---|
元数据过滤(ES filter) |
最高效(先过滤再打分) |
| 多索引/多租户(每权限一组索引) | 最隔离(成本高) |
| 查询改写(把权限拼进 query) | 灵活但易漏 |
第一项是"主流":在 query 里加 filter 子句,于是"无权限的分块根本不参与打分"——它同时解决"安全"与"性能"。
第三项的风险在"拼接":权限条件与用户输入混在一起,容易"被注入或漏括号"——而"漏一个括号"就等于"不过滤"。
// 检索前过滤(filter 与 query 分离,权限不由用户输入决定)
{"query": {
"bool": {
"must": [{"match": {"text": "<用户查询>"}}],
"filter": [{"term": {"acl.visibility": "project"}},
{"term": {"acl.project_id": "<当前用户的 projects>"}}]
}}}
// 关键:filter 里的值来自"服务端的用户上下文",不来自请求参数
4. "检索后过滤"的代价
| 代价 | 机制 |
|---|---|
| 召回数下降 | 过滤掉一部分,可能不够 |
| 必须多召回 | 于是"取 20 条再过滤剩 5 条" |
| 上下文浪费 | 被过滤的内容已占 token |
第三项在"过滤比例高"时很实际:取 20 条、过滤剩 3 条第三项的"量化"是"过滤比例":过滤掉 50% 以上时,"检索后过滤"基本不可用(因为要"取 100 条才能剩 50 条",成本与延迟都翻倍)。
,于是"模型的上下文里只有 3 条,质量下降"——而"调大 top_k"又会带来成本。
所以"检索后过滤"只适合"权限粒度粗、过滤比例低"的场景;否则该做"检索前过滤"。
"缓存"是权限问题最容易被忽略的一处:按 query 缓存的结果,会被"权限更低的人"命中。
// 两种缓存的差别
错:cache["如何申请报销"] = [chunk1, chunk2] ← 不分权限
对:cache["如何申请报销|dept=D12|proj=P77"] = [...] ← 键里含权限维度
// 判据:缓存键是否包含"影响结果集的全部维度"
5. "生成后过滤"为什么不够
| 风险 | 说明 |
|---|---|
| 间接泄漏 | 模型"看过"就能"转述" |
| 无法审计 | "看过什么"不留痕 |
| 拦截不完整 | 只拦"明显引用" |
第一项的"例子"很典型:答案里没有"某项目"这个词,却说了"有个项目存在交付风险"——它没引用,但它"用上了"。
第一项是"最根本"的:模型的输出不是"引用",而是"综合"——它可以"不引用原文"但"用上原文的信息"。
所以"权限必须在"内容进入上下文之前"解决",而不是"在输出之后"这个"之前"也包括"日志与缓存":检索结果的缓存如果"按 query 存"而不按"用户权限"分组,就会把 A 的结果给 B——而它比"输出泄漏"更隐蔽。
。
"权限是否生效"可以用一个"双账号对照"的自检跑,成本两分钟:
// 三个对照组(用两个权限不同的测试账号)
1 全员词(如"考勤制度"):两个账号结果应相同
2 部门词(如"本部门预算"):应只有该部门账号能召回
3 项目词(如"P-77 纪要"):应只有项目成员能召回
// 判据:第 2、3 组若"两个账号结果相同" → 权限过滤没生效
6. 排错:五种"权限相关问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 答案含他人数据 | 生成后才过滤 | 改检索前过滤 |
| 召回数太少 | 过滤比例高 | 提前过滤 + 扩召回 |
| 某些用户搜不到自己的 | 权限标签错 | 校验标签 |
| 分块继承错 | 入库时未复制 ACL | 入库校验 |
| 换权限后旧索引没更新 | 标签未重建 | 重建索引 |
第五项的"识别"是"用两个权限不同的账号搜同一个词,比对结果"——若结果集相同,说明"权限没生效"(或"标签全是 public")。
第五项最容易被漏:用户权限变了(调岗),而分块的 ACL 是"入库时算的"——于是"他还能搜到原部门的内容"。
修法有两种:"实时算权限"(查询时按用户当前权限过滤) 或 "定期重建 ACL"。前者更准,后者更便宜。
最后一个"分块"视角:分块粒度越细,"整块敏感"的概率越低——但"分块越小",上下文越碎。
// 分块粒度与权限的取舍
粗分块(1000 字):上下文完整,但"一块里有敏感段"时只能整块拦
细分块(200 字):敏感段可单独拦,但"上下文不足"(模型看不全)
// 折中:粗分块 + "敏感段标记"(在块内标出"这段不可引用")
7. 边界:什么情况不必做细粒度权限
| 场景 | 为什么 | 做法 |
|---|---|---|
| 全员可见的知识库 | 无权限差异 | 不做 |
| 单一租户(自建自用) | 无跨租户 | 不做 |
| 内容本身不敏感 | 泄漏无后果 | 不做 |
| 已用"物理隔离"(每租户独立实例) | 天然隔离 | 不做 |
判断口径一句话:"同一批分块是否会被"不同权限的人"检索到"。最后一条与"审计"有关的边界:"谁看过什么"要留痕:检索日志里要记录"用户 + 命中的 doc_id 列表"——否则"事后追查泄漏"没有依据。
会(多部门、多项目、多租户),就必须在"检索前"过滤;不会(全员可见),它只是"多余的条件"。
8. 自测
- 三个"过滤位置"里,哪一个"已经泄漏"?为什么"拦下最终答案"不够?
- 权限模型三层里,哪一层是"实践中的默认"?"字段级"什么时候必要?
- "检索前过滤"的三种实现里,哪一种同时解决"安全"与"性能"?
- "查询改写"拼权限的风险是什么?为什么"漏一个括号"就等于不过滤?
- "用户调岗后还能搜到原部门内容"是怎么发生的?两种修法各有什么取舍?
- 判断"是否需要细粒度权限"的那个问题是什么?