一、问题背景
上个月接手一个图文去重项目,需要把 1000 万条商品图文向量(768 维,来自 BGE-base)做近邻检索,用于判断新上传图片是否和历史库重复。业务侧要求:单次查询 P99 控制在 50ms 以内,QPS 峰值 200,支持按 category_id 做标量过滤。
候选方案锁定两个:Milvus 和 Qdrant。网上文章要么是官方 benchmark 的「跑分图」,要么是「Hello World」级别的入门,缺少同机器、同数据、同索引参数下的真实对比。所以我干脆自己搭一套,把所有变量控制住,跑一遍实测。
这篇文章记录完整过程:部署、建索引、压测、踩坑、数据。所有版本号和配置都写清楚,你可以直接复现。
二、环境与版本
- 机器:阿里云 ecs.g7.4xlarge,16 vCPU / 64GB / ESSD PL1 500GB
- OS:Ubuntu 22.04,内核 5.15
- Docker:24.0.7,Docker Compose v2.23.0
- Milvus:2.4.1(standalone 模式,etcd + MinIO 内嵌)
- Qdrant:1.9.2(单节点,无额外依赖)
- 客户端:Python 3.10,pymilvus 2.4.3,qdrant-client 1.9.1
- 数据集:1000 万条 768 维 float32 向量,随机生成但加了聚类结构(模拟真实分布),另带 category_id(0-99)标量字段
注意:Milvus standalone 虽然叫「单机」,但它依赖 etcd 和 MinIO 两个组件,实际是三个容器。Qdrant 是真正的单进程。这一点在资源占用对比里很关键。
三、部署步骤
3.1 Milvus 2.4.1
官方推荐用 docker-compose。我用的 milvus-standalone-docker-compose.yml,只改了一处:把 MILVUS_MEM_LIMIT 去掉,让它吃满机器内存,方便对比真实占用。
wget https://github.com/milvus-io/milvus/releases/download/v2.4.1/milvus-standalone-docker-compose.yml -O docker-compose-milvus.yml
docker compose -f docker-compose-milvus.yml up -d
启动后三个容器:milvus-standalone、milvus-etcd、milvus-minio。健康检查:
curl http://localhost:9091/healthz
# OK
3.2 Qdrant 1.9.2
Qdrant 简单得多,一条命令:
docker run -d --name qdrant \
-p 6333:6333 -p 6334:6334 \
-v /data/qdrant:/qdrant/storage \
qdrant/qdrant:v1.9.2
健康检查 curl http://localhost:6333/healthz 返回 healthz check passed。
3.3 建集合与索引
Milvus 侧,我用 IVF_SQ8 和 HNSW 各建一次对比,最终选 HNSW(M=16, efConstruction=200),因为业务对延迟敏感。
from pymilvus import Collection, CollectionSchema, FieldSchema, DataType, connections, utility
connections.connect("default", host="localhost", port="19530")
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=False),
FieldSchema(name="category_id", dtype=DataType.INT64),
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768),
]
schema = CollectionSchema(fields, description="image dedup")
col = Collection("img_dedup", schema, consistency_level="Bounded")
index_params = {
"index_type": "HNSW",
"metric_type": "COSINE",
"params": {"M": 16, "efConstruction": 200},
}
col.create_index("embedding", index_params)
col.create_index("category_id", {"index_type": "INVERTED"}) # 标量过滤加速
col.load()
Qdrant 侧,HNSW 参数对应 m=16, ef_construct=200,并在 payload 上建 integer 索引:
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, HnswConfigDiff, PayloadSchemaType
client = QdrantClient(host="localhost", port=6333)
client.recreate_collection(
collection_name="img_dedup",
vectors_config=VectorParams(size=768, distance=Distance.COSINE),
hnsw_config=HnswConfigDiff(m=16, ef_construct=200),
)
client.create_payload_index("img_dedup", "category_id", PayloadSchemaType.INTEGER)
两者都开 on_disk 与否我也分别测了,下面数据默认内存模式。
四、核心实现:写入与压测
写入我用批量 1000 条,分 10000 批灌完 1000 万条。压测用 500 个真实分布的 query 向量,串行 + 并发两种模式。
Milvus 查询:
import time, numpy as np
from pymilvus import Collection
col = Collection("img_dedup")
col.load()
def milvus_search(q, topk=10, ef=64, cat=None):
expr = f"category_id == {cat}" if cat is not None else None
t0 = time.perf_counter()
res = col.search(
data=[q], anns_field="embedding",
param={"metric_type": "COSINE", "params": {"ef": ef}},
limit=topk, expr=expr, output_fields=["category_id"],
)
return (time.perf_counter() - t0) * 1000, res
Qdrant 查询:
import time
from qdrant_client import QdrantClient
from qdrant_client.models import Filter, FieldCondition, MatchValue
client = QdrantClient(host="localhost", port=6333)
def qdrant_search(q, topk=10, ef=64, cat=None):
qfilter = None
if cat is not None:
qfilter = Filter(must=[FieldCondition(key="category_id", match=MatchValue(value=cat))])
t0 = time.perf_counter()
res = client.search(
collection_name="img_dedup", query_vector=q.tolist(),
limit=topk, search_params={"hnsw_ef": ef}, query_filter=qfilter,
)
return (time.perf_counter() - t0) * 1000, res
压测脚本对每个 query 跑 10 次取均值,去掉首尾各 5% 极值算 P50/P99。并发用 32 线程,模拟 200 QPS 压力。
五、踩坑与优化
坑一:Milvus 的 ef 参数在 search 里叫 ef,但建索引时叫 efConstruction,两个不是一个东西。我一开始把 ef 设成 200,延迟直接飙到 80ms。降到 64 后 P99 回到 40ms 内,召回率(Recall@10)只掉 0.8%。
坑二:Qdrant 的 search 在 1.9 里 deprecated,官方推荐 query_points。我一开始用 search 还能跑,但日志一直 warning。切到 query_points 后 API 更清晰,过滤写法也统一了。
坑三:Milvus standalone 的 etcd 默认 2GB 内存限制,灌到 800 万条时 etcd 开始 OOM,写入报 etcdserver: request timed out。改 ETCD_QUOTA_BACKEND_BYTES=8589934592 才稳住。Qdrant 没这个问题,但它的 WAL 默认 32MB,高并发写入时建议调到 128MB。
坑四:标量过滤。Milvus 的 expr 过滤在 100 万级数据上很快,但 1000 万级且 category 分布均匀时,P99 会从 38ms 涨到 52ms。Qdrant 的 payload index 效果更明显,同样条件下只涨到 41ms。这点 Qdrant 赢。
六、效果数据
所有数据为 5 轮压测中位数,单位 ms(延迟)和 GB(内存)。
| 指标 | Milvus 2.4.1 | Qdrant 1.9.2 |
|---|---|---|
| 索引构建耗时 | 42 min | 28 min |
| 内存占用(加载后) | 38.6 GB | 22.4 GB |
| 磁盘占用(向量+索引) | 31.2 GB | 24.7 GB |
| P50 延迟(无过滤) | 12 ms | 9 ms |
| P99 延迟(无过滤) | 38 ms | 20 ms |
| P99 延迟(category 过滤) | 52 ms | 41 ms |
| 单机 QPS(32 并发) | 310 | 480 |
| Recall@10 | 0.982 | 0.978 |
几个观察:
- 内存差距最大。Qdrant 22.4GB vs Milvus 38.6GB,差 42%。原因是 Milvus 的 etcd + MinIO + 内部 segment 缓存有固定开销,Qdrant 是单进程直接 mmap。
- 延迟 Qdrant 全面领先,P99 低 18ms。HNSW 实现上 Qdrant 的图遍历更紧凑,缓存命中率更高。
- 过滤场景 Qdrant 优势扩大。它的 payload index 和向量索引是同一套存储,Milvus 的标量索引走独立路径,跨 segment 时开销更大。
- 召回率两者几乎一样,都在 0.98 左右,差异在统计误差内。
- 扩展性 Milvus 完胜。Qdrant 单机到 5000 万条以上开始吃力,Milvus 可以加 query node 水平扩。如果你的数据会涨到亿级,这点要提前想。
七、总结
回到选型。我的结论是:
- 数据量 5000 万以内、追求低延迟低内存、过滤条件复杂 → 选 Qdrant。部署简单,单机性能强,运维成本低。
- 数据量亿级、需要水平扩展、团队已有 K8s 和对象存储 → 选 Milvus。生态成熟,分布式能力是它的护城河。
- 如果只是做 PoC 或小规模业务,Qdrant 的启动成本低到可以忽略,先跑起来再说。
最终这个项目我选了 Qdrant,因为 1000 万条在单机范围内,Qdrant 的内存和延迟优势直接省了一台机器的钱。但如果明年数据涨到 5000 万以上,我会重新评估,可能迁到 Milvus 集群。
没有银弹,只有场景。把变量控制住跑一遍实测,比看十篇 benchmark 文章都有用。