1. 多租户 RAG 到底在解决什么问题
先把场景说清楚。你搭了一套 RAG 知识库系统,公司里三个部门都在用:销售部传产品报价单,法务部传合同模板,研发部传接口文档。结果销售提问的时候,检索结果里冒出了法务的合同条款;研发搜接口参数,返回的却是销售的价格表。这不是模型的问题,是检索层根本没做隔离。
多租户 RAG 的核心命题就一句话:在同一个向量库、同一套检索服务里,让每个用户只能召回属于自己的那部分知识。注意这里的关键词是“同一个”——如果每个租户单独部署一套向量库,那叫物理隔离,不叫多租户。多租户的价值在于共享基础设施、共享 embedding 模型、共享检索链路,但数据逻辑上严格分家。
为什么这件事值得单独拿出来讲?因为大部分 RAG 教程演示的都是单用户场景:灌一批文档,建一个索引,搜就完了。一旦上生产,用户变成几十上百个,问题立刻暴露。我见过最典型的翻车方式是:开发阶段用user_id过滤,上线后发现过滤条件写在了 prompt 里而不是检索查询里,模型该看到别的租户内容还是能看到。还有一种更隐蔽的,过滤条件加在了向量检索之后,也就是先 top-k 召回再筛 user_id,结果某个租户的 top-10 里 9 条是别人的,筛完只剩 1 条,召回率直接崩掉。
所以这篇东西适合三类人看:正在把 RAG demo 往生产推的工程师、需要给 SaaS 产品加知识库功能的开发者、以及已经上线但发现租户数据串了的救火队员。下面我会从表结构设计、pgvector 过滤写法、检索链路改造、到踩坑排查,完整走一遍。
2. 整体方案设计与技术选型考量
2.1 为什么选 pgvector 而不是独立向量库
多租户场景下,向量库的选型逻辑和单租户完全不同。单租户你只看召回性能和易用性,多租户你必须优先考虑过滤能力和事务一致性。
独立向量库(比如某些专用向量数据库)的强项是 ANN 索引性能,但它们的 metadata 过滤往往是后置的——先做向量近邻搜索,再对结果做条件筛选。这在多租户场景下是致命的:假设库里有 100 万个 chunk 分属 1000 个租户,你搜 top-100,期望这 100 条都属于当前租户,但实际上均匀分布的话只有 0.1 条是你的。过滤完几乎什么都不剩。
pgvector 的优势在于它长在 PostgreSQL 里,过滤条件和向量距离计算可以在同一个查询里完成,而且能走 B-tree 索引先缩小范围。PostgreSQL 的查询规划器会智能选择:当user_id过滤后数据量很小时,先走 user_id 索引再算向量距离;当过滤后数据量很大时,走 HNSW 向量索引。这种灵活性是独立向量库很难给的。
另一个考量是事务。多租户系统里,用户删文档、更新知识库、权限变更都是高频操作。pgvector 和业务表在同一个数据库里,删文档和删向量可以放在一个事务里,不会出现“文档删了向量还在”的脏数据。独立向量库你得自己保证两边一致,复杂度陡增。
提示:如果你的租户数量在个位数、数据量在百万级以下,pgvector 完全够用。真正需要独立向量库的场景是单租户数据就上亿、且过滤维度极简单。多租户反而更适合 pgvector。
2.2 隔离级别的三种选择
多租户数据隔离有三个层次,选哪个取决于你的合规要求和成本预算:
| 隔离级别 | 实现方式 | 隔离强度 | 成本 | 适用场景 |
|---|---|---|---|---|
| 行级隔离 | 同表加 tenant_id 字段 | 逻辑隔离 | 最低 | 中小 SaaS、内部系统 |
| Schema 隔离 | 每租户一个 schema | 中等 | 中 | 中大型 SaaS、有合规要求 |
| 库级隔离 | 每租户一个数据库 | 物理隔离 | 最高 | 金融、医疗等强合规 |
绝大多数 RAG 项目用行级隔离就够了。Schema 隔离在 PostgreSQL 里维护成本很高——每加一个租户要建一套表、一套索引,迁移的时候要遍历所有 schema。库级隔离基本等于放弃多租户的共享优势。
行级隔离的关键是:tenant_id 必须进到向量检索的 WHERE 条件里,而不是检索后过滤。这一点后面会详细展开。
2.3 检索链路的分层设计
一个完整的多租户 RAG 检索链路,我习惯拆成四层:
- 认证层:从 JWT 或 session 里拿到 user_id,再映射到 tenant_id。注意 user_id 和 tenant_id 不一定是一对一,一个用户可能属于多个租户。
- 权限层:确定当前用户在当前租户下能访问哪些知识库(knowledge_base_id)。有些系统里租户下面还分知识库,权限要细到这一层。
- 检索层:把 tenant_id 和 knowledge_base_id 作为硬过滤条件,拼进向量检索 SQL。
- 后处理层:对召回结果做重排序、去重、拼接上下文。
很多项目出问题就出在第 3 层——过滤条件没进 SQL,或者进了但没走索引。下面逐层拆。
3. 核心表结构与 pgvector 过滤实操
3.1 表结构设计
先看核心表。我一般会建三张表:租户表、知识库表、文档块表。
-- 租户表 CREATE TABLE tenants ( id BIGSERIAL PRIMARY KEY, name VARCHAR(128) NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW() ); -- 知识库表,一个租户可以有多个知识库 CREATE TABLE knowledge_bases ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT NOT NULL REFERENCES tenants(id), name VARCHAR(128) NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW() ); -- 文档块表,核心表 CREATE TABLE doc_chunks ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT NOT NULL, kb_id BIGINT NOT NULL, doc_id BIGINT NOT NULL, content TEXT NOT NULL, embedding vector(1536), created_at TIMESTAMPTZ DEFAULT NOW() );这里有几个设计决策要解释。
为什么 tenant_id 冗余存一份?因为 doc_chunks 表数据量最大,如果每次检索都要 JOIN knowledge_bases 表拿 tenant_id,查询会变慢。冗余存储 tenant_id 让过滤条件直接落在 doc_chunks 上,配合复合索引效率最高。代价是更新时要保证一致,但租户归属基本不变,这个代价可以接受。
为什么 embedding 维度写 1536?这是 OpenAI text-embedding-ada-002 的维度。如果你用别的模型,比如 BGE-M3 是 1024 维,要相应改。维度一旦定了就不能改,改维度等于重建整张表。
为什么用 BIGSERIAL 而不是 UUID?自增 ID 在索引上更紧凑,B-tree 查询更快。UUID 的优势是分布式生成不冲突,但多租户 RAG 的写入量通常不大,自增够用。如果你确实需要 UUID,用gen_random_uuid(),但要注意索引膨胀问题。
3.2 索引策略
索引是性能的命脉。多租户场景下,我建议建两个索引:
-- 复合 B-tree 索引,用于租户过滤 CREATE INDEX idx_chunks_tenant_kb ON doc_chunks (tenant_id, kb_id); -- HNSW 向量索引,用于相似度检索 CREATE INDEX idx_chunks_embedding ON doc_chunks USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);HNSW 的参数m和ef_construction需要解释。m是每个节点的最大连接数,越大召回率越高但索引越大、构建越慢。16 是通用推荐值,数据量超过千万可以调到 32。ef_construction是构建时的候选列表大小,64 是平衡值,追求召回率可以调到 128 甚至 200,但构建时间会显著增加。
查询时的ef_search参数更关键,它控制搜索时的候选列表大小:
SET hnsw.ef_search = 100;默认值是 40,对于多租户过滤场景往往不够。因为过滤后候选集变小,需要更大的 ef_search 来保证召回。我实测下来,租户数据占比低于 1% 时,ef_search 要设到 100 以上。
注意:HNSW 索引和过滤条件同时使用时,PostgreSQL 可能选择先走向量索引再过滤,导致大量无效计算。这时候需要看执行计划,必要时用
SET enable_seqscan = off强制走 B-tree 索引。这个坑后面会详细讲。
3.3 检索 SQL 的正确写法
这是全文最关键的一段。先看错误写法:
-- 错误:先向量检索再过滤,召回率崩 SELECT * FROM ( SELECT id, content, tenant_id, embedding <=> '[query_vector]' AS distance FROM doc_chunks ORDER BY embedding <=> '[query_vector]' LIMIT 10 ) t WHERE t.tenant_id = 123;这个写法的问题在于,子查询先取了全局 top-10,外层再过滤 tenant_id。如果当前租户的数据在全局排不进前 10,结果就是空。
正确写法是把过滤条件放进向量检索的同一层:
-- 正确:过滤和向量检索在同一层 SELECT id, content, embedding <=> '[query_vector]' AS distance FROM doc_chunks WHERE tenant_id = 123 AND kb_id = ANY(ARRAY[1,2,3]) ORDER BY embedding <=> '[query_vector]' LIMIT 10;这样 PostgreSQL 会先根据tenant_id和kb_id缩小范围,再在这个范围内做向量近邻搜索。召回率有保证。
但这里还有个隐患:如果当前租户的数据量很大(比如占全库 30%),PostgreSQL 可能觉得走 B-tree 索引再排序不如直接全表扫向量索引。这时候执行计划会变成先走 HNSW 再过滤,又回到老问题。解决办法是看EXPLAIN ANALYZE的输出,如果发现Index Scan using idx_chunks_embedding且Filter: tenant_id = 123,说明过滤是后置的,需要干预。
3.4 参数化查询与 SQL 注入防护
多租户系统里,tenant_id 来自用户请求,绝对不能字符串拼接。用参数化查询:
import psycopg2 from pgvector.psycopg2 import register_vector conn = psycopg2.connect(dsn) register_vector(conn) cur = conn.cursor() query_vector = embed("用户的提问") cur.execute(""" SELECT id, content, embedding <=> %s AS distance FROM doc_chunks WHERE tenant_id = %s AND kb_id = ANY(%s) ORDER BY embedding <=> %s LIMIT %s """, (query_vector, tenant_id, kb_ids, query_vector, top_k))注意<=>两边都要传 query_vector,这是 pgvector 的语法要求。kb_id = ANY(%s)传一个 Python list 进去,psycopg2 会自动转成 PostgreSQL 数组。
提示:永远不要在 SQL 里拼接 tenant_id。我见过有人为了“性能”把 tenant_id 直接 format 进 SQL,结果被 SQL 注入,攻击者构造 tenant_id 为
1 OR 1=1直接拖走全库数据。参数化查询是底线。
4. 检索链路改造与权限映射
4.1 从 user_id 到 tenant_id 的映射
用户请求进来,第一步是解析身份。假设你用 JWT,payload 里通常有 user_id,但不一定有 tenant_id。因为一个用户可能属于多个租户,当前用哪个租户取决于请求上下文。
我一般会建一张用户-租户关联表:
CREATE TABLE user_tenants ( user_id BIGINT NOT NULL, tenant_id BIGINT NOT NULL, role VARCHAR(32) DEFAULT 'member', PRIMARY KEY (user_id, tenant_id) );请求进来后,从 JWT 拿 user_id,从请求头或 URL 拿 tenant_id,然后校验这个组合在 user_tenants 里存在。校验通过才继续检索。这一步不能省,否则用户改个 tenant_id 就能越权访问。
def resolve_tenant(user_id: int, tenant_id: int) -> bool: cur.execute( "SELECT 1 FROM user_tenants WHERE user_id = %s AND tenant_id = %s", (user_id, tenant_id) ) return cur.fetchone() is not None4.2 知识库级别的权限过滤
租户隔离是粗粒度,知识库隔离是细粒度。同一个租户下,不同用户能访问的知识库可能不同。比如销售只能看产品库,法务只能看合同库。
这需要一张知识库权限表:
CREATE TABLE kb_permissions ( user_id BIGINT NOT NULL, kb_id BIGINT NOT NULL, permission VARCHAR(16) DEFAULT 'read', PRIMARY KEY (user_id, kb_id) );检索前先查出当前用户能访问的 kb_id 列表:
cur.execute( "SELECT kb_id FROM kb_permissions WHERE user_id = %s AND permission = 'read'", (user_id,) ) kb_ids = [row[0] for row in cur.fetchall()]如果 kb_ids 为空,直接返回空结果,不要走检索。这一步能省掉大量无效查询。
4.3 检索服务的封装
把上面的逻辑封装成一个检索函数,对外只暴露search(user_id, tenant_id, query, top_k):
def search(user_id: int, tenant_id: int, query: str, top_k: int = 10): # 1. 校验租户归属 if not resolve_tenant(user_id, tenant_id): raise PermissionError("user not in tenant") # 2. 查可访问的知识库 cur.execute( "SELECT kb_id FROM kb_permissions WHERE user_id = %s", (user_id,) ) kb_ids = [r[0] for r in cur.fetchall()] if not kb_ids: return [] # 3. 向量检索 query_vector = embed(query) cur.execute(""" SELECT id, content, embedding <=> %s AS distance FROM doc_chunks WHERE tenant_id = %s AND kb_id = ANY(%s) ORDER BY embedding <=> %s LIMIT %s """, (query_vector, tenant_id, kb_ids, query_vector, top_k)) return cur.fetchall()这个函数看起来简单,但每一层都有讲究。第 1 步防止跨租户,第 2 步防止跨知识库,第 3 步才是真正的检索。三层缺一不可。
4.4 缓存与性能优化
多租户场景下,权限查询是高频操作。每次检索都查 user_tenants 和 kb_permissions 会拖慢响应。我一般会加一层 Redis 缓存,key 是perm:{user_id}:{tenant_id},value 是 kb_ids 列表,TTL 设 5 分钟。
def get_kb_ids_cached(user_id: int, tenant_id: int): cache_key = f"perm:{user_id}:{tenant_id}" cached = redis.get(cache_key) if cached: return json.loads(cached) cur.execute(""" SELECT kb_id FROM kb_permissions WHERE user_id = %s AND kb_id IN ( SELECT id FROM knowledge_bases WHERE tenant_id = %s ) """, (user_id, tenant_id)) kb_ids = [r[0] for r in cur.fetchall()] redis.setex(cache_key, 300, json.dumps(kb_ids)) return kb_ids权限变更时要主动删缓存,否则用户被移除权限后 5 分钟内还能访问。这个细节很容易漏。
5. 常见问题与排查技巧实录
5.1 召回率突然下降
现象:上线多租户过滤后,某些租户反馈“搜不到东西”,但数据明明在库里。
排查思路:先看执行计划。
EXPLAIN ANALYZE SELECT id, content FROM doc_chunks WHERE tenant_id = 123 ORDER BY embedding <=> '[vec]' LIMIT 10;如果输出里有Index Scan using idx_chunks_embedding且Filter: (tenant_id = 123),说明 PostgreSQL 选择了先走向量索引再过滤。当租户数据占比低时,向量索引扫到的 top-N 里几乎没有该租户的数据,召回率自然低。
解决:强制走 B-tree 索引。有两种方式,一是调低hnsw.ef_search让规划器觉得向量索引不划算,二是用 CTE 先过滤:
WITH tenant_chunks AS ( SELECT id, content, embedding FROM doc_chunks WHERE tenant_id = 123 ) SELECT id, content FROM tenant_chunks ORDER BY embedding <=> '[vec]' LIMIT 10;CTE 会把过滤后的结果物化,后续向量检索只在这个子集里做。实测下来这个写法最稳。
5.2 跨租户数据泄露
现象:用户 A 搜到了用户 B 的文档。
排查:按这个顺序查。
- 检查检索 SQL 里有没有 tenant_id 过滤。我见过有人把过滤写在应用层,结果某个分支忘了加。
- 检查 tenant_id 来源。如果是从请求参数拿的且没校验,用户改一下就能越权。
- 检查缓存。如果缓存 key 没带 tenant_id,不同租户会命中同一份缓存。
- 检查 embedding 缓存。有些系统会缓存 query 的 embedding,如果 key 只用了 query 文本,不同租户相同 query 会复用,但 embedding 本身不含租户信息,这个通常没问题。
速查表:
| 现象 | 可能原因 | 检查点 |
|---|---|---|
| 搜到别的租户文档 | SQL 缺 tenant_id 过滤 | 检索 SQL 的 WHERE 子句 |
| 改 tenant_id 能越权 | 未校验用户-租户归属 | resolve_tenant 是否调用 |
| 缓存串租户 | 缓存 key 缺 tenant_id | Redis key 设计 |
| 删文档后还能搜到 | 向量未同步删除 | 删除逻辑是否在同一事务 |
5.3 性能瓶颈定位
多租户 RAG 的性能问题通常出在三个地方:向量检索慢、权限查询慢、embedding 计算慢。
向量检索慢,先看ef_search是不是设太大。100 和 40 的差距可能是 3 倍延迟。然后看 HNSW 索引的m值,16 和 32 的索引大小差一倍。
权限查询慢,加缓存。如果缓存也慢,看 user_tenants 和 kb_permissions 表有没有索引。user_id上必须有索引。
embedding 计算慢,这是外部 API 调用,只能靠批量处理和缓存。相同 query 的 embedding 缓存起来,key 用 query 的 hash。
5.4 数据迁移与扩容
租户数据量增长后,可能需要把大租户拆到独立库。这时候要保证迁移过程中不丢数据、不串数据。
我的做法是:先建新库,把大租户的数据按 tenant_id 导出导入,校验行数一致后,在新库上建索引,然后切换路由。切换时用双写过渡,新数据同时写旧库和新库,读走新库。稳定一周后停旧库写入。
这个过程最怕的是迁移期间有新数据写入导致不一致。双写能解决,但要注意事务——两个库的写入不在同一事务里,可能一个成功一个失败。我的经验是接受最终一致,用对账任务定期校验。
6. 几个容易忽略的细节
6.1 embedding 模型变更的影响
如果哪天你把 embedding 模型从 ada-002 换成 BGE-M3,维度从 1536 变成 1024,整张 doc_chunks 表的 embedding 列都要重建。多租户场景下这意味着所有租户的数据都要重新 embedding,成本很高。
我的建议是:embedding 模型版本号存到表里。加一列embedding_model VARCHAR(64),检索时只查当前模型版本的向量。这样切换模型时可以灰度,新数据用新模型,旧数据慢慢迁移。
6.2 分块策略对多租户的影响
分块大小影响召回质量,但多租户场景下还有个额外考量:不同租户的文档类型不同,最优分块大小可能不同。销售的产品文档可能适合 256 token 的小块,法务的合同适合 512 token 的大块。
如果统一分块,总有一部分租户效果差。我的做法是让分块大小成为知识库级别的配置,每个知识库可以设自己的 chunk_size 和 overlap。这样灵活性和复杂度平衡得比较好。
6.3 监控与告警
多租户系统必须监控每个租户的检索质量。我一般会记录每次检索的:tenant_id、query、召回数量、top1 距离、耗时。然后按租户聚合,看有没有租户的召回数量持续偏低。
如果某个租户的 top1 距离普遍很大(比如都大于 0.5),说明它的知识库和 query 匹配度低,可能是分块或 embedding 有问题。这种问题单看全局指标发现不了,必须按租户拆开看。
# 检索日志记录 log.info("rag_search", extra={ "tenant_id": tenant_id, "user_id": user_id, "query_len": len(query), "recall_count": len(results), "top1_distance": results[0][2] if results else None, "latency_ms": elapsed })6.4 租户删除的级联清理
租户注销时,要删掉它的所有数据:doc_chunks、knowledge_bases、kb_permissions、user_tenants。这些删除要在一个事务里,否则删了一半失败会留下孤儿数据。
BEGIN; DELETE FROM doc_chunks WHERE tenant_id = 123; DELETE FROM kb_permissions WHERE kb_id IN ( SELECT id FROM knowledge_bases WHERE tenant_id = 123 ); DELETE FROM knowledge_bases WHERE tenant_id = 123; DELETE FROM user_tenants WHERE tenant_id = 123; DELETE FROM tenants WHERE id = 123; COMMIT;注意删除顺序,先删子表再删主表,否则外键约束会报错。如果数据量大,删除可能锁表很久,建议分批删,每批 1000 行,加个 sleep 让其他事务有机会执行。
7. 关于 RAG 和 MCP 的关系
最近社区里经常有人问 RAG 和 MCP 的区别。简单说,RAG 解决的是“模型怎么获取外部知识”,MCP 解决的是“模型怎么调用外部工具”。两者不冲突,可以叠加。
在多租户场景下,MCP 的价值在于把检索能力封装成一个标准工具,让不同的 agent 框架都能调用。比如你用 AgentScope 或者 LangChain4j,都可以通过 MCP 协议调用同一个多租户检索服务。这样检索逻辑只写一遍,多个 agent 复用。
但要注意,MCP 只是传输层,租户隔离还是要在检索服务内部做。不能因为走了 MCP 就以为隔离自动生效了。我见过有人把 tenant_id 放在 MCP 的 tool 参数里,结果 agent 可以自己构造 tenant_id 越权。正确做法是 tenant_id 从认证上下文拿,不暴露给 agent。
8. 我踩过的几个坑
第一个坑是忘了给 kb_id 加索引。早期只建了(tenant_id)单列索引,后来加了知识库过滤,查询变成WHERE tenant_id = ? AND kb_id = ANY(?),单列索引只能用到 tenant_id,kb_id 过滤在内存里做。数据量上来后慢得离谱。改成(tenant_id, kb_id)复合索引后,查询时间从 800ms 降到 30ms。
第二个坑是HNSW 索引的 ef_search 是会话级的。我在连接池里设了一次,以为全局生效,结果某些连接没设到,召回率时好时坏。后来改成每次查询前都SET LOCAL hnsw.ef_search = 100,用 LOCAL 保证只在当前事务生效,不影响连接池里的其他查询。
第三个坑是embedding 缓存没带模型版本。切换 embedding 模型后,旧缓存还在,导致新 query 命中了旧模型的向量,检索结果完全不对。后来缓存 key 改成emb:{model_version}:{query_hash},问题解决。
第四个坑是租户数据量差异导致的性能不均。大租户的检索慢,小租户快,但连接池是共享的,大租户的慢查询会占满连接,拖垮小租户。后来给大租户单独配了连接池,并且加了查询超时,超过 2 秒直接中断。
这些坑单看都不复杂,但组合起来就是生产事故。多租户 RAG 的难点不在技术本身,而在细节的完备性——每一层过滤、每一个缓存 key、每一个索引,都要考虑到租户维度。少考虑一处,就是数据泄露或者性能雪崩。
最后分享一个实用技巧:写一个租户隔离的自动化测试。用两个租户的数据,交叉检索,断言 A 租户永远搜不到 B 租户的内容。这个测试跑在 CI 里,每次改检索逻辑都跑一遍。我靠这个测试拦住了至少三次潜在的越权 bug。测试代码不长,但价值极高,强烈建议每个多租户 RAG 项目都加上。