Chapter 02
数据与租户:schema 怎么排、租户怎么隔
01 把四个面钉成词汇——这一章展开前两个面:数据怎么组织成 entity、租户怎么在一套 Milvus 里隔离,以及标量字段和过滤比如何反过来约束索引选型。
本章你将建立的 schema
- 一个 chunk 怎么落成 Milvus 的一行 entity:哪些是向量、哪些是过滤用的标量、哪些塞进 dynamic field。
- 多租户的四级隔离(Database / Collection / Partition / Partition Key),以及"可扩展性与隔离强度正好相反"这条主线。
- 标量字段与标量索引的取舍:要 filter 的字段必须建索引,按基数选 BITMAP / INVERTED / range。
- 过滤比↔索引的反直觉收口:极严的 filter 为什么饿死 HNSW,而 IVF 类更扛得住。
2.1RAG 的 schema:把 chunk 落成 entity
一个 chunk 就是 Milvus 里的一行 entity:主键 + 向量 + 一段原文 + 一组用来过滤的标量字段;schema 设计的本质,是预先想清楚"检索时要按什么条件切、答案要回溯到哪份原文"。
demo 往往只存 [id, vector] 两列,召回回来就直接拼进 prompt。生产要回答三件事:召回的这段话来自哪份文档(引用、去重、删整篇)、它能不能被当前查询的条件过滤掉(租户/时间/权限)、原文怎么取回喂 LLM。这三件事都要在写入时就铺成字段,事后补字段意味着重灌库。
一行 entity 的典型字段:pk(主键,自增或外部 ID)、dense(语义向量)、可选的 sparse(稀疏向量,给 BM25/全文检索用,03 章的混合检索靠它)、text(VARCHAR,存 chunk 原文,既喂 LLM 也是 BM25 的分词源)、parent_doc_id(指回整篇文档)、一组过滤用标量(tenant_id / timestamp / source / acl_tags),以及一个 dynamic field(动态字段 $meta,JSON,装那些零散、不稳定的元数据)。
比文档深一层:三个容易被默认值带过去的点。其一,分块发生在 Milvus 之外——切分策略、chunk 大小、重叠都是在 LangChain 或自建管道里决定的,Milvus 只负责存切好的结果,所以"召回质量差"很多时候根因在分块、不在索引。其二,parent_doc_id 不是可有可无的装饰:有了它才能"删整篇文档的所有 chunk"(按 parent_doc_id 批量 delete)和"取相邻块"(small-to-big:命中小 chunk,回查同一 parent 的前后块补上下文);没存它,删一篇文档就得逐 chunk 找、上下文也拼不回来。其三,过滤字段必须是显式标量字段——同一个值塞进 $meta 这个 JSON 里也能过滤,但 JSON 路径过滤要解析、要走通用扫描,比独立标量列上的索引慢一截;凡是高频 filter 的维度,提成显式列。
parent_doc_id 让你能删整篇、取相邻块;凡是要 filter 的维度都得是显式标量列——藏进 $meta JSON 里能查但更慢。有人把 source、lang、tenant_id 全塞进 $meta 这个 JSON 动态字段,理由是"以后加字段不用改 schema"。检索时按这三个维度过滤,会有什么代价?
展开答案(先停 10 秒)
过滤会变慢。dynamic field 存的是 JSON,按 $meta["source"] 这种路径过滤要先解析 JSON、再匹配,且默认走的是通用扫描而非独立标量列上的专用索引。高频过滤维度(尤其 tenant_id 这种几乎每条查询都带的)应当提成显式标量字段并建索引;$meta 留给那些稀疏、低频、形态不稳定的元数据。"不用改 schema"换来的是每次查询都付的过滤税。
与下一节的关系:schema 决定了一行里有哪些字段;其中 tenant_id 这个字段怎么用来切分租户,正是下一节四级隔离要展开的——它既可以只是个普通过滤列,也可以被提升成 partition key。
2.2多租户四级隔离:从物理到逻辑的四档
在一套 Milvus 上隔离多个租户有四档——Database、Collection-per-tenant、Partition-per-tenant、Partition Key(按 tenant_id 分区);从上到下隔离越来越弱、可扩展性越来越强,选型由租户数量主导。
这是 01 面①(租户隔离)落地时的第一个硬决策,也是本章最长的一节。选错了不是性能问题而是结构问题:用 Collection-per-tenant 撑十万租户会撞上容量墙,用一个大 collection 装两个需要物理隔离的大客户又混在一起删不干净。四档各有上限和隔离强度,必须按租户规模和合规要求对号入座。
四档的机制底座是 深潜 01 的数据模型(partition vs shard)。逐档看:
Database——最强的逻辑隔离单元,不同 database 之间近乎物理隔离、RBAC 也能按 database 授权。但每个集群的 database 数量默认上限是 64(maxDatabaseNum)。适合"少数几个彼此完全独立的大业务线/大客户",撑不了海量租户。
Collection-per-tenant——每个租户一个 collection,隔离强、可独立建索引、可单独 drop。配置项上 collection 数量上限写着 65536,但这是个配置值不是真实墙:官方实测在约 10000 个 collection 量级就开始劣化,建议控制在 5000 以内。更隐蔽的是,有效负载是 collection × shard × partition 一起计入 maxGeneralCapacity 这个总预算——partition 会悄悄吃掉 collection 的额度。适合"数百到数千个、需要强隔离的中大客户"。
Partition-per-tenant——一个 collection,每个租户一个 partition。比 collection 轻,但单个 collection 的 partition 数量硬上限是 1024。适合"上千以内、且能共用同一份 schema 的租户",超过 1024 就此路不通。
Partition Key——一个 collection,把 tenant_id 声明为 partition key,Milvus 自动按哈希把每个租户的数据分散进 num_partitions 个物理分区(默认 64)。没有硬上限,可达百万级租户。代价是隔离最弱(所有租户物理上同处一个 collection、共享分区),且带一串硬限制(见下文 callout)。这是海量小租户 RAG 的默认选择。
比文档深一层——记住这条主线:四档的可扩展性是 Partition Key > Partition > Collection > Database,而隔离强度正好反过来:Database > Collection > Partition > Partition Key。租户越多、越要往可扩展那头走,单租户能拿到的隔离就越弱——这不是某一档的缺陷,而是这条轴上无法回避的权衡。决策因此几乎只问一个问题:有多少租户、要多强的隔离。
| 级别 | 隔离强度 | 规模上限 | 何时用 |
|---|---|---|---|
| Database | 最强(近物理 + RBAC) | 64 / 集群(maxDatabaseNum) |
少数完全独立的大业务线 / 大客户 |
| Collection-per-tenant | 强(独立索引 / 可单独 drop) | 实测约 10000、建议 <5000(配置写 65536) | 数百~数千个需强隔离的中大客户 |
| Partition-per-tenant | 中(同 collection 内逻辑分区) | 1024 / collection(硬上限) | ≤1000 且共用一份 schema 的租户 |
| Partition Key | 弱(同 collection、靠应用层 filter) | 无硬上限(~百万,默认 64 分区) | 数千~百万小租户 RAG 的默认 |
Partition Key 的路由机制:把 tenant_id 设为 partition key 后,Milvus 用它哈希到 num_partitions 个分区。关键约束是——查询必须带 tenant_id == "A" 这样的等值过滤,才会触发分区裁剪(深潜 04 的分区裁剪),只扫该租户落到的那个分区;不带这个 filter,就退化成全 collection 扫描,既慢又跨了租户。2.5.4 起还有个 Partition Key Isolation(partitionkey.isolation=true):给每个 key 值建独立索引,进一步提升单租户查询效率——但它要求 filter 里正好一个 key 值,一旦写成 tenant_id IN ["A","B"] 或多租户聚合查询,隔离就被破坏、回退普通扫描。
选了 partition-key 方案,要预先吞下三条限制:① 禁止 bulk import——只能逐行(或批量)insert,没有批量导入快路径,初始灌库会更慢。② 无法只 load 单个租户——load() 是整个 collection 一起加载进内存,不能按租户选择性加载,内存占用随总数据量走。③ RBAC 管不到 collection 内部——回指 01 面①:权限只到 collection / database 粒度,partition-key collection 内部的租户边界零数据库级保护,完全靠应用层那个 tenant_id filter 强制。漏写一次就是跨租户泄漏。
一个 SaaS 用 Collection-per-tenant,每个租户一个 collection,目前 800 个租户、跑得很顺。产品要求每个租户的数据再按"部门"分隔,于是给每个 collection 加了 30 个 partition。为什么这个看似局部的改动会让集群逼近容量墙?
展开答案(先停 10 秒)
因为有效负载是 collection × shard × partition 一起计入 maxGeneralCapacity,partition 会悄悄吃掉总预算。原来 800 个 collection 还算宽裕,加了 partition 后有效单元数变成 800 × shard × 30,量级骤增,足以直接撞上容量上限或显著劣化。"collection 数量没变、只是加了分区"是错觉——分区不是免费的逻辑标签,它和 collection 抢同一个全局预算。这也是 Collection-per-tenant 难以再叠一层细分的原因。
2.3元数据与标量索引:要 filter 的字段就要建索引
显式标量字段装高频过滤维度、dynamic field 装零散元数据;铁律是——任何你要 filter 的字段都得建标量索引,否则 filter 退化成全表扫描,把向量索引的加速整个抵消掉。
生产里几乎每条查询都带 filter(租户、时间、来源、权限)。如果过滤字段没有标量索引,Milvus 得逐行求值这个条件——相当于全表扫一遍标量列,再在结果上做向量检索。向量索引把检索从 O(N) 降到近似 O(logN) 的努力,会被这个全扫直接吃掉。标量索引选型不是锦上添花,是让 filter 不拖垮整条检索的前提。
显式标量字段 vs dynamic field:判据是"过滤频率 + 维度是否稳定"。tenant_id、timestamp、source、acl_tags 这类几乎每条查询都用、形态固定的,做成显式标量列并建索引;那些稀疏出现、形态多变、低频过滤的元数据(某些来源独有的标签、调试信息),塞进 dynamic field($meta JSON)即可。提成显式列的代价是 schema 要预先定义、改动要重灌;换来的是过滤走专用索引而非 JSON 路径解析。
比文档深一层——按基数选索引类型:标量索引不是只有一种,选错类型同样拖慢过滤。基数(cardinality,不同取值的个数)是主判据:
| 索引类型 | 适用基数 / 场景 | 典型字段 | 机制要点 |
|---|---|---|---|
| BITMAP | 低基数类别(取值数 ≲ 数千) | source · lang · category |
每个取值一个位图,等值/集合过滤极快;高基数会爆位图、不适用 |
| INVERTED | 高基数 / JSON 路径 / 字符串 | acl_tags · $meta 路径 · 长尾 ID |
倒排索引,通用、基数无上限;是高基数标量与动态字段过滤的主力 |
| STL_SORT / range | 数值范围查询 | timestamp · 价格 · 评分 |
有序结构,>= / <= / between 范围过滤高效("最近 90 天"靠它) |
低基数类别选 BITMAP,高基数或 JSON 路径选 INVERTED,数值范围选 STL_SORT/range。错配的典型:给 tenant_id(百万基数)建 BITMAP——位图数量爆炸;给 timestamp 建 BITMAP——范围查询无法利用有序性,退化成逐位图求并。字段语义先确定,再挑索引。
与下一节的关系:标量索引让 filter 本身快起来;但即使 filter 飞快,它和向量检索叠加时还有一层更深的反直觉——过滤掉的行越多,向量索引(尤其图索引)反而越容易召不回来。这就是下一节要收口的 01 面④。
2.4过滤比↔索引:极严的 filter 会饿死图遍历
过滤比越高(通过的行越少),越伤 HNSW 的图遍历——图在被过滤掉的点之间走不动、凑不齐连通候选,召回反而塌;这是 01 面④反直觉点的机制收口,也直接影响索引选型。
生产里高选择性 filter 很常见:"这个租户 + 最近 7 天 + 某来源"叠起来,往往只剩全库万分之一的行通过。从关系库直觉来的人以为这会让检索更快更准,实际在图索引上会让召回从 95% 掉到个位数——而且不报错,只是答案变差。这条决定了高选择性场景该选哪类向量索引,必须在 schema/索引阶段就考虑。
比文档深一层——为什么是图索引塌:机制底座是 深潜 03 的过滤比→索引。HNSW 是靠"在邻居图上贪心游走"找近邻的:从入口点出发,每步跳到离 query 更近的邻居。当 filter 极严,图上绝大多数点都被判出局,游走时从一个通过的点跳不到下一个通过的点——邻居全被过滤掉了,搜索要么提前困死、要么扫遍全图也凑不齐 top-k,召回因此塌。IVF 类(先聚类分桶、桶内暴力扫)受影响小得多:它本来就是在选中的桶里线性扫,过滤掉一部分行只是少扫几个,不破坏可达性。
Milvus 2.x 用 iterative filtering(可迭代过滤) 和 概率(alpha)策略缓解这个问题——大致是动态判断 filter 的选择性,过滤太狠时改变遍历/补采样策略,避免空手而归。但缓解不等于消除,选型规则不变:
| 维度 | HNSW(图索引) | IVF 类 / 预过滤+扫描 |
|---|---|---|
| 低过滤比(大多数行通过) | 最优:图遍历快、召回高 | 可用,但延迟通常不及 HNSW |
| 高过滤比(极少行通过) | 风险:图走不动、召回易骤降 | 更稳:桶内扫描不依赖图可达性 |
| 缓解手段 | iterative filtering / alpha 概率策略 | 预过滤后直接暴力扫小集合 |
| 选型倾向 | 默认主力,但要警惕高选择性查询 | 已知高选择性场景的更稳选择 |
2.5综合:给一个多租户 SaaS 客服 RAG 排 schema
把 2.1–2.4 叠到一个具体场景——数千租户、每租户文档带来源和日期、查询按"租户 + 最近 90 天"过滤——一次决定隔离级别、标量字段、各字段索引,以及那个叠加 filter 的选择性如何反推向量索引。
四个决策从来不是独立的:隔离级别决定 tenant_id 是普通列还是 partition key;标量字段决定能过滤什么;索引类型决定过滤快不快;过滤比又反过来约束向量索引。逐项最优拼起来未必整体对——只有把它们放到同一条查询上,才看得出相互牵制。
场景:SaaS 客服知识库,数千个租户,每个租户有自己的文档,文档带 source(来源系统)和发布日期;典型查询是"在本租户、最近 90 天的文档里,找和问题相关的内容"。逐面落地:
① 隔离级别——数千租户、无强物理隔离/合规要求、共用一份 schema。Database(≤64)和 Partition(≤1024)都装不下数千,Collection-per-tenant 数千个已逼近建议上限且运维重。落到 Partition Key(图 2.2 的默认终点):把 tenant_id 设为 partition key。代价照单接收——初始灌库不能用 bulk import、load 整个 collection、RBAC 管不到内部,租户隔离靠应用层强制 tenant_id == 当前租户。
② schema 放哪些标量字段——除向量和 text 外,显式标量列至少要有:tenant_id(partition key,也是隔离边界)、timestamp(文档日期,给"最近 90 天")、source(来源过滤/统计)、parent_doc_id(删整篇、取邻块)。零散的额外元数据进 $meta。
③ 各建什么标量索引——按 2.3 的基数判据:timestamp 用 STL_SORT/range("最近 90 天"是范围查询);source 基数低(就那么几个来源系统)用 BITMAP;tenant_id 作为 partition key 由分区机制承载路由,若还要在过滤里直接命中,按其高基数用 INVERTED。
④ 叠加 filter 的选择性 → 向量索引——查询里 tenant_id == X 先触发分区裁剪,把范围缩到该租户那个分区;再叠 timestamp >= now-90d。在单个租户的数据量内,"最近 90 天"未必很严(90 天往往是大多数文档),所以叠加后的过滤比对该租户通常是中等而非极端——HNSW 一般扛得住。但对那些只有几篇文档、且 90 天内更少的小租户,叠加后往往只剩个位数行通过:这正是 2.4 的高过滤比陷阱,对这类租户的查询要么靠 iterative filtering 兜底、要么对极小集合直接暴力扫。关键不是全局选一种索引,而是认识到同一套 filter 在大租户和小租户上的选择性天差地别,召回风险集中在尾部小租户。
"Partition Key → tenant_id 必须出现在每条 filter 里 → 分区裁剪先收窄 → 再叠 timestamp 范围 → 选择性因租户大小而异 → 召回风险落在尾部小租户"。这条链上任一环改了,下游都跟着变。这也是 04 章辨析题的形态:给场景、判断这条链哪里会断。
§本章 self-check
先合上教程,把答案写下来再展开对照。
- 一个 chunk 落成 entity 后,
parent_doc_id这个字段不存会失去哪两种能力? - 四级隔离里,为什么说"加 partition 会让 Collection-per-tenant 撞容量墙"?预算是怎么算的?
- Partition Key collection 的三条硬限制是什么?其中哪条直接关系到租户安全?
- 设计题:一个 RAG 要按
source(约 8 个取值)和timestamp(范围)过滤,各建什么标量索引?若再叠成极高过滤比,向量索引该偏向哪类、为什么?
答案(先做完再展开)
- 失去"删整篇文档的所有 chunk"(按
parent_doc_id批量 delete)和"取相邻块"(命中小 chunk 后回查同 parent 的前后块补上下文)。 - 有效负载是 collection × shard × partition 一起计入
maxGeneralCapacity,partition 会悄悄吃总预算;collection 数没变但有效单元数随 partition 倍增,逼近上限。 - ① 禁止 bulk import(只能逐行 insert);② 无法只 load 单租户(load 整个 collection);③ RBAC 管不到 collection 内部。第三条直接关系租户安全——隔离只能靠应用层
tenant_idfilter。 source低基数 → BITMAP;timestamp范围 → STL_SORT/range。叠成极高过滤比时偏向 IVF 类或预过滤+扫描:HNSW 的图遍历在被过滤掉的点之间走不动、召回塌,而桶内扫描不依赖图可达性。
把一个"超级租户"塞进 Partition Key 方案
沿用 2.5 的数千租户 SaaS(已选 Partition Key)。现在来了一个超级大客户:单它一家的文档量超过其余所有租户之和,且合规要求它的数据物理隔离、可单独审计/删除。继续把它和别人混在同一个 partition-key collection 里,会出哪些问题?给一个分层方案。
提示(卡住再展开)
混在一起的问题:① 物理隔离/单独删除做不到(partition-key collection 内部无边界、不能选择性 load/drop 单租户);② 它的海量数据会让分区严重倾斜,num_partitions 哈希也压不平,拖累同 collection 其他租户;③ 合规审计粒度到不了单租户。分层方案:对这个超级租户单开一个 Collection(甚至 Database)——拿回强隔离/独立 drop/RBAC 授权;其余数千小租户继续走 Partition Key。即"长尾小租户用 Partition Key、少数大客户上 Collection/Database"的混合架构,正好沿图 2.2 那条隔离强度轴按租户分层取点。