企业级智能问答系统做到第十个章节,终于要面对一道绕不过去的坎:多租户隔离与权限下推。如果你正在做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_id,RLS 也会在最底层把数据拦下来。
实际使用中有个坑:如果连接池复用了连接,上一次会话设置的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(name="id", dtype=DataType.INT64, is_primary=True), FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=1024), FieldSchema(name="tenant_id", dtype=DataType.VARCHAR, max_length=64), FieldSchema(name="doc_id", dtype=DataType.VARCHAR, max_length=128), FieldSchema(name="allowed_groups", dtype=DataType.VARCHAR, max_length=512), FieldSchema(name="allowed_users", dtype=DataType.VARCHAR, max_length=512), FieldSchema(name="is_deleted", dtype=DataType.BOOL, default=False) ]) collection = Collection(name="qa_docs", schema=schema) # 2. 按租户建分区(每个租户一个 partition) collection.create_partition(partition_name="tenant_A") collection.create_partition(partition_name="tenant_B")写入数据时,embedding 和租户字段一起写入对应分区。查询时,在search_params外额外带上partition_names限定当前租户的分区,同时用expr指定更细的权限范围:
# 精确到权限的检索 res = collection.search( data=[query_vector], anns_field="embedding", param={"metric_type": "IP", "params": {"nprobe": 16}}, limit=20, partition_names=["tenant_A"], # 第一道隔离:租户分区 expr='is_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 完整性检查与全局还原理 Clear
3.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, partition=tenant_id, perm=perm, top_k=40) keyword_results = es_search(query, perm=perm, top_k=40) 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_k=20 够用,但加了租户过滤后,每个租户内的文档密度显著降低。建议把候选集从 20 提高到 40-60,召回率肉眼可见回升。同时注意 nprobe 参数也要相应调大,否则候选集太小会把漏网之鱼都放走。
最后确认是否要做"权限感知的切分"。同一份长文档,不同章节可能对应不同权限。如果你的切片粗粒度到整篇文档,权限判断就只能按文档整体可见性来,很容易出现"某用户能看文档前半部分,但因为文档标记为部分可见,整个文档都被过滤掉"的情况。稍微用心一点的方案是把文档按段落切分、按更细粒度打权限标签,但这样会让权限表膨胀,量级上来后需要注意缓存和索引优化。
4.4 删除租户后向量残留清理
多租户系统运维里最容易被忽视的,是租户到期或退订后的清理问题。很多人只删了关系库里的数据,忘了向量库里该租户 partition 下的向量还静静躺着。
这些残留数据有两个危害:一是占用存储;二是在做全量备份恢复或跨环境迁移时,被恢复出来的"幽灵租户"数据可能被其他检索意外命中。Milvus 里删 partition 比较简单,直接collection.drop_partition(partition_name="tenant_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。