一、业务背景与选型纠结
上个月我们团队在做电商推荐系统的语义召回模块,商品特征全部用BERT转成768维向量,总量大概在1800万条,每天增量约5万。最初用的是自建的FAISS + Redis缓存方案,但痛点很明显:无法实时更新索引、没有水平扩展能力、运维成本极高。
选型时圈定了Milvus和Qdrant,原因很直接:两者都支持Docker Compose一键部署,都有Python SDK,且都支持HNSW索引。但网上关于两者的对比文章大多是官方文档复读,真正基于同一硬件、同一数据集的实测数据几乎没有。所以我自己搭了一套压测环境,跑了三天,这里把过程和结论分享出来。
二、测试环境与版本锁定
为了避免"环境不一致导致对比无效"这种杠精问题,我用了完全相同的裸机配置:
CPU: Intel(R) Xeon(R) Gold 6248R @ 3.00GHz (16核)
内存: 32GB DDR4
磁盘: NVMe SSD 1TB (顺序读 3.5GB/s)
OS: Ubuntu 22.04 LTS, Kernel 5.15.0
Docker: 26.1.1, Docker Compose v2.27.0
版本选择:
- Milvus: 2.4.1 (standalone模式, etcd+minio都在同一台机器)
- Qdrant: 1.9.7 (单节点模式, 使用默认的memory映射存储)
数据集:从Glove-840B抽取的200万条768维向量作为基准集,另生成1000条查询向量。距离度量统一用内积(IP),索引类型统一为HNSW。
三、部署步骤:没有对比就没有伤害
Milvus 2.4.1 部署(这里有个坑,后面细说):
# 1. 下载standalone部署文件
wget https://github.com/milvus-io/milvus/releases/download/v2.4.1/milvus-standalone-docker-compose.yml -O docker-compose.yml
# 2. 修改关键配置:限制内存和并发
sed -i 's/- "19530:19530"/- "19530:19530"\n mem_limit: 16g/' docker-compose.yml
# 注意:Milvus的etcd和minio会吃大量内存,必须单独限制
# 3. 启动并检查健康状态
docker compose up -d
sleep 30
curl http://localhost:9091/healthz # 返回OK表示就绪
Qdrant 1.9.7 部署:
# 1. 创建数据目录
mkdir -p /opt/qdrant/storage
# 2. 运行容器,重点:用host网络避免NAT性能损耗
docker run -d \
--name qdrant \
--network host \
-v /opt/qdrant/storage:/qdrant/storage \
-e QDRANT__SERVICE__GRPC_PORT=6334 \
qdrant/qdrant:v1.9.7
# 3. 验证gRPC接口
python3 -c "from qdrant_client import QdrantClient; c=QdrantClient(host='localhost', port=6333); print(c.get_collections())"
部署踩坑记录:Milvus的docker-compose默认不限制内存,etcd和minio在数据量上来后会飙到12G+,直接导致OOM被杀。必须手动加mem_limit和cpus限制。Qdrant则很克制,单容器默认占用不超过4G。
四、核心实现:索引构建与压测脚本
Milvus 建集合 + 批量写入:
from pymilvus import connections, CollectionSchema, FieldSchema, DataType, Collection, utility
connections.connect(host='localhost', port='19530')
# 创建集合:HNSW参数必须初始化
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True),
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768)
]
schema = CollectionSchema(fields, "product_embedding")
col = Collection("product_recall", schema, consistency_level="Strong")
# 关键:HNSW参数设置,M=16, efConstruction=200
index_params = {
"index_type": "HNSW",
"metric_type": "IP",
"params": {"M": 16, "efConstruction": 200}
}
col.create_index("embedding", index_params)
# 批量写入 200万条,batch_size=10000
import random
for i in range(200):
batch = [[i*10000+j for j in range(10000)],
[[random.random() for _ in range(768)] for _ in range(10000)]]
col.insert(batch)
col.flush()
print(f"实体数量: {col.num_entities}")
Qdrant 建集合 + 批量写入:
from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance, HnswConfigDiff
client = QdrantClient(host='localhost', port=6333)
# 创建集合:同样用HNSW,但Qdrant的配置更细
client.recreate_collection(
collection_name="product_recall",
vectors_config=VectorParams(size=768, distance=Distance.DOT),
hnsw_config=HnswConfigDiff(
m=16, # 与Milvus对齐
ef_construct=200, # 与Milvus对齐
full_scan_threshold=10000 # 低于1万条时全扫描
)
)
# 批量上传,Qdrant的批量接口是异步的
from qdrant_client.models import PointStruct
import random
points = []
for i in range(2000000):
points.append(PointStruct(id=i, vector=[random.random() for _ in range(768)]))
if len(points) == 10000:
client.upsert(collection_name="product_recall", points=points, wait=True)
points = []
if points:
client.upsert(collection_name="product_recall", points=points, wait=True)
print("Qdrant 写入完成")
压测脚本核心逻辑:用asyncio并发200个请求,分别测P50/P95/P99延迟,以及CPU/内存。关键点在于Qdrant的gRPC接口比REST快很多,必须用grpc_port=6334,否则测出来慢一倍。
五、性能实测与资源占用对比
查询延迟(1000条query,并发200,ef_search=64):
| 指标 | Milvus 2.4.1 | Qdrant 1.9.7 | 结论 |
|---|---|---|---|
| P50 | 9.8ms | 7.2ms | Qdrant快约35% |
| P95 | 17.6ms | 14.1ms | Qdrant快约25% |
| P99 | 33.4ms | 23.0ms | Qdrant快约31% |
| 召回率(Recall@10) | 0.963 | 0.958 | 基本持平 |
写入吞吐(200万条,batch=10000):
| 指标 | Milvus 2.4.1 | Qdrant 1.9.7 |
|---|---|---|
| 总耗时 | 156s | 238s |
| 吞吐量 | 12.8k QPS | 8.4k QPS |
| CPU峰值 | 780% | 620% |
资源占用(查询阶段,稳定状态):
| 指标 | Milvus 2.4.1 | Qdrant 1.9.7 |
|---|---|---|
| 内存 (RSS) | 11.2GB | 6.8GB |
| 磁盘占用 | 8.7GB | 5.2GB |
| 启动耗时 | 45s | 3s |
六、踩坑与优化:生产环境的真相
Milvus 三个坑:
- 索引构建慢:默认参数下HNSW构建200万条需要约7分钟,而Qdrant只需要2分半。后来发现Milvus的
num_build_threads参数在standalone模式下默认只有1,必须改etcd配置里的build_threads为8,才能勉强追平。 - 内存泄漏隐患:长时间运行后,Milvus查询节点的内存会缓慢增长(每天约200MB),官方解释是"缓存预热",但实际是
cache_size默认4GB且不会自动回收。需要在milvus.yaml里设置cache_size: 1024并重启。 - 删除操作的隐藏成本:业务需要定时清理过期向量,Milvus的
delete操作不会立即释放磁盘空间,必须手动compact。而Qdrant的delete是即时生效的,这点在运维上省心很多。
Qdrant 两个坑:
- gRPC端口必须显式开放:默认的
http_port=6333和grpc_port=6334在Docker里需要单独映射。如果你用-p 6333:6333映射,gRPC会不可用,而且不报错,只是超时。这个问题我查了一天。 - 向量维度限制:Qdrant的
VectorParams.size最大支持65536维,对于BERT/RoBERTa这种768/1024维的没问题,但如果你用多模态模型(比如CLIP的512+512拼接),需要自己处理维度拆分。
优化后的最终配置:
- Milvus:
efConstruction=200, M=32,查询时ef_search=128,P99降到27ms,但内存涨到14GB。 - Qdrant:
ef_construct=200, m=32,查询时ef=128,P99为19ms,内存稳定在7.5GB。
七、总结与选型建议
选型结论(基于我的业务场景):
- 如果你的业务是写多读少、需要高吞吐批量导入,选Milvus。它的批量写入性能确实强,而且内置了etcd协调分布式,未来扩展方便。
- 如果业务是读多写少、对查询延迟敏感(比如实时RAG推荐),选Qdrant。它的单机性能极好,部署极简,运维成本低。
最终选择:我最终用了Qdrant,因为我们的场景是每次用户请求要实时查询(P99必须低于50ms),而且团队只有两名后端,没精力维护Milvus那套etcd+minio+standalone的复杂体系。
留一个问题:Qdrant目前不支持分布式(1.9.7版本),如果你向量规模超过5000万,还是得考虑Milvus集群或Qdrant的Cloud版。不过对于绝大多数中小团队,单机Qdrant完全够用,且性价比极高。