一、问题背景:为什么我要重新做一次选型

我们团队做的是一个图文内容推荐系统,每天新增约 50 万条内容向量,总规模在 1000 万级别,维度是 768(来自 BGE-large-zh)。之前的方案是 FAISS + 自研增量索引,但遇到了几个硬伤:

  • 不支持标量过滤(比如按分类、发布时间、作者 ID 过滤后再检索),只能先全量召回再过滤,召回率掉得厉害;
  • 多进程共享索引麻烦,更新时需要 reload,线上有抖动;
  • 没有成熟的持久化和监控。

于是决定迁移到专业的向量数据库。候选就是 Milvus 和 Qdrant——两者都是开源、社区活跃、支持 HNSW/IVF、支持过滤,但设计哲学差别很大。网上很多文章只讲“Hello World”,缺少同硬件下的真实数据。我花了三天做了这套对比,把过程和数据写下来。

二、环境与版本

  • 机器:阿里云 ecs.g7.4xlarge,16 vCPU / 64 GB / 500 GB ESSD PL1
  • OS:Ubuntu 22.04,Docker 24.0.7,Docker Compose v2.23.0
  • Milvus:2.4.1(standalone 模式,etcd + MinIO 内置)
  • Qdrant:1.9.2(单节点)
  • 客户端:pymilvus 2.4.3,qdrant-client 1.9.1
  • 数据集:1000 万条 768 维 float32 向量,随机生成,附带 5 个标量字段(category、timestamp、author_id 等)

两者都用 Docker 部署,避免环境差异。

三、方案设计

对比维度:

  1. 部署复杂度与启动资源
  2. 写入 1000 万条数据的耗时与内存峰值
  3. HNSW 索引构建时间
  4. TopK=10 查询性能:纯向量检索 vs 带标量过滤
  5. 资源占用:内存 RSS、磁盘占用
  6. 客户端 API 易用性

索引参数尽量对齐:

  • Milvus:HNSW,M=16,efConstruction=200,ef=64;metric=IP
  • Qdrant:HNSW,m=16,ef_construct=200,hnsw_ef=64;distance=Dot

四、核心实现

4.1 部署步骤

Milvus standalone(官方 compose):

# 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
    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
  minio:
    image: minio/minio:RELEASE.2023-03-20T20-16-18Z
    environment:
      MINIO_ACCESS_KEY: minioadmin
      MINIO_SECRET_KEY: minioadmin
    volumes:
      - ./volumes/minio:/minio_data
    command: minio server /minio_data --console-address ":9001"
  standalone:
    image: milvusdb/milvus:v2.4.1
    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
docker stats milvus-standalone --no-stream
# 空载内存约 1.2 GB,包含 etcd 和 minio

Qdrant 单节点:

docker run -d --name qdrant \
  -p 6333:6333 -p 6334:6334 \
  -v $(pwd)/qdrant_storage:/qdrant/storage \
  qdrant/qdrant:v1.9.2
docker stats qdrant --no-stream
# 空载内存约 180 MB

4.2 写入与索引代码

Milvus 侧:

from pymilvus import MilvusClient, DataType
import numpy as np, time

client = MilvusClient(uri="http://localhost:19530")
COLL = "items_milvus"
DIM = 768

if client.has_collection(COLL):
    client.drop_collection(COLL)

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

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

# 分批写入,每批 5000
BATCH = 5000
N = 10_000_000
rng = np.random.default_rng(42)
t0 = time.time()
for start in range(0, N, BATCH):
    n = min(BATCH, N - start)
    rows = [{
        "id": start + i,
        "category": int(rng.integers(0, 50)),
        "ts": 1700000000 + start + i,
        "vec": rng.random(DIM, dtype=np.float32).tolist(),
    } for i in range(n)]
    client.insert(COLL, rows)
print(f"milvus insert {N} cost {time.time()-t0:.1f}s")
client.load_collection(COLL)

Qdrant 侧:

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

client = QdrantClient(host="localhost", port=6333)
COLL = "items_qdrant"
DIM = 768

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

BATCH = 5000
N = 10_000_000
rng = np.random.default_rng(42)
t0 = time.time()
for start in range(0, N, BATCH):
    n = min(BATCH, N - start)
    points = [
        PointStruct(
            id=start + i,
            vector=rng.random(DIM, dtype=np.float32).tolist(),
            payload={"category": int(rng.integers(0, 50)), "ts": 1700000000 + start + i},
        )
        for i in range(n)
    ]
    client.upsert(collection_name=COLL, points=points)
print(f"qdrant upsert {N} cost {time.time()-t0:.1f}s")

4.3 查询压测脚本

import numpy as np, time, statistics

def bench_milvus(q, n=500):
    lats = []
    for _ in range(n):
        t = time.perf_counter()
        client.search(COLL, data=[q.tolist()], limit=10,
                      search_params={"metric_type": "IP", "params": {"ef": 64}})
        lats.append((time.perf_counter() - t) * 1000)
    return lats

def bench_qdrant(q, n=500):
    lats = []
    for _ in range(n):
        t = time.perf_counter()
        client.search(COLL, query_vector=q.tolist(), limit=10,
                      search_params={"hnsw_ef": 64})
        lats.append((time.perf_counter() - t) * 1000)
    return lats

def report(name, lats):
    lats = sorted(lats)
    print(f"{name}: avg={statistics.mean(lats):.2f}ms "
          f"p50={lats[len(lats)//2]:.2f}ms "
          f"p99={lats[int(len(lats)*0.99)]:.2f}ms")

五、踩坑与优化

Milvus 坑 1:写入内存暴涨。 直接用 insert 单条/小批写入时,standalone 的 querynode 和 datanode 内存会飙到 20 GB+。改成每批 5000 并开启 flush 间隔后稳定在 12 GB 左右。另外 enable_dynamic_field=False 能省不少内存。

Milvus 坑 2:load_collection 很慢。 1000 万条数据首次 load 用了接近 4 分钟,因为要把索引从磁盘加载到内存。生产上建议用 load_state 监控,并设置合理的 graceful_time

Qdrant 坑 1:索引构建延迟。 Qdrant 默认是后台异步构建 HNSW,indexing_threshold 默认 20000。写入完成时索引可能还没建好,查询会走暴力扫描。压测前必须等待 optimizer_status 变为 ok

import time
while True:
    info = client.get_collection(COLL)
    if info.optimizer_status == "ok":
        break
    time.sleep(5)
print("qdrant index ready")

Qdrant 坑 2:过滤查询要建 payload 索引。 不建索引时带 filter 的查询会退化成全量扫描,P99 从 8 ms 涨到 400 ms+。对 category 建 keyword/integer 索引后恢复正常:

from qdrant_client.models import PayloadSchemaType
client.create_payload_index(COLL, field_name="category",
                            field_schema=PayloadSchemaType.INTEGER)

Milvus 过滤优化: Milvus 2.4 支持标量字段建 INVERTED 索引,对 category 建索引后过滤查询快约 30%。另外过滤条件选择性高时,Milvus 的 bitset 机制表现不错。

六、效果数据

写入 1000 万条(含索引构建):

指标 Milvus 2.4.1 Qdrant 1.9.2
写入耗时 21 min 14 min
索引构建完成 写入后约 6 min 写入后约 9 min(后台)
写入峰值内存 12.4 GB 4.1 GB

查询性能(TopK=10,500 次取统计):

场景 Milvus avg / P99 Qdrant avg / P99
纯向量 6.8 ms / 14.2 ms 5.1 ms / 11.7 ms
带 category 过滤 9.4 ms / 22.6 ms 7.2 ms / 16.9 ms

资源占用(稳定态):

指标 Milvus Qdrant
常驻内存 13.8 GB 8.5 GB
磁盘占用 32 GB 29 GB
空载内存 1.2 GB 0.18 GB

Qdrant 内存节省约 38%,主要是它用 mmap 存储向量,只有热点数据进内存;Milvus 为了保证查询性能会把索引全量 load 进内存。磁盘两者接近,Qdrant 因为量化选项更灵活略小一点。

七、总结

选型不是“谁更好”,而是“谁更适合你的场景”:

  • 选 Qdrant:单机部署、数据量在千万级以内、内存敏感、想要轻量级运维。它的 Rust 实现内存控制出色,API 直观,payload 过滤灵活。缺点是分布式能力相对弱,超大规模(亿级以上)需要自己做分片。
  • 选 Milvus:需要水平扩展、亿级向量、多租户、丰富的索引类型(IVF_PQ、DiskANN 等),以及成熟的生态(Attu、Prometheus 监控)。代价是部署组件多、内存开销大,运维复杂度高。

我们最终选了 Qdrant,因为当前数据量 1000 万、单机足够,而且省下的 5 GB 内存能跑其他服务。如果明年数据量过亿,会再评估 Milvus 集群方案。建议你也用自己真实数据跑一遍上面的脚本,别只看别人的数字——向量分布、过滤选择性对结果影响很大。