一、问题背景

我们做的是一个内容平台的图文去重与相似推荐服务。素材库里有约 1000 万条图片特征向量(CLIP ViT-L/14,768 维,float32),需求是:

  • 新图入库时做 TopK=10 的近邻检索,判断是否重复/高度相似;
  • 线上 QPS 峰值约 50,P99 延迟要求 < 50ms;
  • 只有一台 4C16G 的测试机可用于 PoC,生产预算也有限,不希望一上来就上集群。

候选就是 Milvus 和 Qdrant。两者都是当前社区热度最高的开源向量库,但设计哲学完全不同:Milvus 是"向量数据库里的 Hadoop",存算分离、组件多、为水平扩展而生;Qdrant 是 Rust 写的单体服务,强调单机性能和易运维。网上大部分对比文章都是"跑个 10 万条 demo 然后说都挺好",没什么参考价值。所以我决定在同一份数据、同一台机器上,把两者都按生产可用的配置跑一遍,拿真实数字说话。

二、环境与版本

  • 机器:阿里云 ecs.g7.xlarge,4 vCPU / 16GB / ESSD 200GB
  • OS:Ubuntu 22.04,内核 5.15
  • Docker:24.0.7,Docker Compose v2.23.0
  • Milvus:2.4.6(standalone 模式,milvus-standalone + etcd + minio 三容器)
  • Qdrant:1.9.1(单容器)
  • 客户端:pymilvus 2.4.4,qdrant-client 1.9.1,Python 3.10

向量数据:1000 万条 768 维 float32,随机生成但做了聚类(用 2000 个簇心加高斯扰动),比纯随机更接近真实 embedding 分布——纯随机向量会让 HNSW 的图结构过于均匀,测出来的召回率虚高。

三、方案设计

对比维度定死四个:部署复杂度、建索引时间与资源、查询性能(不同并发下的 QPS 与 P50/P99)、常驻内存。

索引统一用 HNSW,参数尽量对齐:

  • Milvus:M=16, efConstruction=200,查询 ef=64,metric 用 COSINE,索引类型 HNSW
  • Qdrant:m=16, ef_construct=200,查询 hnsw_ef=64,distance 用 Cosine

Milvus 的 HNSW 索引必须全部加载进内存(没有磁盘索引,DiskANN 在 2.4 还是 beta),Qdrant 支持 mmap 把向量放磁盘、只把图结构放内存,这个差异在内存对比里很关键,我会两种模式都测。

四、核心实现

4.1 部署

Milvus standalone 用官方 compose:

# docker-compose-milvus.yml
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.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]

注意 ETCD_QUOTA_BACKEND_BYTES 我在默认 2GB 基础上调到了 4GB,因为 1000 万条数据建索引期间 etcd 的 revision 涨得很快,默认配额下我们跑到 600 万条时就触发过 etcdserver: mvcc: database space exceeded,整个 Milvus 直接不可写。

Qdrant 就一行:

docker run -d --name qdrant \
  -p 6333:6333 -p 6334:6334 \
  -v $(pwd)/qdrant_storage:/qdrant/storage \
  -e QDRANT__STORAGE__STORAGE_PATH=/qdrant/storage \
  qdrant/qdrant:v1.9.1

4.2 建库建索引与数据写入

Milvus 侧,collection 用 AUTOINDEX 之外显式指定 HNSW:

from pymilvus import MilvusClient, DataType

client = MilvusClient(uri="http://localhost:19530")
client.create_collection(
    collection_name="img_vec",
    dimension=768,
    metric_type="COSINE",
    auto_id=True,
    index_type="HNSW",
    index_params={"M": 16, "efConstruction": 200},
)

但 create_collection 的简化接口没法控制 segment 大小,数据量上千万时默认 1024MB 的 segment 会让建索引阶段内存飙到 12GB 以上,我们的 16G 机器直接 OOM 过一次。改成手动建表并调 max_segment_size:

schema = client.create_schema(auto_id=True, enable_dynamic_field=False)
schema.add_field("id", DataType.INT64, is_primary=True)
schema.add_field("vec", DataType.FLOAT_VECTOR, dim=768)

client.create_collection(
    collection_name="img_vec",
    schema=schema,
    properties={"collection.maxSegmentSize": 512},  # 单位 MB,默认 1024
)

写入用 client.insert 分批,每批 5000 条,10 个线程并发,1000 万条灌完大约 6 分半。Qdrant 侧:

from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance, HnswConfigDiff

qc = QdrantClient(host="localhost", port=6333)
qc.recreate_collection(
    collection_name="img_vec",
    vectors_config=VectorParams(size=768, distance=Distance.COSINE),
    hnsw_config=HnswConfigDiff(m=16, ef_construct=200),
)
qc.upload_collection(
    collection_name="img_vec",
    vectors=vectors,          # numpy array, shape=(10000000, 768)
    ids=list(range(len(vectors))),
    batch_size=2000,
    parallel=4,
)

Qdrant 的 upload_collection 会边写边建索引,1000 万条灌完加索引完成总共 26 分钟(parallel=4,再高在 4 核机器上没收益)。

4.3 压测脚本

两边用同一套压测逻辑,concurrent.futures 起 N 个线程,每次随机取一条已有向量做 TopK=10 查询,统计 QPS 和延迟分位:

import random, time, numpy as np
from concurrent.futures import ThreadPoolExecutor

def bench_milvus(concurrency: int, total: int = 5000):
    client = MilvusClient(uri="http://localhost:19530")
    client.load_collection("img_vec")
    qs = [vecs[random.randrange(len(vecs))].tolist() for _ in range(total)]
    lat = []
    def one(q):
        t0 = time.perf_counter()
        client.search("img_vec", data=[q], limit=10,
                      search_params={"metric_type": "COSINE", "params": {"ef": 64}})
        return (time.perf_counter() - t0) * 1000
    with ThreadPoolExecutor(concurrency) as ex:
        lat = list(ex.map(one, qs))
    lat = np.array(lat)
    print(f"Milvus c={concurrency} QPS={concurrency*1000/lat.sum():.0f} "
          f"P50={np.percentile(lat,50):.1f}ms P99={np.percentile(lat,99):.1f}ms")

Qdrant 版本逻辑完全一样,只把 client.search 换成 qc.search(collection_name="img_vec", query_vector=q, limit=10, search_params=SearchParams(hnsw_ef=64))。压测前都先跑 500 次预热,避免冷启动抖动。

五、踩坑与优化

  1. Milvus 的 etcd 配额:上面提过,建索引期间 revision 暴涨,必须调 ETCD_QUOTA_BACKEND_BYTES,否则 600 万条左右就挂。这是 standalone 模式最常见的坑。
  2. Milvus 的 load_collection 内存:HNSW 索引全量加载,1000 万条 768 维 float32 光向量就 28.6GB 原始数据,加上索引结构,实测常驻内存 9.8GB(有压缩和量化吗?没有,就是 9.8GB——因为 Milvus 2.4 对 float32 做了对齐和 segment 级内存管理,实际比裸算要省,但依然吃内存)。16G 机器跑 standalone + etcd + minio 后剩的余量不多,生产上建议至少 32G。
  3. Qdrant 的 mmap:默认配置下向量全在内存,内存 7.2GB。开启 QDRANT__STORAGE__ON_DISK_PAYLOAD=true 和向量 mmap(optimizers_config.memmap_threshold_kb=200000)后,内存降到 2.1GB,但 P99 从 18ms 涨到 61ms,磁盘 IO 成为瓶颈。我们的结论是:QPS 要求高的场景别开 mmap,内存够就全放内存。
  4. Qdrant 的 parallel 写入:4 核机器上 parallel=4 是拐点,设 8 反而因为上下文切换慢了 15%。
  5. 查询参数 ef 的召回率:ef=64 时,两边对 1000 条人工标注的查询做召回率检查,Milvus 和 Qdrant 都是 0.982 左右,差距在噪声范围内。ef 调到 128 召回 0.995 但 P99 翻倍,我们最终线上用 64。

六、效果数据

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

指标 Milvus 2.4.6 Qdrant 1.9.1
部署容器数 3(standalone/etcd/minio) 1
写入 1000 万耗时 6.5 min —
建索引耗时 41 min 26 min(写入+建索引合计)
常驻内存 9.8 GB 7.2 GB(mmap 后 2.1 GB)
并发 1 QPS / P99 62 / 27 ms 89 / 18 ms
并发 16 QPS / P99 480 / 41 ms 520 / 33 ms
并发 64 QPS / P99 1120 / 78 ms 860 / 96 ms
召回率@10 0.982 0.982

数据挺有意思:低并发下 Qdrant 明显更快,单请求延迟低 33%;但并发拉到 64 后 Milvus 的 QPS 反超 30%,P99 也更稳。原因是 Milvus 的查询链路做了请求级并行和 segment 级调度,Qdrant 单容器在 4 核上更容易被线程调度拖住。我们的峰值 QPS 是 50,落在两者都能轻松扛住的区间,所以性能不是决定性因素。

七、总结

选型结论:我们最终选了 Qdrant,理由不是性能,是运维成本。Milvus 的 standalone 三容器 + etcd 配额 + segment 参数调优,对一个只有 4C16G、没有专职 DBA 的团队来说心智负担太重;Qdrant 一个容器、一份配置,备份就是拷目录,出问题日志一眼能看完。性能上两者在我们的 QPS 区间(<100)都绰绰有余。

如果场景换成数据量上亿、QPS 上千、需要多租户和存算分离,我会毫不犹豫选 Milvus——它的分布式能力和生态(Spark/Flink 集成、多索引类型)是 Qdrant 目前比不了的。选型从来不是比谁快,是比谁的复杂度匹配你的团队和业务阶段。