--- id: ai.vector-db.scenario.multi-tenant-index title: 多租户知识库的向量索引:隔离、过滤召回和增量更新 type: scenario domain: ai stack: [vector-db, postgresql] tags: [多租户, 元数据过滤, 增量更新, 召回率, SaaS] level: advanced prerequisites: [ai.vector-db.selection-pgvector] est_hours: 2.5 related: [ai.rag.scenario.permission-aware-kb, ai.vector-db.interview] week: [6] --- # 多租户知识库的向量索引:隔离、过滤召回和增量更新 ## 业务背景与问题 一个面向中小企业的 SaaS 客服产品,每个企业(租户)上传自己的知识库。上线三个月后出现: 1. 一个小租户(2000 个块)提问,经常返回“没有找到相关资料”,但资料明明存在; 2. 一次代码改动漏了租户条件,A 公司的客服看到了 B 公司的产品价格; 3. 大租户每天更新上千篇文档,删除的文档还会被检索到; 4. 产品想换一个效果更好的嵌入模型,不知道怎么切换。 ## 约束 - 租户数千个,大小差异很大(几千到几百万块)。 - 数据存储在 PostgreSQL + pgvector,不想引入新的组件。 - 租户之间的数据绝对不能串。 ## 方案设计 1. **隔离方式按租户大小分层**: - 普通租户共用一张表,每行带 `tenant_id`; - 超大租户单独分区(PostgreSQL 声明式分区或单独的表),有自己的 HNSW 索引。 2. **问题 1(小租户召回不足)的原因**:全表 HNSW 索引先找全局近邻,再过滤 `tenant_id`,小租户的数据很少出现在全局候选中。解决:开启 pgvector 0.8 的迭代索引扫描 `SET hnsw.iterative_scan = relaxed_order`;或者对小租户直接走精确检索(`tenant_id` 索引过滤后计算距离,几千条很快)。 3. **问题 2(越权)**:租户条件不能依赖每个查询“记得加”。数据访问层封装统一的检索函数,`tenant_id` 是必填参数;数据库层用 **行级安全(RLS)** 兜底,连接时 `SET app.tenant_id`。 4. **问题 3(增量更新)**:每个块带 `doc_id`、`version`、`content_hash`;文档更新在一个事务里删除旧块、插入新块;文档删除同步删除块;内容哈希不变的块跳过重新嵌入,节省成本。 5. **问题 4(换嵌入模型)**:新增一列(或新表)存新模型的向量,后台批量回填,回填完成后对比评测集,再切换读路径,最后删除旧列。块表记录 `embed_model` 字段。 ## 关键代码 ```sql -- 行级安全:即使查询漏了 WHERE tenant_id,也只能看到当前租户的数据 ALTER TABLE chunks ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON chunks USING (tenant_id = current_setting('app.tenant_id')); -- 注意:表的所有者和超级用户默认绕过 RLS,应用要用单独的普通角色连接, -- 或者对表执行 ALTER TABLE chunks FORCE ROW LEVEL SECURITY。 -- 每个请求(事务)开始时设置租户 BEGIN; SELECT set_config('app.tenant_id', 'tenant-42', true); -- true 表示只在当前事务有效 SET LOCAL hnsw.iterative_scan = relaxed_order; -- pgvector 0.8+,过滤后结果不足时继续扫描 SELECT id, content FROM chunks ORDER BY embedding <=> $1 LIMIT 10; COMMIT; -- 文档更新:在一个事务里替换 BEGIN; DELETE FROM chunks WHERE tenant_id = $1 AND doc_id = $2; INSERT INTO chunks (tenant_id, doc_id, version, content, embedding) VALUES (...); COMMIT; ``` 过滤查询和召回率对比的可运行示例:[examples/vector-stores/pgvector_demo.py](../examples/vector-stores/pgvector_demo.py)。 ## 取舍 | 方案 | 优点 | 缺点 | | --- | --- | --- | | 共享表 + `tenant_id` 过滤 | 简单,租户多时管理成本低 | 过滤召回问题;越权风险靠 RLS 兜底 | | 每租户一张表 / 一个集合 | 隔离彻底,索引小 | 租户上千时表和索引数量爆炸 | | **分层(本方案)** | 兼顾 | 有两套路径,代码复杂一点 | | 独立数据库(大客户私有化) | 合规要求最高时 | 成本最高 | ## 易错点 - 用表所有者账号连接,RLS 不生效却以为生效了。 - `set_config(..., false)` 设置的是会话级变量,连接池复用连接时会串到下一个请求;应该用事务级(`true`)或每次请求都重新设置。 - 删除文档只删了数据库里的记录,没删向量库 / ES 里的块(多存储时)。 - 换嵌入模型时新旧向量混在一起检索。 - 只用大租户做测试,没发现小租户的召回问题。评测集要按租户规模分组。 ## 面试怎么讲 > “多租户向量检索我遇到过两个问题:一是小租户召回不足,原因是全局 HNSW 先找近邻再过滤,小租户的数据很少进入候选,我用 pgvector 0.8 的迭代扫描和小租户走精确检索解决;二是租户隔离,我在数据访问层强制要求 tenant_id,数据库层用行级安全兜底,并且注意连接池下要用事务级变量。增量更新按 doc_id 在事务里替换,内容哈希没变的块不重新嵌入。”