一、问题背景:为什么要在Milvus和Qdrant之间做选择
我们做的是一个内容平台的图文去重服务。每天新增约80万条内容,每条内容经过CLIP模型编码成768维向量,需要在历史约1000万条向量中找出Top-10相似项,相似度超过阈值就判定为重复或高度相似。
早期我们用FAISS做离线批处理,但随着业务变成准实时(新内容入库后1秒内要能查到),FAISS的“无服务、无持久化、无并发管理”问题就暴露了:
- 索引需要自己序列化加载,进程重启后恢复慢;
- 多进程并发查询要自己做分片和合并;
- 增删改需要重建索引,无法增量更新。
于是我们转向专业向量数据库。候选里最主流的是Milvus和Qdrant:
- Milvus:CNCF毕业项目,生态最大,支持FLAT、IVF、HNSW、DiskANN等多种索引,天然支持分布式;
- Qdrant:Rust编写,单机性能强,过滤(payload filter)能力突出,部署极简。
网上很多文章只讲“Hello World”式插入查询,缺少同机同数据的横向对比。所以我决定自己做一次实测,把部署、代码、性能、资源全部记录下来。
二、环境与版本
测试机配置:
- CPU:AMD EPYC 7B13,16核
- 内存:64GB
- 磁盘:NVMe SSD 1TB
- OS:Ubuntu 22.04 LTS
软件版本:
- Docker:24.0.7
- Milvus:2.4.0 Standalone(milvusdb/milvus:v2.4.0)
- Qdrant:1.9.0(qdrant/qdrant:v1.9.0)
- Python SDK:pymilvus==2.4.0,qdrant-client==1.9.0
- 向量数据:1000万条,768维,float32,随机生成并做归一化
统一使用HNSW索引,距离度量用COSINE,因为我们的向量已归一化,余弦和内积等价。
三、方案设计与部署步骤
3.1 Milvus Standalone 部署
Milvus Standalone依赖etcd和MinIO,官方提供docker-compose。我使用了官方脚本:
wget https://github.com/milvus-io/milvus/releases/download/v2.4.0/milvus-standalone-docker-compose.yml -O docker-compose.yml
docker compose up -d
启动后容器情况:
milvus-standalone milvusdb/milvus:v2.4.0
milvus-minio minio/minio:RELEASE.2023-03-20T20-16-18Z
milvus-etcd quay.io/coreos/etcd:v3.5.5
Milvus默认端口19530(gRPC)和9091(metrics)。注意:Milvus Standalone本身内存占用就不低,启动后空载约1.2GB。
3.2 Qdrant 部署
Qdrant单容器即可,无需外部依赖:
docker run -d --name qdrant \
-p 6333:6333 -p 6334:6334 \
-v /data/qdrant_storage:/qdrant/storage \
qdrant/qdrant:v1.9.0
6333是HTTP/REST,6334是gRPC。空载内存约80MB。这一点差异非常明显。
3.3 索引参数设计
两者都用HNSW,参数尽量对齐:
- M = 16
- ef_construct = 200
- ef_search = 128(查询时)
Milvus的HNSW参数写在index_params里,ef_search通过search_params传入。Qdrant在collection创建时配置hnsw_config,查询时用hnsw_ef。
四、核心实现代码
4.1 数据写入:Milvus
from pymilvus import connections, Collection, CollectionSchema, FieldSchema, DataType, utility
import numpy as np
connections.connect(host="localhost", port="19530")
dim = 768
collection_name = "dedup_milvus"
if utility.has_collection(collection_name):
utility.drop_collection(collection_name)
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=False),
FieldSchema(name="vec", dtype=DataType.FLOAT_VECTOR, dim=dim),
]
schema = CollectionSchema(fields, description="dedup vectors")
col = Collection(collection_name, schema, consistency_level="Bounded")
index_params = {
"index_type": "HNSW",
"metric_type": "COSINE",
"params": {"M": 16, "efConstruction": 200},
}
col.create_index(field_name="vec", index_params=index_params)
col.load()
# 分批写入,每批10万
BATCH = 100000
TOTAL = 10_000_000
rng = np.random.default_rng(42)
for start in range(0, TOTAL, BATCH):
n = min(BATCH, TOTAL - start)
vecs = rng.random((n, dim), dtype=np.float32)
# 归一化
vecs /= np.linalg.norm(vecs, axis=1, keepdims=True)
ids = list(range(start, start + n))
col.insert([ids, vecs.tolist()])
if start % 1_000_000 == 0:
print(f"inserted {start + n}")
col.flush()
print("milvus num entities:", col.num_entities)
4.2 数据写入:Qdrant
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, HnswConfigDiff, PointStruct
import numpy as np
client = QdrantClient(host="localhost", port=6333)
collection_name = "dedup_qdrant"
client.recreate_collection(
collection_name=collection_name,
vectors_config=VectorParams(size=768, distance=Distance.COSINE),
hnsw_config=HnswConfigDiff(m=16, ef_construct=200),
)
BATCH = 10000
TOTAL = 10_000_000
rng = np.random.default_rng(42)
for start in range(0, TOTAL, BATCH):
n = min(BATCH, TOTAL - start)
vecs = rng.random((n, 768), dtype=np.float32)
vecs /= np.linalg.norm(vecs, axis=1, keepdims=True)
points = [
PointStruct(id=start + i, vector=vecs[i].tolist())
for i in range(n)
]
client.upsert(collection_name=collection_name, points=points, wait=False)
if start % 1_000_000 == 0:
print(f"upserted {start + n}")
print("qdrant count:", client.count(collection_name).count)
注意Qdrant的upsert是异步的,wait=False可以大幅提升写入吞吐,但最后要等索引构建完成再测查询。
4.3 查询对比
import time, numpy as np
def bench_milvus(col, queries, topk=10, ef=128):
params = {"metric_type": "COSINE", "params": {"ef": ef}}
lat = []
for q in queries:
t0 = time.perf_counter()
col.search([q.tolist()], "vec", param=params, limit=topk)
lat.append((time.perf_counter() - t0) * 1000)
return np.percentile(lat, [50, 95, 99])
def bench_qdrant(client, name, queries, topk=10, ef=128):
lat = []
for q in queries:
t0 = time.perf_counter()
client.search(name, query_vector=q.tolist(), limit=topk, search_params={"hnsw_ef": ef})
lat.append((time.perf_counter() - t0) * 1000)
return np.percentile(lat, [50, 95, 99])
五、踩坑与优化
坑1:Milvus写入后必须flush+load才能稳定查询。 不flush时数据在Growing Segment,查询走暴力扫描,延迟波动极大。我们一开始测出P99超过300ms,后来发现是没等索引构建完。正确做法是insert后flush,再等index building完成。
坑2:Qdrant的wait=False写入太快会堆积。 批量upsert如果不等,内存会涨,索引后台构建跟不上。建议每100万条做一次client.count确认,或最后调用一次wait=True。
坑3:ef_search对两者影响都很大。 ef从64提到256,召回率从0.91升到0.98,但P99延迟几乎翻倍。我们业务最终取ef=128,召回0.96,可接受。
坑4:Milvus内存占用高。 1000万×768维float32原始向量约30GB,Milvus加载索引后RSS达到约38GB,接近我们64GB机器的一半。Qdrant同样数据RSS约22GB,明显更省。
六、效果数据
1000万条768维向量,HNSW(M=16, ef_construct=200),ef_search=128,单线程顺序查询1000次:
| 指标 | Milvus 2.4.0 | Qdrant 1.9.0 |
|---|---|---|
| 写入1000万耗时 | 约42分钟 | 约28分钟 |
| 索引构建耗时 | 约18分钟 | 约11分钟 |
| P50延迟 | 8.2ms | 5.1ms |
| P95延迟 | 21ms | 12ms |
| P99延迟 | 34ms | 19ms |
| 单线程QPS | 约118 | 约190 |
| 空载内存 | 1.2GB | 80MB |
| 加载后RSS | 约38GB | 约22GB |
| 磁盘占用 | 约34GB | 约31GB |
| 召回率@10 | 0.962 | 0.958 |
并发测试(32线程压测)下,Milvus QPS约2100,Qdrant约3400。两者召回率接近,Qdrant在延迟和资源上全面占优;Milvus在分布式、多索引类型、生态集成上更强。
七、总结
如果业务是单机、千万级向量、对延迟和内存敏感,Qdrant是更务实的选择:部署一个容器、无需etcd/MinIO、资源占用低、P99延迟19ms。如果业务需要横向扩展到亿级、需要多种索引(DiskANN、GPU)、需要和Flink/Spark生态打通,Milvus更合适,代价是运维复杂度和资源开销更高。
我们最终选型:当前阶段用Qdrant单机,预留迁移到Milvus分布式的能力。选型没有绝对优劣,只有和业务阶段是否匹配。建议你在自己的数据分布上复现这套压测,因为HNSW的实际表现和向量维度、数据分布、过滤条件强相关。