一、问题背景:为什么我要重新做一次选型
我们团队做的是一个图文内容推荐系统,每天新增约 50 万条内容向量,总规模在 1000 万级别,维度是 768(来自 BGE-large-zh)。之前的方案是 FAISS + 自研增量索引,但遇到了几个硬伤:
- 不支持标量过滤(比如按分类、发布时间、作者 ID 过滤后再检索),只能先全量召回再过滤,召回率掉得厉害;
- 多进程共享索引麻烦,更新时需要 reload,线上有抖动;
- 没有成熟的持久化和监控。
于是决定迁移到专业的向量数据库。候选就是 Milvus 和 Qdrant——两者都是开源、社区活跃、支持 HNSW/IVF、支持过滤,但设计哲学差别很大。网上很多文章只讲“Hello World”,缺少同硬件下的真实数据。我花了三天做了这套对比,把过程和数据写下来。
二、环境与版本
- 机器:阿里云 ecs.g7.4xlarge,16 vCPU / 64 GB / 500 GB ESSD PL1
- OS:Ubuntu 22.04,Docker 24.0.7,Docker Compose v2.23.0
- Milvus:2.4.1(standalone 模式,etcd + MinIO 内置)
- Qdrant:1.9.2(单节点)
- 客户端:pymilvus 2.4.3,qdrant-client 1.9.1
- 数据集:1000 万条 768 维 float32 向量,随机生成,附带 5 个标量字段(category、timestamp、author_id 等)
两者都用 Docker 部署,避免环境差异。
三、方案设计
对比维度:
- 部署复杂度与启动资源
- 写入 1000 万条数据的耗时与内存峰值
- HNSW 索引构建时间
- TopK=10 查询性能:纯向量检索 vs 带标量过滤
- 资源占用:内存 RSS、磁盘占用
- 客户端 API 易用性
索引参数尽量对齐:
- Milvus:HNSW,M=16,efConstruction=200,ef=64;metric=IP
- Qdrant:HNSW,m=16,ef_construct=200,hnsw_ef=64;distance=Dot
四、核心实现
4.1 部署步骤
Milvus standalone(官方 compose):
# docker-compose-milvus.yml
version: '3.5'
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
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.1
command: ["milvus", "run", "standalone"]
environment:
ETCD_ENDPOINTS: etcd:2379
MINIO_ADDRESS: minio:9000
volumes:
- ./volumes/milvus:/var/lib/milvus
ports:
- "19530:19530"
- "9091:9091"
depends_on:
- etcd
- minio
docker compose -f docker-compose-milvus.yml up -d
docker stats milvus-standalone --no-stream
# 空载内存约 1.2 GB,包含 etcd 和 minio
Qdrant 单节点:
docker run -d --name qdrant \
-p 6333:6333 -p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage \
qdrant/qdrant:v1.9.2
docker stats qdrant --no-stream
# 空载内存约 180 MB
4.2 写入与索引代码
Milvus 侧:
from pymilvus import MilvusClient, DataType
import numpy as np, time
client = MilvusClient(uri="http://localhost:19530")
COLL = "items_milvus"
DIM = 768
if client.has_collection(COLL):
client.drop_collection(COLL)
schema = client.create_schema(auto_id=False, enable_dynamic_field=False)
schema.add_field("id", DataType.INT64, is_primary=True)
schema.add_field("category", DataType.INT64)
schema.add_field("ts", DataType.INT64)
schema.add_field("vec", DataType.FLOAT_VECTOR, dim=DIM)
index_params = client.prepare_index_params()
index_params.add_index(
field_name="vec",
index_type="HNSW",
metric_type="IP",
params={"M": 16, "efConstruction": 200},
)
client.create_collection(COLL, schema=schema, index_params=index_params)
# 分批写入,每批 5000
BATCH = 5000
N = 10_000_000
rng = np.random.default_rng(42)
t0 = time.time()
for start in range(0, N, BATCH):
n = min(BATCH, N - start)
rows = [{
"id": start + i,
"category": int(rng.integers(0, 50)),
"ts": 1700000000 + start + i,
"vec": rng.random(DIM, dtype=np.float32).tolist(),
} for i in range(n)]
client.insert(COLL, rows)
print(f"milvus insert {N} cost {time.time()-t0:.1f}s")
client.load_collection(COLL)
Qdrant 侧:
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct, HnswConfigDiff
import numpy as np, time
client = QdrantClient(host="localhost", port=6333)
COLL = "items_qdrant"
DIM = 768
client.recreate_collection(
collection_name=COLL,
vectors_config=VectorParams(size=DIM, distance=Distance.DOT),
hnsw_config=HnswConfigDiff(m=16, ef_construct=200),
optimizers_config={"indexing_threshold": 20000},
)
BATCH = 5000
N = 10_000_000
rng = np.random.default_rng(42)
t0 = time.time()
for start in range(0, N, BATCH):
n = min(BATCH, N - start)
points = [
PointStruct(
id=start + i,
vector=rng.random(DIM, dtype=np.float32).tolist(),
payload={"category": int(rng.integers(0, 50)), "ts": 1700000000 + start + i},
)
for i in range(n)
]
client.upsert(collection_name=COLL, points=points)
print(f"qdrant upsert {N} cost {time.time()-t0:.1f}s")
4.3 查询压测脚本
import numpy as np, time, statistics
def bench_milvus(q, n=500):
lats = []
for _ in range(n):
t = time.perf_counter()
client.search(COLL, data=[q.tolist()], limit=10,
search_params={"metric_type": "IP", "params": {"ef": 64}})
lats.append((time.perf_counter() - t) * 1000)
return lats
def bench_qdrant(q, n=500):
lats = []
for _ in range(n):
t = time.perf_counter()
client.search(COLL, query_vector=q.tolist(), limit=10,
search_params={"hnsw_ef": 64})
lats.append((time.perf_counter() - t) * 1000)
return lats
def report(name, lats):
lats = sorted(lats)
print(f"{name}: avg={statistics.mean(lats):.2f}ms "
f"p50={lats[len(lats)//2]:.2f}ms "
f"p99={lats[int(len(lats)*0.99)]:.2f}ms")
五、踩坑与优化
Milvus 坑 1:写入内存暴涨。 直接用 insert 单条/小批写入时,standalone 的 querynode 和 datanode 内存会飙到 20 GB+。改成每批 5000 并开启 flush 间隔后稳定在 12 GB 左右。另外 enable_dynamic_field=False 能省不少内存。
Milvus 坑 2:load_collection 很慢。 1000 万条数据首次 load 用了接近 4 分钟,因为要把索引从磁盘加载到内存。生产上建议用 load_state 监控,并设置合理的 graceful_time。
Qdrant 坑 1:索引构建延迟。 Qdrant 默认是后台异步构建 HNSW,indexing_threshold 默认 20000。写入完成时索引可能还没建好,查询会走暴力扫描。压测前必须等待 optimizer_status 变为 ok:
import time
while True:
info = client.get_collection(COLL)
if info.optimizer_status == "ok":
break
time.sleep(5)
print("qdrant index ready")
Qdrant 坑 2:过滤查询要建 payload 索引。 不建索引时带 filter 的查询会退化成全量扫描,P99 从 8 ms 涨到 400 ms+。对 category 建 keyword/integer 索引后恢复正常:
from qdrant_client.models import PayloadSchemaType
client.create_payload_index(COLL, field_name="category",
field_schema=PayloadSchemaType.INTEGER)
Milvus 过滤优化: Milvus 2.4 支持标量字段建 INVERTED 索引,对 category 建索引后过滤查询快约 30%。另外过滤条件选择性高时,Milvus 的 bitset 机制表现不错。
六、效果数据
写入 1000 万条(含索引构建):
| 指标 | Milvus 2.4.1 | Qdrant 1.9.2 |
|---|---|---|
| 写入耗时 | 21 min | 14 min |
| 索引构建完成 | 写入后约 6 min | 写入后约 9 min(后台) |
| 写入峰值内存 | 12.4 GB | 4.1 GB |
查询性能(TopK=10,500 次取统计):
| 场景 | Milvus avg / P99 | Qdrant avg / P99 |
|---|---|---|
| 纯向量 | 6.8 ms / 14.2 ms | 5.1 ms / 11.7 ms |
| 带 category 过滤 | 9.4 ms / 22.6 ms | 7.2 ms / 16.9 ms |
资源占用(稳定态):
| 指标 | Milvus | Qdrant |
|---|---|---|
| 常驻内存 | 13.8 GB | 8.5 GB |
| 磁盘占用 | 32 GB | 29 GB |
| 空载内存 | 1.2 GB | 0.18 GB |
Qdrant 内存节省约 38%,主要是它用 mmap 存储向量,只有热点数据进内存;Milvus 为了保证查询性能会把索引全量 load 进内存。磁盘两者接近,Qdrant 因为量化选项更灵活略小一点。
七、总结
选型不是“谁更好”,而是“谁更适合你的场景”:
- 选 Qdrant:单机部署、数据量在千万级以内、内存敏感、想要轻量级运维。它的 Rust 实现内存控制出色,API 直观,payload 过滤灵活。缺点是分布式能力相对弱,超大规模(亿级以上)需要自己做分片。
- 选 Milvus:需要水平扩展、亿级向量、多租户、丰富的索引类型(IVF_PQ、DiskANN 等),以及成熟的生态(Attu、Prometheus 监控)。代价是部署组件多、内存开销大,运维复杂度高。
我们最终选了 Qdrant,因为当前数据量 1000 万、单机足够,而且省下的 5 GB 内存能跑其他服务。如果明年数据量过亿,会再评估 Milvus 集群方案。建议你也用自己真实数据跑一遍上面的脚本,别只看别人的数字——向量分布、过滤选择性对结果影响很大。