一、问题背景
我们做的是一个图文内容推荐系统,商品库大概 1200 万条,每条内容用 CLIP 类模型抽取 768 维向量。业务侧对检索的要求是:
- 单次 TopK=50 查询,P99 延迟控制在 100ms 以内;
- 支持按类目、上下架状态做标量过滤;
- 每天增量写入约 20 万条,偶尔有全量重建;
- 机器预算有限,希望单机 16C64G 能扛住,不要一上来就上集群。
候选方案里,Milvus 和 Qdrant 是最常被提到的两个。Milvus 生态大、文档全、社区活跃;Qdrant 用 Rust 写,主打轻量和过滤性能。到底选哪个,光看 benchmark 文章没意义,必须在自己场景里跑一遍。下面是我这轮选型的完整记录。
二、环境与版本
测试机器配置:
- CPU:Intel Xeon Gold 6248R,16 核
- 内存:64GB
- 磁盘:NVMe SSD 1TB
- OS:Ubuntu 22.04,内核 5.15
- Docker:24.0.7,Docker Compose v2.24
软件版本:
- Milvus 2.4.5(standalone 模式,依赖 etcd 3.5.5、MinIO RELEASE.2023-03-20)
- Qdrant 1.9.1
- Python 3.10,pymilvus 2.4.3,qdrant-client 1.9.1
- 数据集:1200 万条 768 维 float32 向量,随机生成,附带 category(0-99)和 status(0/1)两个标量字段
三、方案设计
两组实验都采用 HNSW 索引,因为这是当前中小规模下延迟和召回最均衡的选择。Milvus 使用 COSINE 距离,Qdrant 同样用 Cosine。索引参数尽量对齐:
- Milvus:
M=16, efConstruction=200,查询时ef=128 - Qdrant:
m=16, ef_construct=200,查询时hnsw_ef=128
写入方式统一用批量插入,每批 5000 条,分 2400 批完成。查询测试用 1000 条随机向量做 TopK=50,记录 P50/P95/P99 和 QPS。
四、核心实现
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
milvus:
image: milvusdb/milvus:v2.4.5
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
Qdrant 就简单多了,单容器:
# docker-compose-qdrant.yml
version: '3.8'
services:
qdrant:
image: qdrant/qdrant:v1.9.1
ports:
- "6333:6333"
- "6334:6334"
volumes:
- ./volumes/qdrant:/qdrant/storage
environment:
QDRANT__SERVICE__GRPC_PORT: 6334
启动:docker compose -f docker-compose-qdrant.yml up -d
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("id", DataType.INT64, is_primary=True),
FieldSchema("category", DataType.INT64),
FieldSchema("status", DataType.INT64),
FieldSchema("embedding", DataType.FLOAT_VECTOR, dim=768),
]
schema = CollectionSchema(fields, description="product_vectors")
col = Collection("products_milvus", schema)
index_params = {
"index_type": "HNSW",
"metric_type": "COSINE",
"params": {"M": 16, "efConstruction": 200},
}
col.create_index("embedding", index_params)
col.load()
# 批量写入
BATCH = 5000
t0 = time.time()
for i in range(0, 12_000_000, BATCH):
ids = list(range(i, i + BATCH))
cats = np.random.randint(0, 100, BATCH).tolist()
stats = np.random.randint(0, 2, BATCH).tolist()
vecs = np.random.rand(BATCH, 768).astype(np.float32).tolist()
col.insert([ids, cats, stats, vecs])
print(f"Milvus insert cost: {time.time()-t0:.1f}s")
# 查询
search_params = {"metric_type": "COSINE", "params": {"ef": 128}}
query_vec = np.random.rand(768).astype(np.float32).tolist()
res = col.search(
data=[query_vec],
anns_field="embedding",
param=search_params,
limit=50,
expr="category in [1,2,3] and status == 1",
output_fields=["category", "status"],
)
Qdrant 侧:
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct, Filter, FieldCondition, MatchValue, HnswConfigDiff
import numpy as np, time
client = QdrantClient(host="localhost", port=6333)
client.recreate_collection(
collection_name="products_qdrant",
vectors_config=VectorParams(size=768, distance=Distance.COSINE),
hnsw_config=HnswConfigDiff(m=16, ef_construct=200),
)
BATCH = 5000
t0 = time.time()
for i in range(0, 12_000_000, BATCH):
points = [
PointStruct(
id=i + j,
vector=np.random.rand(768).astype(np.float32).tolist(),
payload={
"category": int(np.random.randint(0, 100)),
"status": int(np.random.randint(0, 2)),
},
)
for j in range(BATCH)
]
client.upsert(collection_name="products_qdrant", points=points, wait=False)
print(f"Qdrant upsert cost: {time.time()-t0:.1f}s")
# 查询
res = client.search(
collection_name="products_qdrant",
query_vector=np.random.rand(768).astype(np.float32).tolist(),
limit=50,
query_filter=Filter(
must=[
FieldCondition(key="category", match=MatchValue(value=1)),
FieldCondition(key="status", match=MatchValue(value=1)),
]
),
search_params={"hnsw_ef": 128},
)
注意 Qdrant 写入时 wait=False 会异步落盘,速度更接近真实吞吐;Milvus 的 insert 默认进内存段,flush 后才持久化,两边口径基本可比。
五、踩坑与优化
第一个坑是 Milvus 的内存。默认配置下,standalone 加载 1200 万条 768 维向量后,querynode 常驻内存接近 28GB,加上 etcd 和 MinIO 总共吃到 34GB。如果同时跑别的服务,很容易 OOM。后来我调了 queryNode.segcore.chunkRows 和 cache.cacheSize,把 cacheSize 限制到 16GB,内存压到 26GB 左右,但 P99 略有上升。
第二个坑是 Qdrant 的 wait 参数。压测写入时如果每条 upsert 都 wait=True,吞吐会掉一半以上。改成 wait=False 后,写入速度从 1.2 万条/秒提升到 3.8 万条/秒,但要注意在写入结束后调用一次 client.http.collections_api.get_collection 确认落盘。
第三个坑是标量过滤。Milvus 在 category in [1,2,3] 这种多值过滤上,如果过滤后候选集太小,会退化成暴力扫描,延迟飙升。Qdrant 的 payload 索引对这类过滤更友好,建议对高频过滤字段建 create_payload_index。
优化后,Milvus 侧我开启了 mmap 模式,把部分段放到磁盘,内存降到 18GB,代价是 P99 从 68ms 涨到 92ms。Qdrant 侧开启了 on_disk_payload=true,内存从 21GB 降到 14GB,P99 几乎没变,这点让我挺意外。
六、效果数据
写入 1200 万条后的实测结果(单次 TopK=50,1000 次查询取分位):
| 指标 | Milvus 2.4.5 | Qdrant 1.9.1 |
|---|---|---|
| 写入总耗时 | 41 分钟 | 33 分钟 |
| 索引构建耗时 | 22 分钟 | 18 分钟 |
| 内存占用(默认) | 34GB | 21GB |
| 内存占用(优化后) | 18GB | 14GB |
| P50 延迟 | 21ms | 14ms |
| P95 延迟 | 47ms | 31ms |
| P99 延迟 | 68ms | 43ms |
| QPS(单并发) | 42 | 67 |
| 过滤查询 P99 | 112ms | 58ms |
从数据看,Qdrant 在这个规模下延迟和内存都占优,尤其是带过滤的查询,优势明显。Milvus 的优势在于批量写入的稳定性,以及在更大规模(上亿)时的分片扩展能力,还有它那套完整的生态:Attu 可视化、多租户、一致性级别可调。
七、总结
如果业务是千万级向量、单机部署、过滤条件多、对延迟敏感,我会优先选 Qdrant,部署简单、资源占用低、过滤性能好。如果预期数据量会快速涨到亿级,或者需要多租户、强一致性、成熟的运维工具链,Milvus 更合适,但要做好内存规划和集群化准备。
我们最终选了 Qdrant,因为当前数据量在 1200 万,且过滤场景占比超过 60%。等数据量到 5000 万以上时,会再评估一次是否迁移到 Milvus 集群。选型没有绝对的对错,只有和业务阶段是否匹配。