一、问题背景:为什么要在 Milvus 和 Qdrant 之间纠结
先说业务场景,不然选型讨论都是空的。我们做的是一个图文内容平台的内容去重与相似推荐服务,核心链路是:用户上传图片 → CLIP 类模型抽 768 维向量 → 向量库做 ANN 检索 → 命中相似度阈值以上就判定为重复或推荐相似内容。
数据规模预估:存量约 1000 万条图文向量,日增 30 万~50 万,QPS 峰值读约 200,写入集中在夜间批处理。这个规模很尴尬——说大不大,单机扛得住;说小不小,Faiss 裸奔做持久化和增删改会很痛苦。所以必须上专业的向量数据库。
候选就两个:Milvus 和 Qdrant。两者都是开源、都支持 HNSW、都有 Python SDK、社区都活跃。网上文章要么是官方 benchmark(数据好看但不可复现),要么是"Hello World"级别的 demo。我需要的是:在我这台机器、我这个数据分布下,谁更合适。
于是有了这篇实测。
二、环境与版本
硬件统一,避免"田忌赛马":
- CPU:AMD EPYC 7B13,16 vCPU
- 内存:64 GB
- 磁盘:NVMe SSD 500 GB
- OS:Ubuntu 22.04.3 LTS
- Docker:24.0.7,Docker Compose v2.23.0
软件版本(都取当时最新稳定版):
- Milvus:v2.4.6(standalone 模式,依赖 etcd 3.5.5 + MinIO RELEASE.2023-03-20)
- Qdrant:v1.9.2(单节点,官方 docker 镜像)
- 客户端:pymilvus 2.4.4、qdrant-client 1.9.1
- 压测工具:自写 Python 脚本(多线程 + 固定随机种子查询集),避免用官方工具带来的口径差异
数据:1000 万条随机生成的 768 维 float32 向量,归一化后写入,另取 10000 条作为查询集(不在库内,模拟真实外查)。
三、方案设计
3.1 部署方案
Milvus standalone 用官方 compose 一把梭,但要注意默认配置是给"能跑"用的,不是给"能跑好"用的。Qdrant 简单得多,一个容器搞定,配置通过环境变量或 config 文件注入。
3.2 索引与参数对齐
为了让对比公平,两边都用 HNSW,关键参数尽量对齐:
| 参数 | Milvus | Qdrant |
|---|---|---|
| 索引类型 | HNSW | HNSW |
| M | 16 | 16 |
| efConstruction | 200 | 200 |
| ef(search) | 64 | 64 (hnsw_ef) |
| 距离度量 | COSINE | Cosine |
| 量化 | 无 | 无(scalar quantization 关闭) |
Milvus 的 ef 在搜索时通过 search_params={"ef": 64} 传;Qdrant 通过 search_params=SearchParams(hnsw_ef=64) 传。
3.3 测量口径
- 写入:批量 5000 条/批,记录总耗时与索引构建完成时间(Milvus 用
flush+ 等待索引 built;Qdrant 用wait=True并观察优化器状态) - 查询:16 线程并发,每线程发 625 次(共 10000 次),记录 QPS、P50、P99
- 资源:稳定后
docker stats采样 60 秒取均值;磁盘看数据目录 du
四、核心实现(含部署与压测代码)
4.1 Milvus 部署
# docker-compose-milvus.yml
version: '3.5'
services:
etcd:
image: quay.io/coreos/etcd:v3.5.5
environment:
- ETCD_AUTO_COMPACTION_MODE=revision
- ETCD_AUTO_COMPACTION_RETENTION=1000
- ETCD_QUOTA_BACKEND_BYTES=4294967296
- ETCD_SNAPSHOT_COUNT=50000
command: etcd -advertise-client-urls=http://127.0.0.1:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd
volumes:
- ./volumes/etcd:/etcd
minio:
image: minio/minio:RELEASE.2023-03-20T20-16-18Z
environment:
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
command: minio server /minio_data --console-address ":9001"
volumes:
- ./volumes/minio:/minio_data
standalone:
image: milvusdb/milvus:v2.4.6
command: ["milvus", "run", "standalone"]
environment:
ETCD_ENDPOINTS: etcd:2379
MINIO_ADDRESS: minio:9000
volumes:
- ./volumes/milvus:/var/lib/milvus
ports:
- "19530:19530"
- "9091:9091"
depends_on:
- etcd
- minio
启动 docker compose -f docker-compose-milvus.yml up -d,等 curl localhost:9091/healthz 返回 OK 即可。
4.2 Qdrant 部署
docker run -d --name qdrant \
-p 6333:6333 -p 6334:6334 \
-v $(pwd)/volumes/qdrant:/qdrant/storage \
-e QDRANT__SERVICE__GRPC_PORT=6334 \
-e QDRANT__STORAGE__OPTIMIZERS__DEFAULT_SEGMENT_NUMBER=4 \
qdrant/qdrant:v1.9.2
DEFAULT_SEGMENT_NUMBER=4 是我调过的,默认 2 段在千万级时单段过大,优化器合并慢,调到 4 后写入更平顺(后面踩坑部分细说)。
4.3 写入与压测脚本(两边共用逻辑)
# bench.py
import time, random, numpy as np, threading
from concurrent.futures import ThreadPoolExecutor
from pymilvus import MilvusClient
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct, HnswConfigDiff, SearchParams
DIM, N = 768, 10_000_000
BATCH = 5000
rng = np.random.default_rng(42)
def gen_batch(n):
v = rng.standard_normal((n, DIM)).astype(np.float32)
v /= np.linalg.norm(v, axis=1, keepdims=True)
return v
def bench_milvus():
c = MilvusClient(uri="http://localhost:19530")
if c.has_collection("bench"): c.drop_collection("bench")
c.create_collection("bench", dimension=DIM, metric_type="COSINE",
index_params={"index_type": "HNSW", "metric_type": "COSINE",
"params": {"M": 16, "efConstruction": 200}})
t0 = time.time()
for i in range(0, N, BATCH):
data = gen_batch(BATCH)
c.insert("bench", [{"id": i + j, "vector": data[j].tolist()} for j in range(BATCH)])
c.flush("bench")
print(f"[Milvus] insert+flush: {time.time()-t0:.1f}s")
return c
def bench_qdrant():
c = QdrantClient(host="localhost", port=6333, timeout=120)
c.recreate_collection("bench",
vectors_config=VectorParams(size=DIM, distance=Distance.COSINE),
hnsw_config=HnswConfigDiff(m=16, ef_construct=200))
t0 = time.time()
for i in range(0, N, BATCH):
data = gen_batch(BATCH)
c.upsert("bench", points=[PointStruct(id=i+j, vector=data[j].tolist())
for j in range(BATCH)], wait=False)
c.upsert("bench", points=[], wait=True) # 等优化器落盘
print(f"[Qdrant] upsert+wait: {time.time()-t0:.1f}s")
return c
def query_bench(search_fn, queries, threads=16):
lat = []
lock = threading.Lock()
def worker(qs):
for q in qs:
t = time.perf_counter()
search_fn(q)
with lock: lat.append((time.perf_counter()-t)*1000)
chunks = np.array_split(queries, threads)
t0 = time.time()
with ThreadPoolExecutor(threads) as ex:
list(ex.map(worker, chunks))
total = time.time()-t0
lat.sort()
print(f"QPS={len(queries)/total:.0f} P50={lat[len(lat)//2]:.2f}ms "
f"P99={lat[int(len(lat)*0.99)]:.2f}ms")
查询部分:Milvus 用 c.search("bench", data=[q], limit=10, search_params={"ef": 64}, anns_field="vector"),Qdrant 用 c.search("bench", query_vector=q, limit=10, search_params=SearchParams(hnsw_ef=64)),这里不重复贴。
五、踩坑与优化
坑 1:Milvus 写入不 flush 等于没写。 一开始我插完就查,结果召回为 0。Milvus 的 insert 是进 growing segment,不 flush 且索引没建好之前,search 走的是暴力扫,慢且结果不全。必须 flush 并等 get_load_state 为 Loaded。这直接导致我第一批"写入耗时"测出来只有 200 秒——假的,因为没等索引建完。修正后写入+索引构建才是有意义的数字。
坑 2:Qdrant 默认段数在千万级下优化器会"卡住"。 默认 2 段,写到 800 万左右时 wait=True 的 upsert 要等几十秒,因为后台在做 segment 合并。调到 DEFAULT_SEGMENT_NUMBER=4 并配合 optimizers_config.indexing_threshold_kb 后明显改善。另外 Qdrant 的 wait=True 是等写入可见,不等于等 HNSW 索引建完,压测前要 curl localhost:6333/collections/bench 看 optimizer_status 是否为 ok。
坑 3:内存。 Milvus standalone 的 etcd + MinIO + 本体三件套,空载就吃了约 2.4 GB。Qdrant 空载约 180 MB。千万级 768 维原始向量约 30 GB(10M × 768 × 4B),两边都会把它放内存/页缓存,但 Milvus 的 segment 元数据和索引额外开销更大。这也是最终资源占用差距的主要来源。
六、效果数据
1000 万条 768 维,HNSW(M=16, efC=200),查询 ef=64,TopK=10:
| 指标 | Milvus 2.4.6 | Qdrant 1.9.2 |
|---|---|---|
| 写入+索引构建总耗时 | 约 41 分钟 | 约 58 分钟 |
| 索引构建后磁盘占用 | 约 46 GB | 约 38 GB |
| 稳定态内存(RSS 合计) | 约 34 GB | 约 27 GB |
| 查询 QPS(16 并发) | 约 620 | 约 780 |
| P50 延迟 | 18.4 ms | 13.9 ms |
| P99 延迟 | 61 ms | 44 ms |
| 空载内存 | 2.4 GB | 0.18 GB |
| 部署复杂度 | 高(3 组件) | 低(1 容器) |
召回率两边在 ef=64 下对 TopK=10 都在 0.95+(用暴力检索做 ground truth 抽样 1000 条比对),差距在噪声范围内,不作为区分点。
解读一下:Milvus 写入快,是因为它的写入路径是"先落对象存储 + WAL,索引异步构建",吞吐更猛;但代价是组件多、内存开销大、运维心智负担高。Qdrant 单进程、Rust 实现,内存效率高、查询延迟低,但单机写入吞吐略逊,且水平扩展能力不如 Milvus 的存算分离架构。
七、总结
回到我们的场景:1000 万级、读多写少(夜间批写)、团队只有 2 个后端兼运维。最终选了 Qdrant。理由很朴素:单容器部署、空载 180 MB、P99 低 17 ms、磁盘省 8 GB,出问题时 docker logs 一眼看到底,不用在 etcd、MinIO、Milvus 三个日志里来回翻。Milvus 不是不好,它的分布式能力和写入吞吐在数据量上亿、需要多副本和弹性扩缩容时才是真正的主场——如果你的场景是亿级向量、多租户、要求高可用,那 Milvus 的存算分离架构值得那份额外的运维成本。
一句话选型建议:千万级以下、团队小、追求部署和内存效率,选 Qdrant;亿级以上、要水平扩展和高可用、能接受多组件运维,选 Milvus。 别被 benchmark 榜单带偏,先量清楚自己的数据量和 QPS,再拿本文的脚本在自己机器上跑一遍,比看十篇文章都管用。