1. 业务场景与选型背景
我们做的是电商拍照购,图搜链路需要支持千万级商品图向量检索。业务有三个硬性指标:P99延迟小于20ms,QPS不低于2000,支持每日百万级增量更新。之前用Faiss单机部署,内存吃紧,扩容困难。于是我们决定在Milvus和Qdrant之间做个PK。
选型考量点很具体:
- 部署复杂度:我们团队只有两个后端,没有专职运维,K8s用得也不熟。所以单机Docker部署的友好度很重要。
- 资源占用:线上机器预算有限,8C32G是上限。需要对比相同数据量下的内存与磁盘占用。
- 过滤能力:图搜需要带类目、品牌等标量过滤。不能只比纯向量检索,混合查询能力必须测。
- 数据导入效率:冷启动要导入1000万条数据,导入时间直接决定上线进度。
2. 测试环境与版本
| 组件 | 版本 | 配置 |
|---|---|---|
| CPU | Intel Xeon Platinum 8255C | 8核 |
| 内存 | DDR4 | 32GB |
| 磁盘 | NVMe SSD | 500GB |
| OS | Ubuntu 20.04.6 LTS | - |
| Docker | 20.10.25 | - |
| Milvus | 2.4.1(standalone) | 8C16G |
| Qdrant | 1.9.2(单节点) | 8C16G |
| 数据集 | 1000万条 | 4096维float32 |
| 客户端 | Python 3.10 | pymilvus 2.4.3 / qdrant-client 1.9.1 |
数据是ResNet50提取的4096维特征,每个向量约16KB,1000万条原始数据约160GB,量化后控制在40GB以内。
3. 部署步骤与参数调优
3.1 Milvus Standalone部署
Milvus 2.4用etcd存储元数据,MinIO存binlog。standalone模式用docker-compose一键起,但注意内存分配。
# docker-compose.yml (Milvus 2.4.1)
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
command: etcd -advertise-client-urls=http://etcd:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd
minio:
image: minio/minio:RELEASE.2023-03-20T20-16-18Z
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
command: minio server /minio_data --console-address ":9001"
milvus:
image: milvusdb/milvus:v2.4.1
command: ["milvus", "run", "standalone"]
environment:
ETCD_ENDPOINTS: etcd:2379
MINIO_ADDRESS: minio:9000
# 关键:限制内存,防止OOM
KNOWHERE_GPU_MEM_SIZE: 0
MILVUS_MSG_SIZE: 512
ports:
- "19530:19530"
depends_on:
- etcd
- minio
启动后创建collection,索引参数是关键:
from pymilvus import (
connections, Collection, CollectionSchema, FieldSchema, DataType,
utility
)
connections.connect(alias="default", host="localhost", port="19530")
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True),
FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=4096),
FieldSchema(name="category", dtype=DataType.INT64), # 标量过滤字段
FieldSchema(name="brand", dtype=DataType.VARCHAR, max_length=64)
]
schema = CollectionSchema(fields, "product image vector")
collection = Collection("product_search", schema)
# index参数直接影响查询性能
index_params = {
"index_type": "IVF_FLAT", # 不用IVF_SQ8,4096维下精度损失大
"metric_type": "IP",
"params": {"nlist": 4096}
}
collection.create_index("vector", index_params)
collection.load()
踩坑记录:Milvus 2.4默认的KNOWHERE_GPU_MEM_SIZE不设的话,如果你机器上有GPU但显存不够,会直接崩。另外MILVUS_MSG_SIZE控制消息队列大小,默认512KB,高并发下千万量级建议调大到1024。我们实测调大后P99下降3ms。
3.2 Qdrant单节点部署
Qdrant部署更简单,一个二进制文件搞定,官方镜像也就40MB。
# Qdrant 1.9.2 单节点部署
docker run -d \
--name qdrant \
-p 6333:6333 \
-p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage \
-e QDRANT__STORAGE__OPTIMIZER__MAX_SEGMENT_SIZE=200MB \
-e QDRANT__STORAGE__OPTIMIZER__MEMORY_INDEX_THRESHOLD=10000 \
-e QDRANT__SERVICE__GRPC_PORT=6334 \
qdrant/qdrant:v1.9.2
创建collection并建索引:
from qdrant_client import QdrantClient
from qdrant_client.models import (
Distance, VectorParams, CollectionCreateParams, OptimizersConfigDiff
)
client = QdrantClient(host="localhost", port=6333, prefer_grpc=True)
client.recreate_collection(
collection_name="product_search",
vectors_config=VectorParams(size=4096, distance=Distance.DOT),
optimizers_config=OptimizersConfigDiff(
indexing_threshold=10000, # 10000条后自动建索引
memmap_threshold=20000,
max_segment_size=200 * 1024 * 1024 # 200MB
),
shard_number=4, # 单机多分片提升并发
replication_factor=1
)
关键调优:Qdrant默认的max_segment_size是50MB,对于4096维向量,段太小会导致段数量过多,查询随机寻址开销大。我们调到200MB后,RPS提升约25%。另外shard_number设为4,充分利用多核。
4. 查询性能实测对比
4.1 纯向量TopK查询
测试方式:随机生成1000个查询向量,每个查询跑10次取平均。数据量1000万,TopK=10。
| 指标 | Milvus 2.4.1 | Qdrant 1.9.2 |
|---|---|---|
| QPS(单客户端并发64) | 2100 | 3200 |
| P99延迟(ms) | 19.2 | 12.8 |
| 平均延迟(ms) | 7.6 | 5.2 |
| 内存占用(used) | 18.7GB | 14.3GB |
| 磁盘占用 | 38GB | 35GB |
Qdrant胜在HNSW索引,Milvus用IVF_FLAT,虽然导入快但查询慢。如果用Milvus的HNSW(index_type: HNSW),QPS能到2800,但索引构建时间从25分钟增加到2小时,且内存多占3GB。
4.2 带标量过滤的混合查询
业务场景:category=1001 AND brand="nike" 过滤后查TopK。
| 过滤条件 | Milvus P99(ms) | Qdrant P99(ms) |
|---|---|---|
| 无过滤 | 19.2 | 12.8 |
| 过滤后剩10万条 | 24.5 | 15.2 |
| 过滤后剩5000条 | 31.8 | 22.6 |
Milvus的filter是后置过滤,先向量检索再标量过滤,过滤条件苛刻时耗时暴增。Qdrant的filter是前置的,在segment遍历时就排除不满足条件的向量,效率高很多。这一点在业务场景里非常关键——我们很多查询过滤后只剩几千条,Qdrant的优势非常明显。
5. 数据导入与更新实测
5.1 冷启动导入1000万条
| 阶段 | Milvus耗时 | Qdrant耗时 |
|---|---|---|
| 数据预处理(BLOB转向量) | 38分钟 | 38分钟 |
| 批量导入+建索引 | 42分钟 | 1小时15分钟 |
| 总量 | 80分钟 | 1小时53分钟 |
Milvus的IVF_FLAT建索引是分段的,导入速度优势明显。Qdrant的HNSW边插入边建索引,速度慢但这是实时的——导入完就能查,Milvus还要等load。
5.2 增量更新
模拟业务场景:每天新增10万条,删除5万条旧数据。
| 操作 | Milvus | Qdrant |
|---|---|---|
| 插入10万条 | 12秒 | 18秒 |
| 删除5万条 | 25秒 | 8秒 |
| 更新向量 | 不支持(需删+插) | 原生支持upsert |
Milvus 2.4的delete是标记删除,数据还在磁盘上,要等compact才真正释放空间。我们测试时连续删了3天,磁盘占用只增不减,必须手动触发collection.compact()。Qdrant的delete是即时生效的,segment直接重写。这个差异在频繁删除更新场景下非常致命。
6. 资源占用与稳定性
6.1 内存对比
- Milvus:常驻内存18.7GB,其中数据段缓存占大头。空闲时内存不释放,通过
MILVUS_MSG_SIZE和KNOWHERE_ALPHA可以微调。长时间运行有内存碎片问题,我们跑了5天后内存从18GB涨到21GB,只能重启。 - Qdrant:14.3GB,且空闲时自动释放部分缓存。跑了7天内存稳定在14.5GB左右。
memmap_threshold参数控制多少条后切换到mmap模式,有效控制内存峰值。
6.2 CPU占用
- 查询压力下:Milvus 8核吃满,Qdrant约6核。Qdrant的HNSW图遍历是CPU密集型的,但优化得更好。
- 导入时:Milvus 4核,Qdrant 7核。Qdrant建HNSW索引时CPU打满。
6.3 稳定性
- Milvus:出现了两次etcd lease过期导致节点假死,需要重启etcd。社区issue里也有类似反馈。
- Qdrant:没遇到崩溃,但有一次segment损坏,好在有WAL日志恢复回来了。
7. 总结与选型建议
我们最终选了Qdrant,核心原因是:
- 混合查询性能强:带过滤的查询P99稳定在20ms内,Milvus在过滤条件苛刻时会飙到30ms+。
- 资源占用更可控:内存稳定,不会像Milvus那样持续涨。
- 实时删除更新:upsert原生支持,不用手动compact。
- 部署简单:单二进制,没有etcd/MinIO这些外部依赖。
但Milvus不是没有优势:
- 冷导入速度快,适合一次性导入大数据的场景。
- 生态更丰富,有GUI管理界面,监控指标完善。
- 分布式扩展成熟,Qdrant单机版到分布式要换架构。
如果你跟我一样是中小团队,业务以混合查询为主,Qdrant值得一试。 如果你要处理PB级数据且需要流式写入,Milvus的分布式能力更靠谱。另外提一句,Qdrant的RESTful API非常友好,排查问题比Milvus的gRPC舒服多了。