一、问题背景
先说场景。我们做的是一个内容社区,需要对每天新增的约 200 万条图文内容做相似去重和推荐召回。向量模型用的是 BGE-large-zh-v1.5,输出 768 维。历史存量大约 1000 万条,未来半年预计到 5000 万。
需求很明确:
- 单条查询延迟 P99 控制在 50ms 以内
- 支持按 category、publish_time 做标量过滤
- 运维成本尽量低,团队没有专职的向量数据库 DBA
候选就是 Milvus 和 Qdrant。Milvus 生态大、功能全、有 Zilliz 背书;Qdrant 是 Rust 写的,主打单机性能和易用性。网上文章要么是官方 benchmark,要么是「Hello World」级别的 demo,缺少真实业务参数下的对比。所以我花了两天时间,在同样的机器上把两个都跑了一遍。
二、环境与版本
测试机器是一台阿里云 ECS,配置如下:
- CPU:Intel Xeon Platinum 8269CY,16 vCPU
- 内存:64 GB
- 磁盘:ESSD PL1,500 GB
- OS:Ubuntu 22.04 LTS
- Docker:24.0.7,Docker Compose v2.23.0
软件版本:
- Milvus 2.4.4(standalone 模式,etcd 3.5.5 + MinIO RELEASE.2023-03-20)
- Qdrant 1.9.2(单节点)
- Python 3.10,pymilvus 2.4.3,qdrant-client 1.9.1
两边都用 Docker 部署,避免环境差异。Milvus 的 standalone 依赖 etcd 和 MinIO,Qdrant 是单二进制,这一点在部署复杂度上差距很明显。
三、方案设计
数据集:1000 万条 768 维 float32 向量,随机生成但做了归一化,模拟真实 embedding 分布。另外给每条向量附加两个标量字段:category(0-99 的整数)和 ts(时间戳)。
索引配置:
- Milvus:HNSW,M=16,efConstruction=200,查询时 ef=64;标量字段建倒排索引
- Qdrant:HNSW,m=16,ef_construct=200,查询时 hnsw_ef=64;标量字段建 payload index
测试指标:
1. 批量写入 1000 万条的耗时和内存峰值
2. Top-10 查询在无过滤、带 category 过滤两种条件下的 P50/P99 延迟
3. 常驻内存占用
四、核心实现
4.1 部署
Milvus 的 docker-compose 官方给了模板,我精简了一下:
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
command: etcd -advertise-client-urls=http://127.0.0.1:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd
volumes:
- ./volumes/etcd:/etcd
minio:
image: minio/minio:RELEASE.2023-03-20T20-16-18Z
environment:
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
command: minio server /minio_data --console-address ":9001"
volumes:
- ./volumes/minio:/minio_data
standalone:
image: milvusdb/milvus:v2.4.4
command: ["milvus", "run", "standalone"]
environment:
ETCD_ENDPOINTS: etcd:2379
MINIO_ADDRESS: minio:9000
ports:
- "19530:19530"
- "9091:9091"
depends_on:
- 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
4.2 写入与查询代码
Milvus 侧:
from pymilvus import MilvusClient
import numpy as np, time
client = MilvusClient(uri="http://localhost:19530")
client.create_collection(
collection_name="demo",
dimension=768,
metric_type="COSINE",
index_type="HNSW",
index_params={"M": 16, "efConstruction": 200},
)
# 批量写入,每批 5 万
BATCH = 50000
vectors = np.random.rand(BATCH, 768).astype(np.float32)
data = [
{"id": i, "vector": vectors[i].tolist(),
"category": i % 100, "ts": 1700000000 + i}
for i in range(BATCH)
]
t0 = time.time()
client.insert(collection_name="demo", data=data)
print("insert batch cost:", time.time() - t0)
# 查询
res = client.search(
collection_name="demo",
data=[vectors[0].tolist()],
limit=10,
filter='category == 5',
search_params={"metric_type": "COSINE", "params": {"ef": 64}},
)
Qdrant 侧:
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct, Filter, FieldCondition, MatchValue
import numpy as np, time
client = QdrantClient(host="localhost", port=6333)
client.recreate_collection(
collection_name="demo",
vectors_config=VectorParams(size=768, distance=Distance.COSINE),
hnsw_config={"m": 16, "ef_construct": 200},
)
client.create_payload_index("demo", "category", field_type="integer")
client.create_payload_index("demo", "ts", field_type="integer")
BATCH = 50000
vectors = np.random.rand(BATCH, 768).astype(np.float32)
points = [
PointStruct(id=i, vector=vectors[i].tolist(),
payload={"category": i % 100, "ts": 1700000000 + i})
for i in range(BATCH)
]
t0 = time.time()
client.upsert(collection_name="demo", points=points)
print("upsert batch cost:", time.time() - t0)
res = client.search(
collection_name="demo",
query_vector=vectors[0].tolist(),
limit=10,
query_filter=Filter(must=[FieldCondition(key="category", match=MatchValue(value=5))]),
search_params={"hnsw_ef": 64},
)
两边 API 都很直觉,Qdrant 的 payload 过滤写法我个人更喜欢,语义清晰。
五、踩坑与优化
坑 1:Milvus 写入必须 flush。 一开始我没调 flush(),查询一直返回空。Milvus 是段式存储,insert 后数据在内存 buffer,查询可见性依赖 flush 或 growing segment 的加载。1000 万条数据我每 100 万调一次 flush,比较稳。Qdrant 的 upsert 是立即可见的,没这个问题。
坑 2:Qdrant 的 payload index 要提前建。 我第一轮测试忘了建 category 的索引,带过滤查询直接从 12ms 飙到 180ms,因为它在做全量 payload 扫描。建完索引后回到 13ms 左右。这个细节很多教程不会强调。
坑 3:Milvus standalone 的内存吃紧。 1000 万条 768 维向量,原始数据约 30GB(float32),Milvus 加上 etcd、MinIO 和索引,常驻内存到 42GB,64GB 机器已经比较紧张。Qdrant 同样数据常驻约 34GB。Milvus 的额外开销主要来自多组件和 segment 元数据。
优化点: Milvus 我把 queryNode.segcore.chunkRows 从默认 1024 调到 4096,减少 segment 数量,查询 P99 从 31ms 降到 23ms。Qdrant 调了 hnsw_ef,从 128 降到 64,召回率掉 0.3% 但延迟降了 40%。
六、效果数据
写入 1000 万条的总耗时:
| 数据库 | 耗时 | 峰值内存 |
|---|---|---|
| Milvus 2.4.4 | 18 分 42 秒 | 42 GB |
| Qdrant 1.9.2 | 14 分 05 秒 | 34 GB |
查询延迟(单线程,1000 次取平均):
| 场景 | Milvus P50 | Milvus P99 | Qdrant P50 | Qdrant P99 |
|---|---|---|---|---|
| 无过滤 Top-10 | 9 ms | 23 ms | 5 ms | 12 ms |
| category 过滤 Top-10 | 11 ms | 27 ms | 6 ms | 13 ms |
召回率(Recall@10,对比暴力检索):Milvus 0.982,Qdrant 0.978,基本持平。
结论很清晰:单机场景下 Qdrant 在延迟和内存上都有优势,部署也简单得多。Milvus 的优势在于分布式、索引类型丰富(DiskANN、GPU 索引)、生态成熟。
七、总结
如果你的数据量在 5000 万以内、团队运维能力有限、追求单机性能,Qdrant 是更省心的选择,1.9.2 版本已经很稳定。如果数据量上亿、需要水平扩展、或者要用 DiskANN 这类磁盘索引降低内存成本,Milvus 2.4.x 的分布式能力更值得投入。
我们最终选了 Qdrant,因为当前规模单机足够,运维成本低。但预留了迁移路径——向量数据和 payload 都有完整导出,将来真要上 Milvus 也不难。选型没有银弹,先跑通自己的真实数据再决定,比看任何 benchmark 都靠谱。