一、问题背景

我们做的是一个内容中台项目,里面有大概 1200 万条图文素材,每条素材用 CLIP 类模型抽出 768 维向量,需要对外提供"以图搜图"和"文搜图"的接口。业务侧给的指标很明确:

  • 数据量:初期 1200 万,一年内可能翻倍到 2500 万
  • 查询:峰值 300 QPS,其中 90% 是 top-20 检索
  • 延迟:P99 < 80ms,P50 < 30ms
  • 硬件:先单机起步,预算就是一台 16C 64G + 2TB NVMe 的物理机,后续再考虑集群

候选就两个:Milvus 和 Qdrant。网上文章要么是官方 benchmark(数据集和查询模式跟我们对不上),要么是"Hello World"级别的 demo,没有参考价值。所以干脆自己搭一套,用真实数据和真实查询分布跑一遍。

这里先说结论,免得有人看不下去:在我们这个单机、千万级、中低 QPS 的场景下,Qdrant 的综合表现更好;但如果预期一年内就要上集群、要多租户隔离、要接 Spark 做离线批处理,Milvus 的架构红利会更明显。 下面把过程和盘托出。

二、环境与版本

硬件与系统:

  • CPU:Intel Xeon Silver 4314,16 核 32 线程
  • 内存:64GB DDR4
  • 磁盘:2TB NVMe SSD(顺序读约 3.2GB/s)
  • OS:Ubuntu 22.04.3 LTS,内核 5.15
  • Docker:24.0.7,Docker Compose v2.23.0

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

  • Milvus:v2.4.4,standalone 模式,etcd 3.5.5 + MinIO RELEASE.2023-03-20
  • Qdrant:v1.9.2,单节点
  • 客户端:pymilvus 2.4.4,qdrant-client 1.9.1
  • Python 3.11.6

数据集:1200 万条 768 维 float32 向量,从业务库里抽样导出,做过 L2 归一化(所以内积等价于余弦)。查询集是 5000 条真实用户查询日志里的向量,同样归一化。

三、方案设计

3.1 部署方案

Milvus standalone 用官方 compose 起,但把 MinIO 的默认配置改了一下(默认的 4 个盘符在单机上没必要,改成单盘)。Qdrant 就一个容器,挂载本地 volume。

Milvus 的 compose 文件(精简后):

# docker-compose.milvus.yml
version: '3.5'
services:
  etcd:
    container_name: milvus-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
    volumes:
      - ./volumes/etcd:/etcd
    command: etcd -advertise-client-urls=http://127.0.0.1:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd
    healthcheck:
      test: ["CMD", "etcdctl", "endpoint", "health"]
      interval: 30s
      timeout: 20s
      retries: 3

  minio:
    container_name: milvus-minio
    image: minio/minio:RELEASE.2023-03-20T20-16-18Z
    environment:
      MINIO_ACCESS_KEY: minioadmin
      MINIO_SECRET_KEY: minioadmin
    ports:
      - "9001:9001"
      - "9000:9000"
    volumes:
      - ./volumes/minio:/minio_data
    command: minio server /minio_data --console-address ":9001"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
      interval: 30s
      timeout: 20s
      retries: 3

  standalone:
    container_name: milvus-standalone
    image: milvusdb/milvus:v2.4.4
    command: ["milvus", "run", "standalone"]
    security_opt:
      - seccomp:unconfined
    environment:
      ETCD_ENDPOINTS: etcd:2379
      MINIO_ADDRESS: minio:9000
      # 关键:限制内存,避免把 64G 吃光
      QUERYNODE_MEMORY_LIMIT: "24"
      DATANODE_MEMORY_LIMIT: "12"
    volumes:
      - ./volumes/milvus:/var/lib/milvus
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9091/healthz"]
      interval: 30s
      start_period: 90s
      timeout: 20s
      retries: 3
    ports:
      - "19530:19530"
      - "9091:9091"
    depends_on:
      - "etcd"
      - "minio"

networks:
  default:
    name: milvus

Qdrant 就简单多了:

# docker-compose.qdrant.yml
version: '3.5'
services:
  qdrant:
    image: qdrant/qdrant:v1.9.2
    container_name: qdrant
    ports:
      - "6333:6333"
      - "6334:6334"
    volumes:
      - ./volumes/qdrant:/qdrant/storage
    environment:
      # 打开 mmap,降低内存占用
      QDRANT__STORAGE__ON_DISK_PAYLOAD: "true"
      QDRANT__STORAGE__HNSW_INDEX__M: "16"
      QDRANT__STORAGE__HNSW_INDEX__EF_CONSTRUCT: "200"
    ulimits:
      nofile:
        soft: 65536
        hard: 65536

启动 docker compose -f docker-compose.xxx.yml up -d 即可。Milvus 从拉起容器到 /healthz 返回正常,实测冷启动约 75 秒;Qdrant 约 6 秒。

3.2 索引与参数选择

公平起见,两边都用 HNSW,参数尽量对齐:

参数 Milvus Qdrant
索引类型 HNSW HNSW
M 16 16
efConstruction 200 200
查询 ef 64(可调) hnsw_ef=64
距离度量 IP(内积) Dot
量化 无 无

Milvus 里 HNSW 的 ef 是在 search 时通过 search_params={"ef": 64} 传的;Qdrant 是 search_params=SearchParams(hnsw_ef=64)。两边都保持一致,避免有人拿不同 ef 比出"性能差异"。

四、核心实现

4.1 数据写入

Milvus 这边,1200 万条数据不能一次 insert,得分批。实测每批 5 万条、并发 4 个写入线程比较合适,再大容易把 datanode 的内存打高。

# ingest_milvus.py
import time, numpy as np
from pymilvus import MilvusClient, DataType

DIM = 768
BATCH = 50_000
TOTAL = 12_000_000

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

# 建表
schema = client.create_schema(auto_id=False, enable_dynamic_field=False)
schema.add_field("id", DataType.INT64, is_primary=True)
schema.add_field("vec", DataType.FLOAT_VECTOR, dim=DIM)
schema.add_field("biz_tag", DataType.INT32)

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

if client.has_collection("material"):
    client.drop_collection("material")
client.create_collection("material", schema=schema, index_params=index_params)

rng = np.random.default_rng(42)
t0 = time.time()
for start in range(0, TOTAL, BATCH):
    n = min(BATCH, TOTAL - start)
    vecs = rng.standard_normal((n, DIM), dtype=np.float32)
    vecs /= np.linalg.norm(vecs, axis=1, keepdims=True)
    rows = [
        {"id": start + i, "vec": vecs[i].tolist(), "biz_tag": int(i % 16)}
        for i in range(n)
    ]
    client.insert(collection_name="material", data=rows)
    if start % (BATCH * 20) == 0:
        print(f"inserted {start + n}, elapsed {time.time() - t0:.1f}s")

# 等索引构建完(standalone 下 flush 后由 indexnode 异步建)
client.flush("material")
print("total ingest time:", time.time() - t0)

Qdrant 用批量 upsert,注意 payload 里塞 biz_tag 方便后面过滤:

# ingest_qdrant.py
import time, numpy as np
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct

DIM = 768
BATCH = 20_000
TOTAL = 12_000_000

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

client.recreate_collection(
    collection_name="material",
    vectors_config=VectorParams(size=DIM, distance=Distance.DOT),
    hnsw_config={"m": 16, "ef_construct": 200},
)

rng = np.random.default_rng(42)
t0 = time.time()
for start in range(0, TOTAL, BATCH):
    n = min(BATCH, TOTAL - start)
    vecs = rng.standard_normal((n, DIM), dtype=np.float32)
    vecs /= np.linalg.norm(vecs, axis=1, keepdims=True)
    points = [
        PointStruct(
            id=start + i,
            vector=vecs[i].tolist(),
            payload={"biz_tag": int(i % 16)},
        )
        for i in range(n)
    ]
    client.upsert(collection_name="material", points=points, wait=False)
    if start % (BATCH * 50) == 0:
        print(f"upserted {start + n}, elapsed {time.time() - t0:.1f}s")

client.close()

注意 Qdrant 这里 wait=False,批量写入更快;最后再调一次 client.count() 确认数据到齐。

4.2 查询压测

压测脚本两边共用一套逻辑,用固定 seed 生成查询向量,多线程打满,统计 P50/P95/P99。

# bench.py
import time, random, statistics, threading
import numpy as np
from concurrent.futures import ThreadPoolExecutor

def bench(fn, qps_target, duration=120, threads=16):
    lat = []
    lock = threading.Lock()
    stop_at = time.time() + duration
    interval = 1.0 / (qps_target / threads)

    def worker():
        local = []
        while time.time() < stop_at:
            t = time.perf_counter()
            fn()
            local.append((time.perf_counter() - t) * 1000)
            time.sleep(max(0, interval - (time.perf_counter() - t)))
        with lock:
            lat.extend(local)

    with ThreadPoolExecutor(max_workers=threads) as ex:
        [ex.submit(worker) for _ in range(threads)]

    lat.sort()
    p = lambda x: lat[int(len(lat) * x)]
    return {
        "n": len(lat),
        "qps": len(lat) / duration,
        "p50": p(0.50), "p95": p(0.95), "p99": p(0.99),
        "max": lat[-1],
    }

Milvus 的查询函数:

def milvus_query(client, vec):
    client.search(
        collection_name="material",
        data=[vec],
        anns_field="vec",
        limit=20,
        search_params={"metric_type": "IP", "params": {"ef": 64}},
        output_fields=["biz_tag"],
    )

Qdrant 的查询函数:

from qdrant_client.models import SearchParams

def qdrant_query(client, vec):
    client.search(
        collection_name="material",
        query_vector=vec,
        limit=20,
        search_params=SearchParams(hnsw_ef=64, exact=False),
    )

五、踩坑与优化

踩的坑不少,挑几个有代表性的说。

坑 1:Milvus 默认内存配置会吃光 64G。 第一次跑完数据导入,docker stats 里 milvus-standalone 占了 51GB,再压测直接 OOM。后来在 compose 里显式设了 QUERYNODE_MEMORY_LIMIT=24、DATANODE_MEMORY_LIMIT=12,并给容器加 mem_limit: 48g,才稳住。Qdrant 默认就克制,导入完 1200 万条后 RSS 约 18GB,开了 ON_DISK_PAYLOAD 后 payload 走磁盘,内存还降了。

坑 2:Milvus 索引构建是异步的。 你 flush 完立刻去搜,召回率会明显偏低,因为 indexnode 还在建索引。要么轮询 get_index_build_progress,要么等 collection.load() 返回。我们第一次压测就是没等索引,P99 好看得离谱(因为走了暴力扫描),结果召回率只有 0.71。踩完这个坑之后,所有数据都等 load_state == Loaded 再开始。

坑 3:Qdrant 批量 upsert 的 batch size 不能太大。 一开始用 5 万一批,客户端经常抛 timeout,服务端日志显示单请求处理时间超过默认的 60s。改成 2 万一批、wait=False 后稳定。另外 Qdrant 的 search 在 1.9 里已经标记 deprecated,官方推荐 query_points,我们为了跟旧代码兼容继续用 search,但新项目建议直接上 query_points。

优化 1:Qdrant 开 scalar quantization 省内存。 在 recreate_collection 里加 quantization_config=ScalarQuantization(scalar=ScalarQuantizationConfig(type=ScalarType.INT8, always_ram=True)),内存从 18GB 降到 9GB,召回率从 0.962 掉到 0.947,延迟反而降了约 15%,因为 int8 距离计算更快。这个 trade-off 我们接受了。

优化 2:Milvus 用 partition_key 做业务隔离。 我们按 biz_tag 分了 16 个分区键,查询时带上 partition_key 过滤,P99 从 96ms 降到 71ms,因为搜索空间从 1200 万缩到平均 75 万。但注意 partition_key 一旦设定不能改,且分区数不能太多(官方建议 < 1024)。

六、效果数据

所有数据都是同一台机器、同一份数据集、ef=64、top-20 条件下测得,每组跑 120 秒取稳定段。

查询性能(300 QPS 目标,16 线程):

指标 Milvus 2.4.4 Qdrant 1.9.2
实际 QPS 302 305
P50 31ms 19ms
P