创建日期:2026-09-17 | 最近更新:2026-09-17 事实以本机实测为准:Milvus 2.5.10(
milvusdb/milvus:v2.5.10,standalone + 内嵌 etcd/minio)+ pymilvus 3.0.1,数据为 5 万行 × 128 维。脚本与真实输出留档在source/milvus-lab/。 ⚠️ 本文只在单机 standalone 上测过,未跑分布式集群。凡涉及集群组件、运维的部分标注为「文档事实」,并在下一篇展开。
Milvus 入门:从起一个实例到混合检索
Milvus 是目前最主流的独立向量数据库——和「给 Postgres 装个 pgvector 扩展」不同,它是一个专门为向量检索从零构建的分布式系统。这个出身决定了它的两个特点:能扛住亿级向量,以及组件比你想的多得多(下一篇的主题)。
这篇的目标很具体:半小时内起一个实例、写进第一批向量、跑通检索和混合检索,并先踩掉六个新手必踩的坑——这六个都是我实测撞出来的,不是抄文档。
1. 先搞清三种部署形态,别一上来就上集群
| 形态 | 是什么 | 适合 |
|---|---|---|
| Milvus Lite | 一个 Python 库(pip install milvus-lite),进程内嵌,数据落本地文件 | 原型、笔记本、Notebook;功能是子集 |
| Standalone | 单容器/单进程,内嵌 etcd + 内嵌 MinIO,对外就是一个 19530 | 开发、测试、中小规模生产(本文实测的形态) |
| Distributed | 完整集群:proxy / coordinator / querynode / datanode / indexnode + 独立 etcd / 对象存储 / 消息队列 | 大规模生产、要高可用与水平扩展 |
关键提醒:Milvus Lite 和 Standalone 不是「同一套东西的不同包装」——Lite 是功能子集(比如没有完整的分布式能力、部分索引类型不可用)。所以用 Lite 验证过的方案,上生产前必须在 Standalone 上再验一遍。
Standalone 起法(本文实测用的命令,注意端口绑 loopback):
docker run -d --name milvus-lab \
-e ETCD_USE_EMBED=true -e ETCD_DATA_DIR=/var/lib/milvus/etcd -e ETCD_CONFIG_PATH=/milvus/configs/advanced/etcd.yaml \
-e MINIO_USE_EMBED=true -e MINIO_ADDRESS=localhost -e MINIO_PORT=9000 \
-e MINIO_DATA_DIR=/var/lib/milvus/minio -e MINIO_CONFIG_PATH=/milvus/configs/advanced/minio.yaml \
-e COMMON_STORAGETYPE=local \
-p 127.0.0.1:19531:19530 -p 127.0.0.1:9092:9091 \
-v /tmp/milvus-lab-data:/var/lib/milvus \
milvusdb/milvus:v2.5.10 milvus run standalone
为什么绑
127.0.0.1:Milvus 默认没有任何鉴权。绑0.0.0.0就等于把一个可读可写的向量库丢到局域网上。生产必须配 TLS + RBAC,开发机至少绑回环。
健康检查就一个端点,返回 OK 即可:
curl -s http://127.0.0.1:9092/healthz # -> OK (9091 是 metrics/健康端口)
curl -s http://127.0.0.1:9092/metrics # -> 6463 行指标(下一篇讲怎么用)
2. 核心概念,一张表过掉
| 概念 | 是什么 | 关键点 |
|---|---|---|
| Collection | 一组向量+标量的集合 | 类比「表」;schema 一旦建好基本不能改(能加字段,不能改类型/dim) |
| Field | 集合里的列 | 向量字段(FLOAT_VECTOR 带 dim)+ 标量字段(INT64/VARCHAR…)+ 主键 |
| Index | 向量字段上的索引 | 不建索引就不能检索(只能全量扫描或直接报错) |
| Metric | 距离度量 | COSINE / L2 / IP;建索引和检索必须用同一个 |
| Partition | 集合内的物理分组 | 按租户/时间切分,检索可只扫指定分区 |
| Consistency | 一致性级别 | Strong / Bounded / Eventually——这个选择能差 40 倍延迟,见 §6 |
| Load / Release | 把集合加载进内存 / 释放 | 检索前必须 Loaded;大集合的内存成本主要在这 |
一句话记忆:「建 schema → 建索引 → 插入 → flush → 等就绪 → search」。中间那步「等就绪」是新手最容易漏的(§6 第 1 条)。
3. 第一个可运行例子
from pymilvus import MilvusClient, DataType
client = MilvusClient(uri="http://127.0.0.1:19531")
schema = client.create_schema(auto_id=False, enable_dynamic_field=False)
schema.add_field("id", DataType.INT64, is_primary=True)
schema.add_field("vec", DataType.FLOAT_VECTOR, dim=128)
schema.add_field("topic", DataType.INT64) # 标量字段,用于过滤
ip = client.prepare_index_params()
ip.add_index(field_name="vec", index_type="HNSW", metric_type="COSINE",
params={"M": 16, "efConstruction": 200})
client.create_collection("demo", schema=schema, index_params=ip)
client.insert("demo", [{"id": 1, "vec": [0.1] * 128, "topic": 7}])
client.flush("demo")
res = client.search("demo", data=[[0.1] * 128], limit=5,
search_params={"params": {"ef": 64}}, output_fields=["topic"])
search 的返回是 [[{id, distance, entity}, ...]]——注意最外层是「每个查询一组答案」,所以单条查询要用 res[0]。
真实跑出来的召回与延迟(5 万行 × 128 维,批量 32 条分摊):FLAT 2.638 ms/查询、IVF_FLAT(nprobe=8) 1.021 ms、HNSW(ef=64) 1.056 ms。索引怎么选是下一篇的重点,这里先记住:默认从 HNSW 开始。
4. 混合检索:内置 BM25 + 稠密向量 + RRF
这是 Milvus 2.5 最值钱的新能力之一:不用外挂 ES,直接用「稀疏 + 稠密 + RRF」做混合检索(RRF 的原理和公式见本站多路检索那篇)。
关键在于 BM25 是内置函数,不用自己算稀疏向量:
from pymilvus import DataType, Function, FunctionType, AnnSearchRequest, RRFRanker
schema = client.create_schema(auto_id=False, enable_dynamic_field=False)
schema.add_field("id", DataType.INT64, is_primary=True)
schema.add_field("dense", DataType.FLOAT_VECTOR, dim=64)
schema.add_field("text", DataType.VARCHAR, max_length=1024,
enable_analyzer=True, analyzer_params={"type": "chinese"}) # ★ 中文分析器
schema.add_field("sparse", DataType.SPARSE_FLOAT_VECTOR)
# ★ 声明「由 text 自动生成 sparse」的 BM25 函数
schema.add_function(Function(name="bm25", function_type=FunctionType.BM25,
input_field_names=["text"], output_field_names=["sparse"]))
ip = client.prepare_index_params()
ip.add_index(field_name="dense", index_type="HNSW", metric_type="COSINE",
params={"M": 16, "efConstruction": 200})
ip.add_index(field_name="sparse", index_type="SPARSE_INVERTED_INDEX", metric_type="BM25")
检索时把两路各自包成 AnnSearchRequest,再交给 hybrid_search 融合:
reqs = [
AnnSearchRequest(data=[dense_vec], anns_field="dense",
param={"params": {"ef": 64}}, limit=10),
AnnSearchRequest(data=["消息怎么保证不丢"], anns_field="sparse",
param={"params": {}}, limit=10),
]
res = client.hybrid_search("docs", reqs=reqs, ranker=RRFRanker(), limit=5,
output_fields=["text"])
真实输出很有说服力——注意 RRF 分数本身就把公式印在脸上:
查询「消息怎么保证不丢」— RRF 融合:
1. id=4 score=0.0328 消息不丢的三个配置:acks=all、幂等、min.insync.replicas
2. id=9 score=0.0161 Redis 集群扩容:先加节点、再迁移 slot、最后校验一致性
3. id=3 score=0.0159 Kafka 消费积压排查:先看 Lag 曲线,再扩分区提升并行度
4. id=2 score=0.0156 缓存雪崩治理:给过期时间加随机抖动,并做多级缓存
5. id=8 score=0.0154 故障复盘:Kafka 分区节点磁盘故障导致订单写入受阻
把分数验算一遍(RRF 取 k=60,score = Σ 1/(k + rank)):
- id=4 得
0.0328 ≈ 2/61——它在两路里都排第 1,所以拿两份1/61; - id=9/3/2/8 得
0.0161/0.0159/0.0156/0.0154 ≈ 1/62、1/63、1/64、1/65——它们只在稠密那一路排第 2~5。
也就是说:Milvus 内置的 RRF 就是教科书公式,没有偷偷加权重。这也直接印证了多路检索那篇的核心结论——「被多路同时高排」的文档会拿到成倍的分数。
5. 六个新手必踩的坑(全部实测)
坑 1:flush() 返回 ≠ 索引可用(最坑,会导致「测出来的数据是假的」)
我以为 flush() 完成就能压测了。结果索引还在后台构建,此时检索完全忽略 nprobe/ef:
插入 5 万行 + flush 之后立即检索:
nprobe=1 召回 0.383
nprobe=256 召回 0.383 ← 参数完全没生效!
等 45 秒后再测:
nprobe=1 召回 0.033
nprobe=256 召回 0.798 ← 这才是对的曲线
更坑的是 describe_index 会骗你——实测在 pending_index_rows = 50000、indexed_rows = 0 的时候,state 已经显示 Finished:
t(s) describe_index
0.0 {'indexed_rows': 0, 'pending_index_rows': 50000, 'state': 'Finished'} ← 别信 state
6.0 {'indexed_rows': 0, 'pending_index_rows': 50000, 'state': 'Finished'}
12.1 {'indexed_rows': 50000, 'pending_index_rows': 0, 'state': 'Finished'} ← 这才是好了
正确做法:轮询 pending_index_rows == 0。
def wait_index_ready(client, name, timeout=300):
idx = client.list_indexes(name)[0]
t0 = time.time()
while time.time() - t0 < timeout:
di = client.describe_index(name, index_name=idx)
if di.get("pending_index_rows") in (None, 0):
return
time.sleep(1)
raise TimeoutError(name)
这个坑我是靠自检抓出来的:我写了个断言「召回必须随参数显著变化,否则结果不可信」,第一次跑就报「可疑」——否则我会把一份「所有参数召回都是 0.384」的假数据当成实测结论写出来。
坑 2:HNSW 的 ef 必须大于 k
ef=8、k=10 时服务端直接报错:
out of range in json: ef(8) should be larger than k(10)
at internal/core/src/index/VectorMemIndex.cpp:438
ef 是搜索时探索的候选池大小,逻辑上必须能装下要返回的 k 个结果。调参扫描时记得从 k+1 起步。
坑 3:consistency_level="Strong" 让单条查询慢 40 倍
同一个集合、同一个索引,只改一致性级别(单条查询 P50):
| consistency | P50 | P95 |
|---|---|---|
Strong | 400.46 ms | 407.99 ms |
Bounded(默认) | 10.15 ms | 12.36 ms |
Eventually | 6.58 ms | 10.99 ms |
Strong 每次查询都要等时间戳同步,固定多出约 390 ms。这个开销会把所有索引之间的差异彻底淹没——我第一次做索引对比时就是被它骗了,所有索引都「慢得一样」。
建议:默认用 Bounded;确实需要「读己之写」时,优先用主键 query() 而不是 search()(前者可以精确取到,不需要全库一致性保证)。
坑 4:BM25 稀疏检索「没词命中就没结果」
实测同一个查询:稠密通道返回了 5 条,BM25 通道只返回 1 条。
仅稠密: 5 条结果
仅 BM25: 1 条结果 ← 只有真正含相关词的那篇
这和稠密检索「无论如何都返回 K 条」截然不同。含义:稀疏通道的结果条数是「可用信号量的体现」,做融合时不要假设每路都返回 K 条(这也是那篇多路检索里 RRF 只加不减项、短列表天然吃亏的地方)。
坑 5:用了显式分区后,_default 是空的
我把 4 万行按 p0..p7 显式分区插入后:
_default 分区前 5 个 id: [] ← 空
p3 分区前 5 个 id: [3, 11, 19, 27, 35] (全满足 id % 8 == 3)
含义:不指定 partition_names 的查询仍会跨所有分区(能查到全部),但如果你按 _default 去找数据会一无所获——别用「数据在 _default 里」这个假设写运维脚本。
坑 6:中文必须显式配分析器
VARCHAR 字段默认分析器不切中文(和 SQLite FTS5 那个坑同源)。必须显式声明:
schema.add_field("text", DataType.VARCHAR, max_length=1024,
enable_analyzer=True,
analyzer_params={"type": "chinese"})
实测配上 chinese 分析器后,hybrid_search 的中文 BM25 能正常召回(见 §4 的真实输出)。
6. 一页速查
建 schema → 建索引 → insert → flush → ★等 pending_index_rows == 0 → search
↑ 这一步漏了,后面测什么都是假的
| 决策 | 默认选择 | 什么时候改 |
|---|---|---|
| 索引 | HNSW(M=16, efConstruction=200) | 要省内存/亿级规模 → 看下一篇的量化磁盘索引 |
| 检索参数 | ef 从 64 起,按召回目标上调 | ef 必须大于 k |
| 一致性 | Bounded | 需要读己之写 → 用主键 query() |
| 度量 | COSINE(归一化向量的默认选择) | 建索引与检索必须一致 |
| 中文 | analyzer_params={"type": "chinese"} | 不配就等于没分词 |
| 开发端口 | 绑 127.0.0.1 | 生产必须 TLS + RBAC |
关联
- 下一篇:Milvus 企业级开发运维方案——索引选型的召回-延迟曲线、过滤+ANN 的召回陷阱、写入就绪与流量闸门、一致性代价、集群组件与可观测性
- RRF 与多路检索的原理和公式:多路检索实战
- 另一个向量化检索的落地(FTS5 + BM25 的本地实现):高性能 SQLite 理论分析入门
- 检索在整个 RAG 里的位置:RAG 的现状与像素方案
自测
- Milvus Lite 与 Standalone 是「同一套东西的两种包装」吗?为什么这个区别重要?
flush()完成后能立即压测吗?该看哪个字段判断索引就绪?为什么state不可信?ef=8, k=10会怎样?为什么?consistency_level="Strong"的代价有多大(用实测数字回答)?需要读己之写时更该用什么?- 实测里 BM25 通道为什么只返回 1 条结果?这对融合意味着什么?
- 举个 RRF 分数验算的例子:为什么某条文档的分数是
2/61而不是1/61?
参考
- 实测环境:Milvus 2.5.10(
milvusdb/milvus:v2.5.10,standalone + 内嵌 etcd/minio)/ pymilvus 3.0.1 / Docker 28.3.0 / macOS(Darwin 24.6.0) - 脚本与真实输出(随仓库留档):
source/milvus-lab/(bench_index.py/bench_features.py/dbg4.py/dbg5.py+ 日志),跑法与局限见其中README.md - Milvus 官方文档:milvus.io/docs(架构、索引类型、一致性级别的权威定义)
- 混合检索与 BM25 函数:milvus.io/docs/full-text-search.md