ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

多租户RAG检索隔离实战:基于user_id与pgvector的SQL设计

多租户RAG检索隔离实战:基于user_id与pgvector的SQL设计

1. 多租户 RAG 的真实痛点:为什么“检索串库”比想象中更常见

做过 RAG 项目的人大概都有过这样的经历:本地 demo 跑得飞起,知识库问答准确率看着也不错,结果一上多用户环境,问题就来了。A 用户问“我的合同什么时候到期”,系统把 B 用户的合同条款检索出来当上下文;C 用户上传的内部文档,被 D 用户的提问命中了。这不是模型的问题,是检索层没有做租户隔离。

多租户 RAG 的核心诉求其实就一句话:每个用户只能检索到属于自己的知识库内容。听起来简单,但真正落地时会发现,它牵扯到数据建模、向量检索过滤、权限校验、索引设计、查询性能等一整条链路。任何一个环节偷懒,都会导致越权检索。

我见过太多项目在早期为了快速验证,把所有文档往一张表里塞,向量字段也不带任何归属标识,检索时直接做全表相似度排序。这种方案在单用户场景下没问题,一旦接入第二个用户,就是灾难。更隐蔽的是,有些方案在应用层做了过滤,但过滤发生在向量检索之后——先召回 Top-K,再按 user_id 筛掉不属于当前用户的。这种做法有两个致命问题:一是召回数量被稀释,本该属于当前用户的结果可能因为 Top-K 被别人的内容挤掉;二是如果过滤逻辑写错或者漏写,直接就是数据泄露。

所以这篇内容我想聊的不是“RAG 怎么做”,而是“多租户场景下 RAG 的检索隔离到底该怎么设计”。涉及的技术栈会围绕user_id、SQL、pgvector这几个关键词展开,因为这是我实测下来最容易落地、也最容易讲清楚的一条路径。适合正在做 RAG 项目、准备接入多用户、或者已经踩过串库坑的开发者参考。

2. 租户隔离的三种落地层级:从应用层到数据库层怎么选

在动手之前,有必要先把“隔离”这件事拆开看。多租户 RAG 的隔离不是单一维度的,它至少涉及三个层级:数据存储隔离、向量检索隔离、应用逻辑隔离。不同层级的组合,决定了你的方案是“能用”还是“稳”。

2.1 应用层过滤:最直观但最容易出错的方案

应用层过滤的思路很直接:检索时先查出当前用户的 user_id,然后在业务代码里对检索结果做筛选。比如先用 pgvector 做一次全量相似度查询,拿到 Top-50,再在 Python 里过滤出 user_id 匹配的记录,最后取 Top-5 送给模型。

这种方案的问题在于,它把隔离责任完全交给了业务代码。只要有一个查询接口忘了加过滤条件,或者过滤条件写成了user_id != current_user这种反向逻辑,就会出问题。而且从性能角度看,全量检索再过滤,等于让数据库做了大量无用功。数据量小的时候感知不明显,一旦向量表到了百万级,每次查询都扫全表,慢 SQL 是必然的。

我早期做过一个内部工具,就是这种模式。当时只有三个用户,没觉得有问题。后来用户涨到几十个,检索延迟从 200ms 涨到 2s 以上,排查后发现瓶颈就在这个“先全量再过滤”的逻辑上。更尴尬的是,有一次改过滤条件时手滑,把==写成了!=,导致用户看到了别人的内容,虽然及时发现没造成实际影响,但那次之后我就彻底放弃了应用层过滤做主隔离。

2.2 数据库行级隔离:用 user_id 做硬约束

更稳的做法是把隔离下沉到数据库层。核心思路是:每一条向量记录都绑定 user_id,检索时在 SQL 层面直接带上 user_id 条件。这样隔离逻辑和查询逻辑是一体的,不存在“忘了过滤”的可能。

在 pgvector 场景下,这意味着你的表结构大概长这样:

CREATE TABLE knowledge_chunks ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL, doc_id BIGINT NOT NULL, chunk_text TEXT NOT NULL, embedding VECTOR(1536), created_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX idx_chunks_user_id ON knowledge_chunks(user_id);

检索时这样写:

SELECT id, chunk_text, embedding <=> $1 AS distance FROM knowledge_chunks WHERE user_id = $2 ORDER BY embedding <=> $1 LIMIT 5;

注意这里的WHERE user_id = $2是写在向量排序之前的。pgvector 会先按 user_id 过滤,再在过滤后的子集里做相似度计算。这样既保证了隔离,又避免了全表扫描。前提是 user_id 上有索引,否则过滤本身也会慢。

2.3 物理隔离:独立 schema 或独立表

如果租户数量可控、且对隔离要求极高,可以考虑每个租户一张表,或者每个租户一个 schema。这种方案隔离最彻底,但运维成本高。租户数量一多,表数量爆炸,迁移和备份都会变得复杂。

我的建议是:中小规模用行级隔离(user_id 字段),大规模且租户边界清晰的场景再考虑物理隔离。行级隔离配合正确的索引,在千万级数据量下性能依然可控。物理隔离更适合那种“租户之间绝对不能共享任何存储资源”的合规场景。

隔离层级实现方式隔离强度性能影响适用场景
应用层过滤代码筛选低高(全量检索)单用户或原型验证
行级隔离user_id 字段 + SQL 条件中高低(索引过滤)大多数多租户场景
物理隔离独立表/schema最高中(连接管理复杂)强合规要求

3. pgvector 下的检索隔离:SQL 怎么写才既安全又快

选定了行级隔离之后,真正的挑战在于 SQL 的写法。很多人以为加个WHERE user_id = ?就完事了,但实际跑起来会发现,查询计划可能根本不走索引,或者向量索引和标量过滤的配合有问题。

3.1 向量索引与标量过滤的执行顺序

pgvector 支持两种向量索引:IVFFlat 和 HNSW。这两种索引在配合标量过滤时,行为不太一样。

IVFFlat 是聚类索引,查询时会先找到最近的几个聚类中心,再在聚类内部做精确计算。如果你加了WHERE user_id = ?,PostgreSQL 的查询规划器有两种选择:一是先用 user_id 索引过滤,再在结果集上做向量排序;二是先用向量索引召回,再过滤 user_id。哪种更快,取决于数据分布。

如果某个用户的文档特别少,比如只占全表的 1%,那先用 user_id 过滤再排序会快很多。但如果每个用户的数据量都差不多,且总量很大,向量索引召回后再过滤可能更优。问题是,向量索引召回后再过滤,会导致召回数量不足。比如你LIMIT 5,向量索引返回了 5 条,结果其中 3 条属于别的用户,被过滤掉后只剩 2 条,最终结果就不完整。

解决办法是:在向量检索时设置一个更大的候选集,过滤后再取最终 Top-K。但更推荐的做法是让 PostgreSQL 直接用 user_id 过滤,这需要你确保查询规划器选择了正确的执行路径。

3.2 实测有效的 SQL 模板与参数调优

我在实际项目中用的 SQL 模板是这样的:

-- 确保 user_id 索引存在 CREATE INDEX CONCURRENTLY idx_chunks_user_id ON knowledge_chunks(user_id); -- 检索查询 SELECT id, doc_id, chunk_text, embedding <=> $1 AS distance FROM knowledge_chunks WHERE user_id = $2 ORDER BY embedding <=> $1 LIMIT $3;

关键点在于ORDER BY embedding <=> $1和WHERE user_id = $2的配合。为了让规划器优先走 user_id 过滤,可以适当调大LIMIT,或者用 CTE 强制先过滤:

WITH user_chunks AS ( SELECT id, doc_id, chunk_text, embedding FROM knowledge_chunks WHERE user_id = $2 ) SELECT id, doc_id, chunk_text, embedding <=> $1 AS distance FROM user_chunks ORDER BY embedding <=> $1 LIMIT $3;

CTE 的写法在 user_id 过滤性很强的时候效果很好,但如果某个用户数据量极大,CTE 物化反而会增加开销。我的经验是:先用 EXPLAIN ANALYZE 看执行计划,确认是否走了 user_id 索引,再决定用哪种写法。

另外,pgvector 的ivfflat.probes参数也值得调。默认值是 1,意味着只探测最近的 1 个聚类。如果你的 user_id 过滤后数据分布在不同聚类里,probes 太小会导致召回不全。可以适当调到 10 左右,但会增加查询时间。这个需要根据实际数据分布做权衡。

3.3 一个容易被忽略的坑:向量维度与索引类型不匹配

还有个小坑值得提一下。pgvector 的 HNSW 索引对维度有限制,超过 2000 维就不支持了。如果你用的是 1536 维的 embedding(比如某些常见模型),HNSW 是可以的。但如果你用了 3072 维的模型,就只能用 IVFFlat,或者做降维。

我有一次帮朋友排查问题,他的检索结果一直不准,最后发现是建索引时用了 HNSW,但 embedding 维度是 3072,索引根本没建成功,查询走的是全表扫描。所以建完索引后,一定要用\d+ 表名确认索引状态,别假设它一定生效了。

4. 从上传到检索:一条完整的 user_id 贯穿链路

隔离不是只在检索时做文章,它必须贯穿整个数据生命周期。从用户上传文档、切块、生成 embedding、入库,到最终检索,每一步都要带着 user_id。任何一环丢了,隔离链条就断了。

4.1 文档入库时 user_id 的传递与校验

文档上传的典型流程是:用户上传文件 → 解析文本 → 切块 → 生成 embedding → 写入向量表。在这个过程中,user_id 应该从请求上下文里取,而不是从请求体里读。为什么?因为请求体是可以被篡改的。如果 user_id 来自前端传参,恶意用户完全可以传别人的 user_id 来写入数据。

正确的做法是:user_id 从认证令牌或会话中解析,在服务端注入到入库逻辑里。比如在 FastAPI 里,你可以用依赖注入拿到当前用户:

from fastapi import Depends, UploadFile async def upload_document( file: UploadFile, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): user_id = current_user.id # 从认证信息取,不从前端取 chunks = split_document(file) for chunk in chunks: embedding = embed(chunk) db.execute( "INSERT INTO knowledge_chunks (user_id, doc_id, chunk_text, embedding) VALUES ($1, $2, $3, $4)", user_id, doc_id, chunk, embedding )

这里的关键是current_user.id来自认证层,前端无法伪造。同时,在入库前最好再做一次校验:确认这个 user_id 确实有权限写入该知识库。如果系统里有“共享知识库”的概念,那还需要额外的权限表来管理。

4.2 切块与 embedding 阶段如何避免跨租户污染

切块和 embedding 阶段本身不涉及租户隔离,但有一个隐患:批量处理时的上下文混淆。如果你用异步任务队列处理多个用户的文档,任务之间如果没有做好隔离,可能会出现 A 用户的文档块被写入 B 用户的记录里。

这种情况通常发生在任务参数传递错误,或者数据库连接复用时事务边界不清。我的做法是:每个上传任务都携带明确的 user_id 和 doc_id,入库时以这两个字段作为联合约束。同时在数据库层面加一个唯一约束,防止同一个 doc_id 被重复写入不同 user_id:

ALTER TABLE knowledge_chunks ADD CONSTRAINT unique_doc_chunk UNIQUE (user_id, doc_id, chunk_index);

这样即使任务参数传错,数据库也会拒绝写入,避免脏数据。

4.3 检索请求中的 user_id 注入与二次校验

检索阶段是隔离的最后一道防线。除了 SQL 里的WHERE user_id = ?,我建议在应用层再做一次二次校验:对检索结果逐条检查 user_id 是否匹配。这看起来是冗余的,但能防止 SQL 写错或参数绑定错误导致的越权。

def retrieve(query: str, user_id: int, top_k: int = 5): embedding = embed(query) rows = db.execute( "SELECT id, user_id, chunk_text FROM knowledge_chunks WHERE user_id = %s ORDER BY embedding <=> %s LIMIT %s", (user_id, embedding, top_k) ).fetchall() # 二次校验 for row in rows: if row["user_id"] != user_id: raise SecurityError("租户隔离校验失败") return rows

这个二次校验的成本很低,但能在关键时刻兜底。我宁愿多写这几行代码,也不想在某天收到“用户看到了别人数据”的告警。

5. 性能与安全的平衡:索引、缓存与慢查询治理

多租户 RAG 的检索性能,很大程度上取决于索引设计和查询模式。user_id 过滤虽然保证了安全,但也给查询优化带来了新的约束。

5.1 user_id 索引与向量索引的协同

前面提到,user_id 上要有 B-tree 索引,向量字段上要有 IVFFlat 或 HNSW 索引。但这两个索引怎么协同,是个问题。PostgreSQL 在一次查询里通常只能用一个索引(除非做 bitmap 合并,但向量索引不支持 bitmap)。

所以实际执行时,规划器会二选一:要么走 user_id 索引过滤后再排序,要么走向量索引召回后再过滤。哪种更好,取决于 user_id 的选择性。如果每个用户的数据量占全表的比例很小(比如 0.1%),走 user_id 索引更优。如果每个用户数据量占比很大(比如 10%),走向量索引可能更优。

我的建议是:用 EXPLAIN ANALYZE 实测两种路径的耗时,然后通过调整查询写法或索引来引导规划器。如果发现规划器选错了,可以用 CTE 强制先过滤,或者调整random_page_cost等参数。

5.2 检索结果缓存:按 user_id 分区的缓存策略

RAG 检索的另一个优化点是缓存。同一个用户可能会反复问相似的问题,如果每次都要走一遍向量检索,浪费资源。可以加一层缓存,key 用user_id + query_hash,value 存检索结果。

但缓存必须按 user_id 分区,不能全局共享。否则 A 用户的检索结果可能被 B 用户命中,直接导致越权。我用 Redis 做缓存时,key 的格式是rag:retrieve:{user_id}:{query_hash},并且设置较短的过期时间(比如 5 分钟),避免用户更新知识库后缓存不一致。

cache_key = f"rag:retrieve:{user_id}:{hashlib.md5(query.encode()).hexdigest()}" cached = redis.get(cache_key) if cached: return json.loads(cached) # 走检索逻辑 result = retrieve(query, user_id) redis.setex(cache_key, 300, json.dumps(result))

5.3 慢 SQL 排查:当 user_id 过滤拖慢了查询

加了 user_id 过滤后,如果发现查询变慢,第一件事是看执行计划。常见原因有几个:user_id 索引没建、索引失效(比如类型不匹配)、或者数据分布倾斜导致某个用户的数据特别多。

数据倾斜是个容易被忽略的问题。如果某个用户上传了海量文档,他的 user_id 对应的数据量远超其他用户,那么走 user_id 索引过滤后,数据量依然很大,向量排序就会慢。这种情况可以考虑对该用户做额外的分区,或者限制单用户的知识库容量。

另外,ORDER BY embedding <=> $1这个操作本身是计算密集型的。如果过滤后的数据量还是很大,可以考虑先用向量索引召回一个候选集,再在候选集里做精确排序。但这又回到了召回数量的问题。我的经验是:在召回数量和查询延迟之间找一个平衡点,通常候选集设为最终 Top-K 的 10 到 20 倍比较合适。

6. 几个真实踩过的坑与排查思路

理论讲完了,说几个我在实际项目里踩过的坑。这些问题的共同点是:表面看是检索不准,根因却在隔离逻辑或数据建模上。

6.1 坑一:user_id 类型不一致导致索引失效

有一次检索突然变慢,排查后发现是 user_id 的类型问题。数据库里 user_id 是 BIGINT,但应用层传参时用了字符串。PostgreSQL 在比较 BIGINT 和 TEXT 时,会做隐式类型转换,导致 user_id 索引失效,查询走了全表扫描。

这个问题的隐蔽性在于,查询结果是对的,只是慢。如果不是特意去看执行计划,很难发现。解决办法很简单:确保应用层传参类型和数据库字段类型一致。在 Python 里,用 int(user_id) 显式转换一下,或者在 ORM 层做好类型映射。

6.2 坑二:软删除与租户隔离的冲突

很多系统用软删除,给记录加一个deleted_at字段。如果检索时忘了加deleted_at IS NULL,已删除的文档还会被检索到。更麻烦的是,如果软删除逻辑和 user_id 过滤逻辑写在一起,容易出现逻辑漏洞。

比如这样的 SQL:

SELECT * FROM knowledge_chunks WHERE user_id = $1 AND deleted_at IS NULL ORDER BY embedding <=> $2 LIMIT 5;

看起来没问题,但如果某个文档被软删除后,user_id 被改成了 NULL(有些删除逻辑会这样做),那这条记录就永远不会被任何用户检索到,也不会被清理。时间长了,表里全是垃圾数据。

我的做法是:软删除时保留 user_id,只标记 deleted_at,检索时统一加过滤条件。同时定期清理已删除超过一定时间的记录,避免表膨胀。

6.3 坑三:批量检索时的租户混淆

最后一个坑是关于批量检索的。有些场景下,系统需要同时为多个用户做检索(比如管理员看板)。如果批量检索时没有为每个用户单独构造查询,而是把所有 user_id 混在一起查,就会导致结果混淆。

正确的做法是:批量检索时,为每个 user_id 单独执行一次检索,或者在 SQL 里用WHERE user_id = ANY($1)并确保结果按 user_id 分组。千万不要图省事,把所有用户的数据混在一起做向量排序。

# 错误做法:混在一起查 all_results = db.execute( "SELECT * FROM knowledge_chunks WHERE user_id = ANY(%s) ORDER BY embedding <=> %s LIMIT %s", (user_ids, embedding, top_k * len(user_ids)) ) # 正确做法:按用户分组 results = {} for uid in user_ids: results[uid] = db.execute( "SELECT * FROM knowledge_chunks WHERE user_id = %s ORDER BY embedding <=> %s LIMIT %s", (uid, embedding, top_k) )

批量场景下,性能确实会受影响,但安全优先。如果确实需要优化,可以考虑用并行查询,或者为每个用户维护独立的向量索引。

7. 多租户 RAG 的边界与后续扩展方向

多租户 RAG 的隔离做到行级,对大多数场景已经够用了。但如果你的系统要支持更复杂的场景,比如共享知识库、团队协作、跨租户检索,那就需要在 user_id 之外再加一层权限模型。

比如“团队知识库”的场景,一个文档可能属于某个团队,团队内所有成员都能检索。这时候隔离维度就从 user_id 变成了 team_id,或者 user_id + team_id 的组合。SQL 里的过滤条件也要相应调整:

WHERE (user_id = $1 OR team_id = $2)

这种 OR 条件对索引不太友好,可能需要拆成两个查询再合并,或者用 UNION。具体怎么选,要看数据分布和查询频率。

另一个扩展方向是检索结果的权限后处理。有些系统里,文档本身有密级,即使用户有权限检索,也要根据密级过滤内容。这已经超出了多租户隔离的范畴,属于更细粒度的访问控制。如果要做,建议在检索结果返回前加一层策略引擎,根据用户角色和文档属性做过滤。

我在实际项目里的体会是:隔离逻辑越早做越好,不要等到用户多了再补。早期多花一天设计表结构和查询模板,后期能省掉无数排查越权问题的时间。而且隔离这件事,一旦出问题就是数据泄露,代价远高于性能优化。所以宁可牺牲一点检索速度,也要把 user_id 贯穿到每一条 SQL 里。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进