90 lines
5.0 KiB
Markdown
90 lines
5.0 KiB
Markdown
---
|
||
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 在事务里替换,内容哈希没变的块不重新嵌入。”
|