一、问题背景:为什么要在 Milvus 和 Qdrant 之间纠结

先说业务场景,不然选型讨论都是空的。我们做的是一个图文内容平台的内容去重与相似推荐服务,核心链路是:用户上传图片 → CLIP 类模型抽 768 维向量 → 向量库做 ANN 检索 → 命中相似度阈值以上就判定为重复或推荐相似内容。

数据规模预估:存量约 1000 万条图文向量,日增 30 万~50 万,QPS 峰值读约 200,写入集中在夜间批处理。这个规模很尴尬——说大不大,单机扛得住;说小不小,Faiss 裸奔做持久化和增删改会很痛苦。所以必须上专业的向量数据库。

候选就两个:Milvus 和 Qdrant。两者都是开源、都支持 HNSW、都有 Python SDK、社区都活跃。网上文章要么是官方 benchmark(数据好看但不可复现),要么是"Hello World"级别的 demo。我需要的是:在我这台机器、我这个数据分布下,谁更合适。

于是有了这篇实测。

二、环境与版本

硬件统一,避免"田忌赛马":

  • CPU:AMD EPYC 7B13,16 vCPU
  • 内存:64 GB
  • 磁盘:NVMe SSD 500 GB
  • OS:Ubuntu 22.04.3 LTS
  • Docker:24.0.7,Docker Compose v2.23.0

软件版本(都取当时最新稳定版):

  • Milvus:v2.4.6(standalone 模式,依赖 etcd 3.5.5 + MinIO RELEASE.2023-03-20)
  • Qdrant:v1.9.2(单节点,官方 docker 镜像)
  • 客户端:pymilvus 2.4.4、qdrant-client 1.9.1
  • 压测工具:自写 Python 脚本(多线程 + 固定随机种子查询集),避免用官方工具带来的口径差异

数据:1000 万条随机生成的 768 维 float32 向量,归一化后写入,另取 10000 条作为查询集(不在库内,模拟真实外查)。

三、方案设计

3.1 部署方案

Milvus standalone 用官方 compose 一把梭,但要注意默认配置是给"能跑"用的,不是给"能跑好"用的。Qdrant 简单得多,一个容器搞定,配置通过环境变量或 config 文件注入。

3.2 索引与参数对齐

为了让对比公平,两边都用 HNSW,关键参数尽量对齐:

参数 Milvus Qdrant
索引类型 HNSW HNSW
M 16 16
efConstruction 200 200
ef(search) 64 64 (hnsw_ef)
距离度量 COSINE Cosine
量化 无 无(scalar quantization 关闭)

Milvus 的 ef 在搜索时通过 search_params={"ef": 64} 传;Qdrant 通过 search_params=SearchParams(hnsw_ef=64) 传。

3.3 测量口径

  • 写入:批量 5000 条/批,记录总耗时与索引构建完成时间(Milvus 用 flush + 等待索引 built;Qdrant 用 wait=True 并观察优化器状态)
  • 查询:16 线程并发,每线程发 625 次(共 10000 次),记录 QPS、P50、P99
  • 资源:稳定后 docker stats 采样 60 秒取均值;磁盘看数据目录 du

四、核心实现(含部署与压测代码)

4.1 Milvus 部署

# 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
      - ETCD_SNAPSHOT_COUNT=50000
    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.6
    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,等 curl localhost:9091/healthz 返回 OK 即可。

4.2 Qdrant 部署

docker run -d --name qdrant \
  -p 6333:6333 -p 6334:6334 \
  -v $(pwd)/volumes/qdrant:/qdrant/storage \
  -e QDRANT__SERVICE__GRPC_PORT=6334 \
  -e QDRANT__STORAGE__OPTIMIZERS__DEFAULT_SEGMENT_NUMBER=4 \
  qdrant/qdrant:v1.9.2

DEFAULT_SEGMENT_NUMBER=4 是我调过的,默认 2 段在千万级时单段过大,优化器合并慢,调到 4 后写入更平顺(后面踩坑部分细说)。

4.3 写入与压测脚本(两边共用逻辑)

# bench.py
import time, random, numpy as np, threading
from concurrent.futures import ThreadPoolExecutor
from pymilvus import MilvusClient
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct, HnswConfigDiff, SearchParams

DIM, N = 768, 10_000_000
BATCH = 5000
rng = np.random.default_rng(42)

def gen_batch(n):
    v = rng.standard_normal((n, DIM)).astype(np.float32)
    v /= np.linalg.norm(v, axis=1, keepdims=True)
    return v

def bench_milvus():
    c = MilvusClient(uri="http://localhost:19530")
    if c.has_collection("bench"): c.drop_collection("bench")
    c.create_collection("bench", dimension=DIM, metric_type="COSINE",
        index_params={"index_type": "HNSW", "metric_type": "COSINE",
                      "params": {"M": 16, "efConstruction": 200}})
    t0 = time.time()
    for i in range(0, N, BATCH):
        data = gen_batch(BATCH)
        c.insert("bench", [{"id": i + j, "vector": data[j].tolist()} for j in range(BATCH)])
    c.flush("bench")
    print(f"[Milvus] insert+flush: {time.time()-t0:.1f}s")
    return c

def bench_qdrant():
    c = QdrantClient(host="localhost", port=6333, timeout=120)
    c.recreate_collection("bench",
        vectors_config=VectorParams(size=DIM, distance=Distance.COSINE),
        hnsw_config=HnswConfigDiff(m=16, ef_construct=200))
    t0 = time.time()
    for i in range(0, N, BATCH):
        data = gen_batch(BATCH)
        c.upsert("bench", points=[PointStruct(id=i+j, vector=data[j].tolist())
                                  for j in range(BATCH)], wait=False)
    c.upsert("bench", points=[], wait=True)  # 等优化器落盘
    print(f"[Qdrant] upsert+wait: {time.time()-t0:.1f}s")
    return c

def query_bench(search_fn, queries, threads=16):
    lat = []
    lock = threading.Lock()
    def worker(qs):
        for q in qs:
            t = time.perf_counter()
            search_fn(q)
            with lock: lat.append((time.perf_counter()-t)*1000)
    chunks = np.array_split(queries, threads)
    t0 = time.time()
    with ThreadPoolExecutor(threads) as ex:
        list(ex.map(worker, chunks))
    total = time.time()-t0
    lat.sort()
    print(f"QPS={len(queries)/total:.0f} P50={lat[len(lat)//2]:.2f}ms "
          f"P99={lat[int(len(lat)*0.99)]:.2f}ms")

查询部分:Milvus 用 c.search("bench", data=[q], limit=10, search_params={"ef": 64}, anns_field="vector"),Qdrant 用 c.search("bench", query_vector=q, limit=10, search_params=SearchParams(hnsw_ef=64)),这里不重复贴。

五、踩坑与优化

坑 1:Milvus 写入不 flush 等于没写。 一开始我插完就查,结果召回为 0。Milvus 的 insert 是进 growing segment,不 flush 且索引没建好之前,search 走的是暴力扫,慢且结果不全。必须 flush 并等 get_load_state 为 Loaded。这直接导致我第一批"写入耗时"测出来只有 200 秒——假的,因为没等索引建完。修正后写入+索引构建才是有意义的数字。

坑 2:Qdrant 默认段数在千万级下优化器会"卡住"。 默认 2 段,写到 800 万左右时 wait=True 的 upsert 要等几十秒,因为后台在做 segment 合并。调到 DEFAULT_SEGMENT_NUMBER=4 并配合 optimizers_config.indexing_threshold_kb 后明显改善。另外 Qdrant 的 wait=True 是等写入可见,不等于等 HNSW 索引建完,压测前要 curl localhost:6333/collections/bench 看 optimizer_status 是否为 ok。

坑 3:内存。 Milvus standalone 的 etcd + MinIO + 本体三件套,空载就吃了约 2.4 GB。Qdrant 空载约 180 MB。千万级 768 维原始向量约 30 GB(10M × 768 × 4B),两边都会把它放内存/页缓存,但 Milvus 的 segment 元数据和索引额外开销更大。这也是最终资源占用差距的主要来源。

六、效果数据

1000 万条 768 维,HNSW(M=16, efC=200),查询 ef=64,TopK=10:

指标 Milvus 2.4.6 Qdrant 1.9.2
写入+索引构建总耗时 约 41 分钟 约 58 分钟
索引构建后磁盘占用 约 46 GB 约 38 GB
稳定态内存(RSS 合计) 约 34 GB 约 27 GB
查询 QPS(16 并发) 约 620 约 780
P50 延迟 18.4 ms 13.9 ms
P99 延迟 61 ms 44 ms
空载内存 2.4 GB 0.18 GB
部署复杂度 高(3 组件) 低(1 容器)

召回率两边在 ef=64 下对 TopK=10 都在 0.95+(用暴力检索做 ground truth 抽样 1000 条比对),差距在噪声范围内,不作为区分点。

解读一下:Milvus 写入快,是因为它的写入路径是"先落对象存储 + WAL,索引异步构建",吞吐更猛;但代价是组件多、内存开销大、运维心智负担高。Qdrant 单进程、Rust 实现,内存效率高、查询延迟低,但单机写入吞吐略逊,且水平扩展能力不如 Milvus 的存算分离架构。

七、总结

回到我们的场景:1000 万级、读多写少(夜间批写)、团队只有 2 个后端兼运维。最终选了 Qdrant。理由很朴素:单容器部署、空载 180 MB、P99 低 17 ms、磁盘省 8 GB,出问题时 docker logs 一眼看到底,不用在 etcd、MinIO、Milvus 三个日志里来回翻。Milvus 不是不好,它的分布式能力和写入吞吐在数据量上亿、需要多副本和弹性扩缩容时才是真正的主场——如果你的场景是亿级向量、多租户、要求高可用,那 Milvus 的存算分离架构值得那份额外的运维成本。

一句话选型建议:千万级以下、团队小、追求部署和内存效率,选 Qdrant;亿级以上、要水平扩展和高可用、能接受多组件运维,选 Milvus。 别被 benchmark 榜单带偏,先量清楚自己的数据量和 QPS,再拿本文的脚本在自己机器上跑一遍,比看十篇文章都管用。