group_id 为什么能实现群知识库隔离?从我的聊天项目说起
在做聊天项目接入 AI 知识库的时候,一个很容易被忽略的问题是:
如果每个群都能上传自己的知识库,那系统怎么保证 A 群查不到 B 群的资料?
比如系统里有两个群:
G10001:Java 学习群
G20002:Python 学习群
Java 学习群上传了一份 Netty 文档,Python 学习群上传了一份 FastAPI 文档。用户在 G10001 里问 AI 问题时,理论上只能查:
公共知识库 + G10001 这个群自己的知识库
不能查到:
G20002 的群知识库
这就是所谓的群知识库隔离。
在我的 X-Chat 项目里,这个隔离不是靠复杂的权限框架完成的,而是靠一个非常清晰的设计:
scope_type + scope_id
其中,群知识库场景下:
scope_type = group
scope_id = groupId
也就是说,前端和 Java 里叫 groupId,到了 Python 知识库系统里,它就变成了 scope_id。
本文就结合项目代码,从初学者角度讲清楚:为什么 group_id 可以实现群知识库隔离?它到底在哪些地方发挥了作用?
1. 先说结论:不是 group_id 天然安全,而是它贯穿了整条链路
很多初学者容易以为:
只要请求里带了 group_id,就能隔离群知识库。
其实不是。
group_id 本身只是一个字符串,比如:
G10001
它没有任何魔法。如果系统只是简单相信前端传来的 groupId,那用户完全可以伪造请求:
groupId=G20002
然后访问别的群知识库。
所以,真正实现隔离的是这套完整链路:
前端传 groupId
↓
Java 从登录态拿 userId
↓
Java 校验 userId 是否属于这个 groupId
↓
Java 转发给 Python:scope_type=group,scope_id=groupId
↓
Python 上传文档时保存 scope_type/scope_id
↓
Python 查询列表、删除、解析、索引时校验 scope
↓
RAG 检索时只查 global + 当前 groupId
↓
向量库 Chroma 也用 scope_id 做 metadata 过滤
因此,更准确地说:
group_id之所以能隔离群知识库,是因为它被设计成了知识库作用域 ID,并且在权限校验、文档入库、SQL 查询、向量检索、AI 问答中都被持续传递和过滤。
2. 项目里 groupId 和 scope_id 的关系
在前端和 Java 业务层,这个字段叫:
groupId
在 Python AI 知识库服务里,它叫:
scope_id
同时还会搭配一个字段:
scope_type
所以它们的对应关系是:
| 场景 | scope_type | scope_id |
|---|---|---|
| 公共知识库 | global | 空字符串 |
| 群知识库 G10001 | group | G10001 |
| 群知识库 G20002 | group | G20002 |
这套设计非常重要。
如果只保存一个 groupId,那公共知识库怎么表示?如果后面还要支持“部门知识库”“项目知识库”“个人知识库”,又该怎么办?
使用 scope_type + scope_id 的好处是扩展性更好:
global + "" 表示公共知识库
group + G10001 表示某个群知识库
user + U10001 将来可以表示个人知识库
project + P10001 将来可以表示项目知识库
所以,groupId 在项目里不是孤立存在的,它被纳入了“知识库作用域”这个设计里。
3. 第一层:前端只负责传 groupId,不能决定权限
前端群知识库 API 在:
frontend/src/api/groupKnowledge.js
里面的接口大概是这样:
export function listGroupKnowledge(groupId) {
const formData = new URLSearchParams()
formData.append('groupId', groupId)
return request.post('/chat/groupKnowledge/list', formData)
}
export function uploadGroupKnowledge(groupId, file) {
const formData = new FormData()
formData.append('groupId', groupId)
formData.append('file', file)
return request.post('/chat/groupKnowledge/upload', formData)
}
export function debugGroupKnowledgeSearch(groupId, query, topK = 5) {
const formData = new URLSearchParams()
formData.append('groupId', groupId)
formData.append('query', query)
formData.append('topK', String(topK))
return request.post('/chat/groupKnowledge/debugSearch', formData)
}
前端会把当前群的 groupId 传给 Java 后端。
但是这里要记住一个原则:
前端传什么,后端都不能直接信。
因为前端参数是可以被用户篡改的。比如用户原本在 G10001 群里,但他用浏览器开发者工具或者 Postman 手动发请求:
groupId=G20002
如果 Java 后端不校验,那隔离就失效了。
所以前端这一步只是“告诉后端我想访问哪个群”,真正决定能不能访问的是 Java 后端。
4. 第二层:Java 先判断用户是否属于这个群
群知识库接口在:
src/main/java/com/xchat/backend/controller/ChatMessageController.java
例如:
@PostMapping("/groupKnowledge/upload")
public Result<Object> uploadGroupKnowledge(String groupId, MultipartFile file, HttpServletRequest request) {
String userId = (String) request.getAttribute("userId");
return aiChatService.uploadGroupKnowledge(userId, groupId, file);
}
这里有一个关键点:
String userId = (String) request.getAttribute("userId");
也就是说,当前用户是谁,不是前端自己传的,而是后端从登录态或拦截器里拿到的。
然后进入:
src/main/java/com/xchat/backend/ai/AiChatService.java
群知识库相关操作都会先调用:
private Result<Void> validateGroupKnowledgeAccess(String userId, String groupId) {
if (!isEnabled()) {
return Result.error(600, "AI 功能未开启");
}
if (StringUtils.isEmpty(groupId) || !groupId.startsWith("G")) {
return Result.error(600, "群 ID 不合法");
}
if (!canReadConversation(userId, groupId)) {
return Result.error(600, "无权访问该群知识库");
}
return Result.success();
}
这里做了三件事:
- AI 功能必须开启;
groupId必须是合法群 ID,项目里群 ID 以G开头;- 当前用户必须有权限读取这个群会话。
权限判断是:
private boolean canReadConversation(String userId, String contactId) {
UserContact relation = userContactMapper.selectByUserIdAndContactId(userId, contactId);
return relation != null && UserContactStatusEnum.FRIEND.getStatus().equals(relation.getStatus());
}
对于群聊来说,contactId 就是 groupId。如果 user_contact 表里没有当前用户和这个群的正常关系,就不能访问。
所以,如果用户不在 G20002 群里,即使他伪造请求:
groupId=G20002
Java 也会返回:
无权访问该群知识库
这一层解决的是:
用户有没有资格访问这个群。
5. 第三层:Java 把 groupId 转成 Python 的 scope_id
Java 校验通过后,并不会自己保存知识库文件,而是把请求转发给 Python AI 服务。
对应代码在:
src/main/java/com/xchat/backend/ai/AiFastApiClient.java
上传群知识库时:
RequestBody body = new MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("scope_type", "group")
.addFormDataPart("scope_id", groupId)
.addFormDataPart("file", file.getOriginalFilename(), fileBody)
.build();
列表查询时:
String query = "?scope_type=group&scope_id=" + urlEncode(groupId);
删除、解析、构建索引时,也会拼上:
private String buildGroupScopeQuery(String groupId) {
return "?scope_type=group&scope_id=" + urlEncode(groupId);
}
也就是说,Java 不只是把 groupId 原样传过去,而是明确告诉 Python:
这次操作的是 group 类型知识库
这个 group 的 ID 是 groupId
换成接口参数就是:
scope_type=group
scope_id=G10001
这一层解决的是:
Python 怎么知道这份文档属于哪个知识库范围。
6. 第四层:Python 统一规范化 scope
Python 服务入口在:
X-RAG Agent/api.py
里面有一个非常关键的函数:
def normalize_knowledge_scope(scope_type: str = "", scope_id: str = "") -> tuple[str, str]:
normalized_scope_type = (scope_type or KNOWLEDGE_SCOPE_GLOBAL).strip().lower()
normalized_scope_id = (scope_id or "").strip()
if normalized_scope_type not in ALLOWED_KNOWLEDGE_SCOPE_TYPES:
raise HTTPException(status_code=400, detail="scope_type 只支持 global 或 group")
if normalized_scope_type == KNOWLEDGE_SCOPE_GLOBAL:
return KNOWLEDGE_SCOPE_GLOBAL, ""
if not normalized_scope_id:
raise HTTPException(status_code=400, detail="scope_type=group 时 scope_id 不能为空")
return KNOWLEDGE_SCOPE_GROUP, normalized_scope_id
这个函数做了几件很重要的事。
第一,默认是公共知识库:
scope_type 为空 -> global
第二,只允许两种类型:
global
group
第三,如果是公共知识库,scope_id 会被清空:
scope_type=global -> scope_id=""
第四,如果是群知识库,必须传 scope_id:
scope_type=group -> scope_id 不能为空
这一步看起来简单,但它把知识库作用域统一了。后面的上传、列表、删除、检索都不需要各自重复处理脏参数,只需要使用规范化后的:
normalized_scope_type
normalized_scope_id
7. 第五层:上传文档时,把 scope 写入数据库
Python 的知识库元数据表在:
X-RAG Agent/database.py
表结构里有两个字段:
CREATE TABLE IF NOT EXISTS knowledge_documents (
doc_id TEXT PRIMARY KEY,
filename TEXT NOT NULL,
file_type TEXT NOT NULL,
file_size INTEGER NOT NULL,
file_md5 TEXT NOT NULL DEFAULT '',
scope_type TEXT NOT NULL DEFAULT 'global',
scope_id TEXT NOT NULL DEFAULT '',
storage_path TEXT NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL
)
这两个字段就是隔离的基础:
scope_type
scope_id
上传文档时,Python 会把它们保存进去:
document = {
"doc_id": doc_id,
"filename": original_filename,
"file_type": file_type,
"file_size": file_size,
"file_md5": file_md5,
"scope_type": normalized_scope_type,
"scope_id": normalized_scope_id,
"storage_path": str(storage_path),
"status": "uploaded",
"created_at": now,
}
这样每一份文档都会有明确归属。
举个例子:
| doc_id | filename | scope_type | scope_id |
|---|---|---|---|
| doc_001 | netty.md | group | G10001 |
| doc_002 | fastapi.md | group | G20002 |
| doc_003 | common.md | global |
有了这个字段,系统才能知道:
netty.md 属于 G10001
fastapi.md 属于 G20002
common.md 属于公共知识库
如果文档入库时没有保存作用域,后面再怎么查询都很难隔离。
8. 第六层:列表查询只查当前群的文档
当用户打开 G10001 的群知识库管理页面时,Java 请求 Python:
/knowledge/list?scope_type=group&scope_id=G10001
Python 最终会构造查询条件:
def build_scope_where_clause(scope_type: str, scope_id: str, table_alias: str = "") -> tuple[str, tuple]:
normalized_scope_type = (scope_type or "").strip()
normalized_scope_id = (scope_id or "").strip()
if not normalized_scope_type:
return "", ()
prefix = f"{table_alias}." if table_alias else ""
if normalized_scope_type == "global":
return f"WHERE {prefix}scope_type = ?", ("global",)
return f"WHERE {prefix}scope_type = ? AND {prefix}scope_id = ?", (
normalized_scope_type,
normalized_scope_id,
)
所以 G10001 的文档列表 SQL 条件本质是:
WHERE scope_type = 'group'
AND scope_id = 'G10001'
这样用户在 G10001 页面里只能看到 G10001 的知识库文档,看不到 G20002 的文档。
这一层解决的是:
页面展示时不能看到别的群文档。
9. 第七层:删除、解析、索引时也要校验文档归属
只做列表隔离还不够。
为什么?
因为用户可能拿到了别的群的 doc_id,然后手动请求:
删除 doc_002
如果系统只根据 doc_id 删除,那就有越权风险。
所以 Python 还有一个校验函数:
def ensure_document_matches_scope(document, scope_type: str = "", scope_id: str = "") -> None:
if not (scope_type or scope_id):
return
normalized_scope_type, normalized_scope_id = normalize_knowledge_scope(scope_type, scope_id)
if document.scope_type != normalized_scope_type or document.scope_id != normalized_scope_id:
raise HTTPException(status_code=404, detail="文档不存在或不属于当前知识库")
这个函数会用于删除、解析、构建索引等操作。
比如用户在 G10001 里尝试操作 G20002 的文档:
当前请求:scope_type=group,scope_id=G10001
目标文档:scope_type=group,scope_id=G20002
两者不匹配,于是 Python 返回:
文档不存在或不属于当前知识库
注意这里不是返回“你正在访问 G20002 的文档”,而是返回“文档不存在或不属于当前知识库”。这种写法也比较安全,因为它不会暴露别的群是否真的存在这份文档。
这一层解决的是:
就算知道别的群 doc_id,也不能操作别的群文档。
10. 第八层:重复文件判断也要带 scope
项目里有一个很容易忽略但很重要的细节:重复文件判断也带了作用域。
数据库查询逻辑是:
WHERE file_md5 = ?
AND file_type = ?
AND scope_type = ?
AND scope_id = ?
这表示:只有在同一个知识库作用域里,才会判断为重复文档。
举个例子,同一个 PDF:
Netty实战.pdf
如果 G10001 上传过,G20002 也上传一份,系统不应该阻止 G20002 上传。因为这是两个群,各自有自己的知识库。
如果重复判断只看:
file_md5 + file_type
那么 G10001 上传后,G20002 再上传就可能被误判为重复。
项目里把 scope_type + scope_id 也放进重复判断条件,就避免了这个问题。
这一层解决的是:
同一个文件可以在不同群里独立存在。
11. 第九层:普通 SQL 检索时,只查 global + 当前群
知识库问答真正危险的地方,不只是“列表能不能看到”,而是“AI 检索时会不会召回别的群资料”。
如果列表页面隔离了,但是 RAG 检索时没过滤,那用户仍然可能通过提问拿到别的群内容。
项目里检索知识库片段时,用的是这个函数:
def build_accessible_scope_where_clause(scope_type: str, scope_id: str, table_alias: str = "") -> tuple[str, tuple]:
normalized_scope_type = (scope_type or "global").strip()
normalized_scope_id = (scope_id or "").strip()
prefix = f"{table_alias}." if table_alias else ""
if normalized_scope_type == "group" and normalized_scope_id:
return (
f"WHERE ({prefix}scope_type = ? OR ({prefix}scope_type = ? AND {prefix}scope_id = ?))",
("global", "group", normalized_scope_id),
)
return f"WHERE {prefix}scope_type = ?", ("global",)
这段代码非常关键。
如果当前请求是:
scope_type=group
scope_id=G10001
那么检索范围就是:
WHERE scope_type = 'global'
OR (scope_type = 'group' AND scope_id = 'G10001')
也就是:
公共知识库 + 当前群知识库
不会查:
G20002
G30003
其他任何群
这就是群知识库隔离在 RAG 检索里的核心逻辑。
为什么要允许查 global?
因为公共知识库通常是所有群都可以使用的基础资料。例如项目说明、通用帮助文档、公司制度等。
所以群知识库检索不是“只查当前群”,而是:
global + 当前 groupId
这个语义很常见,也很实用。
12. 第十层:向量库 Chroma 也必须做 scope 过滤
RAG 通常不只靠 SQL 关键词检索,还会用向量检索。
如果 SQLite 查询做了 scope 过滤,但向量库没有过滤,仍然可能召回别的群文档。
所以项目在构建 Chroma 文档时,把作用域写进 metadata:
metadata = {
"chunk_id": chunk.chunk_id,
"doc_id": document.doc_id,
"chunk_index": chunk.chunk_index,
"source": document.filename,
"scope_type": document.scope_type or "global",
"scope_id": document.scope_id or "",
**(chunk.metadata or {}),
}
检索 Chroma 时,又用这个过滤器:
def build_chroma_scope_filter(scope_type: str, scope_id: str) -> dict:
normalized_scope_type = (scope_type or "global").strip()
normalized_scope_id = (scope_id or "").strip()
if normalized_scope_type == "group" and normalized_scope_id:
return {
"$or": [
{"scope_type": "global"},
{"$and": [{"scope_type": "group"}, {"scope_id": normalized_scope_id}]},
]
}
return {"scope_type": "global"}
意思和 SQL 检索一样:
群聊检索:global + 当前群
普通检索:只查 global
这个设计很重要。
因为对于 AI 问答来说,真正参与回答生成的是“被召回的 chunk”。如果向量召回阶段没过滤,那么即使文档列表看不到别的群,AI 也可能读到别的群内容。
这一层解决的是:
向量检索阶段也不能召回其他群的知识库片段。
13. 第十一层:AI 问答时,也会根据群会话决定 scope
除了群知识库管理,AI 问答时也要知道当前应该查哪个知识库。
项目里 AiChatService 有两个方法:
private String resolveKnowledgeScopeType(String contactId) {
if (!StringUtils.isEmpty(contactId) && contactId.startsWith("G")) {
return SCOPE_GROUP;
}
return SCOPE_GLOBAL;
}
private String resolveKnowledgeScopeId(String contactId) {
if (!StringUtils.isEmpty(contactId) && contactId.startsWith("G")) {
return contactId;
}
return "";
}
也就是说,如果当前会话 ID 是群 ID:
contactId=G10001
那么 Java 调用 Python AI 服务时,会传:
scope_type=group
scope_id=G10001
如果是普通私聊或者 AI 私聊,则默认:
scope_type=global
scope_id=""
这保证了 AI 在群上下文中回答问题时,会使用当前群的知识库范围。
14. 用一个完整例子串起来
假设现在有两个群:
| 群 | groupId | 知识库文档 |
|---|---|---|
| Java 学习群 | G10001 | netty.md |
| Python 学习群 | G20002 | fastapi.md |
还有一个公共知识库:
| 类型 | 文档 |
|---|---|
| global | project-common.md |
现在用户在 G10001 群里问:
Netty 的粘包拆包怎么处理?
整个链路是:
1. 前端把当前群 groupId=G10001 传给 Java
2. Java 从登录态拿到 userId
3. Java 检查 userId 是否是 G10001 的成员
4. 校验通过后,Java 调 Python:scope_type=group,scope_id=G10001
5. Python RAG 检索时构造范围:global + G10001
6. SQLite 和 Chroma 都只在这个范围内召回 chunk
7. AI 根据召回结果生成答案
最终 AI 可以使用:
project-common.md
netty.md
不能使用:
fastapi.md
因为 fastapi.md 的作用域是:
scope_type=group
scope_id=G20002
它不属于当前请求的可访问范围。
15. 为什么不能只靠 doc_id 隔离?
有些初学者可能会问:
每个文档不是已经有 doc_id 了吗?为什么还需要 groupId?
因为 doc_id 只是文档自己的 ID,它不表达权限边界。
比如:
doc_001 = G10001 的文档
doc_002 = G20002 的文档
如果删除接口只接收:
docId=doc_002
那么只要用户猜到或拿到别的群 docId,就有可能越权操作。
正确做法是每次操作都带上当前作用域:
docId=doc_002
scope_type=group
scope_id=G10001
然后后端检查:
doc_002 是否属于 G10001?
如果不属于,就拒绝。
所以 doc_id 解决的是“这是哪份文档”,而 scope_id 解决的是“这份文档属于哪个知识库范围”。两者不是一回事。
16. 为什么不能只在前端隐藏?
另一个常见误区是:
只要前端不显示别的群知识库入口,不就行了吗?
不行。
前端隐藏只能改善用户体验,不能作为安全边界。
真正的安全边界必须在后端:
Java 校验用户和群的关系
Python 校验文档和 scope 的关系
SQL / Chroma 检索时过滤 scope
如果只靠前端隐藏,用户仍然可以通过接口工具直接发请求。
所以这类权限问题一定要记住:
前端负责展示,后端负责校验。
17. 为什么 SQL 和向量库都要隔离?
因为知识库系统里通常有两套数据:
1. 文档元数据和 chunk 文本:SQLite
2. chunk 的向量索引:Chroma
如果只隔离 SQLite,不隔离 Chroma,就会出现这种风险:
列表页面看不到别的群文档
但是向量检索仍然召回了别的群 chunk
AI 最终把别的群资料写进回答里
所以项目里做了两次过滤:
SQLite:WHERE scope_type='global' OR scope_id=当前群
Chroma:filter global OR 当前 group scope
这才是完整的 RAG 隔离。
在 AI 项目里,很多数据泄漏不是发生在“列表接口”,而是发生在“检索召回阶段”。这一点特别值得注意。
18. 这个设计可以怎么扩展?
现在项目只支持:
global
group
但因为已经抽象成 scope_type + scope_id,以后可以比较自然地扩展。
比如个人知识库:
scope_type=user
scope_id=U10001
项目知识库:
scope_type=project
scope_id=P10001
部门知识库:
scope_type=department
scope_id=D10001
到时候只需要扩展:
ALLOWED_KNOWLEDGE_SCOPE_TYPES;- Java 权限校验逻辑;
- SQL 可访问范围构造;
- Chroma filter 构造;
- 前端管理入口。
核心思想不需要变:
每份文档都有自己的作用域
每次访问都带当前作用域
每次查询都过滤可访问作用域
19. 设计模式
这个项目里的群知识库隔离,可以抽象成一个很常见的后端设计模式:
资源归属字段 + 当前访问上下文 + 后端统一过滤
在这个项目里对应关系是:
| 抽象概念 | 项目实现 |
|---|---|
| 资源 | 知识库文档、知识库 chunk、向量数据 |
| 资源归属字段 | scope_type、scope_id |
| 当前访问上下文 | 当前登录 userId、当前 groupId/contactId |
| 权限校验 | Java 的 validateGroupKnowledgeAccess |
| 普通查询过滤 | SQL WHERE scope_type/scope_id |
| 向量查询过滤 | Chroma metadata filter |
| 越权操作防护 | ensure_document_matches_scope |
这个模式不仅能用在知识库,也能用在很多业务里:
多租户系统
团队项目管理
企业部门文件库
SaaS 用户数据隔离
IM 群聊文件隔离
只要有“不同用户只能访问自己范围内资源”的需求,都可以参考这个思路。
20. 一句话总结
group_id 能实现群知识库隔离,不是因为它本身有什么特殊能力,而是因为项目把它作为知识库的作用域标识,并且贯穿了整个访问链路:
groupId
-> scope_type=group
-> scope_id=groupId
-> 文档入库保存 scope
-> 列表/删除/解析/索引校验 scope
-> SQL 检索过滤 scope
-> Chroma 向量检索过滤 scope
-> AI 问答只使用 global + 当前群知识库
最终效果就是:
G10001 可以访问:global + G10001
G20002 可以访问:global + G20002
G10001 不能访问:G20002
G20002 不能访问:G10001
如果用一句话来说:
群知识库隔离的本质,不是“传了 groupId”,而是“所有资源都带 scope,所有访问都校验 scope,所有检索都过滤 scope”。
这也是做 AI 知识库项目时非常重要的一点:权限隔离不能只做在页面上,也不能只做在文档列表上,必须一直做到 RAG 检索和向量召回阶段。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)