一、问题背景:为什么又做了一次选型

我们做的是一个图文素材检索系统,用户上传图片或输入自然语言,系统返回相似素材。数据量从最初的 50 万条涨到现在的 1200 万条,向量维度 768(CLIP ViT-L/14 输出),QPS 峰值约 300,要求 P99 延迟控制在 50ms 以内。

早期用的是 Faiss 本地索引,简单粗暴,但有两个硬伤:一是更新要重建索引,二是没法多副本容灾。于是转向专业向量数据库。市面上的选择不少,最终把范围收窄到 Milvus 和 Qdrant,原因很直接:两者社区活跃、都有生产级案例、都支持 HNSW 和过滤检索。

但网上的对比文章大多停留在"Hello World"层面,跑个 10 万条数据就下结论,参考价值有限。我干脆自己搭环境,用真实业务数据跑一轮。

二、环境与版本

测试机配置如下,两台物理机规格一致:

  • CPU:Intel Xeon Gold 6248R,16 核 32 线程
  • 内存:64GB DDR4
  • 磁盘:NVMe SSD 1TB
  • 操作系统:Ubuntu 22.04.3 LTS
  • Docker:26.1.4

软件版本:

  • Milvus 2.5.4(standalone 模式,etcd 3.5.16 + MinIO RELEASE.2024-09-01)
  • Qdrant 1.12.4
  • Python 3.10.12,pymilvus 2.5.0,qdrant-client 1.12.0

数据集:1200 万条 768 维 float32 向量,从线上素材库导出,附带 payload 字段 category(20 个类别)、ctime(时间戳)。查询集为 1000 条随机采样的真实 query,取 top-10。

三、方案设计

两条路线:

Qdrant 侧:单容器部署,配置 HNSW,m=16ef_construct=200,量化开启 scalar quantization(int8),内存优先。

Milvus 侧:standalone 模式(官方说 standalone 也能扛千万级,正好验证一下),HNSW 索引,M=16efConstruction=200,同样开 SCANN 量化的替代方案——Milvus 这边用 IVF_SQ8 做对照,另外单跑一组 HNSW 无量化。

指标关注三块:查询延迟(P50/P95/P99)、内存与磁盘占用、写入吞吐。

四、部署步骤

Qdrant 部署

Qdrant 的部署是我见过最省心的,一条命令搞定:

docker run -d --name qdrant \
  -p 6333:6333 -p 6334:6334 \
  -v /data/qdrant_storage:/qdrant/storage \
  -e QDRANT__SERVICE__GRPC_PORT=6334 \
  --ulimit nofile=65536:65536 \
  qdrant/qdrant:v1.12.4

配置文件 config/production.yaml 里重点调两个参数:

storage:
  optimizers:
    memmap_threshold_kb: 20000
    indexing_threshold_kb: 20000
  hnsw_index:
    m: 16
    ef_construct: 200
    full_scan_threshold_kb: 10000

memmap_threshold_kb 设成 20000 意味着超过 20MB 的 segment 走 mmap,内存不够时不会 OOM。这个参数在千万级数据上很关键。

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

会起来三个容器:milvus-standalone、milvus-etcd、milvus-minio。docker-compose.yml 里我改了内存限制:

services:
  standalone:
    environment:
      ETCD_ENDPOINTS: etcd:2379
      MINIO_ADDRESS: minio:9000
    deploy:
      resources:
        limits:
          memory: 48G

Milvus 的 milvus.yaml 里调整了 queryNode 的缓存比例:

queryNode:
  cache:
    cacheSize: 32GB

默认值偏保守,千万级数据下不改会频繁落盘。

五、核心实现与压测代码

数据写入

Qdrant 的写入用 batch upsert,每批 2000 条:

from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct
import numpy as np

client = QdrantClient(host="localhost", port=6333, grpc_port=6334, prefer_grpc=True)

client.recreate_collection(
    collection_name="materials",
    vectors_config=VectorParams(size=768, distance=Distance.COSINE),
    hnsw_config={"m": 16, "ef_construct": 200},
    quantization_config={"scalar": {"type": "int8", "always_ram": True}},
)

def batch_upsert(vectors, payloads, start_id):
    points = [
        PointStruct(id=start_id + i, vector=vectors[i].tolist(), payload=payloads[i])
        for i in range(len(vectors))
    ]
    client.upsert(collection_name="materials", points=points, wait=False)

# 实测:1200万条,batch=2000,耗时 41 分钟,平均 4870 条/秒

Milvus 侧写入:

from pymilvus import MilvusClient, DataType
import numpy as np

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

schema = client.create_schema(auto_id=False, enable_dynamic_field=True)
schema.add_field("id", DataType.INT64, is_primary=True)
schema.add_field("vector", DataType.FLOAT_VECTOR, dim=768)
schema.add_field("category", DataType.VARCHAR, max_length=64)
schema.add_field("ctime", DataType.INT64)

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

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

# 分批 insert,每批 5000 条
# 实测:1200万条,耗时 53 分钟,平均 3770 条/秒

写入阶段 Qdrant 快了约 29%,我猜跟 Milvus 多了一层 WAL 和 segment 落盘有关。

查询压测

用 locust 写了压测脚本,1000 条真实 query 循环打,并发 50,跑 10 分钟。核心查询逻辑:

# Qdrant
res = client.search(
    collection_name="materials",
    query_vector=qvec,
    limit=10,
    search_params={"hnsw_ef": 128, "exact": False},
    query_filter=None,
)

# Milvus
res = client.search(
    collection_name="materials",
    data=[qvec],
    limit=10,
    search_params={"metric_type": "COSINE", "params": {"ef": 128}},
)

六、踩坑与优化

坑 1:Qdrant 的 always_ram 量化配置。 一开始没开 always_ram,前几轮查询延迟抖动很大,P99 到了 80ms。排查发现是量化向量在磁盘上,每次查询要读盘。开了之后内存涨了 3GB,但 P99 直接降到 18ms。这个参数在官方文档里不显眼,但千万级场景必开。

坑 2:Milvus standalone 的 etcd 瓶颈。 写入阶段 Milvus 的 etcd 容器 CPU 一度飙到 400%,写入速率卡在 3000 条/秒上不去。把 etcd.yaml 里的 auto-compaction-retention 从默认 1 小时改成 15 分钟,写入速率回升到 3800 条/秒。standalone 模式下 etcd 是单点,数据量大时确实会拖后腿。

坑 3:Milvus 的 cacheSize 配置。 默认 cacheSize 是按内存比例算的,16C64G 机器上给得偏小,导致 chunk cache 命中率只有 68%。手动设成 32GB 后,命中率到 94%,P99 从 41ms 降到 27ms。

优化:Qdrant 的 hnsw_ef 调参。 默认 ef=128 时召回率 0.982,把 ef 提到 256 召回率到 0.996,但 P99 涨到 26ms。最终业务上取 ef=160,召回率 0.991,P99 21ms,性价比最高。

七、效果数据

1000 条 query、并发 50、top-10,结果如下:

指标 Qdrant 1.12.4 Milvus 2.5.4 (HNSW) Milvus 2.5.4 (IVF_SQ8)
P50 延迟 6ms 11ms 9ms
P95 延迟 13ms 19ms 22ms
P99 延迟 18ms 27ms 35ms
召回率@10 0.991 0.993 0.964
内存占用 21GB 34GB 26GB
磁盘占用 19GB 31GB 24GB
写入吞吐 4870 条/秒 3770 条/秒 3770 条/秒

Qdrant 在延迟和资源占用上全面占优,主要赢在单进程架构没有 etcd/MinIO 这层开销。Milvus 召回率略高一点点,差异在统计误差范围内。

但 Milvus 的优势不在单机性能,而在分布式扩展和生态。如果数据量上到亿级、需要水平扩展、要接 Flink/Spark 做流式写入,Milvus 的架构优势就出来了。standalone 模式只是它最小的形态。

八、总结

回到选型结论:

选 Qdrant 的场景:数据量在 5000 万以内、单机或少量节点、追求低延迟和低资源占用、团队运维人手有限。我们这次最终选了 Qdrant,因为它在这个量级下表现更均衡,部署运维成本也低。

选 Milvus 的场景:数据量亿级以上、需要分布式水平扩展、有流批一体的写入需求、已经在用 Milvus 生态工具(Attu、Birdwatcher)。Milvus 的 standalone 只是入门,真正发挥实力要上集群。

一个提醒:任何选型对比都别只看别人的数字。硬件、数据分布、查询模式、过滤条件比例,都会让结果差出好几倍。我这次测试的过滤条件占比只有 5%,如果你的业务过滤条件重(比如按类目+时间范围过滤),两者的表现排序可能会反过来。自己搭环境跑一遍,比看十篇文章都管用。