一、问题背景:为什么要在 Milvus 和 Qdrant 之间做选择
我们做的是电商商品图文检索,用户上传一张图,返回相似商品。向量由 CLIP 类模型产出,768 维,全量约 2000 万条,每天增量 30 万左右。业务对在线检索的要求是:单次 TopK=20,P99 延迟控制在 50ms 以内,峰值 300 QPS,同时要支持按类目、价格区间做标量过滤。
候选方案里,FAISS 太底层,运维和增量更新要自己造轮子;pgvector 在千万级下过滤性能掉得厉害。剩下两个主流选项就是 Milvus 和 Qdrant。社区里关于两者的讨论很多,但大多停留在"Milvus 功能全、Qdrant 轻量"这种层面,缺少同硬件、同数据量下的实测数字。这篇文章就是我们自己跑出来的一手数据。
二、环境与版本
硬件统一为一台 16 核 32G 内存、单卡 NVIDIA A10(24G 显存)的云主机,系统 Ubuntu 22.04,Docker 24.0.7,Python 3.10。
- Milvus:2.4.4,standalone 模式,依赖 etcd 3.5.5 + MinIO(RELEASE.2023-03-20T20-16-18Z)
- Qdrant:1.9.2,单节点,开启 on_disk_payload
- 客户端:pymilvus 2.4.3、qdrant-client 1.9.1
需要说明的是,Milvus 我们只测了 standalone,集群模式在 2000 万规模下不是必需,而且资源对比会失真。Qdrant 本身就是单机架构,天然对齐。
三、方案设计
数据集:2000 万条 768 维 float32 向量,随机生成但做了聚类(用 KMeans 生成 1000 个簇心,模拟真实 embedding 的分布),附带 3 个标量字段:category_id(int,取值 0-999)、price(float)、create_time(int64)。
索引方案:
- Milvus 用 IVF_FLAT,nlist=4096,nprobe=32;标量字段建倒排索引。
- Qdrant 用 HNSW,m=16,ef_construct=200,检索时 hnsw_ef=64;category_id 建 keyword payload index。
压测用 locust 起 50 并发,跑 10 分钟,统计 QPS、P50/P99 延迟、内存和显存占用。过滤查询占 30%,纯向量查询占 70%。
四、核心实现
4.1 部署
Milvus 用官方 docker-compose(standalone 版),关键是把 etcd 和 MinIO 的卷挂到本地盘,避免容器重启丢数据:
# docker-compose.yml 节选
services:
etcd:
image: quay.io/coreos/etcd:v3.5.5
environment:
- ETCD_AUTO_COMPACTION_MODE=revision
- ETCD_AUTO_COMPACTION_RETENTION=1000
volumes:
- ./volumes/etcd:/etcd
command: etcd -advertise-client-urls=http://127.0.0.1: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_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
volumes:
- ./volumes/minio:/minio_data
command: minio server /minio_data --console-address ":9001"
standalone:
image: milvusdb/milvus:v2.4.4
command: ["milvus", "run", "standalone"]
environment:
ETCD_ENDPOINTS: etcd:2379
MINIO_ADDRESS: minio:9000
volumes:
- ./volumes/milvus:/var/lib/milvus
ports:
- "19530:19530"
- "9091:9091"
Qdrant 简单得多,一条命令:
docker run -d --name qdrant \
-p 6333:6333 -p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage \
qdrant/qdrant:v1.9.2
4.2 写入与检索代码
Milvus 侧(pymilvus 2.4.3):
from pymilvus import connections, FieldSchema, CollectionSchema, DataType, Collection, utility
connections.connect("default", host="127.0.0.1", port="19530")
fields = [
FieldSchema("id", DataType.INT64, is_primary=True),
FieldSchema("embedding", DataType.FLOAT_VECTOR, dim=768),
FieldSchema("category_id", DataType.INT64),
FieldSchema("price", DataType.FLOAT),
FieldSchema("create_time", DataType.INT64),
]
schema = CollectionSchema(fields, description="product_vectors")
if not utility.has_collection("products"):
coll = Collection("products", schema)
coll.create_index("embedding", {
"index_type": "IVF_FLAT", "metric_type": "IP",
"params": {"nlist": 4096}
})
coll.create_index("category_id", {"index_type": "INVERTED"})
coll.load()
coll = Collection("products")
# 批量写入,每批 5000 条
import random
batch = 5000
for start in range(0, 20_000_000, batch):
rows = [[start + i for i in range(batch)],
[[random.random() for _ in range(768)] for _ in range(batch)],
[random.randint(0, 999) for _ in range(batch)],
[random.uniform(10, 5000) for _ in range(batch)],
[1700000000 + i for i in range(batch)]]
coll.insert(rows)
coll.flush()
# 检索:纯向量 + 标量过滤
res = coll.search(
data=[[random.random() for _ in range(768)]],
anns_field="embedding",
param={"metric_type": "IP", "params": {"nprobe": 32}},
limit=20,
expr="category_id in [12, 88] and price < 1000",
output_fields=["category_id", "price"]
)
Qdrant 侧(qdrant-client 1.9.1):
from qdrant_client import QdrantClient
from qdrant_client.models import (
VectorParams, Distance, PointStruct, Filter,
FieldCondition, MatchAny, Range, PayloadSchemaType
)
import random
client = QdrantClient(host="127.0.0.1", port=6333)
client.recreate_collection(
collection_name="products",
vectors_config=VectorParams(size=768, distance=Distance.DOT),
hnsw_config={"m": 16, "ef_construct": 200},
on_disk_payload=True,
)
client.create_payload_index("products", "category_id", PayloadSchemaType.INTEGER)
batch = 5000
for start in range(0, 20_000_000, batch):
points = [
PointStruct(
id=start + i,
vector=[random.random() for _ in range(768)],
payload={
"category_id": random.randint(0, 999),
"price": random.uniform(10, 5000),
"create_time": 1700000000 + i,
},
) for i in range(batch)
]
client.upsert("products", points=points, wait=False)
client.count("products") # 确认写入完成
res = client.search(
collection_name="products",
query_vector=[random.random() for _ in range(768)],
limit=20,
query_filter=Filter(must=[
FieldCondition(key="category_id", match=MatchAny(any=[12, 88])),
FieldCondition(key="price", range=Range(lt=1000)),
]),
search_params={"hnsw_ef": 64},
with_payload=True,
)
五、踩坑与优化
第一个坑是 Milvus 的 nprobe。默认值 8 在 2000 万数据上召回率只有 0.72,调到 32 才到 0.94,延迟从 12ms 涨到 38ms,这个 trade-off 必须明确。Qdrant 的 hnsw_ef 同理,64 是召回 0.95 的拐点,再往上收益递减。
第二个坑是 Qdrant 的 payload 索引。一开始没建 category_id 索引,带过滤的查询直接扫全量 payload,P99 飙到 800ms。建了 integer 索引后降到 30ms 以内,这个差距非常夸张,千万别省这一步。
第三个坑是 Milvus 的 flush 策略。我们一开始每批 insert 后都 flush,写入 2000 万花了 6 小时,后来改成批量插入、最后统一 flush,2 小时搞定。但注意 flush 期间查询会变慢,生产上要错峰。
第四个是资源。Milvus standalone 因为要跑 etcd、MinIO、querynode、datanode 等一堆组件,空载就吃掉 6G 内存;Qdrant 空载约 1.2G。加载 2000 万向量后,Milvus 常驻 22G 内存 + 8G 显存,Qdrant 常驻 14G 内存 + 6G 显存。Qdrant 的 on_disk_payload=True 帮了大忙,否则 payload 全内存还得再加 5G。
六、效果数据
10 分钟压测结果(50 并发,300 QPS 目标):
| 指标 | Milvus 2.4.4 | Qdrant 1.9.2 |
|---|---|---|
| 实际 QPS | 312 | 305 |
| P50 延迟 | 11ms | 9ms |
| P99 延迟(纯向量) | 41ms | 28ms |
| P99 延迟(带过滤) | 58ms | 47ms |
| 召回率@20 | 0.94 | 0.95 |
| 常驻内存 | 22G | 14G |
| 显存占用 | 8G | 6G |
| 写入 2000 万耗时 | 2h05m | 1h38m |
| 冷启动加载 | 9min | 4min |
结论很清晰:在我们的场景下,Qdrant 全面占优——延迟低 30% 左右,内存省 35%,写入快 20%。Milvus 的优势在于分布式扩展和生态,单机场景下反而被架构复杂度拖累。最终我们选了 Qdrant,2000 万规模单机完全扛得住,后续上 5000 万再考虑分片。如果业务未来有跨机房、多副本强一致的需求,Milvus 的集群能力还是更成熟,这也是它值得保留在技术雷达里的原因。选型没有绝对的对错,只有和场景贴不贴。