news 2026/9/26 6:45:44

多租户RAG隔离实战:pgvector中user_id过滤与召回陷阱

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
多租户RAG隔离实战:pgvector中user_id过滤与召回陷阱

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确认一下执行计划。这个坑不踩则已,一踩就是线上事故。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 6:45:11

微信大数据挑战赛2021实战:用户行为预测与特征工程全解析

简介&#xff1a;2021微信大数据挑战赛&#xff08;WBDC&#xff09;参赛代码与方案整理&#xff0c;面向对点击率预测、多目标行为建模感兴趣的算法学习者。赛题要求基于用户与feed信息预测读评论、点赞、收藏、转发等7类行为&#xff0c;本质是点击率预估任务&#xff1b;资源…

作者头像 李华
网站建设 2026/9/26 6:45:09

Java时间API与包装类核心知识点与避坑指南

学Java学到第20天&#xff0c;正好卡在一个有意思的坎上&#xff1a;语法基本摸清了&#xff0c;类、对象、继承这些概念也都有了印象&#xff0c;但伸手写代码的时候&#xff0c;一碰到想查个时间、转个数字、处理个字符串&#xff0c;就傻眼了——明明感觉得有现成工具&#…

作者头像 李华
网站建设 2026/9/26 6:45:06

WebGPU 1.0实战:浏览器内AI推理的性能跃迁与落地指南

作为常年泡在图形学和前端交叉领域的老兵&#xff0c;看到WebGPU 1.0正式落地&#xff0c;说实话心里挺感慨的。从最早在Chrome实验室里摸WICG草案&#xff0c;到如今各大主流浏览器默认开启&#xff0c;这条路走了快六年。更让人激动的是&#xff0c;它把“在浏览器里跑AI推理…

作者头像 李华
网站建设 2026/9/26 6:45:06

AI代码工具实战:业余开发者如何高效写出能跑的程序

说实话&#xff0c;我接触AI代码工具之前&#xff0c;是个标准的“业余开发者”——本职工作不是程序员&#xff0c;但总有些需求不想求人&#xff1a;整理报表、批量处理文件、爬点公开数据、给日常办公写点小工具。以前这种活儿要么到处搜代码然后改得头皮发麻&#xff0c;要…

作者头像 李华
网站建设 2026/9/26 6:44:29

SpringBoot集成GBase8s实战:JDBC配置与CRUD避坑指南

简介&#xff1a;面向Spring Boot开发者的GBase 8s集成示例项目&#xff0c;基于Spring Boot与MyBatis搭建&#xff0c;演示从JDBC驱动依赖引入、数据库连接配置、数据源指定&#xff0c;到Mapper接口编写、SQL语法适配及异常处理的全过程。项目采用MyBatis作为持久层框架&…

作者头像 李华
网站建设 2026/9/26 6:44:26

AI编程工具管控实战:Java Agent沙箱与提示词安全

1. 这周不是在更新工具&#xff0c;是在给AI编程“立规矩”这周刷技术社区&#xff0c;明显感觉到风向变了——大家不再狂晒“我又用AI写了300行代码”&#xff0c;而是集体围在几个新问题前反复打转&#xff1a;Cursor的提示词为什么总被悄悄发出去&#xff1f;GitHub Copilot…

作者头像 李华