一、问题背景:从ES到向量数据库,为什么我们不得不换
我们的业务是做电商主图的以图搜图,早期用Elasticsearch的dense_vector插件扛了半年。但随着数据量从800万涨到3000万,ES的暴力扫描式检索(HNSW参数调优后仍需要400ms+)彻底撑不住了。更致命的是,ES的写入放大问题导致每次全量重建索引要跑7小时,业务方完全无法接受。
于是我们立项做向量数据库选型。当时候选有FAISS、Milvus、Qdrant、Weaviate四个,但FAISS需要自己封装服务且没有内置过滤,Weaviate的文档和社区活跃度让我们心里打鼓,最终只剩下Milvus和Qdrant进入POC测试。
二、环境与版本:同一台裸机,杜绝“作弊”
为了公平对比,我们用同一台物理机(浪潮NF5280M5,Intel Gold 6248R * 2,32核64线程,128GB DDR4 ECC,NVMe SSD RAID0)分别部署两个数据库。操作系统为Ubuntu 20.04.6 LTS,内核5.4.0。Docker版本24.0.7,docker-compose 2.23.3。
版本选择:
- Milvus:2.4.1(standalone模式,etcd+minio+milvus三个容器)
- Qdrant:1.9.2(单节点,官方镜像)
- 数据:COCO2017 + 自采商品图共5000万张,用ResNet50(PyTorch 2.1.0)提取768维特征,归一化后存储。
- 评估工具:自研golang压测客户端,模拟真实查询分布(80%查询附带商品类目过滤条件)。
三、方案设计:部署与配置的“第一次亲密接触”
先说结论:Milvus的部署复杂度明显高于Qdrant。Milvus standalone虽然叫“单机版”,依赖了etcd(元数据)、MinIO(对象存储)和真正的Milvus服务,docker-compose文件有200多行。而Qdrant就一个镜像,默认端口6333,一条命令搞定。
这里贴出两边的关键启动配置(docker-compose片段):
# Milvus 2.4.1 docker-compose.yml 核心片段
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
minio:
image: minio/minio:RELEASE.2023-03-20T20-16-18Z
environment:
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
milvus:
image: milvusdb/milvus:v2.4.1
command: ["milvus", "run", "standalone"]
environment:
ETCD_ENDPOINTS: etcd:2379
MINIO_ADDRESS: minio:9000
volumes:
- /mnt/milvus/data:/var/lib/milvus
ports:
- "19530:19530"
# Qdrant 1.9.2 docker-compose.yml
services:
qdrant:
image: qdrant/qdrant:v1.9.2
ports:
- "6333:6333"
- "6334:6334" # gRPC端口
volumes:
- /mnt/qdrant/storage:/qdrant/storage
command: ["/qdrant", "--config-path", "/qdrant/config/production.yaml"]
注意Qdrant默认日志级别是INFO,会大量刷屏,我直接在command里加了--log-level ERROR。Milvus的配置则分散在etcd和MinIO里,排查问题时要看三个容器的日志。
四、核心实现:数据灌入与索引构建的“暴力美学”
我们先用Python脚本(pymilvus 2.4.x和qdrant-client 1.9.x)灌数据。这里有个关键差异:Milvus的批量写入(insert)和Qdrant的upsert性能差距巨大。
Milvus写入实测:5000万条向量,batch_size=1024,并发10个写入线程。实测吞吐稳定在8.5K vectors/s,总耗时98分钟。期间CPU利用率只有40%左右,瓶颈在Milvus的segment合并和etcd同步。
Qdrant写入实测:同样batch_size=1024,并发10。Qdrant的gRPC接口吞吐达到22K vectors/s,总耗时仅38分钟。但Qdrant的写入会立刻触发WAL(Write-Ahead Log)刷盘,NVMe SSD的写入放大明显,监控看到SSD的DWPD(每日全盘写入次数)飙到1.2。
索引构建上,两边都采用HNSW算法:
# Milvus创建索引的python代码
from pymilvus import connections, Collection, FieldSchema, CollectionSchema, DataType, utility
connections.connect(host='localhost', port='19530')
fields = [
FieldSchema(name='id', dtype=DataType.INT64, is_primary=True),
FieldSchema(name='embedding', dtype=DataType.FLOAT_VECTOR, dim=768),
FieldSchema(name='category', dtype=DataType.INT64) # 用于过滤
]
schema = CollectionSchema(fields, '商品图向量')
collection = Collection('product_embedding', schema)
# HNSW参数:M=16, efConstruction=200
index_params = {
'index_type': 'HNSW',
'metric_type': 'IP', # 内积,因为向量已归一化
'params': {'M': 16, 'efConstruction': 200}
}
collection.create_index('embedding', index_params)
collection.load()
# Qdrant创建索引的python代码
from qdrant_client import QdrantClient, models
client = QdrantClient(host='localhost', port=6333, grpc_port=6334, prefer_grpc=True)
client.create_collection(
collection_name='product_embedding',
vectors_config=models.VectorParams(size=768, distance=models.Distance.COSINE),
hnsw_config=models.HnswConfigDiff(m=16, ef_construct=200),
optimizers_config=models.OptimizersConfigDiff(
indexing_threshold=0 # 立即开始构建索引
)
)
注意Qdrant的indexing_threshold默认是20000(即超过2万条向量才开始构建索引),我们显式设为0强制立即建索引,否则查询性能会极其拉胯。
实测索引构建时间(全量5000万条):
- Milvus:2小时17分,构建完成后内存稳定在24GB
- Qdrant:3小时41分,构建期间内存峰值达31GB,完成后稳定在19GB
五、踩坑与优化:没有对比就没有伤害
踩坑1:Milvus的load/unload机制坑死个人
Milvus 2.x要求先load()才能查询,但load是异步的。我们第一次压测时直接查询,返回错误collection not loaded,排查半天发现要轮询get_load_state接口。后来改为启动后强制load()并等待,耽误了不少时间。
踩坑2:Qdrant的filter与向量检索融合度
我们业务80%查询带类目过滤(category字段)。Qdrant对payload过滤(类似ES的filter)支持得非常好,内部用BitMask优化,过滤+Top-K查询的RPS几乎不下降。而Milvus的过滤走的是expr参数,在5000万数据量下,带过滤的查询RPS直接掉了53%(从286降到134)。这个差距直接导致我们最终选择了Qdrant。
优化1:Qdrant的WAL刷盘策略
Qdrant默认每次写入都fsync,我们调整为wal_fsync=0(生产环境可接受丢少量数据),写入吞吐翻倍到44K vectors/s,索引构建时间缩短到2小时52分。
优化2:Milvus的segment大小调整
Milvus默认segment大小是512MB,我们调大到1GB并设置segment.maxSize=1024,减少了segment数量,查询RPS从286提升到312,但内存占用上升了2GB。
六、效果数据:一图流对比与最终选型
最终压测数据(5000万向量,768维,16C32G单机,100并发查询,P99延迟):
| 指标 | Milvus 2.4.1 | Qdrant 1.9.2 |
|---|---|---|
| 纯向量查询RPS | 286 | 312 |
| 带过滤查询RPS(category=123) | 134 | 298 |
| P99延迟(纯向量) | 48ms | 39ms |
| P99延迟(带过滤) | 112ms | 42ms |
| 索引构建时间 | 2h17m | 2h52m(优化后) |
| 内存占用(稳态) | 24GB | 19GB |
| 磁盘占用 | 127GB | 149GB |
| 部署时间(含踩坑) | 2天 | 4小时 |
结论:如果业务只有纯向量检索,Milvus性价比更高(索引快、磁盘省);如果像我们一样重度依赖结构化过滤(类目、价格区间、标签),Qdrant的filter性能碾压Milvus。我们最终选择Qdrant,部署在K8s里用operator管理,目前上线3个月,每天处理2000万次查询,P99稳定在45ms以内。
另外吐槽一句:Milvus的文档和社区虽然更热闹,但版本升级太频繁(2.3到2.4的API就变了),而Qdrant的API设计更符合直觉,Python客户端类型提示非常完善,给后端同学省了不少心。
七、总结:选型没有银弹,只有适不适合
如果你正在选型,我的建议是:
1. 先用1000万条数据做POC,不要信官方benchmark。
2. 明确你的查询模式:过滤条件多不多?过滤字段基数大不大?
3. 如果你的过滤字段是低基数的(比如性别、类目),Qdrant优势巨大;如果是高基数的(比如用户ID),Milvus的expr可能更合适。
4. 部署运维能力强的团队可以选Milvus,反之选Qdrant,它的single binary设计太香了。
以上数据基于特定硬件和业务场景,仅供参考。如果有不同意见,欢迎评论区交流。