主题
Milvus 索引与向量检索
一句话:向量索引是用「少量召回损失」换「查询从全量扫描变成近似查找」,选型的本质是在内存、延迟、召回率三者之间选两个。
适用版本:Milvus 2.6.x。索引参数与默认值以官方 Index 文档为准。
为什么需要
100 万条 1536 维向量,暴力算余弦要 15 亿次乘加,单次查询几十毫秒到上百毫秒,QPS 一高就撑不住。
近似最近邻(ANN, Approximate Nearest Neighbor)的思路是:不保证找到「真正最近」的 K 条,只保证大概率找到。用这个概率上的让步,把查询复杂度从 O(N) 降到接近 O(log N)。
所以向量检索的指标永远是成对出现的:延迟 + 召回率。只谈「快」没有意义。
核心机制
三类索引,三种取舍
| 类型 | 代表 | 原理 | 内存 | 适合 |
|---|---|---|---|---|
| 图索引 | HNSW | 近邻图逐层跳转 | 高(图 + 原始向量常驻) | 低延迟、高召回,数据能进内存 |
| 量化索引 | IVF_FLAT / IVF_SQ8 / IVF_PQ / RaBitQ | 先聚类分桶,再在桶内比 | 低(可压缩到 1/4 甚至 1/32) | 数据大、内存敏感 |
| 磁盘索引 | DiskANN | 图存在 SSD,内存只放压缩向量 | 低 | 十亿级、有本地 NVMe |
HNSW 为什么快
HNSW(Hierarchical Navigable Small World)把点组织成多层图:上层稀疏、用于长跳,下层稠密、用于精查。查询从顶层入口点开始,每层贪心地走向更近的邻居,逐层下降,最后在底层做 beam search。
M:每个节点的最大出边数。越大图越密,召回越高,内存和建索引时间越高。常用 16–64。efConstruction:建索引时的候选队列长度。越大图质量越好,建索引越慢。常用 100–500。ef:查询时的候选队列长度,必须 ≥limit。它是唯一能在线上调的召回旋钮:调大召回升、延迟升。
HNSW 的代价:图结构 + 原始向量都要放内存,删除只能标记,长期高频删改会留下大量墓碑。
IVF 为什么省内存
IVF(Inverted File)先用 k-means 把向量聚成 nlist 个簇,查询时只扫最近的 nprobe 个簇。
nlist越大,簇越细,单簇越小,但训练成本和边界误差上升。nprobe越大,扫的簇越多,召回越高、延迟越高。
IVF_FLAT 桶里存原始向量;IVF_SQ8 每维压成 1 字节(约为 float32 的 1/4);IVF_PQ 把向量切成 m 段、每段用码本编码,压缩比更高但召回下降明显。
IVF 类索引需要训练数据,且新增数据分布漂移后要重建,否则簇边界失效。
量化:RaBitQ 与 2.6 的变化
RaBitQ 用 1 bit 表示每一维的方向,配合查询时的校正,把候选集快速缩小,再用精排(refiner)找回精度。它适合「数据量很大、内存放不下原始向量、又能接受一次重排开销」的场景。
选型顺序可以记成:
| 数据条件 | 首选索引 | 备注 |
|---|---|---|
| 能全放内存 + 要低延迟 | HNSW | 内存换延迟 |
| 数据大 + 内存有限 | IVF_SQ8 / IVF_PQ,或 HNSW + 量化 | 量化降精度,召回要实测 |
| 十亿级 + 有 NVMe | DiskANN / 分层存储 | 依赖本地盘随机读 |
| 极致压缩 + 可接受重排 | RaBitQ(2.6+) | 上线前先验证召回 |
稀疏向量与全文检索
稠密向量擅长语义,稀疏向量擅长关键词。Milvus 支持 SPARSE_FLOAT_VECTOR,配 SPARSE_INVERTED_INDEX 或 SPARSE_WAND(后者剪枝更快)。2.5 起可以用 Function 在写入时自动把文本转成 BM25 稀疏向量,省掉外部稀疏编码器。
这部分和混合检索一起看,见 03 篇。
度量方式
| 度量 | 适用 | 注意 |
|---|---|---|
COSINE | 文本 embedding | Milvus 内部做归一化,等价于归一化后的内积 |
IP | 已归一化向量、稀疏向量 | 未归一化时分数无上界,不能当相似度百分比 |
L2 | 图像、坐标类 | 与余弦的排序结果不一定一致 |
索引的 metric_type 必须和查询一致,写错不会报错,只会返回一堆不相关的结果。
最小上手
python
from pymilvus import MilvusClient, DataType
client = MilvusClient(uri="http://localhost:19530")
schema = client.create_schema(auto_id=False)
schema.add_field("id", DataType.INT64, is_primary=True)
schema.add_field("text", DataType.VARCHAR, max_length=1024)
schema.add_field("vector", DataType.FLOAT_VECTOR, dim=768)
# HNSW:内存充足时的默认选择
index_params = client.prepare_index_params()
index_params.add_index(
field_name="vector", index_type="HNSW", metric_type="COSINE",
params={"M": 16, "efConstruction": 200},
)
client.create_collection("docs", schema=schema, index_params=index_params)
# 查询时调 ef,而不是重建索引
res = client.search(
"docs", data=[query_vec], limit=10, output_fields=["text"],
search_params={"metric_type": "COSINE", "params": {"ef": 128}},
)验证召回率(离线做一次就够):
python
# 1. 用 FLAT(暴力)在同一批数据上取 ground truth
# 2. 用 HNSW 取 topK
# 3. recall@10 = |HNSW ∩ FLAT| / 10,在 200~500 条查询上取平均生产动作
- 先定内存,再定索引:
内存 ≈ N × dim × 4 字节 × 放大系数 + 图结构。1536 维、float32 时每条原始向量约 6 KB;HNSW 的图在 M=16 时每点再加百字节量级,放大系数接近 1.1。 ef是线上旋钮:ef=64起步,召回不够就加到 128/256,观察 P99;不要靠重建索引来调召回。nprobe同理:从nlist的 1%–5% 起步;nlist常用 1024–4096,小数据集可以降到 128–256。- 建索引会吃 CPU 和内存:大批量导入后集中建索引,避开查询高峰;建完再 load。
- 删除是标记删除:高频删改的场景定期
compact,否则查询会扫到大量无效数据。 - 换索引要重建:Milvus 不支持原地改索引类型,必须新建索引并重新 load,规划时留出双倍存储。
- 排障路径:
- 现象:查询慢 → 看
ef/nprobe、段数量、QueryNode 内存是否触发换页。 - 现象:召回突然变差 → 检查 metric 是否一致、是否有大量新写入落在 growing 段(走暴力扫描但可能未建索引)、索引是否还在建。
- 现象:load 超时 → 看对象存储带宽与段总大小,而不是只看 QueryNode CPU。
- 现象:查询慢 → 看
边界
- ANN 不是精确检索:需要「绝对不能漏」的场景(合规、去重)要用 FLAT 或检索后精确复核。
- 过滤条件会破坏图索引的连通性:高选择性过滤(如只命中 0.1% 数据)下 HNSW 召回会下降,必要时先分区再检索,见 03 篇。
- 索引不是越多越好:每个索引都占内存、拖慢写入;一个向量字段只建一个索引。
- 训练型索引怕分布漂移:IVF/PQ 的码本基于历史数据,业务 embedding 模型换版本后必须重建。
- 内存测算是估算:标量列、动态字段、查询缓存都会叠加,按官方 sizing 工具或实测校准。
讲清楚
向量检索绕不开近似。全量比对太慢,所以用索引把候选集缩小,代价是可能漏掉真正的最近邻。所有参数调整都是在这两者之间挪。
HNSW 用多层近邻图换低延迟,图和原始向量都放内存,所以快但吃内存;IVF 先聚类分桶,查询只扫少数桶,桶里还能做量化压缩,所以省内存但需要训练、怕分布漂移;DiskANN 把图放 SSD,用磁盘 IO 换内存;RaBitQ 用 1 bit 量化把候选集压到极小,再重排。
线上唯一该频繁调的是查询侧的
ef和nprobe,它们直接决定召回和延迟。索引类型和M、nlist属于建库时的决策,改一次要重建。