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