一、问题背景:为什么又做了一次选型
我们做的是一个图文素材检索系统,用户上传图片或输入自然语言,系统返回相似素材。数据量从最初的 50 万条涨到现在的 1200 万条,向量维度 768(CLIP ViT-L/14 输出),QPS 峰值约 300,要求 P99 延迟控制在 50ms 以内。
早期用的是 Faiss 本地索引,简单粗暴,但有两个硬伤:一是更新要重建索引,二是没法多副本容灾。于是转向专业向量数据库。市面上的选择不少,最终把范围收窄到 Milvus 和 Qdrant,原因很直接:两者社区活跃、都有生产级案例、都支持 HNSW 和过滤检索。
但网上的对比文章大多停留在"Hello World"层面,跑个 10 万条数据就下结论,参考价值有限。我干脆自己搭环境,用真实业务数据跑一轮。
二、环境与版本
测试机配置如下,两台物理机规格一致:
- CPU:Intel Xeon Gold 6248R,16 核 32 线程
- 内存:64GB DDR4
- 磁盘:NVMe SSD 1TB
- 操作系统:Ubuntu 22.04.3 LTS
- Docker:26.1.4
软件版本:
- Milvus 2.5.4(standalone 模式,etcd 3.5.16 + MinIO RELEASE.2024-09-01)
- Qdrant 1.12.4
- Python 3.10.12,pymilvus 2.5.0,qdrant-client 1.12.0
数据集:1200 万条 768 维 float32 向量,从线上素材库导出,附带 payload 字段 category(20 个类别)、ctime(时间戳)。查询集为 1000 条随机采样的真实 query,取 top-10。
三、方案设计
两条路线:
Qdrant 侧:单容器部署,配置 HNSW,m=16、ef_construct=200,量化开启 scalar quantization(int8),内存优先。
Milvus 侧:standalone 模式(官方说 standalone 也能扛千万级,正好验证一下),HNSW 索引,M=16、efConstruction=200,同样开 SCANN 量化的替代方案——Milvus 这边用 IVF_SQ8 做对照,另外单跑一组 HNSW 无量化。
指标关注三块:查询延迟(P50/P95/P99)、内存与磁盘占用、写入吞吐。
四、部署步骤
Qdrant 部署
Qdrant 的部署是我见过最省心的,一条命令搞定:
docker run -d --name qdrant \
-p 6333:6333 -p 6334:6334 \
-v /data/qdrant_storage:/qdrant/storage \
-e QDRANT__SERVICE__GRPC_PORT=6334 \
--ulimit nofile=65536:65536 \
qdrant/qdrant:v1.12.4
配置文件 config/production.yaml 里重点调两个参数:
storage:
optimizers:
memmap_threshold_kb: 20000
indexing_threshold_kb: 20000
hnsw_index:
m: 16
ef_construct: 200
full_scan_threshold_kb: 10000
memmap_threshold_kb 设成 20000 意味着超过 20MB 的 segment 走 mmap,内存不够时不会 OOM。这个参数在千万级数据上很关键。
Milvus 部署
Milvus standalone 用官方 compose:
wget https://github.com/milvus-io/milvus/releases/download/v2.5.4/milvus-standalone-docker-compose.yml -O docker-compose.yml
docker compose up -d
会起来三个容器:milvus-standalone、milvus-etcd、milvus-minio。docker-compose.yml 里我改了内存限制:
services:
standalone:
environment:
ETCD_ENDPOINTS: etcd:2379
MINIO_ADDRESS: minio:9000
deploy:
resources:
limits:
memory: 48G
Milvus 的 milvus.yaml 里调整了 queryNode 的缓存比例:
queryNode:
cache:
cacheSize: 32GB
默认值偏保守,千万级数据下不改会频繁落盘。
五、核心实现与压测代码
数据写入
Qdrant 的写入用 batch upsert,每批 2000 条:
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct
import numpy as np
client = QdrantClient(host="localhost", port=6333, grpc_port=6334, prefer_grpc=True)
client.recreate_collection(
collection_name="materials",
vectors_config=VectorParams(size=768, distance=Distance.COSINE),
hnsw_config={"m": 16, "ef_construct": 200},
quantization_config={"scalar": {"type": "int8", "always_ram": True}},
)
def batch_upsert(vectors, payloads, start_id):
points = [
PointStruct(id=start_id + i, vector=vectors[i].tolist(), payload=payloads[i])
for i in range(len(vectors))
]
client.upsert(collection_name="materials", points=points, wait=False)
# 实测:1200万条,batch=2000,耗时 41 分钟,平均 4870 条/秒
Milvus 侧写入:
from pymilvus import MilvusClient, DataType
import numpy as np
client = MilvusClient(uri="http://localhost:19530")
schema = client.create_schema(auto_id=False, enable_dynamic_field=True)
schema.add_field("id", DataType.INT64, is_primary=True)
schema.add_field("vector", DataType.FLOAT_VECTOR, dim=768)
schema.add_field("category", DataType.VARCHAR, max_length=64)
schema.add_field("ctime", DataType.INT64)
index_params = client.prepare_index_params()
index_params.add_index(
field_name="vector",
index_type="HNSW",
metric_type="COSINE",
params={"M": 16, "efConstruction": 200},
)
client.create_collection(
collection_name="materials",
schema=schema,
index_params=index_params,
)
# 分批 insert,每批 5000 条
# 实测:1200万条,耗时 53 分钟,平均 3770 条/秒
写入阶段 Qdrant 快了约 29%,我猜跟 Milvus 多了一层 WAL 和 segment 落盘有关。
查询压测
用 locust 写了压测脚本,1000 条真实 query 循环打,并发 50,跑 10 分钟。核心查询逻辑:
# Qdrant
res = client.search(
collection_name="materials",
query_vector=qvec,
limit=10,
search_params={"hnsw_ef": 128, "exact": False},
query_filter=None,
)
# Milvus
res = client.search(
collection_name="materials",
data=[qvec],
limit=10,
search_params={"metric_type": "COSINE", "params": {"ef": 128}},
)
六、踩坑与优化
坑 1:Qdrant 的 always_ram 量化配置。 一开始没开 always_ram,前几轮查询延迟抖动很大,P99 到了 80ms。排查发现是量化向量在磁盘上,每次查询要读盘。开了之后内存涨了 3GB,但 P99 直接降到 18ms。这个参数在官方文档里不显眼,但千万级场景必开。
坑 2:Milvus standalone 的 etcd 瓶颈。 写入阶段 Milvus 的 etcd 容器 CPU 一度飙到 400%,写入速率卡在 3000 条/秒上不去。把 etcd.yaml 里的 auto-compaction-retention 从默认 1 小时改成 15 分钟,写入速率回升到 3800 条/秒。standalone 模式下 etcd 是单点,数据量大时确实会拖后腿。
坑 3:Milvus 的 cacheSize 配置。 默认 cacheSize 是按内存比例算的,16C64G 机器上给得偏小,导致 chunk cache 命中率只有 68%。手动设成 32GB 后,命中率到 94%,P99 从 41ms 降到 27ms。
优化:Qdrant 的 hnsw_ef 调参。 默认 ef=128 时召回率 0.982,把 ef 提到 256 召回率到 0.996,但 P99 涨到 26ms。最终业务上取 ef=160,召回率 0.991,P99 21ms,性价比最高。
七、效果数据
1000 条 query、并发 50、top-10,结果如下:
| 指标 | Qdrant 1.12.4 | Milvus 2.5.4 (HNSW) | Milvus 2.5.4 (IVF_SQ8) |
|---|---|---|---|
| P50 延迟 | 6ms | 11ms | 9ms |
| P95 延迟 | 13ms | 19ms | 22ms |
| P99 延迟 | 18ms | 27ms | 35ms |
| 召回率@10 | 0.991 | 0.993 | 0.964 |
| 内存占用 | 21GB | 34GB | 26GB |
| 磁盘占用 | 19GB | 31GB | 24GB |
| 写入吞吐 | 4870 条/秒 | 3770 条/秒 | 3770 条/秒 |
Qdrant 在延迟和资源占用上全面占优,主要赢在单进程架构没有 etcd/MinIO 这层开销。Milvus 召回率略高一点点,差异在统计误差范围内。
但 Milvus 的优势不在单机性能,而在分布式扩展和生态。如果数据量上到亿级、需要水平扩展、要接 Flink/Spark 做流式写入,Milvus 的架构优势就出来了。standalone 模式只是它最小的形态。
八、总结
回到选型结论:
选 Qdrant 的场景:数据量在 5000 万以内、单机或少量节点、追求低延迟和低资源占用、团队运维人手有限。我们这次最终选了 Qdrant,因为它在这个量级下表现更均衡,部署运维成本也低。
选 Milvus 的场景:数据量亿级以上、需要分布式水平扩展、有流批一体的写入需求、已经在用 Milvus 生态工具(Attu、Birdwatcher)。Milvus 的 standalone 只是入门,真正发挥实力要上集群。
一个提醒:任何选型对比都别只看别人的数字。硬件、数据分布、查询模式、过滤条件比例,都会让结果差出好几倍。我这次测试的过滤条件占比只有 5%,如果你的业务过滤条件重(比如按类目+时间范围过滤),两者的表现排序可能会反过来。自己搭环境跑一遍,比看十篇文章都管用。