ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

企业级智能问答系统的多租户隔离与权限下推实践

企业级智能问答系统的多租户隔离与权限下推实践 做企业级智能问答系统从 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_code401, detailmissing token) payload decode_token(token) # 内部解出 uid, tenant_id, roles tenant_ctx TenantContext( user_idpayload[uid], tenant_idpayload[tenant_id], groupspayload.get(groups, []), rolespayload.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_idctx.tenant_id, user_permissionsctx.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 过滤的模式。也就是说所有租户的文档切片都放在同一个向量集合里但是每个切片都带三个关键 metadatatenant_id租户归属必填缺失即丢弃group_id部门/项目组归属doc_id文档 ID用于回查权限明细检索时强制加上预过滤条件# Milvus / OpenSearch / Pinecone 等基本都支持 filter 参数 results collection.search( data[query_embedding], anns_fieldembedding, param{metric_type: COSINE}, limit20, filterftenant_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、缓存不带租户维度、权限继承的级联风暴——几乎都能靠回到这三点重新审视来提前规避。如果你也在搭企业级问答系统可以把这篇当作一个排查对照清单少走我走过的弯路。
返回列表