1. 从一次数据串库事故说起:多租户 RAG 到底难在哪
去年帮一个做企业培训 SaaS 的朋友排查线上问题,他们的 AI 问答助手突然把 A 公司的内部薪酬制度回答给了 B 公司的员工。事故原因很典型:向量库里所有租户的知识片段混在一张表,检索时只按语义相似度排序,压根没带user_id过滤条件。用户提问"年终奖怎么算",系统就把相似度最高的那条——恰好是另一家公司的制度文档——直接喂给了大模型。
这件事之后我彻底想明白一个道理:RAG 的检索层如果没做好租户隔离,前面文档解析、切块、嵌入做得再漂亮都是白搭。多租户 RAG 的核心命题不是"怎么让检索更准",而是"怎么保证每个用户只检索到自己的知识库",这是一个数据边界问题,优先级高于一切召回率优化。
所谓多租户,就是一套系统同时服务多个互相隔离的客户(租户),每个租户有自己的文档、自己的知识库、自己的对话历史。放到 RAG 场景里,隔离要贯穿全链路:文档入库时打上租户标记、切块时继承租户标记、向量化时元数据带上租户标记、检索时强制按租户过滤、生成时二次校验来源。任何一环漏掉,都可能造成跨租户数据泄露。
这篇文章面向正在做或准备做多租户 RAG 的开发者,尤其是用 PostgreSQL + pgvector 这套组合的团队。我会把user_id隔离的完整设计思路、SQL 写法、索引策略、踩坑经验全部摊开讲,代码可以直接抄。如果你用的是别的向量库(Milvus、Qdrant、Weaviate),隔离思想是相通的,只是语法要换。
先说结论:多租户 RAG 的隔离,最稳的方案是在向量检索的 SQL 里硬编码租户过滤条件,而不是靠应用层"记得加过滤"。应用层靠人自觉,迟早出事;数据库层强制约束,才是工程上可靠的做法。
2. 三种隔离方案的真实取舍:为什么我最终选了共享表加 user_id
做多租户隔离,业界主流有三条路:独立库、独立 schema、共享表加租户字段。这三条路没有绝对优劣,关键看你的租户规模、成本预算和运维能力。我把它们放在一起对比,顺便说说我为什么最终选了第三条。
| 方案 | 隔离强度 | 成本 | 运维复杂度 | 适用规模 |
|---|---|---|---|---|
| 独立数据库 | 最高 | 最高 | 高(每租户一套备份、迁移) | 大客户、强合规 |
| 独立 schema | 高 | 中 | 中(schema 数量膨胀) | 中等租户数 |
| 共享表 + user_id | 中(靠代码保证) | 最低 | 低 | 海量小租户 |
独立数据库的方案,每个租户一个 PG 实例或一个 database,隔离最彻底,但成本也最吓人。一百个租户就是一百套连接池、一百套备份策略,迁移脚本要跑一百遍。除非是金融、医疗这种强合规场景,否则中小团队根本扛不住。
独立 schema 是折中方案,同一个 database 下每个租户一个 schema,表结构相同。听起来不错,但 schema 数量一多,information_schema查询会变慢,DDL 变更要遍历所有 schema,pgvector 的索引也是每个 schema 单独建。租户数超过几百个之后,运维会非常痛苦。
共享表加user_id(或tenant_id)是我最终选的方案。所有租户的数据存在同一张表,靠一个租户字段区分,检索时用WHERE user_id = ?过滤。成本最低、扩展性最好,缺点是隔离靠代码纪律——一旦某处查询忘了加过滤条件,就会串库。
注意:共享表方案的安全底线是"过滤条件不能由应用层随意拼接"。我见过有团队把
user_id做成可选参数,结果某个接口漏传就查了全表。正确做法是把租户过滤封装进数据访问层,业务代码根本拿不到"不带过滤"的查询入口。
这里有个关键决策点:用user_id还是tenant_id。如果系统里一个租户只有一个用户,两者等价;如果租户下有多个用户,且用户之间也要隔离(比如同一家公司不同部门),那就得用user_id做更细粒度的隔离,或者用tenant_id + user_id两级。我建议一开始就设计成两级字段,tenant_id管租户边界,user_id管租户内细分,检索时两个条件都带上,未来要放开或收紧都灵活。
还有一个容易被忽略的点:向量维度和距离度量要全局统一。共享表意味着所有租户的向量存在同一列,如果不同租户用了不同的嵌入模型(维度不同),根本没法存一张表。所以多租户 RAG 必须锁定一个嵌入模型,或者按模型分表。我一般建议全系统统一嵌入模型,省心。
3. pgvector 表结构设计:把租户字段焊进检索路径
方案定了,接下来是表结构。pgvector 的用法很直接:装好扩展,建一个vector类型的列,建索引,查询时用距离操作符排序。但多租户场景下,表结构设计有几个细节必须提前想清楚,否则后期改起来很痛。
先看建表语句。我习惯把租户字段放在最前面,向量列单独一列,元数据用jsonb存:
CREATE EXTENSION IF NOT EXISTS vector; CREATE TABLE knowledge_chunks ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT NOT NULL, user_id BIGINT NOT NULL, doc_id BIGINT NOT NULL, chunk_index INT NOT NULL, content TEXT NOT NULL, metadata JSONB DEFAULT '{}'::jsonb, embedding vector(1024) NOT NULL, created_at TIMESTAMPTZ DEFAULT now() ); -- 租户过滤 + 向量检索的复合索引 CREATE INDEX idx_chunks_tenant ON knowledge_chunks (tenant_id, user_id); -- 向量近似索引(HNSW) CREATE INDEX idx_chunks_embedding ON knowledge_chunks USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);这里有几个设计考量值得展开说。
第一,向量维度写死。vector(1024)里的 1024 要和你的嵌入模型输出维度严格一致。用 OpenAI 的 text-embedding-3-small 是 1536,用 BGE-large 是 1024,用 m3e-base 是 768。维度写错,插入时直接报错,这其实是好事——早报错早发现。千万别用不指定维度的vector,那样存进去的向量维度可以不一致,检索时会出各种诡异问题。
第二,距离度量选型。pgvector 支持三种操作符:<->是 L2 距离,<#>是负内积,<=>是余弦距离。文本嵌入一般用余弦距离(vector_cosine_ops),因为嵌入向量的方向比长度更有意义。索引和查询操作符必须匹配,用余弦索引就得用<=>查询,否则索引不生效,会退化成全表扫描。
第三,HNSW 还是 IVFFlat。这是 pgvector 的两大近似索引。HNSW 查询快、召回高,但建索引慢、占内存多;IVFFlat 建索引快、占空间小,但召回率依赖lists参数调优,且必须在有数据之后建。我的经验是:数据量在百万级以内、内存够用,直接上 HNSW,省心。m=16, ef_construction=64是官方推荐的起步参数,m越大召回越高但内存越贵,ef_construction越大建索引越慢但质量越好。
第四,租户字段的索引。(tenant_id, user_id)复合索引是必须的,因为每次检索都会带上这两个条件。注意字段顺序:把选择性高、过滤后数据量小的字段放前面。如果租户数量多、每个租户数据少,tenant_id放前面过滤效果最好。
提示:pgvector 的 HNSW 索引是全局的,它不知道租户隔离这回事。所以查询时数据库会先用 HNSW 找出全局最近的 N 条,再用
WHERE过滤租户。如果某个租户数据占比很小,可能出现"全局最近 10 条里没有一条属于该租户"的情况,导致召回为空。解决办法是调大hnsw.ef_search参数,或者用下面要讲的"分区 + 局部索引"策略。
这个"全局索引 + 后过滤"的问题是共享表方案最大的坑,我在第四节会专门讲怎么解决。
4. 检索 SQL 的写法与那个绕不开的召回陷阱
表建好了,接下来是检索。多租户 RAG 的检索 SQL 看起来简单,但里面藏着一个非常隐蔽的召回陷阱,我踩过,很多团队也踩过。
先看基础写法:
SELECT id, content, metadata, 1 - (embedding <=> $1::vector) AS similarity FROM knowledge_chunks WHERE tenant_id = $2 AND user_id = $3 ORDER BY embedding <=> $1::vector LIMIT $4;$1是查询向量,$2$3是租户和用户 ID,$4是返回条数。<=>算余弦距离,1 - 距离就是相似度。这个 SQL 逻辑上没问题,但性能和行为上有讲究。
陷阱一:先排序后过滤,还是先过滤后排序?上面这个 SQL,PostgreSQL 的执行计划取决于它怎么选。如果tenant_id过滤后数据量很小,优化器可能先用 B-tree 索引过滤租户,再在小集合里算向量距离;如果租户数据量大,它可能先用 HNSW 索引取全局 TopN,再过滤租户。后者就是前面说的召回陷阱——全局 TopN 里可能一条该租户的都没有。
怎么判断走的是哪条路?用EXPLAIN ANALYZE看执行计划。如果看到Index Scan using idx_chunks_embedding且Filter: tenant_id = ...,说明是先向量后过滤,有风险;如果看到Bitmap Index Scan on idx_chunks_tenant再排序,说明是先过滤后向量,安全但可能慢。
陷阱二:ef_search参数没调。HNSW 检索时,hnsw.ef_search控制候选集大小,默认 40。候选集越大召回越高但越慢。多租户场景下,因为要后过滤,候选集必须开得比单租户大得多。我一般设成LIMIT的 5 到 10 倍:
SET LOCAL hnsw.ef_search = 100;SET LOCAL只在当前事务生效,不会污染连接池里的其他查询,这点很重要。
陷阱三:相似度阈值过滤。很多教程会加WHERE similarity > 0.7这样的阈值。但注意,similarity是计算列,不能在WHERE里直接用别名,得重复写表达式,或者套一层子查询。而且阈值设多少要看你的嵌入模型,不同模型的相似度分布差异很大,别照搬别人的 0.7。
SELECT * FROM ( SELECT id, content, metadata, 1 - (embedding <=> $1::vector) AS similarity FROM knowledge_chunks WHERE tenant_id = $2 AND user_id = $3 ORDER BY embedding <=> $1::vector LIMIT $4 ) t WHERE t.similarity > $5;陷阱四:多租户下的连接池污染。如果你用SET而不是SET LOCAL改ef_search,这个设置会留在连接上,影响后续复用该连接的其他租户查询。用连接池(如 PgBouncer)时尤其危险。所以务必用SET LOCAL,或者干脆在 SQL 里用/*+ ... */提示(PG 不直接支持,得靠pg_hint_plan扩展)。
我实测下来,最稳的写法是把租户过滤和向量检索放在同一个查询里,配合SET LOCAL hnsw.ef_search,并且用EXPLAIN ANALYZE定期检查执行计划有没有跑偏。数据量大了之后,如果发现某个大租户拖慢了整体,就该考虑分区了。
5. 分区表与局部索引:大租户场景下的性能解法
共享表方案跑到一定规模,会遇到两个问题:一是前面说的召回陷阱,二是大租户的数据把索引撑大,小租户查询被拖累。这时候该上分区表了。
PostgreSQL 支持声明式分区,可以按tenant_id做范围分区或列表分区。列表分区适合租户数量固定且不多的场景,范围分区适合租户 ID 连续增长的场景。我一般用哈希分区,把租户均匀打散:
CREATE TABLE knowledge_chunks ( id BIGSERIAL, tenant_id BIGINT NOT NULL, user_id BIGINT NOT NULL, content TEXT NOT NULL, metadata JSONB DEFAULT '{}'::jsonb, embedding vector(1024) NOT NULL, created_at TIMESTAMPTZ DEFAULT now(), PRIMARY KEY (id, tenant_id) ) PARTITION BY HASH (tenant_id); -- 建 16 个分区 CREATE TABLE knowledge_chunks_p0 PARTITION OF knowledge_chunks FOR VALUES WITH (MODULUS 16, REMAINDER 0); -- ... p1 到 p15 同理分区之后,每个分区单独建 HNSW 索引:
CREATE INDEX idx_chunks_p0_embedding ON knowledge_chunks_p0 USING hnsw (embedding vector_cosine_ops);这样查询时,PostgreSQL 会根据tenant_id的值做分区裁剪(partition pruning),只扫描对应的那个分区,向量索引也只在那个分区内生效。召回陷阱基本消失,因为每个分区里的数据都属于同一批租户,全局 TopN 和租户 TopN 的差距小很多。
分区的代价是运维复杂度上升:建分区、维护分区、跨分区查询要小心。而且分区数量要提前规划好,16 个还是 64 个,取决于你的租户规模。分区太多,每个分区的数据太少,索引反而浪费;分区太少,起不到隔离效果。
注意:分区表的
PRIMARY KEY必须包含分区键,所以是(id, tenant_id)而不是单独的id。这个约束经常被忽略,建表时会报错。
如果你的租户规模还没到需要分区的程度(比如租户数在几百以内,单表数据在千万级以内),我建议先别上分区,用复合索引 + 调大ef_search就够了。分区是"数据量逼出来的优化",不是"一开始就该上的架构"。过早分区只会增加复杂度。
还有一个替代思路:按租户分表。每个租户一张knowledge_chunks_{tenant_id}表,应用层根据租户 ID 路由到对应表。隔离最彻底,但表数量爆炸,DDL 变更要遍历所有表,不推荐。分区表本质上是"逻辑一张表、物理多张表",兼顾了隔离和运维,是更好的选择。
6. 从入库到生成:把租户校验贯穿整条链路
前面讲的都是检索层,但多租户隔离不能只做检索。从文档上传到最终生成答案,整条链路都要带租户标记,任何一环断了都可能泄露。我把这条链路拆开讲。
入库阶段。用户上传文档时,后端必须从会话或 token 里解析出tenant_id和user_id,写进文档记录。切块时,每个 chunk 继承文档的租户标记。这里有个坑:如果切块是异步任务(比如丢进消息队列),任务消息里必须带上租户标记,不能靠任务执行时再去查——因为异步任务可能跨请求上下文,查不到当前用户。
def ingest_document(file, tenant_id, user_id): doc = save_document(file, tenant_id, user_id) for i, chunk in enumerate(split(file)): embedding = embed(chunk) save_chunk( tenant_id=tenant_id, user_id=user_id, doc_id=doc.id, chunk_index=i, content=chunk, embedding=embedding )检索阶段。前面讲过了,WHERE tenant_id = ? AND user_id = ?是硬约束。我建议把检索封装成一个函数,租户参数是必填的,没有默认值:
def search_chunks(query, tenant_id, user_id, top_k=5): assert tenant_id is not None and user_id is not None vec = embed(query) return db.query(SEARCH_SQL, vec, tenant_id, user_id, top_k)assert是最后一道防线,防止有人传了None导致查询变成全表。生产环境可以把assert换成显式抛异常。
生成阶段。检索到的 chunk 喂给大模型之前,再校验一次来源。虽然检索层已经过滤了,但多一道校验不亏——万一检索层有 bug,生成层能兜住。校验逻辑很简单:检查每个 chunk 的tenant_id是否等于当前租户,不等就丢弃并告警。
def generate_answer(query, tenant_id, user_id): chunks = search_chunks(query, tenant_id, user_id) safe_chunks = [c for c in chunks if c.tenant_id == tenant_id] if len(safe_chunks) != len(chunks): logger.error("租户隔离校验失败,疑似串库") context = "\n".join(c.content for c in safe_chunks) return llm.generate(query, context)对话历史也要隔离。多轮对话场景下,历史消息存在哪、怎么查,同样要带租户标记。如果历史消息表没做隔离,用户 A 可能通过某种方式读到用户 B 的对话。这块和知识库隔离是两回事,但同样重要。
缓存也要隔离。如果你对检索结果或生成结果做了缓存,缓存 key 必须包含tenant_id。我见过有团队用hash(query)做缓存 key,结果不同租户问同样的问题,直接命中别人的缓存答案。缓存 key 应该是hash(tenant_id + user_id + query)。
7. 那些让我半夜爬起来修 bug 的坑
讲完设计,说几个真实踩过的坑。这些坑在文档里基本找不到,都是血泪教训。
坑一:user_id类型不一致导致过滤失效。有一次检索突然返回了全租户数据,排查半天发现是user_id在数据库里是BIGINT,但应用层传进来的是字符串"123",PostgreSQL 隐式转换失败后……居然没报错,而是把条件当成了恒真。后来我强制在应用层做类型转换,并在 SQL 里显式写user_id = $3::bigint。类型这东西,宁可多写一个 cast。
坑二:软删除的数据被检索到。用户删了文档,但向量还在库里,检索时照样被召回。解决办法是加deleted_at IS NULL条件,或者物理删除。我倾向物理删除,因为软删除的向量还占着索引空间,影响性能。删除时记得同时删 chunk 和向量,别只删文档主记录。
坑三:ef_search设太大拖垮数据库。有次为了提升召回,把hnsw.ef_search设成了 1000,结果 QPS 直接掉到个位数。ef_search和查询耗时基本是线性关系,设太大等于每次查询都扫一大片。正确做法是根据LIMIT动态设置,比如ef_search = max(40, limit * 10),而不是无脑调大。
坑四:连接池里的SET残留。前面提过,这里再强调一次。用 PgBouncer 的 transaction 模式时,SET命令的作用域很微妙。我现在的做法是所有会话级参数都用SET LOCAL,并且确保它在事务里执行。如果不在事务里,SET LOCAL会立即失效,等于没设。
坑五:迁移脚本忘了带租户字段。有次加了个新字段,迁移脚本写成了UPDATE knowledge_chunks SET new_field = ...,忘了WHERE,把所有租户的数据都改了。虽然这个字段不影响隔离,但吓出一身冷汗。任何UPDATE和DELETE都必须带租户条件,这是铁律。
坑六:测试环境单租户,生产环境多租户。测试时只有一个租户,所有查询都"正常",上线后多租户一混,问题全暴露。我的建议是测试环境至少造两个租户的数据,并且写自动化测试专门验证"租户 A 查不到租户 B 的数据"。这个测试用例比什么都有用。
8. 一套可复用的隔离自检清单
最后,把我这些年总结的多租户 RAG 隔离自检清单分享出来。每次上线新功能,我都会对着过一遍。
| 检查项 | 检查方法 | 风险等级 |
|---|---|---|
| 检索 SQL 是否带租户条件 | 代码审查 + 自动化测试 | 致命 |
| 租户参数是否必填 | 函数签名检查 | 致命 |
| 入库是否继承租户标记 | 抽查数据库记录 | 高 |
| 异步任务是否传递租户标记 | 检查消息体 | 高 |
| 缓存 key 是否含租户 | 代码审查 | 高 |
| 对话历史是否隔离 | 跨租户测试 | 高 |
| 软删除数据是否被过滤 | 查询验证 | 中 |
| 生成层是否二次校验 | 代码审查 | 中 |
| 连接池参数是否用 SET LOCAL | 代码审查 | 中 |
| 测试环境是否多租户 | 环境检查 | 中 |
这份清单不是摆设。我现在的习惯是,任何涉及数据查询的 PR,review 时第一眼就看有没有租户条件。没有的话,直接打回。这个习惯帮我挡掉了至少三次潜在的串库事故。
多租户 RAG 的隔离,说到底是个"纪律问题"而不是"技术问题"。技术方案就那几种,pgvector 的用法也不复杂,难的是让团队每个人都时刻记得"这个查询必须带租户条件"。把过滤封装进数据访问层、写自动化测试、维护自检清单,这三件事做到位,基本就能睡个安稳觉了。
如果你正在做多租户 RAG,欢迎对照这套思路检查一下自己的系统。尤其是那个"全局索引 + 后过滤"的召回陷阱,值得你花半小时用EXPLAIN ANALYZE确认一下执行计划。这个坑不踩则已,一踩就是线上事故。