基于元数据的 RAG 知识库细粒度权限控制:三层过滤架构设计与实践
一、背景与问题
在企业级 RAG(检索增强生成)应用中,知识库权限控制是一个绕不开的话题。传统的权限模型通常停留在知识库级别------一个用户要么能访问整个知识库,要么完全不能访问。
但真实业务场景往往更复杂:
- 一份《薪酬管理制度》文档,只有 HR 部门可见
- 一份《华东区销售政策》,只有华东区销售人员可见
- 同一知识库中,不同文档面向不同职级、不同岗位的员工
知识库级权限的粒度太粗,无法满足文档级的差异化访问控制需求。
本文介绍我们设计并实现的一套三层过滤架构,在 RAG 检索链路中实现从知识库到文档的细粒度权限控制。
二、整体架构:三层过滤模型
用户发起对话
│
▼
┌─────────────────────────────────────────┐
│ 第一层:知识库级可见性过滤 │
│ 按 部门 / 人员 / 角色 判断: │
│ 这个用户能访问哪些知识库 │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 第二层:文档级元数据过滤 │
│ 在可见知识库内,按元数据字段值 │
│ vs 当前用户属性,过滤出可读文档 │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 第三层:检索执行 │
│ 在剩余文档集合上做向量/关键词检索 │
│ SQL 层前置过滤 document_id IN (...) │
└─────────────────────────────────────────┘
│
▼
进入模型上下文
核心设计原则:权限过滤前置到检索之前,越权内容永远不进入向量检索、重排序和模型上下文。
三、数据模型设计
3.1 知识库侧:元数据字段定义
知识库需要声明"我有哪些元数据字段可用于权限控制"。我们在知识库表中增加两个字段:
sql
-- 元数据总开关:0关闭 1启用
meta_enabled INTEGER DEFAULT 0
-- 元数据字段定义(JSON 数组)
meta_fields TEXT
meta_fields 存储的 JSON 结构:
json
[
{
"key": "department",
"name": "所属部门",
"type": "dept",
"typeName": "选择部门",
"options": null,
"isCustom": false,
"enabled": true
},
{
"key": "securityLevel",
"name": "密级",
"type": "enum",
"typeName": "枚举",
"options": ["公开", "内部", "机密"],
"isCustom": true,
"enabled": true
}
]
3.2 文档侧:元数据值存储
每篇文档记录自己的元数据值:
sql
-- 文档元数据值(JSON 对象)
metadata_values TEXT
存储结构采用 introduced + values 双段式设计:
json
{
"introduced": {
"department": true,
"securityLevel": true
},
"values": {
"department": "7110622248982255582",
"securityLevel": "内部"
}
}
introduced:标记哪些字段参与了本次配置(未引入的字段不做任何限制)values:实际的字段值
为什么需要 introduced? 知识库可能定义了 10 个元数据字段,但某篇文档只配置了其中 2 个。introduced 显式记录了"这篇文档用哪些字段做权限控制",避免"字段定义存在但文档未配置"时的歧义。
四、第一层:知识库级可见性过滤
第一层沿用经典的 ACL 模型,支持三种可见范围:
java
private static final int VISIBILITY_PUBLIC = 1; // 全员公开
private static final int VISIBILITY_SCOPED = 2; // 指定范围(部门/角色/人员),基于 RBAC(基于角色的访问控制)
private static final int VISIBILITY_PRIVATE = 3; // 私有
五、第二层:文档级元数据过滤
这是本次架构改造的核心。第一层过滤出"用户能进哪些知识库"之后,第二层回答"在这些知识库里,用户能看哪些文档"。
基于当前用户属性匹配规则
两个关键边界规则(fail-closed,失败即拒绝):
- 任一已配置字段不匹配 → 整篇文档不可见(AND 关系,不是 OR)
- 文档未配置任何元数据 → 可见(未设防的文档不设权限门槛)
java
private boolean isDocumentVisible(DocumentMetaValues metaValues,
List<MetaFieldDef> fieldDefs,
UserProfile profile) {
for (MetaFieldDef field : fieldDefs) {
if (!Boolean.TRUE.equals(metaValues.introduced.get(field.key))) {
continue; // 字段未引入 → 不做限制
}
Object docValue = metaValues.values.get(field.key);
if (!matchField(docValue, field.type, field.key, profile)) {
return false; // 任一字段不匹配 → 不可见
}
}
return true;
}
六、第三层:检索执行时的前置过滤
第二层产出的可见文档 ID 集合,需要在检索时生效。
向量检索 SQL 中追加 document_id IN (...) 条件,将权限过滤下沉到数据库层:
七、补充(自动化管理)
场景一:员工属性变更时,自动重算可见性
- 员工转岗:从"销售部"调到"技术部"
- 员工晋升:从 P6 升到 P7
- 员工离职:账号禁用
这些变更发生时,该员工能看到的文档集合应该自动变化。
bash
花名册变更事件
→ 触发该用户的"可见文档集合"重算
→ 更新缓存(如果有的话)
→ 下次检索时使用新的可见集合
场景二:文档元数据配置的自动校验
文档在配置元数据时,可以自动校验:
- 配置的 department 值是否在花名册的部门列表中?
- 配置的 jobLevel 范围是否合法?
- 配置的 region 是否在有效区域枚举内?
这能避免"文档配了一个花名册里根本不存在的部门,导致永远没人能看到"的尴尬。
场景三:文档时效性自动提醒
如果文档元数据里有 publish_date 或 effective_date,可以:
- 发布满 N 天,自动通知负责人复审
- 关联的部门/岗位发生变化时,通知文档负责人确认元数据是否还有效
场景四:孤儿文档检测
如果一篇文档配置的 department 是一个已经被撤销的部门,或者配置的 jobLevel 范围没有任何在职员工,那这篇文档实际上是"孤儿文档"------配置了权限,但没人能满足。
系统可以定期扫描,自动告警:
bash
文档X 配置的 department = "已撤销的XX部"
当前花名册中无此部门员工
→ 该文档当前无人可见,请检查配置
这个功能在企业知识库里非常实用,能发现大量"配错了导致没人看得到"的文档。