know-engine 实现了基于角色的知识库检索权限管理,确保不同身份的用户只能检索到其权限范围内的知识文档。权限控制贯穿文档上传 → 切片元数据写入 → 检索过滤全链路,在 Elasticsearch 检索层进行硬过滤,无权文档不会进入 RAG 上下文。
角色体系
系统定义了三级角色,权限由低到高逐级继承: | 角色 | 枚举值 | 含义 | 可访问的文档范围 | | --- | --- | --- | --- | | 访客 | VISITOR | 未购车用户 | 仅 VISITOR 文档 | | 车主 | OWNER | 已购车用户 | VISITOR + OWNER 文档 | | 客服 | CUSTOMER_SERVICE | 在职客服员工 | VISITOR + OWNER + CUSTOMER_SERVICE 文档 |
核心原则:高权限角色继承低权限角色的可访问范围。角色定义见 RoleEnum。
全链路实现
文档上传——设定可见范围
上传文档时,通过 upload.html 页面的"可见范围"下拉框选择文档的目标权限级别: - 未购车用户 (VISITOR):所有人可见 - 已购车用户 (OWNER):客服和已购车用户可见 - 客服 (CUSTOMER_SERVICE):仅客服可见 选择的值通过 DocumentPermissionUtils.getDocumentPermission() 直接映射为枚举名称,保存到 knowledge_document 表的 accessible_by 字段。
文档切片——权限元数据写入
文档切片阶段,系统将文档的 accessibleBy 值写入每个知识片段(KnowledgeSegment)的元数据中:
// DocumentProcessServiceImpl#enrichMetadata
private static String enrichMetadata(KnowledgeDocument document, KnowledgeSegment knowledgeSegment, Metadata metadata) {
Map<String, Object> metadataMap = metadata.toMap();
metadataMap.put(MetadataKeyConstant.ACCESSIBLE_BY, document.getAccessibleBy());
return JSON.toJSONString(metadataMap);
}
这样,当切片被向量化写入 Elasticsearch 后,每个文档都携带 metadata.accessibleBy 字段,为检索时过滤提供数据基础。
用户角色识别——动态判定身份
用户发起对话时,系统通过 UserRoleServiceImpl 动态判定其角色:
public RoleEnum getUserRole(ChatParam chatParam) {
// 1. 查询车辆归属(避免水平权限漏洞:后端二次校验 car_id 与 userId 的关联关系)
MyCar myCar = myCarService.getCarByUser(
chatParam.intentRecognitionResult().entities().car_id(),
chatParam.userId());
if (myCar != null) {
return RoleEnum.OWNER;
}
// 2. 查询员工信息,在职客服判定为 CUSTOMER_SERVICE
StaffInfo staffInfo = staffInfoService.getById(chatParam.userId());
if (staffInfo != null && staffInfo.getStatus() == StaffStatus.ON_JOB) {
return RoleEnum.CUSTOMER_SERVICE;
}
// 3. 默认为访客
return RoleEnum.VISITOR;
}
判定逻辑: 1. 先查车主身份:利用意图识别提取的 car_id,后端再次查询车辆归属关系(防止水平权限漏洞——客户端可伪造 car_id),若验证通过则为 OWNER 2. 再查员工身份:查询 staff_info 表,若在职则为 CUSTOMER_SERVICE 3. 兜底为访客:均不满足则为 VISITOR 安全设计:不信任前端传入的 car_id,后端必须二次查询 MyCar 表验证 car_id 与 userId 的真实关联关系,杜绝水平越权访问。
权限过滤器构造
ChatApplicationService#buildFilter 根据用户角色构造 langchain4j 的 Filter 对象:
private Filter buildFilter(ChatParam chatParam) {
// 基线:访客权限
Filter permissionFilter = metadataKey(ACCESSIBLE_BY).isEqualTo(RoleEnum.VISITOR.name());
// 根据用户角色获取所有可访问权限级别
RoleEnum roleEnum = userRoleService.getUserRole(chatParam);
String[] permissions = DocumentPermissionUtils.getDocumentAccessiblePermission(roleEnum);
// 用 OR 连接各权限级别
for (String permission : permissions) {
if (!RoleEnum.VISITOR.name().equals(permission)) {
permissionFilter = permissionFilter.or(metadataKey(ACCESSIBLE_BY).isEqualTo(permission));
}
}
return permissionFilter;
}
DocumentPermissionUtils.getDocumentAccessiblePermission() 负责角色到可访问权限数组的映射: | 用户角色 | 返回的可访问权限 | | --- | --- | | VISITOR | [VISITOR] | | OWNER | [OWNER, VISITOR] | | CUSTOMER_SERVICE | [VISITOR, OWNER, CUSTOMER_SERVICE] |
构造出的 Filter 逻辑示例(CUSTOMER_SERVICE 用户):
metadata.accessibleBy == "VISITOR" OR metadata.accessibleBy == "OWNER" OR metadata.accessibleBy == "CUSTOMER_SERVICE"
检索层过滤——双路检索权限适配
权限过滤器同时应用于向量检索和全文检索两条路径: 向量检索 向量检索通过 langchain4j 原生的 EmbeddingSearchRequest.filter() 传递 Filter,框架会自动将 Filter 转换为 Elasticsearch 的查询条件:
EmbeddingSearchRequest request = EmbeddingSearchRequest.builder()
.queryEmbedding(referenceEmbedding)
.maxResults(maxResults)
.minScore(minScore)
.filter(filter) // 权限过滤器
.build();
全文检索 langchain4j 默认的 Elasticsearch 全文检索实现不支持 Filter 参数,因此 know-engine 定制了 doFullTextQuery() 方法: 1. Filter 树解析:通过 extractFilterValues() 递归遍历 langchain4j 的 Filter 树(Or → IsEqualTo),提取所有权限值 2. ES 查询构造: - 无权限过滤时:使用简单 match 查询 - 有权限过滤时:使用 bool 查询,must 子句做全文匹配,filter 子句通过 terms 查询限定 metadata.accessibleBy 字段
// 有权限过滤时的 ES 查询结构
q.bool(b -> b
.must(m -> m.match(mm -> mm.field("text").query(query.text()))) // 全文匹配
.filter(f -> f.terms(t -> t
.field("metadata.accessibleBy") // 权限字段
.terms(tv -> tv.value(accessibleValues.stream()
.map(FieldValue::of).toList()))))); // 允许的权限值列表
对应的实际查询的条件为:
{
"query": {
"bool": {
"must": [
{ "match": { "text": "我的车怎么打不着火了" } }
],
"filter": [
{ "terms": { "metadata.accessibleBy": ["VISITOR", "OWNER"] } }
]
}
}
}