Files

90 lines
5.0 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 在事务里替换,内容哈希没变的块不重新嵌入。”