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

我们做的是一个电商商品图文检索系统:用户上传一张图,系统返回相似商品。向量由 CLIP ViT-B/32 生成,128 维(这里做了 PCA 降维,原始 512 维),总量约 1200 万条,每天增量 5 万左右。查询侧高峰期 QPS 约 80,要求 P99 延迟控制在 50ms 内,并且要支持“类目 + 价格区间”这类结构化过滤。

一开始团队默认选 Milvus——名气大、社区活跃、背后有 Zilliz。但真到部署阶段,Milvus 的组件栈(etcd、MinIO、Pulsar/ WoodPecker、proxy、query node…)在单机上跑起来内存直接吃掉 20G+,运维同学脸都绿了。后来看到 Qdrant 用 Rust 写、单二进制、REST/gRPC 都有,社区口碑在“单机轻量”上很好,就决定做一次同条件实测,而不是拍脑袋。

这篇文章就是那次选型的记录。结论我先放前面:单机中等规模、过滤条件不极端,Qdrant 更划算;如果未来要上集群、要复杂标量过滤加向量混合查询、要生态工具链,Milvus 更稳。 下面是过程。

二、环境与版本

  • 机器:阿里云 ecs.g7.4xlarge,16 vCPU / 64GB,ESSD PL1 云盘 500GB
  • OS:Ubuntu 22.04,内核 5.15
  • Docker:24.0.7,Docker Compose v2.23
  • Milvus:2.4.4(standalone 模式,docker-compose 官方模板)
  • Qdrant:1.9.2(单容器)
  • Python 客户端:pymilvus==2.4.4,qdrant-client==1.9.1
  • 数据集:1200 万条 128 维 float32 向量,附带 category_id(int,取值 0-199)和 price(float)两个标量字段
  • 距离度量:内积(IP),两边索引参数按各自推荐值配置

三、方案设计

目标是对比四个维度:部署复杂度、索引构建耗时、查询延迟(含过滤)、资源占用。为了公平,两个库都只跑这一个服务,机器上不跑其他负载,每次测试前 drop_caches。

索引参数:
- Milvus:IndexType=IVF_FLAT,nlist=4096,nprobe=32;标量字段建 STL_SORT 索引用于过滤
- Qdrant:HNSW,m=16,ef_construct=200,查询 hnsw_ef=64;标量字段建 payload index

查询负载分三组:
1. 纯向量 top-10
2. 向量 top-10 + category_id == 42 过滤
3. 向量 top-10 + category_id in (…) 且 price between 100 and 500 复合过滤

每组用 2000 条 query 预热 200 条后统计 P50/P95/P99。

四、核心实现(含代码)

4.1 部署:Milvus standalone

官方 compose 文件拉下来直接起,注意 standalone 里 etcd 和 MinIO 是必须的:

# docker-compose-milvus.yml (节选,基于官方 v2.4.4 模板)
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]

启动后 docker stats 看到常驻内存约 21.8GB(etcd 0.2G + MinIO 0.6G + standalone 21G),这是空载。

4.2 部署:Qdrant

Qdrant 就干净多了:

# docker-compose-qdrant.yml
services:
  qdrant:
    image: qdrant/qdrant:v1.9.2
    ports: ["6333:6333", "6334:6334"]
    volumes: ["./qdrant_storage:/qdrant/storage"]
    environment:
      QDRANT__SERVICE__GRPC_PORT: 6334
      QDRANT__STORAGE__OPTIMIZERS__MEMMAP_THRESHOLD_KB: 20000

空载常驻内存 1.1GB。单看部署这一步,Qdrant 对单机环境友好太多。

4.3 数据写入与索引构建(Python 可运行)

# ingest.py —— 两个库共用同一批 numpy 向量
import numpy as np, time
from pymilvus import MilvusClient
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct

N, DIM = 12_000_000, 128
rng = np.random.default_rng(42)
vecs = rng.random((N, DIM), dtype=np.float32)
cats = rng.integers(0, 200, N).tolist()
prices = (rng.random(N) * 1000).round(2).tolist()

# ---------- Milvus ----------
mc = MilvusClient(uri="http://localhost:19530")
mc.create_collection("items", dimension=DIM, metric_type="IP",
                     index_type="IVF_FLAT", params={"nlist": 4096})
t0 = time.time()
BATCH = 5000
for i in range(0, N, BATCH):
    rows = [{"id": i + j, "vector": vecs[i + j].tolist(),
             "category_id": cats[i + j], "price": prices[i + j]}
            for j in range(min(BATCH, N - i))]
    mc.insert("items", rows)
mc.create_index("items", "category_id")   # 标量索引
print(f"Milvus insert+index: {time.time()-t0:.1f}s")

# ---------- Qdrant ----------
qc = QdrantClient(url="http://localhost:6333")
qc.recreate_collection("items", vectors_config=VectorParams(size=DIM, distance=Distance.DOT))
qc.create_payload_index("items", "category_id", field_schema="integer")
qc.create_payload_index("items", "price", field_schema="float")
t0 = time.time()
for i in range(0, N, BATCH):
    pts = [PointStruct(id=i + j, vector=vecs[i + j].tolist(),
                       payload={"category_id": cats[i + j], "price": prices[i + j]})
           for j in range(min(BATCH, N - i))]
    qc.upsert("items", points=pts)
qc.update_collection("items", optimizer_config={"indexing_threshold": 20000})
print(f"Qdrant upsert: {time.time()-t0:.1f}s")

注意 Qdrant 的 upsert 是异步建 HNSW 的,写入返回快但索引可能还没完全构建,压测前需要检查 collection_info 里的 indexed_vectors_count 是否等于 N。Milvus 的 insert 也是先进 growing segment,需要等 flush + 索引完成,我用 mc.flush("items") 然后轮询 get_index_build_progress。

五、踩坑与优化

坑 1:Milvus standalone 内存门槛。 一开始 16G 机器直接 OOM,查日志是 querynode 加载 segment 时内存翻倍。官方推荐 standalone 最低 16G 只是“能跑”,实际 1200 万向量 + IVF 索引,稳定运行需要 32G 以上。

坑 2:Qdrant 的 payload index 没建对会全表扫。 一开始只在 category_id 上建了 integer 索引,复合查询里 price between 走了暴力过滤,P99 直接飙到 400ms。补上 float 索引后回到 30ms 内,这个差异非常致命。

坑 3:两边距离度量语义不同。 Milvus IP 和 Qdrant DOT 都是内积,但如果用 Cosine,Qdrant 需要显式对向量归一化(1.9 的 Distance.COSINE 会自己归一化,但写入时多一步开销)。我们统一在内积上比较,避免度量差异污染结论。

优化动作:
- Milvus 把 nprobe 从 16 调到 32,召回从 0.91 提到 0.97,延迟增加约 8ms,可接受
- Qdrant 把 hnsw_ef 从 32 调到 64,召回 0.93→0.98,P99 增加约 5ms
- 两边都把向量以 float32 而非 float16 存入(float16 召回掉 2 个点,不划算)

六、效果数据

指标 Milvus 2.4.4 Qdrant 1.9.2
空载常驻内存 21.8 GB 1.1 GB
满载内存(含索引) 38.5 GB 24.7 GB
索引构建耗时 约 52 min 约 38 min
纯向量 P99 22 ms 18 ms
单条件过滤 P99 26 ms 31 ms
复合过滤 P99 34 ms 47 ms
召回率@10(nprobe/ef 调优后) 0.971 0.978

解释一下:Qdrant 在纯向量检索上确实快,Rust + HNSW 实现效率高;但一旦上复合过滤,Milvus 的标量索引 + 位图过滤路径更成熟,Qdrant 在“多条件组合”上要额外开销。内存方面 Qdrant 的 mmap 策略让常驻内存低很多,Milvus 是“吃内存换查询速度”的路子。

最终我们选了 Qdrant 作为当前单机方案,因为业务过滤条件是“类目 + 价格”这种相对固定的组合,Qdrant 把 payload index 建全后 P99 47ms 刚好卡在 SLA 边缘但可接受,而内存省下的 14G 让我们能同机部署其他服务。但如果明年向量量级上到 5000 万或者要上多副本集群,我会切回 Milvus——它的分布式架构和生态工具(Attu、Birdwatcher)在运维侧更省心。

七、总结

选型没有银弹。这次实测给我的三个结论:

  1. 单机、中等规模、内存敏感 → Qdrant,部署一个容器搞定,运维成本极低;
  2. 复杂过滤、未来要水平扩展、要成熟监控工具链 → Milvus,代价是资源门槛和组件复杂度;
  3. 别信 benchmark 文章里的绝对数字,一定要拿自己的数据分布和查询 pattern 跑一遍,尤其过滤条件的组合方式会显著改变结论。

代码和 compose 文件都在上面,改改路径就能复现。如果你们也在做类似选型,欢迎评论区交流踩坑经历。