一、问题背景:为什么要在 Milvus 和 Qdrant 之间做选择
我们团队做的是图文内容推荐,召回阶段需要从千万级素材库里找 TopK 相似向量。早期用 Faiss 离线索引,但每次增量更新都要重建,线上无法接受。于是转向向量数据库,候选落在 Milvus 和 Qdrant 上。
两者的定位差异很明显。Milvus 是国内社区最活跃的向量数据库之一,2.x 之后拆分了 coordinator、proxy、query node、data node 等角色,天然支持水平扩展,标量过滤和一致性级别做得比较完整。Qdrant 是 Rust 写的,单二进制部署,资源占用低,过滤检索的性能口碑很好,但分布式能力相对晚一些。
我们的约束是:单机 16C64G,SSD 1TB,向量 1000 万条,维度 768,每天增量约 20 万条,查询 QPS 峰值 200,要求 P99 控制在 50ms 以内。这个规模不大不小,正好是选型最容易纠结的区间。所以干脆两台都部署,用同一批数据跑一遍。
二、环境与版本
- 服务器:阿里云 ecs.g7.4xlarge,16 vCPU,64GB 内存,ESSD PL1 1TB
- 操作系统:Ubuntu 22.04,内核 5.15
- Docker:26.1.4,Docker Compose v2.27.1
- Milvus:2.5.4,standalone 模式,etcd 3.5.16,MinIO RELEASE.2024-12-18
- Qdrant:1.12.4,单节点
- 客户端:pymilvus 2.5.4,qdrant-client 1.12.1
- 数据集:1000 万条 768 维 float32 向量,随机生成后归一化,模拟余弦相似度场景
三、方案设计
索引选择上,Milvus 用 HNSW(M=16,efConstruction=200)和 IVF_FLAT(nlist=4096)各测一轮;Qdrant 用 HNSW(m=16,ef_construct=200)。查询参数:Milvus HNSW 的 ef=128,Qdrant 的 hnsw_ef=128,TopK=10。
测试分三块:
1. 写入 1000 万条向量的耗时和资源曲线
2. 无过滤条件下 200 并发、持续 5 分钟的 QPS 与 P99
3. 带标量过滤(category in [3,7,11])的 QPS 与 P99
4. 索引构建完成后的常驻内存和磁盘占用
压测工具用 locust,客户端和服务端分离部署,避免客户端吃满 CPU 影响结果。
四、核心实现
4.1 Milvus 部署与写入
Milvus standalone 用官方 compose 起:
wget https://github.com/milvus-io/milvus/releases/download/v2.5.4/milvus-standalone-docker-compose.yml -O docker-compose.yml
docker compose up -d
docker compose ps
写入和建索引代码:
from pymilvus import MilvusClient, DataType
import numpy as np
client = MilvusClient(uri="http://127.0.0.1:19530")
schema = client.create_schema(auto_id=False, enable_dynamic_field=False)
schema.add_field("id", DataType.INT64, is_primary=True)
schema.add_field("category", DataType.INT64)
schema.add_field("embedding", DataType.FLOAT_VECTOR, dim=768)
index_params = client.prepare_index_params()
index_params.add_index(
field_name="embedding",
index_type="HNSW",
metric_type="COSINE",
params={"M": 16, "efConstruction": 200},
)
client.create_collection(
collection_name="demo_milvus",
schema=schema,
index_params=index_params,
)
BATCH = 2000
for i in range(0, 10_000_000, BATCH):
ids = list(range(i, i + BATCH))
cats = np.random.randint(0, 20, size=BATCH).tolist()
vecs = np.random.rand(BATCH, 768).astype(np.float32)
vecs /= np.linalg.norm(vecs, axis=1, keepdims=True)
client.insert("demo_milvus", [
{"id": ids[j], "category": cats[j], "embedding": vecs[j].tolist()}
for j in range(BATCH)
])
client.load_collection("demo_milvus")
4.2 Qdrant 部署与写入
Qdrant 单节点启动,挂载数据目录并限制内存:
docker run -d --name qdrant \
-p 6333:6333 -p 6334:6334 \
-v /data/qdrant:/qdrant/storage \
-e QDRANT__STORAGE__ON_DISK_PAYLOAD=true \
qdrant/qdrant:v1.12.4
写入与查询:
from qdrant_client import QdrantClient, models
import numpy as np
client = QdrantClient(host="127.0.0.1", port=6333)
client.recreate_collection(
collection_name="demo_qdrant",
vectors_config=models.VectorParams(
size=768, distance=models.Distance.COSINE,
hnsw_config=models.HnswConfigDiff(m=16, ef_construct=200),
),
)
BATCH = 2000
for i in range(0, 10_000_000, BATCH):
vecs = np.random.rand(BATCH, 768).astype(np.float32)
vecs /= np.linalg.norm(vecs, axis=1, keepdims=True)
client.upsert(
collection_name="demo_qdrant",
points=models.Batch(
ids=list(range(i, i + BATCH)),
vectors=vecs.tolist(),
payloads=[{"category": int(c)} for c in np.random.randint(0, 20, BATCH)],
),
wait=False,
)
查询侧对比:
# Milvus
res = client.search(
collection_name="demo_milvus",
data=[query_vec],
limit=10,
filter='category in [3, 7, 11]',
search_params={"metric_type": "COSINE", "params": {"ef": 128}},
)
# Qdrant
res = client.search(
collection_name="demo_qdrant",
query_vector=query_vec,
limit=10,
query_filter=models.Filter(must=[
models.FieldCondition(key="category", match=models.MatchAny(any=[3, 7, 11]))
]),
search_params=models.SearchParams(hnsw_ef=128),
)
五、踩坑与优化
第一个坑是 Milvus 的 insert 批次。我们一开始用 500 条一批,10 个并发写,结果 data node 的 flush 频繁触发,写入只有 1.2 万条/秒。改成 2000 条一批、单线程写,反而到了 4.8 万条/秒。原因是小批次导致 segment 过多,compaction 压力大。
第二个坑是 Qdrant 的 upsert 默认 wait=True,逐批等待索引刷新,写入慢得离谱。改成 wait=False 后,写入速度从 8000 条/秒提升到 6.1 万条/秒。但要注意,此时数据还在 memtable,需要留出 flush 时间再做压测,否则查不到最新数据。
第三个坑是内存。Milvus 的 query node 默认会尽量把 segment 缓存在内存,64G 机器上 1000 万条 768 维向量(约 30GB 原始数据)加载后 RSS 冲到 41GB。可以通过 queryNode.cache.enabled 和 mmap 相关参数降低,但延迟会上升。Qdrant 开启 on_disk_payload 后,常驻内存稳定在 17GB 左右,向量本体走 mmap,磁盘占用 34GB。
第四个坑是过滤。Milvus 的 category in [...] 如果命中比例高,会走暴力扫描;Qdrant 的 filterable HNSW 在 payload 索引建好后表现更稳。我们给 category 建了 payload 索引,过滤查询 P99 从 61ms 降到 22ms。
六、效果数据
写入完成后,统一 load 再压测,结果如下(200 并发,5 分钟均值):
| 指标 | Milvus HNSW | Milvus IVF_FLAT | Qdrant HNSW |
|---|---|---|---|
| 写入 1000 万耗时 | 34 min | 34 min | 27 min |
| 无过滤 QPS | 312 | 358 | 405 |
| 无过滤 P99 | 31 ms | 27 ms | 18 ms |
| 过滤 QPS | 187 | 203 | 264 |
| 过滤 P99 | 44 ms | 39 ms | 22 ms |
| 常驻内存 | 41 GB | 38 GB | 17 GB |
| 磁盘占用 | 36 GB | 33 GB | 34 GB |
| 召回率@10 | 0.972 | 0.941 | 0.976 |
结论比较清楚:单机千万级场景下,Qdrant 在延迟、内存、过滤性能上全面占优,部署也简单,一个容器就够。Milvus 的 IVF_FLAT 在 QPS 上略好于自家 HNSW,但召回率掉了 3 个点,不适合召回敏感业务。Milvus 的优势在于生态和扩展性:如果数据涨到 5 亿、需要多副本、需要强一致或跨机房,它的架构更撑得住。
我们最终选了 Qdrant 作为当前阶段方案,同时保留 Milvus 的接入层抽象,等数据量过 5000 万再切。选型没有绝对答案,把业务规模、延迟要求、运维成本三件事列清楚,答案通常就浮出来了。