一、问题背景
去年底我们上线了一个图文推荐系统,用户侧需要“以图搜图”和“兴趣向量召回”。早期用 FAISS 单机顶着,数据量到 800 万后,重建索引要停服 40 分钟,而且没法在线增删。团队决定迁移到专业向量数据库。
候选方案很快收敛到两个:Milvus 和 Qdrant。两者都是开源、支持 HNSW、有 Python SDK、社区活跃。但网上文章要么只讲概念,要么跑个 10 万条就下结论。我们决定用真实业务数据做一次完整对比:1000 万条 768 维向量,模拟推荐场景的 topK=50、带过滤条件查询。
这篇文章不吹不黑,所有数字都是我们实测的,环境、参数、代码全部贴出来。
二、环境与版本
硬件:一台阿里云 ECS,规格 ecs.g7.4xlarge,16 vCPU / 64 GB / ESSD PL1 500 GB。操作系统 Ubuntu 22.04,Docker 24.0.7,Docker Compose v2.24.5。
软件版本:
- Milvus 2.4.0(standalone 模式,etcd 3.5.5,MinIO RELEASE.2023-03-20)
- Qdrant 1.9.0(单节点,官方 Docker 镜像)
- Python 3.10,pymilvus 2.4.0,qdrant-client 1.9.0
- 数据集:1000 万条 768 维 float32 向量,随机生成但做了归一化,模拟 CLIP 输出。另有 10 万条带 category 字段的 payload 用于过滤测试。
注意:Milvus 和 Qdrant 分两次部署,避免资源争抢。每次测试前重启机器,清空 page cache。
三、方案设计
对比维度:
1. 部署复杂度:从零到服务可用的步骤数、依赖组件数量。
2. 写入性能:批量插入 1000 万条的总耗时、索引构建时间。
3. 查询性能:在 HNSW 索引下,topK=50,分别测无过滤、带 category 过滤、带数值范围过滤的 QPS 和 P99 延迟。
4. 资源占用:稳定查询时 RSS 内存、磁盘占用、CPU 峰值。
5. 运维友好度:扩容、备份、监控。
索引参数统一:
- HNSW:M=16,efConstruction=200,efSearch=128(查询时)。
- 距离度量:内积(IP),因为向量已归一化,等价于余弦。
Qdrant 的 HNSW 参数在 collection 创建时指定;Milvus 在 create_index 时指定。
四、核心实现
4.1 部署步骤
Milvus standalone(docker-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:
- ./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:
- ./minio:/minio_data
command: minio server /minio_data --console-address ":9001"
milvus:
image: milvusdb/milvus:v2.4.0
command: ["milvus", "run", "standalone"]
environment:
ETCD_ENDPOINTS: etcd:2379
MINIO_ADDRESS: minio:9000
volumes:
- ./milvus:/var/lib/milvus
ports:
- "19530:19530"
- "9091:9091"
depends_on:
- etcd
- minio
启动:docker compose -f docker-compose-milvus.yml up -d。等 30 秒左右,docker logs milvus-standalone 看到 Milvus Proxy successfully initialized 即可。
Qdrant 部署简单得多:
docker run -d --name qdrant \
-p 6333:6333 -p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage \
qdrant/qdrant:v1.9.0
一条命令,无外部依赖。这是 Qdrant 的明显优势。
4.2 写入与索引代码
Milvus 写入:
from pymilvus import connections, Collection, CollectionSchema, FieldSchema, DataType, utility
import numpy as np, time
connections.connect("default", host="localhost", port="19530")
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=False),
FieldSchema(name="category", dtype=DataType.INT64),
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768),
]
schema = CollectionSchema(fields, description="image_vectors")
collection = Collection("images", schema)
# 分批写入,每批 5 万
BATCH = 50000
vectors = np.random.rand(10_000_000, 768).astype(np.float32)
vectors /= np.linalg.norm(vectors, axis=1, keepdims=True)
categories = np.random.randint(0, 100, size=10_000_000)
t0 = time.time()
for i in range(0, 10_000_000, BATCH):
ids = list(range(i, i + BATCH))
collection.insert([ids, categories[i:i+BATCH].tolist(), vectors[i:i+BATCH].tolist()])
if i % 1_000_000 == 0:
print(f"inserted {i}, elapsed {time.time()-t0:.1f}s")
collection.flush()
print(f"insert done: {time.time()-t0:.1f}s")
# 建索引
index_params = {
"index_type": "HNSW",
"metric_type": "IP",
"params": {"M": 16, "efConstruction": 200},
}
t1 = time.time()
collection.create_index("embedding", index_params)
collection.load()
print(f"index build: {time.time()-t1:.1f}s")
Qdrant 写入:
from qdrant_client import QdrantClient, models
import numpy as np, time
client = QdrantClient(host="localhost", port=6333)
client.recreate_collection(
collection_name="images",
vectors_config=models.VectorParams(
size=768, distance=models.Distance.DOT,
hnsw_config=models.HnswConfigDiff(m=16, ef_construct=200),
),
)
BATCH = 5000 # qdrant 单次 upsert 不宜过大
vectors = np.random.rand(10_000_000, 768).astype(np.float32)
vectors /= np.linalg.norm(vectors, axis=1, keepdims=True)
categories = np.random.randint(0, 100, size=10_000_000)
t0 = time.time()
for i in range(0, 10_000_000, BATCH):
points = [
models.PointStruct(
id=i+j,
vector=vectors[i+j].tolist(),
payload={"category": int(categories[i+j])},
)
for j in range(BATCH)
]
client.upsert(collection_name="images", points=points)
if i % 1_000_000 == 0:
print(f"upserted {i}, elapsed {time.time()-t0:.1f}s")
print(f"upsert done: {time.time()-t0:.1f}s")
4.3 查询代码
Milvus 查询:
search_params = {"metric_type": "IP", "params": {"ef": 128}}
query = vectors[0:1].tolist()
# 无过滤
t0 = time.time()
res = collection.search(query, "embedding", search_params, limit=50)
print(f"milvus no filter: {(time.time()-t0)*1000:.2f}ms")
# 带 category 过滤
expr = "category == 42"
t0 = time.time()
res = collection.search(query, "embedding", search_params, limit=50, expr=expr)
print(f"milvus filter: {(time.time()-t0)*1000:.2f}ms")
Qdrant 查询:
query_vec = vectors[0].tolist()
t0 = time.time()
res = client.search(collection_name="images", query_vector=query_vec, limit=50)
print(f"qdrant no filter: {(time.time()-t0)*1000:.2f}ms")
t0 = time.time()
res = client.search(
collection_name="images", query_vector=query_vec, limit=50,
query_filter=models.Filter(
must=[models.FieldCondition(key="category", match=models.MatchValue(value=42))]
),
)
print(f"qdrant filter: {(time.time()-t0)*1000:.2f}ms")
五、踩坑与优化
Milvus 坑 1:insert 批次过大导致 OOM。 一开始每批 50 万,Proxy 内存直接飙到 20 GB。改成 5 万后稳定。官方建议单批不超过 10 万。
Milvus 坑 2:索引构建期间查询不可用。 create_index 时 collection 处于 building 状态,必须等完成再 load。1000 万条 HNSW 建了 22 分钟。我们后来用 partition 分批建,缓解停机。
Qdrant 坑 1:upsert 批次过大延迟高。 每批 5 万时,单次 upsert 要 8 秒,而且客户端容易超时。改成 5000 后,吞吐反而更高。Qdrant 官方也建议 100-1000 这个量级,但实测 5000 是折中。
Qdrant 坑 2:payload 索引缺失导致过滤慢。 带 category 过滤的查询一开始 P99 到 800ms。后来给 category 建了 keyword payload index,降到 120ms。命令:
client.create_payload_index(
collection_name="images",
field_name="category",
field_schema=models.PayloadSchemaType.INTEGER,
)
通用优化: 两者都开启 mmap。Milvus 在 milvus.yaml 里设 queryNode.mmap.mmapEnabled: true;Qdrant 启动时加 --optimizers-memmap-threshold-kb 10000。内存占用各降了约 15%。
六、效果数据
写入与索引:
| 指标 | Milvus 2.4.0 | Qdrant 1.9.0 |
|---|---|---|
| 1000万条写入耗时 | 18分12秒 | 26分40秒 |
| 索引构建耗时 | 22分05秒 | 自动后台构建,约31分 |
| 写入峰值内存 | 11.2 GB | 7.8 GB |
查询性能(单线程,topK=50,ef=128,取 1000 次平均):
| 场景 | Milvus QPS | Milvus P99 | Qdrant QPS | Qdrant P99 |
|---|---|---|---|---|
| 无过滤 | 1850 | 8.2 ms | 1420 | 11.5 ms |
| category 过滤 | 920 | 18.7 ms | 1100 | 14.3 ms |
| 数值范围过滤 | 780 | 24.1 ms | 950 | 17.8 ms |
资源占用(稳定查询 10 分钟后):
| 指标 | Milvus | Qdrant |
|---|---|---|
| RSS 内存 | 14.6 GB | 10.2 GB |
| 磁盘占用 | 38 GB | 31 GB |
| CPU 峰值 | 14 核 | 9 核 |
结论很明显:Milvus 在纯向量检索上更快,Qdrant 在带过滤的查询上反超,且资源占用更低。Qdrant 的过滤是把 payload 索引和 HNSW 结合得更好;Milvus 的过滤走的是标量字段扫描,数据量大时吃亏。
七、总结与选型建议
如果让我一句话总结:数据量 5000 万以内、过滤条件多、想省机器,选 Qdrant;数据量上亿、要分布式、写入吞吐优先,选 Milvus。
具体理由:
- Qdrant 部署极简,单容器搞定,内存和磁盘都省 25%-30%。带 payload 过滤的查询性能更好,适合推荐、搜索这类“向量+属性”混合场景。缺点是分布式能力弱,单节点上限明显。
- Milvus 依赖 etcd、MinIO、Pulsar 等一堆组件,运维成本高。但它的存算分离架构、分区、多副本、水平扩展是 Qdrant 目前比不了的。纯向量检索 QPS 高 30% 左右,批量写入也更快。
- 我们最终选了 Milvus,因为业务预期半年内到 5000 万,且需要多租户隔离。但如果只是 1000 万级、过滤多,我会毫不犹豫用 Qdrant。
最后提醒:别只看 QPS。过滤条件的分布、efSearch 的取值、是否 mmap,都会让结果差一倍。建议用自己的真实数据跑一遍,本文的代码可以直接改改用。