一、问题背景:为什么要在 Milvus 和 Qdrant 之间做选择

我们团队做的是图文内容推荐,召回阶段需要从千万级素材库里找 TopK 相似向量。早期用 Faiss 离线索引,但每次增量更新都要重建,线上无法接受。于是转向向量数据库,候选落在 Milvus 和 Qdrant 上。

两者的定位差异很明显。Milvus 是国内社区最活跃的向量数据库之一,2.x 之后拆分了 coordinator、proxy、query node、data node 等角色,天然支持水平扩展,标量过滤和一致性级别做得比较完整。Qdrant 是 Rust 写的,单二进制部署,资源占用低,过滤检索的性能口碑很好,但分布式能力相对晚一些。

我们的约束是:单机 16C64G,SSD 1TB,向量 1000 万条,维度 768,每天增量约 20 万条,查询 QPS 峰值 200,要求 P99 控制在 50ms 以内。这个规模不大不小,正好是选型最容易纠结的区间。所以干脆两台都部署,用同一批数据跑一遍。

二、环境与版本

  • 服务器:阿里云 ecs.g7.4xlarge,16 vCPU,64GB 内存,ESSD PL1 1TB
  • 操作系统:Ubuntu 22.04,内核 5.15
  • Docker:26.1.4,Docker Compose v2.27.1
  • Milvus:2.5.4,standalone 模式,etcd 3.5.16,MinIO RELEASE.2024-12-18
  • Qdrant:1.12.4,单节点
  • 客户端:pymilvus 2.5.4,qdrant-client 1.12.1
  • 数据集:1000 万条 768 维 float32 向量,随机生成后归一化,模拟余弦相似度场景

三、方案设计

索引选择上,Milvus 用 HNSW(M=16,efConstruction=200)和 IVF_FLAT(nlist=4096)各测一轮;Qdrant 用 HNSW(m=16,ef_construct=200)。查询参数:Milvus HNSW 的 ef=128,Qdrant 的 hnsw_ef=128,TopK=10。

测试分三块:
1. 写入 1000 万条向量的耗时和资源曲线
2. 无过滤条件下 200 并发、持续 5 分钟的 QPS 与 P99
3. 带标量过滤(category in [3,7,11])的 QPS 与 P99
4. 索引构建完成后的常驻内存和磁盘占用

压测工具用 locust,客户端和服务端分离部署,避免客户端吃满 CPU 影响结果。

四、核心实现

4.1 Milvus 部署与写入

Milvus standalone 用官方 compose 起:

wget https://github.com/milvus-io/milvus/releases/download/v2.5.4/milvus-standalone-docker-compose.yml -O docker-compose.yml
docker compose up -d
docker compose ps

写入和建索引代码:

from pymilvus import MilvusClient, DataType
import numpy as np

client = MilvusClient(uri="http://127.0.0.1:19530")

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("embedding", DataType.FLOAT_VECTOR, dim=768)

index_params = client.prepare_index_params()
index_params.add_index(
    field_name="embedding",
    index_type="HNSW",
    metric_type="COSINE",
    params={"M": 16, "efConstruction": 200},
)

client.create_collection(
    collection_name="demo_milvus",
    schema=schema,
    index_params=index_params,
)

BATCH = 2000
for i in range(0, 10_000_000, BATCH):
    ids = list(range(i, i + BATCH))
    cats = np.random.randint(0, 20, size=BATCH).tolist()
    vecs = np.random.rand(BATCH, 768).astype(np.float32)
    vecs /= np.linalg.norm(vecs, axis=1, keepdims=True)
    client.insert("demo_milvus", [
        {"id": ids[j], "category": cats[j], "embedding": vecs[j].tolist()}
        for j in range(BATCH)
    ])

client.load_collection("demo_milvus")

4.2 Qdrant 部署与写入

Qdrant 单节点启动,挂载数据目录并限制内存:

docker run -d --name qdrant \
  -p 6333:6333 -p 6334:6334 \
  -v /data/qdrant:/qdrant/storage \
  -e QDRANT__STORAGE__ON_DISK_PAYLOAD=true \
  qdrant/qdrant:v1.12.4

写入与查询:

from qdrant_client import QdrantClient, models
import numpy as np

client = QdrantClient(host="127.0.0.1", port=6333)

client.recreate_collection(
    collection_name="demo_qdrant",
    vectors_config=models.VectorParams(
        size=768, distance=models.Distance.COSINE,
        hnsw_config=models.HnswConfigDiff(m=16, ef_construct=200),
    ),
)

BATCH = 2000
for i in range(0, 10_000_000, BATCH):
    vecs = np.random.rand(BATCH, 768).astype(np.float32)
    vecs /= np.linalg.norm(vecs, axis=1, keepdims=True)
    client.upsert(
        collection_name="demo_qdrant",
        points=models.Batch(
            ids=list(range(i, i + BATCH)),
            vectors=vecs.tolist(),
            payloads=[{"category": int(c)} for c in np.random.randint(0, 20, BATCH)],
        ),
        wait=False,
    )

查询侧对比:

# Milvus
res = client.search(
    collection_name="demo_milvus",
    data=[query_vec],
    limit=10,
    filter='category in [3, 7, 11]',
    search_params={"metric_type": "COSINE", "params": {"ef": 128}},
)

# Qdrant
res = client.search(
    collection_name="demo_qdrant",
    query_vector=query_vec,
    limit=10,
    query_filter=models.Filter(must=[
        models.FieldCondition(key="category", match=models.MatchAny(any=[3, 7, 11]))
    ]),
    search_params=models.SearchParams(hnsw_ef=128),
)

五、踩坑与优化

第一个坑是 Milvus 的 insert 批次。我们一开始用 500 条一批,10 个并发写,结果 data node 的 flush 频繁触发,写入只有 1.2 万条/秒。改成 2000 条一批、单线程写,反而到了 4.8 万条/秒。原因是小批次导致 segment 过多,compaction 压力大。

第二个坑是 Qdrant 的 upsert 默认 wait=True,逐批等待索引刷新,写入慢得离谱。改成 wait=False 后,写入速度从 8000 条/秒提升到 6.1 万条/秒。但要注意,此时数据还在 memtable,需要留出 flush 时间再做压测,否则查不到最新数据。

第三个坑是内存。Milvus 的 query node 默认会尽量把 segment 缓存在内存,64G 机器上 1000 万条 768 维向量(约 30GB 原始数据)加载后 RSS 冲到 41GB。可以通过 queryNode.cache.enabled 和 mmap 相关参数降低,但延迟会上升。Qdrant 开启 on_disk_payload 后,常驻内存稳定在 17GB 左右,向量本体走 mmap,磁盘占用 34GB。

第四个坑是过滤。Milvus 的 category in [...] 如果命中比例高,会走暴力扫描;Qdrant 的 filterable HNSW 在 payload 索引建好后表现更稳。我们给 category 建了 payload 索引,过滤查询 P99 从 61ms 降到 22ms。

六、效果数据

写入完成后,统一 load 再压测,结果如下(200 并发,5 分钟均值):

指标 Milvus HNSW Milvus IVF_FLAT Qdrant HNSW
写入 1000 万耗时 34 min 34 min 27 min
无过滤 QPS 312 358 405
无过滤 P99 31 ms 27 ms 18 ms
过滤 QPS 187 203 264
过滤 P99 44 ms 39 ms 22 ms
常驻内存 41 GB 38 GB 17 GB
磁盘占用 36 GB 33 GB 34 GB
召回率@10 0.972 0.941 0.976

结论比较清楚:单机千万级场景下,Qdrant 在延迟、内存、过滤性能上全面占优,部署也简单,一个容器就够。Milvus 的 IVF_FLAT 在 QPS 上略好于自家 HNSW,但召回率掉了 3 个点,不适合召回敏感业务。Milvus 的优势在于生态和扩展性:如果数据涨到 5 亿、需要多副本、需要强一致或跨机房,它的架构更撑得住。

我们最终选了 Qdrant 作为当前阶段方案,同时保留 Milvus 的接入层抽象,等数据量过 5000 万再切。选型没有绝对答案,把业务规模、延迟要求、运维成本三件事列清楚,答案通常就浮出来了。