一、问题背景
我们做的是一个内容中台项目,里面有大概 1200 万条图文素材,每条素材用 CLIP 类模型抽出 768 维向量,需要对外提供"以图搜图"和"文搜图"的接口。业务侧给的指标很明确:
- 数据量:初期 1200 万,一年内可能翻倍到 2500 万
- 查询:峰值 300 QPS,其中 90% 是 top-20 检索
- 延迟:P99 < 80ms,P50 < 30ms
- 硬件:先单机起步,预算就是一台 16C 64G + 2TB NVMe 的物理机,后续再考虑集群
候选就两个:Milvus 和 Qdrant。网上文章要么是官方 benchmark(数据集和查询模式跟我们对不上),要么是"Hello World"级别的 demo,没有参考价值。所以干脆自己搭一套,用真实数据和真实查询分布跑一遍。
这里先说结论,免得有人看不下去:在我们这个单机、千万级、中低 QPS 的场景下,Qdrant 的综合表现更好;但如果预期一年内就要上集群、要多租户隔离、要接 Spark 做离线批处理,Milvus 的架构红利会更明显。 下面把过程和盘托出。
二、环境与版本
硬件与系统:
- CPU:Intel Xeon Silver 4314,16 核 32 线程
- 内存:64GB DDR4
- 磁盘:2TB NVMe SSD(顺序读约 3.2GB/s)
- OS:Ubuntu 22.04.3 LTS,内核 5.15
- Docker:24.0.7,Docker Compose v2.23.0
软件版本(都是当时的最新稳定版):
- Milvus:v2.4.4,standalone 模式,etcd 3.5.5 + MinIO RELEASE.2023-03-20
- Qdrant:v1.9.2,单节点
- 客户端:pymilvus 2.4.4,qdrant-client 1.9.1
- Python 3.11.6
数据集:1200 万条 768 维 float32 向量,从业务库里抽样导出,做过 L2 归一化(所以内积等价于余弦)。查询集是 5000 条真实用户查询日志里的向量,同样归一化。
三、方案设计
3.1 部署方案
Milvus standalone 用官方 compose 起,但把 MinIO 的默认配置改了一下(默认的 4 个盘符在单机上没必要,改成单盘)。Qdrant 就一个容器,挂载本地 volume。
Milvus 的 compose 文件(精简后):
# docker-compose.milvus.yml
version: '3.5'
services:
etcd:
container_name: milvus-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
- ETCD_SNAPSHOT_COUNT=50000
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
healthcheck:
test: ["CMD", "etcdctl", "endpoint", "health"]
interval: 30s
timeout: 20s
retries: 3
minio:
container_name: milvus-minio
image: minio/minio:RELEASE.2023-03-20T20-16-18Z
environment:
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
ports:
- "9001:9001"
- "9000:9000"
volumes:
- ./volumes/minio:/minio_data
command: minio server /minio_data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
standalone:
container_name: milvus-standalone
image: milvusdb/milvus:v2.4.4
command: ["milvus", "run", "standalone"]
security_opt:
- seccomp:unconfined
environment:
ETCD_ENDPOINTS: etcd:2379
MINIO_ADDRESS: minio:9000
# 关键:限制内存,避免把 64G 吃光
QUERYNODE_MEMORY_LIMIT: "24"
DATANODE_MEMORY_LIMIT: "12"
volumes:
- ./volumes/milvus:/var/lib/milvus
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9091/healthz"]
interval: 30s
start_period: 90s
timeout: 20s
retries: 3
ports:
- "19530:19530"
- "9091:9091"
depends_on:
- "etcd"
- "minio"
networks:
default:
name: milvus
Qdrant 就简单多了:
# docker-compose.qdrant.yml
version: '3.5'
services:
qdrant:
image: qdrant/qdrant:v1.9.2
container_name: qdrant
ports:
- "6333:6333"
- "6334:6334"
volumes:
- ./volumes/qdrant:/qdrant/storage
environment:
# 打开 mmap,降低内存占用
QDRANT__STORAGE__ON_DISK_PAYLOAD: "true"
QDRANT__STORAGE__HNSW_INDEX__M: "16"
QDRANT__STORAGE__HNSW_INDEX__EF_CONSTRUCT: "200"
ulimits:
nofile:
soft: 65536
hard: 65536
启动 docker compose -f docker-compose.xxx.yml up -d 即可。Milvus 从拉起容器到 /healthz 返回正常,实测冷启动约 75 秒;Qdrant 约 6 秒。
3.2 索引与参数选择
公平起见,两边都用 HNSW,参数尽量对齐:
| 参数 | Milvus | Qdrant |
|---|---|---|
| 索引类型 | HNSW | HNSW |
| M | 16 | 16 |
| efConstruction | 200 | 200 |
| 查询 ef | 64(可调) | hnsw_ef=64 |
| 距离度量 | IP(内积) | Dot |
| 量化 | 无 | 无 |
Milvus 里 HNSW 的 ef 是在 search 时通过 search_params={"ef": 64} 传的;Qdrant 是 search_params=SearchParams(hnsw_ef=64)。两边都保持一致,避免有人拿不同 ef 比出"性能差异"。
四、核心实现
4.1 数据写入
Milvus 这边,1200 万条数据不能一次 insert,得分批。实测每批 5 万条、并发 4 个写入线程比较合适,再大容易把 datanode 的内存打高。
# ingest_milvus.py
import time, numpy as np
from pymilvus import MilvusClient, DataType
DIM = 768
BATCH = 50_000
TOTAL = 12_000_000
client = MilvusClient(uri="http://localhost:19530")
# 建表
schema = client.create_schema(auto_id=False, enable_dynamic_field=False)
schema.add_field("id", DataType.INT64, is_primary=True)
schema.add_field("vec", DataType.FLOAT_VECTOR, dim=DIM)
schema.add_field("biz_tag", DataType.INT32)
index_params = client.prepare_index_params()
index_params.add_index(
field_name="vec",
index_type="HNSW",
metric_type="IP",
params={"M": 16, "efConstruction": 200},
)
if client.has_collection("material"):
client.drop_collection("material")
client.create_collection("material", schema=schema, index_params=index_params)
rng = np.random.default_rng(42)
t0 = time.time()
for start in range(0, TOTAL, BATCH):
n = min(BATCH, TOTAL - start)
vecs = rng.standard_normal((n, DIM), dtype=np.float32)
vecs /= np.linalg.norm(vecs, axis=1, keepdims=True)
rows = [
{"id": start + i, "vec": vecs[i].tolist(), "biz_tag": int(i % 16)}
for i in range(n)
]
client.insert(collection_name="material", data=rows)
if start % (BATCH * 20) == 0:
print(f"inserted {start + n}, elapsed {time.time() - t0:.1f}s")
# 等索引构建完(standalone 下 flush 后由 indexnode 异步建)
client.flush("material")
print("total ingest time:", time.time() - t0)
Qdrant 用批量 upsert,注意 payload 里塞 biz_tag 方便后面过滤:
# ingest_qdrant.py
import time, numpy as np
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct
DIM = 768
BATCH = 20_000
TOTAL = 12_000_000
client = QdrantClient(host="localhost", port=6333)
client.recreate_collection(
collection_name="material",
vectors_config=VectorParams(size=DIM, distance=Distance.DOT),
hnsw_config={"m": 16, "ef_construct": 200},
)
rng = np.random.default_rng(42)
t0 = time.time()
for start in range(0, TOTAL, BATCH):
n = min(BATCH, TOTAL - start)
vecs = rng.standard_normal((n, DIM), dtype=np.float32)
vecs /= np.linalg.norm(vecs, axis=1, keepdims=True)
points = [
PointStruct(
id=start + i,
vector=vecs[i].tolist(),
payload={"biz_tag": int(i % 16)},
)
for i in range(n)
]
client.upsert(collection_name="material", points=points, wait=False)
if start % (BATCH * 50) == 0:
print(f"upserted {start + n}, elapsed {time.time() - t0:.1f}s")
client.close()
注意 Qdrant 这里 wait=False,批量写入更快;最后再调一次 client.count() 确认数据到齐。
4.2 查询压测
压测脚本两边共用一套逻辑,用固定 seed 生成查询向量,多线程打满,统计 P50/P95/P99。
# bench.py
import time, random, statistics, threading
import numpy as np
from concurrent.futures import ThreadPoolExecutor
def bench(fn, qps_target, duration=120, threads=16):
lat = []
lock = threading.Lock()
stop_at = time.time() + duration
interval = 1.0 / (qps_target / threads)
def worker():
local = []
while time.time() < stop_at:
t = time.perf_counter()
fn()
local.append((time.perf_counter() - t) * 1000)
time.sleep(max(0, interval - (time.perf_counter() - t)))
with lock:
lat.extend(local)
with ThreadPoolExecutor(max_workers=threads) as ex:
[ex.submit(worker) for _ in range(threads)]
lat.sort()
p = lambda x: lat[int(len(lat) * x)]
return {
"n": len(lat),
"qps": len(lat) / duration,
"p50": p(0.50), "p95": p(0.95), "p99": p(0.99),
"max": lat[-1],
}
Milvus 的查询函数:
def milvus_query(client, vec):
client.search(
collection_name="material",
data=[vec],
anns_field="vec",
limit=20,
search_params={"metric_type": "IP", "params": {"ef": 64}},
output_fields=["biz_tag"],
)
Qdrant 的查询函数:
from qdrant_client.models import SearchParams
def qdrant_query(client, vec):
client.search(
collection_name="material",
query_vector=vec,
limit=20,
search_params=SearchParams(hnsw_ef=64, exact=False),
)
五、踩坑与优化
踩的坑不少,挑几个有代表性的说。
坑 1:Milvus 默认内存配置会吃光 64G。 第一次跑完数据导入,docker stats 里 milvus-standalone 占了 51GB,再压测直接 OOM。后来在 compose 里显式设了 QUERYNODE_MEMORY_LIMIT=24、DATANODE_MEMORY_LIMIT=12,并给容器加 mem_limit: 48g,才稳住。Qdrant 默认就克制,导入完 1200 万条后 RSS 约 18GB,开了 ON_DISK_PAYLOAD 后 payload 走磁盘,内存还降了。
坑 2:Milvus 索引构建是异步的。 你 flush 完立刻去搜,召回率会明显偏低,因为 indexnode 还在建索引。要么轮询 get_index_build_progress,要么等 collection.load() 返回。我们第一次压测就是没等索引,P99 好看得离谱(因为走了暴力扫描),结果召回率只有 0.71。踩完这个坑之后,所有数据都等 load_state == Loaded 再开始。
坑 3:Qdrant 批量 upsert 的 batch size 不能太大。 一开始用 5 万一批,客户端经常抛 timeout,服务端日志显示单请求处理时间超过默认的 60s。改成 2 万一批、wait=False 后稳定。另外 Qdrant 的 search 在 1.9 里已经标记 deprecated,官方推荐 query_points,我们为了跟旧代码兼容继续用 search,但新项目建议直接上 query_points。
优化 1:Qdrant 开 scalar quantization 省内存。 在 recreate_collection 里加 quantization_config=ScalarQuantization(scalar=ScalarQuantizationConfig(type=ScalarType.INT8, always_ram=True)),内存从 18GB 降到 9GB,召回率从 0.962 掉到 0.947,延迟反而降了约 15%,因为 int8 距离计算更快。这个 trade-off 我们接受了。
优化 2:Milvus 用 partition_key 做业务隔离。 我们按 biz_tag 分了 16 个分区键,查询时带上 partition_key 过滤,P99 从 96ms 降到 71ms,因为搜索空间从 1200 万缩到平均 75 万。但注意 partition_key 一旦设定不能改,且分区数不能太多(官方建议 < 1024)。
六、效果数据
所有数据都是同一台机器、同一份数据集、ef=64、top-20 条件下测得,每组跑 120 秒取稳定段。
查询性能(300 QPS 目标,16 线程):
| 指标 | Milvus 2.4.4 | Qdrant 1.9.2 |
|---|---|---|
| 实际 QPS | 302 | 305 |
| P50 | 31ms | 19ms |
| P |