在做聊天项目接入 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();
}

这里做了三件事:

  1. AI 功能必须开启;
  2. groupId 必须是合法群 ID,项目里群 ID 以 G 开头;
  3. 当前用户必须有权限读取这个群会话。

权限判断是:

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

到时候只需要扩展:

  1. ALLOWED_KNOWLEDGE_SCOPE_TYPES
  2. Java 权限校验逻辑;
  3. SQL 可访问范围构造;
  4. Chroma filter 构造;
  5. 前端管理入口。

核心思想不需要变:

每份文档都有自己的作用域
每次访问都带当前作用域
每次查询都过滤可访问作用域

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 检索和向量召回阶段。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐