多租户问答系统如何做权限下推?从数据隔离到检索安全全解析 企业级智能问答系统做到第十个章节终于要面对一道绕不过去的坎多租户隔离与权限下推。如果你正在做SaaS形态的问答产品或者要给内部多个业务线统一提供问答服务这篇内容就是为你准备的。它要解决的核心问题很直白——A租户的文档绝不能被B租户的提问检索到一个销售问到的数据范围必须老老实实限制在他有权限看的项目里。这里说的权限下推指的是把权限判断从最上层应用逻辑渗透到数据库、向量检索、全文检索这些底层引擎里去执行而不是在召回之后再慢慢过滤。这套搞法做对了系统既能装得下几百个租户又能扛得住复杂的权限体系还不至于牺牲太多召回率和响应速度。我把这套方案从零到一完整拆给大家包括我踩过的坑、调过的参、改过三版的权限模型一次讲清楚。1. 内容整体设计与思路拆解1.1 为什么多租户隔离是问答系统的生死线先聊一个最直观的场景。一个企业把几百份内部培训资料、产品文档、客户案例都接入了智能问答系统然后开放给全公司几千人使用。这时候问题来了销售部同事问华东区最大客户今年的续约情况系统如果权限控制没做好返回的可能是法务部尚未公开的合同草案。这种事只要发生一次信任就崩了。多租户隔离要做的就是让一套系统同时服务多个互不可见的租户。每个租户可以是一个企业客户也可以是同一家公司里的一个部门、一个项目组。它隔离的不只是数据还有资源占用、调用频率、模型配额、审计日志。数据隔离是底线资源隔离是体验审计隔离是合规。很多团队早期为了省事每个租户单独部署一套完整系统N个客户跑N套服务。这种做法看似简单实际上维护成本爆炸模型服务重复部署、知识库索引各建各的、升级要逐个打补丁。更麻烦的是客户一多算力空转严重。真正规模化的做法是共享基础设施、按需隔离数据与权限这也是多租户和多实例的本质差异。1.2 智能问答系统的权限链路智能问答系统的请求链路一般长这样用户提问 → 应用网关 → 对话服务 → 检索服务 → 向量数据库/全文检索引擎 → 大模型生成。权限控制在这条链路上有三层可以落第一层是应用层权限也叫粗粒度权限。用户在登录后能进入哪个工作空间、能看到哪些知识库列表这一层决定了入口。但这层控制不了检索结果——因为用户提问经过检索服务时如果不额外传租户和权限上下文底层引擎会把这个请求当成一个超级用户来查。第二层是检索层权限这是权限下推的主战场。向量检索和全文检索在召回阶段就带上租户ID和权限条件把大部分无关数据挡在门外。这一层做得好上层大模型永远看不到权限之外的内容因为压根没被召回。第三层是存储层权限比如数据库的行级安全策略、表分区、字段级脱敏。这一层是最后一道防线防止任何绕过应用逻辑的查询直接打到存储引擎上。三层的关系可以用一道门禁来理解应用层是小区大门口的保安看你有没有门禁卡能不能进小区检索层是楼栋单元门的门禁决定你能进哪几层存储层是你房间的钥匙决定你能开哪扇门。三道门都锁上了才叫安全。1.3 三种主流隔离方案怎么选真正落地的时候常见的隔离方案有三种数据库级隔离每个租户一套独立的数据库实例或独立库表。隔离强度最高但资源开销大连接管理复杂适合收费高、合规要求强的大客户。共享数据库、独立Schema/表一张主表里加 tenant_id 字段所有租户数据混存靠字段区分。成本低、扩展性好但每次查询都必须在条件里带上 tenant_id一旦漏写就是数据泄露。Schema级隔离每个租户一个独立的Schema表结构相同数据分开。隔离性和共享性折中但多租户DDL迁移要逐个Schema执行运维上稍微麻烦一些。我见过很多团队一开始选方案一做到后面发现实例太多监控、备份、升级全得成倍处理最后又迁回方案二。也见过一上来就方案二结果某天开发写查询时忘了 where tenant_id测试环境没发现上线直接泄露。我的建议是除非客户明确要求独立部署否则优先走共享库加 tenant_id 字段这条路控制在模型层和检索层用严格的规范和自动化检查兜底。具体原因在下面实操部分展开。2. 核心细节解析与实操要点2.1 租户上下文与请求穿透权限下推的第一件事是把当前是谁、他属于哪个租户、他有哪些可见范围忠实、完整地传到链路的每一层。听起来简单实际落地时至少有四个坑。第一个坑是上下文丢失。很多系统的提问接口只传了 question 和 user_id检索服务到了向量库面前根本不知道 user 属于哪个租户只能把全局索引查一遍。我见过一个团队就是这么干的服务间调用时用了一个不完整的 DTO租户ID字段漏传检索层就默认查全量好在是内网系统没酿成大祸。正确做法是从网关开始每个内部接口的请求头里强制带上 X-Tenant-ID并在中间件层统一校验缺失就拒绝。第二个坑是上下文传递靠手写参数。每个服务之间调用来回写一遍 tenant_id很容易漏。更稳妥的方式是引入 trace 上下文机制用拦截器自动把租户ID注入到调用链。这样即使未来新增下游服务也不会忘记传租户。第三个坑是租户映射混乱。有些场景基于企业微信、钉钉渠道接入用户身份可能是手机号、邮箱、第三方ID这些ID到租户和权限组的映射关系必须有一个统一的服务来维护不能在各个系统里各维护一套。第四个坑是管理员视角和普通用户视角要区分。管理员需要跨租户运维、查看全局数据但不能让管理员账号在检索环节也带上全部租户权限否则一个误操作就可能让普通用户请求拿到管理员的上下文。这里要单独设计 admin 上下文和用户上下文两套路由。2.2 权限数据模型设计权限模型是整个权限下推的地基。我一开始照着 RBAC 的标准模型设计了 用户-角色-权限 三张表做通用后台没问题但放到问答系统的检索下推里就卡住了——因为检索需要的是数据范围而不是操作权限。问答系统真正需要的是一个文档级或知识库级的可见范围模型。我落地时用的是一张 doc_permission 表核心字段如下CREATE TABLE doc_permission ( id BIGSERIAL PRIMARY KEY, tenant_id VARCHAR(64) NOT NULL, doc_id VARCHAR(128) NOT NULL, allowed_group_ids TEXT[] NOT NULL, allowed_user_ids TEXT[] NOT NULL, allowed_dept_ids TEXT[] NOT NULL, updated_at TIMESTAMPTZ DEFAULT now(), UNIQUE (tenant_id, doc_id) );allowed_group_ids、allowed_user_ids、allowed_dept_ids 这三列支撑最常见的三种授权粒度用户组、具体用户、组织部门。检索时把当前用户命中的这些 ID 全部取出来传给下游做条件匹配。这张表不存正文只存权限关系确保了权限查询本身不会太重。设计时有个容易忽略的点知识库是分层级的文档可能挂在某个项目下项目又挂在某个部门下。用户如果对某个项目有权限那么该项目下所有文档都应有权限。所以除了 doc_permission还要维护一份 doc_groups 的父子关系表每次给文档打权限时要顺带校验上层目录的权限继承。我见过只给文档打权限、没考虑目录继承的系统结果用户能看到文档标题列表但点进去一问详情就报无权限体验非常割裂。2.3 权限下推的核心原则别做后置过滤很多第一次做多租户问答系统的团队最容易犯的错误是先把所有文档都召回来再在应用层用代码过滤掉没权限的文档。这种后置过滤的做法会带来两个致命问题一是召回污染。向量检索基于相似度召回 top K 文档。如果一个租户外面的相似文档占了 top K 里的大半有权限的文档反而被挤出去了召回率直接崩掉。就算你在应用层把没权限的过滤掉了剩下能用的可能只有一两条甚至一条不剩用户看到的是没有答案。二是性能灾难。多租户场景下全局文档可能上千万。每次提问都把全库扫一遍做相似度计算再过滤响应时间会从几百毫秒飙升到几秒。更别提大模型上下文窗口有限你传给它一堆先召回再过滤的噪音片段生成质量也会下降。所以正确姿势是把权限条件推进到底层引擎的检索条件里在召回阶段就只查用户可见的那部分数据。这就引出了下面实操部分的核心如何分别在关系库、向量库、全文检索引擎里下推权限。3. 实操过程与核心环节实现3.1 关系库PostgreSQL 行级安全策略配置权限元数据放在 PostgreSQL 里那数据库层就得配行级安全Row Level Security。RLS 的好处是任何一条 SQL无论来自哪个应用接口、哪个查询工具只要没有在会话里设置租户上下文就查不到数据。配置方式并不复杂-- 开启 doc_permission 表的 RLS ALTER TABLE doc_permission ENABLE ROW LEVEL SECURITY; -- 创建一个策略只有当前租户的权限数据可见 CREATE POLICY tenant_isolation_policy ON doc_permission USING (tenant_id current_setting(app.tenant_id)::text);关键参数有两个current_setting(app.tenant_id)需要通过数据库会话变量传入当前租户ID。应用侧每次建立数据库连接后先执行SET app.tenant_id tenant_A;。USING子句决定哪些行对于当前查询可见这样即使应用层 SQL 漏写了 where tenant_idRLS 也会在最底层把数据拦下来。实际使用中有个坑如果连接池复用了连接上一次会话设置的app.tenant_id会残留到下一个请求。所以必须在归还连接前清理或者在连接池初始化时把set_config设置为局部会话并在事务结束时清空。我们用了一个强制手段连接池每次 getConnection 后用一个拦截器先RESET app.tenant_id;再重新 SET从源头杜绝残留。RLS 防御的是查询没带租户条件的傻错但它不是万能的。如果你用同一个数据库账号跑批量任务又不小心设置了一个全租户的会话变量那所有的 RLS 策略都会失效。所以 RLS 适合兜底不能替代应用层的显式条件。3.2 向量库Partition 与 Metadata Filter 组合向量检索是多租户问答系统最核心的检索路径。选型上如果用的是 Milvus大型系统建议用 Partition 和 Metadata Filter 双重隔离如果用的是 PGVector则可以直接把 tenant 作为普通条件过滤。先看 Milvus 的分区思路from pymilvus import Collection, CollectionSchema, FieldSchema, DataType # 1. 创建集合时增加租户字段 schema CollectionSchema([ FieldSchema(nameid, dtypeDataType.INT64, is_primaryTrue), FieldSchema(nameembedding, dtypeDataType.FLOAT_VECTOR, dim1024), FieldSchema(nametenant_id, dtypeDataType.VARCHAR, max_length64), FieldSchema(namedoc_id, dtypeDataType.VARCHAR, max_length128), FieldSchema(nameallowed_groups, dtypeDataType.VARCHAR, max_length512), FieldSchema(nameallowed_users, dtypeDataType.VARCHAR, max_length512), FieldSchema(nameis_deleted, dtypeDataType.BOOL, defaultFalse) ]) collection Collection(nameqa_docs, schemaschema) # 2. 按租户建分区每个租户一个 partition collection.create_partition(partition_nametenant_A) collection.create_partition(partition_nametenant_B)写入数据时embedding 和租户字段一起写入对应分区。查询时在search_params外额外带上partition_names限定当前租户的分区同时用expr指定更细的权限范围# 精确到权限的检索 res collection.search( data[query_vector], anns_fieldembedding, param{metric_type: IP, params: {nprobe: 16}}, limit20, partition_names[tenant_A], # 第一道隔离租户分区 expris_deleted false (allowed_users user_1 || allowed_groups group_dev), output_fields[doc_id, tenant_id] )这里的逻辑很清楚partition_names是粗粒度隔离把检索范围直接锁死在一个租户内部expr是细粒度权限下推在租户内部再筛掉该用户无权访问的文档。两道条件都能被 Milvus 在向量检索阶段利用而不是召回后过滤。使用 PGVector 时则更直接因为 embedding 就是表里的一个字段权限条件就是普通 SQL 的 where 子句SELECT doc_id, embedding $1::vector AS distance FROM qa_docs WHERE tenant_id $2 AND (allowed_users ARRAY[$3] OR allowed_groups ARRAY[$4]) AND is_deleted false ORDER BY embedding $1::vector LIMIT 20;混合检索Dense Sparse时需要注意如果 Dense 走向量库、Sparse 走 ES两边都要各自带上同样的权限条件最后融合排序时才能保证来源一致、权限一致。我见过一个场景是向量检索做了租户过滤但 BM25 那路的 filter 漏了租户条件结果重排阶段把只有 ES 侧能召回的跨租户内容混了进来还好在融合前发现否则又是事故。3.3 完整性检查与全局还原理 Clear3.3 全文检索ES 的 filter 下推除了向量检索传统关键词检索在企业知识库场景依然有很大价值。用户的提问里如果包含产品型号、政策编号这类精确词BM25 往往比向量召回更准。ES 的 query DSL 天然支持用 bool 查询的 filter 子句做权限下推。filter 子句在这里有双重好处一是命中结果不参与相关性打分权限条件不会干扰排序二是 ES 会缓存 filter 结果集同一租户反复提问时性能更好。{ query: { bool: { must: [ { multi_match: { query: 续约条款变化, fields: [title^3, content] } } ], filter: [ { term: { tenant_id: tenant_A } }, { bool: { should: [ { terms: { allowed_users: [user_1] } }, { terms: { allowed_groups: [group_dev] } } ] } }, { term: { is_deleted: false } } ] } } }注意filter 里如果你用should表示只要满足用户或组任一权限就行需要保证同一层级里是逻辑或关系。这里有个细节上面这个 bool 里没有任何 must只有 should那么默认情况下 ES 要求至少满足一个 should 条件这正好符合必须有权或属于白名单组才能看。但是要注意如果将来你在 filter 里同时放了很多条件某些条件又是可选的话ES 的行为会变成should 里有满足就够没有至少满足一个 should 就会出空结果这会导致权限重叠的用户反而看到更少内容。可靠做法是把用户必属某个租户设为 filter 硬条件把用户/组授权作为一个独立的嵌套 bool在脚本里拼装好避免逻辑歧义。3.4 混合检索统一封装在实际的问答系统中往往不是单独跑向量或全文而是混合检索再重排。在这个阶段权限下推最忌讳的就是各管各的。我的做法是做一个统一的检索入口封装把所有权限上下文算成一份标准条件然后分发给各个子检索引擎def build_permission_filter(tenant_id, user_id, group_ids, dept_ids): return { tenant_id: tenant_id, allowed_user_ids: [user_id], allowed_group_ids: group_ids, allowed_dept_ids: dept_ids, is_deleted: False } def hybrid_search(query, tenant_id, user_id, group_ids, dept_ids): perm build_permission_filter(tenant_id, user_id, group_ids, dept_ids) vector_results vector_search(query, partitiontenant_id, permperm, top_k40) keyword_results es_search(query, permperm, top_k40) fused_results reciprocal_rank_fusion(vector_results, keyword_results) return fused_results[:10]这样做的收益是新增一个检索引擎时只需要统一传入权限条件不用每个引擎单独实现一套权限逻辑。到了重排阶段拿到的候选集合天然都是当前用户可见的大模型的上下文也干净可控。4. 常见问题与排查技巧实录4.1 高频问题速查表我把实际运维中遇到的高频问题整理成了一个速查表先存再看遇到问题对着找现象大概率原因排查方向用户提问能看到别的租户数据子检索引擎漏传租户条件检查每个下游调用的权限上下文是否完整重点排查 ES、Milvus、PG 各自的 filter同租户内能看到无权文档权限表更新后未同步到检索索引对比 doc_permission 表与检索索引中 allowed_user_ids 是否一致某个用户突然什么都查不到allowed_groups 与默认组冲突检查默认组是否为全员可见新用户是否被正确加入到默认组管理员跨租户查询返回空当前连接残留了普通租户上下文查看数据库连接池的会话变量是否被缓存了旧租户ID性能下降明显权限条件没走索引explain 分析 RLS/向量检索表达式确认 tenant_id 字段有索引删除文档后仍能被检索到软删除标记未下推或隔离索引未重建检查 is_deleted 标记是否在向量检索 expr 里、ES filter 里都生效4.2 跨租户数据泄露的典型成因线下复盘过三次数据泄露事件原因惊人的一致不是权限体系设计得不够强而是某个开发在写新接口时忘了带上权限上下文。典型案例是这样的系统上线初期只服务一个大客户所有接口都默认不带 tenant_id代码里写死了查全部。后来系统开放给第二个租户测试时没覆盖多租户并发场景某个历史接口仍然裸奔。第一个客户的数据量又大向量检索时这几个裸奔接口把全部数据都扫了一遍。还好是内网流量不大后台及时发现异常调用没有造成实际泄露。排查跨租户泄露推荐用最小权限故意查询法准备一个只有空权限的测试用户在每个检索接口上设置一个提问观察返回结果里是否出现任何知识库内容如果出现说明该接口的权限条件没有生效。把这个测试用户固化到自动化测试用例里每次发版前跑一遍。4.3 权限下推后召回率骤降的排查权限下推做对了安全是安全了但你可能发现之前 90% 的召回率掉到了 60%。这不一定是你代码写错了而是权限下推天然会缩小候选集。排查时先分三步走先确认权限过滤条件本身是否过严。看 doc_permission 表里allowed_user_ids 和 allowed_group_ids 是否把用户所在部门也算进去。很多企业里一份文档的可见范围是某部门全体而不是某几个具体用户。如果只配了具体用户那部门里的其他人自然什么都搜不到。我最初就吃过这个亏权限表里存的是部门ID但检索时没把 dept 展开成部门下所有用户导致同一部门员工提问全空。再确认向量检索的 top_k 是不是太小。全局检索时 top_k20 够用但加了租户过滤后每个租户内的文档密度显著降低。建议把候选集从 20 提高到 40-60召回率肉眼可见回升。同时注意 nprobe 参数也要相应调大否则候选集太小会把漏网之鱼都放走。最后确认是否要做权限感知的切分。同一份长文档不同章节可能对应不同权限。如果你的切片粗粒度到整篇文档权限判断就只能按文档整体可见性来很容易出现某用户能看文档前半部分但因为文档标记为部分可见整个文档都被过滤掉的情况。稍微用心一点的方案是把文档按段落切分、按更细粒度打权限标签但这样会让权限表膨胀量级上来后需要注意缓存和索引优化。4.4 删除租户后向量残留清理多租户系统运维里最容易被忽视的是租户到期或退订后的清理问题。很多人只删了关系库里的数据忘了向量库里该租户 partition 下的向量还静静躺着。这些残留数据有两个危害一是占用存储二是在做全量备份恢复或跨环境迁移时被恢复出来的幽灵租户数据可能被其他检索意外命中。Milvus 里删 partition 比较简单直接collection.drop_partition(partition_nametenant_A)。但要注意如果你忘了删 partition然后在同一个 collection 里又重建了同名 partition旧向量会和新向量混在一起而且从日志上很难发现。我的习惯是租户退订走一套标准化的删除流水线——先标记租户状态为 pending_deletion停止该租户的写入和检索然后依次清理 ES 索引里该租户的文档、关系库里的权限表和资料表、对象存储中的原始文件最后才删向量库 partition。整个流程用定时任务扫描确保不会漏掉某个环节。另外强烈建议在向量 schema 里预留 is_deleted 字段遇到单独删除的文档先置软删标记而不是直接物理删。因为物理删除向量后如果当时有进行中的问答会话大模型可能引用一段已经消失的内容用户追问时上下文断裂。软删标记配合检索表达式里的过滤条件可以平滑过渡。4.5 关于权限变更一致性权限变更比如给一个用户新开某个文档的权限不是改了数据库就行关键是下游的索引数据要同步更新。我踩过几次坑后采用了一套变更发布订阅机制权限表更新后通过消息队列广播一条权限变更事件下游三处消费向量检索索引更新该文档的 allowed_* 字段ES 索引重建或局部更新 filter 字段本地缓存失效防止旧权限状态被继续命中。这里要特别注意不要为了省事直接全量重灌索引。租户数据量大时全量重灌会阻塞查询体验很差。正确做法是局部更新根据 doc_id 精确定位向量用 upsert 更新权限标签ES 则用 update_by_query 更新指定文档的权限字段。这样权限变更在秒级生效。还遇到过一种情况一个用户多次授权、多次撤销最后权限表和索引之间产生了微妙的漂移。查问题时发现两边记录不一致但没人能说清是哪一步漏了。后来我在权限表里加了一个 version 字段每次变更递增然后定时脚本核对权限表版本号和索引里的版本号不一致就告警问题就再也藏不住了。5. 从租户隔离到更细的权限下推5.1 段落级权限的扩展路径文档级权限满足大多数场景但企业里的法务、财务、人事这类敏感部门往往要求段落级或字段级权限。比如一份项目合同正文里可能包含对外公开的商务条款也包含只允许法务和财务看的成本条款。段落级权限的实现思路是在文档切片时每个切片打上权限标签而不是把权限标签打在整个文档上。检索时过滤条件从文档级下推到切片级。但这样做的代价是权限表行数暴涨一张 100 万文档的表切 50 个切片就是 5000 万条权限记录。所以实际项目中通常采用文档权限为主、敏感段落覆盖为辅的策略默认继承文档权限只有对少数敏感段落打独立的权限标签。检索时优先匹配段落级标签未打标签的按文档权限处理。这样可以兼顾性能和安全。5.2 租户级算力隔离策略数据隔离到位了还有一个问题租户 A 搞促销活动流量暴涨大量提问把 GPU 算力吃光隔壁租户 B 的问答延迟从 300ms 涨到 5 秒。这不是数据层面能解决的而是资源调度层面的多租户隔离。我试过两套方案各有适用场景。一是按租户分配独立的向量检索副本或 ES 副本数据按租户分区存储在不同副本上流量互不干扰适用于大客户。二是按租户配置限流和优先级队列比如每个租户的每秒检索次数配额、每用户每分钟提问次数配额超过配额则排队或降级。实际系统一般两种混用普通租户走共享池加配额限制大客户走独立副本。还有一个比较容易忽略的点LLM 推理本身也要按租户隔离。如果所有租户共用一个模型服务实例一个小租户的疯狂调用可能会把算力耗尽。比较务实的做法是给模型推理服务加租户维度的并发控制和请求排队策略并且针对不同租户设置不同的模型路由小客户用轻量模型大客户用更强模型既控制成本又保障体验。5.3 审计与回溯最后聊聊合规视角下的多租户。权限只做防还不够企业客户会明确要求可审计我司的哪位员工在什么时间向系统问了什么系统检索了哪些文档返回了哪些片段。这套审计日志必须细化到租户和用户维度甚至文档维度。我落地的审计方案是在统一检索入口处记录一次问答的标准日志内容包含 request_id、tenant_id、user_id、查询词、命中的 doc_id 列表、返回的片段 hash、耗时。检索子引擎各自记录细粒度调用日志用 request_id 关联。在写日志时要注意日志本身也可能成为数据泄露的渠道如果日志里明文记录了完整的文档正文切片一名有日志平台权限的低权限员工就可能绕过系统直接看日志。所以正文片段只存 hash不存原文需要回溯时再通过受控的查询接口按 doc_id 和 request_id 反查。最后一点实在话这套多租户隔离与权限下推的方案是我在一个真实的企业级问答项目中反复打磨出来的。第一次上线前我们单纯依赖应用层过滤结果测试期就发现了跨租户召回吓得整个团队花了两周专门重构检索层。后来把权限下推到底层引擎数据安全有了底但召回率又跌了一截又是一轮切片和参数调优。再后来把权限变更的发布订阅补齐整个系统才算真正稳下来。如果让我给后来者一句话我会说多租户隔离别指望靠自觉写对每个查询要把权限条件当作检索协议的一部分在每一个子系统入口强制校验。宁可前期多花时间在权限模型的统一设计上也别让后续每一次加功能都提心吊胆地排查有没有忘了传租户ID。