做企业级智能问答系统,从 demo 到生产环境之间隔着一道天堑,这道天堑的名字叫"多租户"。我带着团队从零搭企业级智能问答系统的整个技术架构时,前几个章节都在讲 RAG 链路、向量检索、模型调用这些"看得见摸得着"的技术,但真正决定这套系统能不能安全上线、能不能扛住多部门同时使用的,其实是到达这个阶段之后的一件事——多租户隔离与权限下推。它不炫技,甚至有点枯燥,但每一个真正把系统推到生产的人都绕不开,因为它直接决定了你的知识库会不会泄露给不该看到的人。这篇文章就谈谈我在设计这套机制时踩过的坑、做过的取舍,以及最终落地的一整套可复用方案,适合那些已经在做或准备做企业级问答系统的工程师参考。
1. 为什么多租户是企业级问答系统的分水岭
1.1 多租户到底在解决什么问题
先理解场景。所谓"企业级智能问答系统",在我的语境里不是给某个部门做一个单机 demo,而是面向一家公司里多个业务单元(租户)同时提供服务。每个业务单元都有自己的知识库、自己的文档、自己的问答历史和模型调用额度。这时候一个最基本的诉求就是:A 部门的销售话术库,B 部门的产品经理绝对不能问出来,甚至连片段都不能泄露。
多租户要解决的本质问题有三个层面。第一是数据隔离:A 租户看不到 B 租户的数据,这是底线。第二是资源隔离:一个租户的突发大查询不能拖垮所有人的服务。第三是权限隔离:同一个租户内部,不同角色、不同职级的人,能看到的文档范围也不一样——比如普通员工可以问 SOP 流程,但只有管理层能问薪酬策略。
如果只做一个内部 demo,你完全可以把所有文档塞到一个 collection 里,查询时全局召回。但一旦接入生产,租户之间的数据串味就不是 bug 而是安全事故。我见过一个项目,上线第一天就出问题:销售团队的人问"竞品话术怎么应对",结果系统把它自己内部的成本核算文档也带出来了,因为两批文档混在同一个知识库切片里。这种事只要发生一次,整个系统的信任度就归零了。
1.2 三种常见隔离方案的取舍
聊到多租户,很多第一反应是"直接拆库建表不就行了"。实际上隔离方案的选型要结合问答系统的技术特点,这里我把常见方案做了个对比:
| 隔离方案 | 隔离粒度 | 实现成本 | 扩展性 | 适用场景 |
|---|---|---|---|---|
| 行级隔离(tenant_id) | 数据行 | 低 | 好 | 大多数业务场景 |
| Schema/库级隔离 | 表/数据库 | 中高 | 一般 | 合规要求严格的租户 |
| 实例级隔离(独立集群) | 整套系统 | 最高 | 差 | 超大型客户定制 |
对于智能问答系统这种以知识库文档为核心、需求密集迭代的产品,我最终选择了行级隔离为主、库级隔离为辅的组合。原因有两个:第一,问答系统的核心资产是文档切片和 embedding 向量,它们天然适合通过 tenant_id 做元数据过滤,实现成本最低;第二,少数有严格合规要求的租户(比如金融、医药客户),可以单独拆库,做到物理隔离,这部分不需要所有租户都付出同等代价。
另外还有一个容易被忽略的点:隔离方案的选型不是越强越好。实例级隔离确实安全,但每个租户一套模型部署、一套向量库索引,运维成本和资源开销是成倍增长的,最后这些成本都会变成系统的使用门槛。对绝大多数场景来说,行级隔离加严格的应用层校验,已经能挡住 99% 的问题。
2. 权限下推:从应用层到数据层的闭环
2.1 一次权限请求的完整旅程
权限下推这个词听起来高级,拆开其实就是:把"谁能看什么"这条规则,从最上层的应用一路压到最底层的检索数据源,并且在这条链路里不丢失、不衰减。
一次标准问答请求的完整链条是:用户登录 → 身份服务签发令牌(携带租户 ID 和角色信息)→ 网关校验 → 问答服务解析意图 → 从知识库召回相关切片 → 组装上下文 → 调用大模型生成答案 → 返回给用户。
权限丢失大多发生在"召回"这一步。最简单的场景是:用户在前端界面看到的按钮是灰的、菜单是折叠的,但他如果绕过界面直接调 API,后端到底能不能挡住?我见过不少系统在 API 层面只校验了"登录态",没有校验"数据范围",结果就是任何登录用户都可以通过构造请求拿到其他租户的文档片段。这就是典型的权限只停留在 UI 层、没有下推到数据层。
2.2 向量数据库中的权限过滤:为什么这么难
现在主流的 RAG 都用向量库,而向量库和关系型数据库有个本质区别:关系库里你写WHERE tenant_id = ?非常自然,索引一命中的事;向量库做的是相似度检索,维度是"语义相似"而不是结构化查询。你没法直接告诉向量库"排除其他租户",只能在检索条件上想办法。
向量检索的权限过滤有两条路线:
- 预过滤(Pre-filter):先在元数据层面圈定可见的数据范围(比如限定 tenant_id 和可见分组),再在圈定范围内做相似度检索。
- 后过滤(Post-filter):先按相似度召回 Top K 个结果,然后逐条检查这些命中的切片是否属于当前用户权限范围,把无权访问的过滤掉。
实测下来,pre-filter 比 post-filter 稳得多。原因很实在:向量检索本来就是"矮子里拔将军",召回数量由相似度决定。如果用后过滤,假设一个租户在这个知识域里的文档只有 3 片,而你召回了 20 片结果,过滤完可能只剩 1 片真实可用的——不仅答非所问,而且有一半概率吐出来的是其他租户的内容。这就是很多系统"上线跑得通,一换数据就翻车"的根源。
注意:如果向量库后端不支持 pre-filter(比如部分老版本的开源向量库),宁可把"单租户向量集合"拆开,也不能裸奔用 post-filter 硬扛。
2.3 RBAC 还是 ABAC:权限模型的选型
权限下推的另一半是权限模型。企业级问答系统里最常见的需求是:文档属于某个租户;租户内部分成多个部门/项目组;每个组里的人只能看自己组授权的文档。
- RBAC(基于角色的权限控制):把权限绑定到角色上,用户关联角色。优点是模型简单、容易理解,适合"部门经理"这类稳定角色。
- ABAC(基于属性的权限控制):通过用户的属性(部门、职级、项目归属)动态判断权限。优点是灵活,适合"一个人同时属于多个项目、每个项目可见范围不同"的场景。
我的建议是:主体用 RBAC,扩展层用 ABAC。具体来说,租户内定义基础角色(管理员、编辑者、访客),角色决定可访问的知识域;而像"项目 A 的文档只能让项目 A 成员看"这种动态权限,用属性规则做补充判断。我发现如果一上来就全套上 ABAC,权限引擎本身会比业务还复杂,后期维护成本极高。先定一个明确的核心权限维度(通常是"租户 + 部门 + 角色"),后续再按需叠加。
3. 系统实现:一个可落地的多租户隔离方案
3.1 租户上下文如何贯穿全链路
整个多租户方案里最重要的一个设计决策是:让租户上下文贯穿从请求进入到底层存储的每一个环节。这句话说起来容易,做起来难,因为中间任何一环如果只依赖"上一个函数传了个参数",迟早会漏。
我采用的方案是:在网关层解析登录态,把租户 ID 和用户权限信息写入一个上下文对象,然后通过中间件注入到微服务的请求上下文里;再对接向量库和关系库的封装层,强制所有数据访问语句带上前置的过滤条件。
以 Python 为例,使用 FastAPI 的依赖注入可以很干净地解决这个问题:
# middleware: 解析 token 并构造 TenantContext from fastapi import Request, HTTPException @app.middleware("http") async def inject_tenant_context(request: Request, call_next): token = request.headers.get("Authorization") if not token: raise HTTPException(status_code=401, detail="missing token") payload = decode_token(token) # 内部解出 uid, tenant_id, roles tenant_ctx = TenantContext( user_id=payload["uid"], tenant_id=payload["tenant_id"], groups=payload.get("groups", []), roles=payload.get("roles", []) ) # 塞进 request.state request.state.tenant = tenant_ctx response = await call_next(request) return response然后在每个需要访问数据资产的接口里,不传"当前用户是谁"这种参数,而是统一从 context 里取:
def get_doc_service(request: Request): ctx = request.state.tenant return DocService(tenant_id=ctx.tenant_id, user_permissions=ctx.roles)这里有个关键经验:永远不要在业务接口的参数里暴露 tenant_id 让前端传。前端传过来的任何字段都不可信,租户归属必须从服务端解析出的 token 里拿。我见过一个真实案例,某系统为了调试方便,允许在请求头里带一个X-Tenant-Id,结果测试环境忘了删,生产上被人循环遍历租户 ID,把所有公开文档切片全拉走了。
3.2 数据模型改造:关系库和向量库双管齐下
问答系统的数据资产通常分两部分:原始文档表和向量索引。我做数据模型改造时是按这个思路拆的:
关系库侧(存文档元数据、权限关系、问答日志):
-- 文档表:行级租户隔离 CREATE TABLE knowledge_docs ( id BIGINT PRIMARY KEY, tenant_id VARCHAR(32) NOT NULL, -- 租户 ID group_id VARCHAR(32) NOT NULL, -- 部门/项目组 doc_name VARCHAR(255), doc_status TINYINT DEFAULT 1, owner_user VARCHAR(64), created_at DATETIME, updated_at DATETIME, KEY idx_tenant_group (tenant_id, group_id), KEY idx_updated (updated_at) ); -- 文档-权限关系表:用户/角色与文档可见范围 CREATE TABLE doc_permissions ( doc_id BIGINT NOT NULL, tenant_id VARCHAR(32) NOT NULL, principal_type VARCHAR(16) NOT NULL, -- USER / ROLE / GROUP principal_id VARCHAR(64) NOT NULL, can_read TINYINT DEFAULT 1, PRIMARY KEY (doc_id, principal_type, principal_id) );索引设计上,(tenant_id, group_id)的联合索引是必须的,因为几乎所有的权限查询都会走这个前缀。这个索引在数据量上来之前看起来无所谓,但一旦文档量过百万,没有它就是全表扫描。
向量库侧:我最终选择了共享 collection + metadata 过滤的模式。也就是说所有租户的文档切片都放在同一个向量集合里,但是每个切片都带三个关键 metadata:
tenant_id:租户归属,必填,缺失即丢弃group_id:部门/项目组归属doc_id:文档 ID,用于回查权限明细
检索时强制加上预过滤条件:
# Milvus / OpenSearch / Pinecone 等基本都支持 filter 参数 results = collection.search( data=[query_embedding], anns_field="embedding", param={"metric_type": "COSINE"}, limit=20, filter=f'tenant_id == "{ctx.tenant_id}" and group_id in {visible_groups}' )这里需要特别提一下 embedding 索引的构建:所有切片的 embedding 必须来自同一套向量化模型,并且切片的 metadata 必须在写入时严格校验。如果校验不严,很容易出现某个切片没有 tenant_id,查询时直接漏到其他租户的结果里。我这边写了一个写入时的元数据校验函数,租户 ID 缺失或者格式不合法直接拒绝写入,宁可丢数据也不放隐患进入线上。
3.3 权限感知的召回链路改造
原始的 RAG 召回链路很纯粹:用户问题 → embedding 化 → 向量检索 → 取 Top K 切片。加入权限下推后,链路变成:
- 用户问题进入问答服务,同时从请求上下文取出租户信息和权限范围。
- 基于权限范围计算可见的
group_id列表(这一步走关系库,查询用户可访问的文档分组)。 - 向量检索时用这些
group_id构造 pre-filter 条件。 - 即使 pre-filter 已经把范围圈定,我仍然会在拿到 Top K 后做一道二次校验:回查这些切片对应的
doc_id是否真的在用户的可见文档列表里。这一步是双保险,专门防那种"元数据被写脏"的极端情况——比如某文档变更了归属,但切片索引还没来得及更新。
这里有一个重要的细节,就是权限范围和相似度召回是互相影响的。假设用户权限只覆盖 30 个切片,而你要求召回 20 个结果,语义上可能根本不相似;更合理的做法是动态调整 limit:如果圈定范围内的切片数量少于limit,就降低召回数量,宁可给模型少一些上下文,也不要硬凑无关切片进去。这直接影响答案质量,我总是强调"少而准好过多而杂"。
组装上下文时,还要注意切片顺序。权限过滤后的召回结果顺序不代表"对用户最有用的顺序",我通常在送入大模型前按"相似度得分 × 时效权重"重新排序,避免过时文档抢占上下文窗口。
3.4 性能与安全的平衡:缓存、限流与隔离
多租户对性能的影响是实打实的:原来一个向量库全集查询可能只需几十毫秒,加了 pre-filter 后,查询计划变复杂,有可能到几百毫秒。为了把性能拉回来,我做了三件事:
- 租户级缓存:按
tenant_id + group_id维度缓存常见问题的检索结果,缓存失效时间设 30~60 秒。问答场景对秒级新鲜度不敏感,缓存命中率通常能到 40% 左右。 - 动态限流:在网关层为每个租户配置独立的 QPS 配额。比如付费高的租户 100 QPS,普通租户 20 QPS。这能防止一个租户的突发流量把模型服务的上下文窗口和下游向量库连接池打爆。
- 连接池按租户分组:数据库连接池虽然无法按租户物理拆开,但我们可以给每个租户设置连接池的使用上限,避免某个租户占满所有连接导致全局雪崩。
安全方面另外要提醒一个坑:不要把错误信息暴露得太细。多租户系统里,当用户访问一个不存在的文档时,如果你返回"doc 不存在",攻击者就可以通过遍历判断哪些 doc 是存在的但对他不可见的;应该统一返回"无权访问或文档不存在",模糊化错误语义。这个看似小事,实际上是对抗租户枚举攻击的关键细节。
4. 踩坑实录与排查技巧
4.1 典型问题速查表
整理了一份我在这套系统落地过程中最常见的几个问题,按症状、原因、解决方式列出:
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
| 用户能问到其他租户的文档内容 | 切片元数据缺失 tenant_id,或检索忽略了过滤条件 | 检查写入链路元数据校验;检索链路增加二次权限校验 |
| 加了权限过滤后召回准确率骤降 | 用户可见范围过窄,向量库在限定范围内相似结果太少 | 动态调整 limit;对稀疏权限租户做知识库补齐 |
| 同一个问题不同租户返回一样 | 缓存 key 没带 tenant_id | 缓存 key 必须拼接租户维度;清理历史脏缓存 |
| 新用户登录后看不到任何内容 | 用户在关系库的权限映射未初始化 | 登录时同步初始化权限映射;空权限提示而非空白页 |
| 大租户查询拖慢全局 | 连接池被某个租户占满 | 租户级连接池上限;限流降级 |
4.2 我踩过的三个大坑
第一个坑,也是我最后悔的一个:初期为了快速上线,向量集合的 metadata 校验写得不够严格,结果出现了一批没有 tenant_id 的切片。这类切片在 pre-filter 条件下的检索逻辑里如果写的是tenant_id == "xxx",它们会被正确排除;但有一次维护人员写过滤条件时不小心只写了group_id in [...],把租户条件漏了,这批脏数据直接被查出来了。后来我做了两层修复:写入时强制校验,缺失 tenant_id 拒绝写入;查询封装层统一拼接租户条件,业务方不感知过滤细节。
第二个坑是权限继承关系的级联问题。我们的文档允许父目录权限下推到子文档,一开始我用了触发器做级联更新,但文档数量一上去,一次目录变更要更新上千条子文档权限记录,慢得离谱。最后改成"权限查询时实时计算继承关系 + 缓存",只有在目录变更时刷新相关文档的权限缓存,才把这个性能问题解决。如果你们的文档层级很深,务必在"空间换时间"上做权衡。
第三个坑是系统里存在多语言、多版本模型导致的 embedding 空间不一致。我们把老版本的切片和新版本的切片放在同一个集合里,检索时发现同一段文字的相似度打分不稳定,权限过滤后的结果也时好时坏。排查了很久才意识到是 embedding 模型版本不一致。建议任何多租户系统里,统一向量化模型的版本管理工作,模型升级时要对全量切片做增量重向量化,否则检索质量就没法保证。这个问题和权限隔离本身无关,却会直接影响权限系统的可信度——因为同样一个 query,有的租户老是答非所问,大家就会以为是权限过滤搞坏了检索。
4.3 排查工具与测试策略
多租户问题的排查难点在于:你没法轻易复现"另一租户的视角"。我的做法是给所有核心日志都打上tenant_id和group_id标签,并且在检索服务的日志里输出"过滤前候选数 → 过滤后实际数 → 最终返回切片列表"。一旦用户反馈结果不对,直接拉日志就能定位是权限过滤的问题还是语义召回的问题。
测试策略上,除了常规单测,我会专门维护一个"权限矩阵测试用例":一个测试租户、一个跨租户测试用户、一个同租户不同组用户,对每个用例验证三个断言——有权限的内容能查到、无权限的内容查不到、跨租户内容绝对查不到。这套矩阵在每次发布前跑一遍,花不了几分钟,但能拦住绝大多数权限回归。
5. 给同样在折腾这套系统的你:一点收尾心得
做了这么多轮多租户改造,我最大的感受是:权限下推这件事,真正难的不是技术,而是始终把"数据边界"放在第一位的设计习惯。很多系统前端做得很好、模型调得很好,但一问到"这个租户的用户能查哪些切片"就支支吾吾,那就说明权限模型还没有真正下推到数据层。任何一次数据访问,都应该有一个明确的边界判断:这个查询发生在哪个租户上下文里、访问者的可见范围是什么、最终落到存储层的过滤条件是什么。这三点想清楚了,多租户隔离就不是加分项,而是扎实的底线。我这个项目里踩过的那些坑——脏 metadata、缓存不带租户维度、权限继承的级联风暴——几乎都能靠"回到这三点重新审视"来提前规避。如果你也在搭企业级问答系统,可以把这篇当作一个排查对照清单,少走我走过的弯路。