Chapter 01
核心概念:Milvus 的词汇表
起点页给了你一句话本质和概念地图——这一章把地图上的每个名词钉实:你写代码时直接操作的逻辑模型,和它背后的物理组织。读完,partition≠shard、growing≠sealed 这两条最常被混淆的边界会变得清晰。
本章你将建立的 schema
- 逻辑模型:Collection / Schema / 向量字段类型 / Entity / 主键 / 动态字段——你写代码时碰的那一层。
- 两条正交的切分轴:Partition(按业务字段裁剪查询)vs Shard(按主键哈希分流写入)。
- 物理单元 Segment 的一生:growing → sealed → flushed → indexed,以及 Flush 在其中的作用。
- 两个“留到后面深挖”的名词:Index / Metric(内核见 03)、一致性级别(机制见 04)。
这一章是词汇表,不是 API 手册。每个概念按同一套结构讲:一句话定义 → 为什么需要它 → 比文档深一层的机制 → 一个带边界的类比。代码片段只用来让概念落地,未在本机运行,按概念读、不必照抄。
1.1Collection、Schema、Entity
Collection 是 Milvus 里的一张表——一组共享同一 schema 的 entity 的集合。
向量检索要在“同构”的一批数据上做。Collection 圈定了字段结构(哪些是向量、维度多少、哪些是可过滤的标量)、相似度度量、索引边界。没有它,每条向量都是孤立的,无法批量建索引、无法带条件过滤。
比文档深一层的机制:Collection 本身只是元数据——schema 加配置,存在 etcd 里,不存一行数据。真正的数据被切成 segment 散落在对象存储,Collection 只是“把这些 segment 认作同一张表”的逻辑名义。所以建一个 Collection 几乎零成本(写一条元数据而已);往里写数据才触发 segment 分配、WAL 写入、异步建索引这一整套。这一点解释了一个常见困惑:建好 collection 立刻去查为什么是空的——因为 collection 的“存在”和数据的“存在”是两件事。
像关系库的表定义。边界在于:关系库的表通常对应一份连续存储,Milvus 的 Collection 对应的是一堆互相独立、各自带索引的 segment(见 §1.4)——没有“整表一个大索引”这回事。这个差别会一路影响到检索怎么执行(04 章)。
一个 entity 长什么样
一个 entity(一行)= 一个主键 + 一个或多个向量字段 + 任意标量字段。向量字段的类型不止 float32:
| 类型 | 用途 | 相容度量 |
|---|---|---|
FloatVector | 最常见的稠密 float32 嵌入 | L2 / IP / COSINE |
Float16 / BFloat16 | 半精度,省一半内存 | L2 / IP / COSINE |
Int8Vector | 量化嵌入,2.6 起一等公民 | L2 / IP / COSINE |
BinaryVector | 二值哈希指纹 | HAMMING / JACCARD |
SparseFloatVector | 稀疏嵌入 / 全文 BM25 | IP / BM25 |
主键可以设 AutoID 自动生成。一个容易栽跟头的细节:自动生成的是 snowflake 风格的大整数(按时间戳派生),不是 1、2、3 递增——别在代码里假设主键连续。动态字段($meta)让你在固定 schema 之外塞任意 JSON key,代价是这些字段上的过滤不如显式定义的字段高效。
# MilvusClient 是 2.4+ 的统一入口;旧的 ORM 风格 Collection() 已被取代
from pymilvus import MilvusClient, DataType
client = MilvusClient(uri="http://localhost:19530")
schema = client.create_schema(auto_id=True, enable_dynamic_field=True)
schema.add_field("pk", DataType.INT64, is_primary=True) # 主键:snowflake 大整数
schema.add_field("vector", DataType.FLOAT_VECTOR, dim=768) # 稠密向量字段
schema.add_field("tenant", DataType.VARCHAR, max_length=64) # 标量:可过滤、可做 partition key
client.create_collection("docs", schema=schema) # 只写一条元数据到 etcd,几乎零成本
dim=768维度写死在 schema 里,建表后不可改——换嵌入模型 = 换 collection。create_collection此刻对象存储里还没有任何 segment;数据要 insert 后才出现。
与下一个概念的关系:一张 collection 的数据可以再切分。切分有两个完全不同的维度——一个为了查询裁剪(partition),一个为了写入并行(shard)。下面两节分别讲,它们正交、且最常被混为一谈。
1.2Partition 与 Partition Key
Partition 是 Collection 内部按业务维度切出的逻辑子集,查询时可只扫相关 partition。
当数据天然带一个高频过滤维度(租户 ID、日期、地区),把它切成 partition,查询带上该维度就能跳过无关数据——这叫分区裁剪,省掉大量无谓的段扫描。
比文档深一层的机制:partition 不是另建一张表,而是给 segment 打上 partition 归属标签——一个 partition 拥有自己的一组 segment。查询若指定 partition,调度器直接不把其他 partition 的 segment 派给 query node 去搜。Partition Key 是它的自动版:指定某个标量字段为 partition key,Milvus 用它的哈希把 entity 自动路由到固定数量的 partition,省去手动建和选 partition。
像关系库的分区表。边界在于:Milvus 的分区裁剪只在“查询显式带上分区维度”时生效——不带,就退化成全 collection 扫描,partition 白切了。裁剪是查询侧主动用的,不是写进去就自动省。
与下一个概念的关系:partition 是“逻辑上把数据分组、给查询裁剪用”;下一个概念 shard 是“物理上把写入分流、给吞吐用”。两者正交——这是本章最重要的一组对照。
1.3Shard:与 Partition 正交的另一条轴
Shard 是 Collection 的水平写入分片,按主键哈希决定一条数据进哪个 shard,建表时定死、之后不可改。
单条写入流的吞吐有上限。把 Collection 切成 N 个 shard,每个 shard 对应一条独立的写入通道(vchannel),N 路并行写,写吞吐近似线性放大。
比文档深一层的机制:每个 shard 绑定一个 vchannel(逻辑写入流),vchannel 映射到底层 WAL 的 pchannel。一条 insert 先按 hash(主键) 选 shard → 进对应 vchannel → 落 WAL。所以 shard 数 = 写入并行度,建表时设定且事后难改,因为它绑定了底层日志流的分区,改 shard 数等于重排所有数据所属的日志流。
Kafka 的 partition ≈ Milvus 的 shard(都管并行、建后难改);而 Milvus 自己的 partition(§1.2)是另一回事——管查询裁剪、可动态增删。同一个词在两个系统里指相反的东西,面试和排查时最容易说错。
图 1.2 把这两条轴画成正交的二维网格:一条数据同时落在“某个 shard”(主键哈希决定)和“某个 partition”(业务字段决定)的交叉格里。
一个 Collection 设了 2 个 shard、3 个 partition。一条新数据写进来,它会落进几个 shard、几个 partition?整个 collection 的写入并行度是多少?
展开答案(先停 10 秒)
它落进 恰好 1 个 shard(由 hash(主键) 唯一决定)和 恰好 1 个 partition(由业务字段 / partition key 决定)。两者各自独立。写入并行度由 shard 数 = 2 决定,与 partition 数无关——partition 影响的是查询能裁掉多少,不是写得多快。
这正是正交的含义:调高吞吐要加 shard(且得建表时定),缩小查询范围要用 partition(可随时加)。
1.4Segment:数据真正的物理单元
Segment 是 Milvus 真正存数据的物理单元——一批 entity 的不可变文件,有 growing → sealed → indexed 的生命周期。
把一张大表切成定长 segment,每个 segment 能独立建索引、独立被某个 query node 加载、独立参与检索再合并结果。这让“建索引”“查询”“负载均衡”全部变成段级并行操作,而不是对整表上锁。
比文档深一层的机制:写入先进 growing segment(驻留在流式节点,可持续追加,此时查询只能暴力扫描它);达到大小或时间阈值后 sealed(密封、只读);sealed 段被 data node 持久化成 binlog 落对象存储(flushed);随后异步建向量索引(indexed)。删除不是原地改,而是写一条 tombstone 到 delta binlog,查询时把被删的过滤掉,空间要等 compaction 才回收。这一整套就是“segment 是统一调度单元”的含义——存储、索引、查询、负载均衡、节点间搬运,全部以 segment 为粒度。
像 LSM-tree 的 SSTable:不可变、追加写、后台合并。边界在于:SSTable 内部为范围查询排序,segment 内部为向量 ANN 建图或聚类——组织目标不同,但“不可变 + 后台 compaction”的骨架一致。学过 RocksDB / LevelDB 的话,这套节奏会很眼熟。
插入一条向量后立刻用它做检索,能命中吗?
展开答案(先停 10 秒)
不一定,而且“能不能命中”和“命中后快不快”是两个独立问题:
能不能看到:取决于一致性级别(§1.7 / 04 章)。默认 Bounded 下,刚写的数据可能短暂不可见。要立刻可见用 Session 或 Strong。
看到了快不快:新数据还在 growing 段,只能暴力扫描;要等它 sealed + indexed 才走向量索引。
这条问题把 §1.5 的 flush 和 §1.7 的一致性串了起来——记住它,04 章会回收。
1.5Flush
Flush 是把当前 growing segment 立即密封、并触发持久化的操作。
growing 段驻留在流式节点、只能暴力检索、且尚未以 binlog 形式落对象存储。flush 把它 seal 加持久化,推动它进入建索引流程。
比文档深一层的机制:flush 不等于“数据现在才安全”——数据在落 WAL 那一刻就已经持久(事实源是日志,02 章详述)。flush 真正做的是提前触发 growing → sealed → flushed 这步状态转换,把内存里的增量固化成 binlog。理解这点能避开一个真实的失败模式:手动 flush 过频会制造大量小 segment,反而加重后续 compaction 和查询时的段扇出开销。
别为了“让数据立刻可查”而每写一条就 flush()。可查与否由一致性级别和索引状态决定,不由 flush 决定;频繁 flush 只会制造小段风暴,拖慢整体。flush 是低频的固化动作,不是写入确认。
1.6Index 与 Metric Type
Index 是建在向量字段上、加速 ANN 检索的结构;Metric Type 是衡量两个向量“多近”的距离函数。
暴力比对每一条向量,在百万级以上不可行。索引用图(HNSW)或聚类(IVF)等结构,把“扫全部”变成“扫一小部分”,以一点召回率换数量级的速度。Metric 则定义“近”:L2 / IP / COSINE(稠密),HAMMING / JACCARD(二值),BM25(稀疏全文)。
比文档深一层的机制:索引是每个 sealed segment 各建一份,不是整个 collection 一份(呼应 §1.1 的“没有整表大索引”)。所以一次检索其实是“在每个相关 segment 上各跑一次索引检索,再把各段的 top-k 合并”——这条在 04 章会展开成完整读路径。这里只需记住一条硬约束:向量类型 + 索引类型 + 度量类型三者必须相容(例如 GPU 索引不支持 COSINE,要先归一化再用 IP)。索引内部怎么工作,留到 03 章。
像关系库的 B-tree 索引:用空间换查询速度。边界在于:B-tree 给精确查找,向量索引给近似查找——后者天生多一个“召回率”维度可调,这是关系库索引没有的旋钮,也是 03 章的主线。
与下一个概念的关系:索引决定“查得多快、多准”;下一个概念一致性级别决定“查得多新”。两者是检索质量的两个独立旋钮。
1.7Consistency Level
一致性级别决定一次检索能看到多新的数据,Milvus 提供 Strong / Bounded / Session / Eventually 四档。
分布式下“刚写的数据立刻可见”要付延迟代价(等所有写入对齐)。把这个取舍开放成 4 档,让能容忍轻微滞后的场景(RAG、推荐)用一点新鲜度换吞吐。
比文档深一层的机制:每条写入带一个 TSO 时间戳;每次检索带一个 guarantee timestamp,要求“看到所有 ≤ 该时刻的数据”。Strong 用最新时刻(等待对齐,最慢);Bounded 用一个略旧的容忍窗口(默认);Session 保证看到自己这个 client 的写入;Eventually 不等待,最快。时间戳怎么发、guarantee ts 怎么卡,是 04 章的内容。
默认是 Bounded,不是 Strong。从关系库来的工程师默认期待 read-your-writes,而 Milvus 默认给的是有界滞后——刚写的数据会有一个短暂的不可见窗口。要 read-your-writes 用 Session;要绝对最新用 Strong(付延迟)。
# 检索时显式指定一致性级别;不指定则用 collection 的默认(Bounded)
res = client.search(
collection_name="docs",
data=[query_vector],
limit=10,
filter='tenant == "acme"', # 标量过滤,配合 partition key 可触发裁剪
search_params={"metric_type": "COSINE", "params": {"ef": 64}}, # ef 是查询旋钮(03 章)
consistency_level="Session", # 想要 read-your-writes 就用 Session,不是默认
)
§本章 self-check
先合上教程,把答案写在纸上或编辑器里。写完再点开对照——直接点开等于把这一节当再读一遍。
- 一个 Collection 刚建好、还没写数据。此刻它在 etcd 里和在对象存储里,各占了什么?
- 是什么决定一条数据进哪个 shard?又是什么决定它进哪个 partition?这两者哪个能在建表后改、哪个不能?
- growing segment 和 sealed segment,在“能否被向量索引加速检索”这件事上有什么区别?
- 默认一致性级别是哪个?要让“刚写的向量在同一个 client 会话里立刻可见”,应该选哪个?
答案(先做完再展开)
- etcd 里有一条 schema + 配置元数据;对象存储里什么都没有——没有任何 segment,直到首次
insert。collection 的“存在”和数据的“存在”是两件事。 hash(主键)决定 shard;业务字段 / partition key 决定 partition。shard 数建表定死、不可改(绑定底层日志流分区);partition 可动态增删。- growing 段只能暴力扫描(还没建索引);要等 sealed → flushed → indexed 之后才走向量索引。所以刚写入的数据查起来更慢。
- 默认是 Bounded(有界滞后)。要同会话 read-your-writes,选 Session;要绝对最新选 Strong,但付延迟代价。
把两条轴叠在一起推一遍
一个 collection:2 个 shard,按日期做 partition key(每天一个 partition,目前 30 天 = 30 个 partition)。现在要查“最近 7 天、某用户的相似向量”。
(a) 理想情况下,检索会扫描哪些 partition 的 segment、哪些会被裁掉?(b) 这次写入历史数据时的并行度是多少?(c) 如果现在把 shard 从 2 改成 4,已经写好的 30 天数据会发生什么?
提示(卡住再展开)
(a) 分区裁剪要靠查询带上日期条件——带了,就只扫 7 个 partition,其余 23 个裁掉。(b) 写并行度看 shard 数,与 partition 数无关。(c) shard 绑定底层日志流分区、建表定死——想想“改 shard 数等于重排所有数据所属的日志流”意味着什么。