第2377篇:RAG的权限过滤——不同用户只能检索自己有权看的文档
大约 7 分钟
第2377篇:RAG的权限过滤——不同用户只能检索自己有权看的文档
适读人群:构建企业内部RAG系统的AI工程师 | 阅读时长:约18分钟 | 核心价值:掌握基于角色和标签的文档权限过滤方案,确保RAG系统的信息安全边界
这是一个做企业内部知识库时一定会遇到的问题,但很多团队在早期完全没考虑。
我们给一家集团公司做内部问答系统时,集团里有六家子公司,每家公司都有自己的合同、财务数据、人事信息。集团总部的人可以看所有的,但子公司A的员工不应该能看到子公司B的文档。
更复杂的是,即使在同一家子公司内部,财务数据只有财务部和高管能看,HR数据只有HR部能看。
这种多层级、多维度的权限控制,如果在RAG层面没有做好,后果很严重——有人会通过问AI来绕过文件系统的权限控制,拿到本来看不到的敏感信息。
权限模型设计
/**
* 企业RAG的权限控制模型
*
* 维度1:组织维度(公司/部门)
* 文档属于某个组织单元,只有该组织的成员才能访问
*
* 维度2:角色维度(职级/职能)
* 部分文档只有特定角色能访问(如:高管文件、HR文件)
*
* 维度3:分类维度(机密级别)
* 公开 < 内部 < 机密 < 绝密
*
* 维度4:个人维度(个人相关文档)
* 绩效评估只有本人和直属上级能看
*
* 控制逻辑:AND关系
* 必须同时满足所有有效维度的权限,才能访问
*/
@Data
@Builder
public class DocumentPermission {
// 允许访问的组织ID列表(为空=全组织可见)
private List<String> allowedOrgIds;
// 允许访问的角色列表(为空=不限角色)
private List<String> allowedRoles;
// 最低机密级别要求(用户的机密等级必须>=文档等级才能访问)
private SecurityLevel securityLevel;
// 允许访问的用户ID列表(用于个人文档)
private List<String> allowedUserIds;
// 是否需要额外审批才能访问
private boolean requiresApproval;
}
@Data
@Builder
public class UserContext {
private String userId;
private String orgId; // 所在组织
private List<String> roles; // 拥有的角色
private SecurityLevel clearance; // 安全等级
private List<String> extraPermissions; // 额外授予的权限
}向量库层面的权限元数据
@Service
public class PermissionAwareDocumentIngester {
/**
* 文档入库时,把权限信息写入元数据
*
* 关键原则:权限信息存在文档元数据里,
* 检索时直接用元数据过滤,不需要额外查权限系统
* 这样检索延迟低,且避免了运行时权限查询的复杂性
*/
public void ingestWithPermissions(String content,
String docId,
DocumentPermission permission) {
// 把权限信息序列化进元数据
Map<String, Object> metadata = new HashMap<>();
metadata.put("doc_id", docId);
// 组织权限:用逗号分隔的字符串存储,便于过滤
if (permission.getAllowedOrgIds() != null && !permission.getAllowedOrgIds().isEmpty()) {
metadata.put("allowed_orgs", String.join(",", permission.getAllowedOrgIds()));
} else {
metadata.put("allowed_orgs", "ALL"); // 全组织可见
}
// 角色权限
if (permission.getAllowedRoles() != null && !permission.getAllowedRoles().isEmpty()) {
metadata.put("allowed_roles", String.join(",", permission.getAllowedRoles()));
} else {
metadata.put("allowed_roles", "ALL");
}
// 安全等级
metadata.put("security_level", permission.getSecurityLevel().getLevel());
// 个人用户权限
if (permission.getAllowedUserIds() != null && !permission.getAllowedUserIds().isEmpty()) {
metadata.put("allowed_users", String.join(",", permission.getAllowedUserIds()));
}
Document doc = Document.builder()
.id(docId)
.content(content)
.metadata(metadata)
.build();
vectorStore.add(List.of(doc));
}
}检索时的权限过滤
@Service
public class PermissionFilteredRetriever {
private final VectorStore vectorStore;
private final UserContextProvider userContextProvider;
/**
* 带权限过滤的检索
*
* 核心思路:在向量检索的filter里加入权限条件
* 这比检索后再过滤更安全(检索后过滤可能泄露文档存在信息)
*/
public List<Document> retrieve(String query, String userId) {
UserContext userCtx = userContextProvider.getContext(userId);
FilterExpression permissionFilter = buildPermissionFilter(userCtx);
return vectorStore.similaritySearch(
SearchRequest.query(query)
.withTopK(10)
.withFilterExpression(permissionFilter)
);
}
/**
* 构建权限过滤表达式
*
* 过滤逻辑:
* 文档可被访问 当且仅当:
* (allowed_orgs == "ALL" OR 用户orgId在allowed_orgs中)
* AND (allowed_roles == "ALL" OR 用户有allowed_roles中的角色)
* AND (security_level <= 用户clearance)
* AND (allowed_users为空 OR 用户userId在allowed_users中)
*/
private FilterExpression buildPermissionFilter(UserContext userCtx) {
FilterExpressionBuilder builder = FilterExpressionBuilder.builder();
// 组织过滤
// 难点:向量库的过滤通常不支持"字符串包含"操作
// 解决方案:把允许的组织ID列表拆成多个OR条件
FilterExpression orgFilter = buildOrgFilter(userCtx.getOrgId());
// 角色过滤
FilterExpression roleFilter = buildRoleFilter(userCtx.getRoles());
// 安全等级过滤
FilterExpression securityFilter = builder
.lte("security_level", userCtx.getClearance().getLevel())
.build();
return FilterExpressionBuilder.and(orgFilter, roleFilter, securityFilter);
}
/**
* 组织过滤条件的构建
* 文档的allowed_orgs是"ALL",或者包含用户所在的orgId
*/
private FilterExpression buildOrgFilter(String userOrgId) {
// Pinecone/Weaviate等向量库支持metadata过滤
// 但"字符串包含"的支持因库而异
// 一种可靠的做法:把文档的allowed_orgs存成数组而非字符串
return FilterExpressionBuilder.or(
FilterExpressionBuilder.eq("allowed_orgs", "ALL"),
FilterExpressionBuilder.contains("allowed_orgs_array", userOrgId)
);
}
private FilterExpression buildRoleFilter(List<String> userRoles) {
// 构建:allowed_roles == "ALL" OR 任意一个用户角色在allowed_roles_array中
List<FilterExpression> conditions = new ArrayList<>();
conditions.add(FilterExpressionBuilder.eq("allowed_roles", "ALL"));
for (String role : userRoles) {
conditions.add(FilterExpressionBuilder.contains("allowed_roles_array", role));
}
return FilterExpressionBuilder.or(conditions.toArray(new FilterExpression[0]));
}
}权限感知的提示词工程
除了检索过滤,还需要在提示词层面防止信息泄露。
@Service
public class PermissionAwarePromptBuilder {
/**
* 提示词里不能透露无权访问的文档的存在
*
* 错误示例:
* "根据权限过滤,排除了3份机密文档,以下是你能看到的..."
* → 这暗示了存在机密文档,本身就是信息泄露
*
* 正确做法:
* 直接基于有权访问的文档回答,不提及被过滤的内容
*/
public String buildPrompt(String question, List<Document> authorizedDocs,
UserContext userCtx) {
if (authorizedDocs.isEmpty()) {
// 没有可访问的文档,但不能透露是因为权限问题还是真的没有
return buildNoContentPrompt(question);
}
String context = buildContext(authorizedDocs);
return """
基于以下信息回答问题。
%s
问题:%s
请给出完整回答:
""".formatted(context, question);
}
/**
* 没有可访问内容时的提示
* 不泄露是权限限制还是知识库确实没有
*/
private String buildNoContentPrompt(String question) {
return """
我目前没有找到与"%s"相关的信息。
如果您认为应该有相关文档,请联系管理员确认权限配置。
""".formatted(question);
}
}权限变更时的同步
@Service
public class PermissionSyncService {
/**
* 权限变更时的处理
*
* 场景:用户角色变更(晋升/降职/调岗),需要实时更新访问权限
*
* 向量库里的权限元数据是静态的(存的是允许谁访问)
* 用户的权限是动态的(从HR系统实时获取)
*
* 所以:不需要更新向量库,只需要更新用户上下文的来源(HR系统/权限系统)
* 每次查询时实时获取最新的用户权限
*/
@EventListener
public void onUserRoleChanged(UserRoleChangedEvent event) {
// 清除该用户的权限缓存,下次查询时重新从权限系统获取
userContextProvider.invalidateCache(event.getUserId());
log.info("User permission cache cleared for user: {} due to role change",
event.getUserId());
}
/**
* 文档权限变更时的处理
*
* 场景:一份内部文档改成了机密级别
* 需要更新向量库里这份文档的权限元数据
*/
public void updateDocumentPermission(String docId, DocumentPermission newPermission) {
// 由于向量库通常不支持元数据更新(只能删除后重新添加)
// 这里需要:删除旧文档 + 重新添加带新权限的文档
Document existingDoc = findDocumentById(docId);
if (existingDoc == null) {
log.warn("Document not found for permission update: {}", docId);
return;
}
// 删除旧版本
vectorStore.delete(List.of(docId));
// 用新权限重新添加
ingestWithPermissions(existingDoc.getContent(), docId, newPermission);
log.info("Document permission updated: {}", docId);
}
}审计日志:谁查了什么
企业级系统里,权限控制必须配合审计日志,不然出了安全事件无法追溯。
@Aspect
@Component
public class RAGAuditAspect {
/**
* AOP拦截所有RAG查询,记录审计日志
*/
@Around("execution(* com.company.rag.service..*retrieve*(..))")
public Object auditRetrieval(ProceedingJoinPoint joinPoint) throws Throwable {
String userId = getCurrentUserId();
String query = extractQuery(joinPoint.getArgs());
long startTime = System.currentTimeMillis();
Object result = joinPoint.proceed();
long duration = System.currentTimeMillis() - startTime;
// 记录查询日志
AuditLog log = AuditLog.builder()
.userId(userId)
.query(query)
.retrievedDocIds(extractDocIds(result))
.duration(duration)
.timestamp(LocalDateTime.now())
.build();
auditLogRepository.save(log);
return result;
}
}权限控制是企业级RAG里最容易被忽视的工程问题。在PoC阶段大家往往不在意,等到上线前被安全审查打回来,再来加权限逻辑,改动就很大了。建议从第一个版本开始就把权限元数据设计好,哪怕初期所有人都能看所有文档,也要留好架子,扩展起来容易很多。
