ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

多租户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 变更要遍历所有 schemapgvector 的索引也是每个 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省心。m16, ef_construction64是官方推荐的起步参数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 逻辑上没问题但性能和行为上有讲究。陷阱一先排序后过滤还是先过滤后排序上面这个 SQLPostgreSQL 的执行计划取决于它怎么选。如果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_idtenant_id, user_iduser_id, doc_iddoc.id, chunk_indexi, contentchunk, embeddingembedding )检索阶段。前面讲过了WHERE tenant_id ? AND user_id ?是硬约束。我建议把检索封装成一个函数租户参数是必填的没有默认值def search_chunks(query, tenant_id, user_id, top_k5): 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但应用层传进来的是字符串123PostgreSQL 隐式转换失败后……居然没报错而是把条件当成了恒真。后来我强制在应用层做类型转换并在 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代码审查中测试环境是否多租户环境检查中这份清单不是摆设。我现在的习惯是任何涉及数据查询的 PRreview 时第一眼就看有没有租户条件。没有的话直接打回。这个习惯帮我挡掉了至少三次潜在的串库事故。多租户 RAG 的隔离说到底是个纪律问题而不是技术问题。技术方案就那几种pgvector 的用法也不复杂难的是让团队每个人都时刻记得这个查询必须带租户条件。把过滤封装进数据访问层、写自动化测试、维护自检清单这三件事做到位基本就能睡个安稳觉了。如果你正在做多租户 RAG欢迎对照这套思路检查一下自己的系统。尤其是那个全局索引 后过滤的召回陷阱值得你花半小时用EXPLAIN ANALYZE确认一下执行计划。这个坑不踩则已一踩就是线上事故。
返回列表