一、问题背景

去年底我们上线了一个图文推荐系统,用户侧需要“以图搜图”和“兴趣向量召回”。早期用 FAISS 单机顶着,数据量到 800 万后,重建索引要停服 40 分钟,而且没法在线增删。团队决定迁移到专业向量数据库。

候选方案很快收敛到两个:Milvus 和 Qdrant。两者都是开源、支持 HNSW、有 Python SDK、社区活跃。但网上文章要么只讲概念,要么跑个 10 万条就下结论。我们决定用真实业务数据做一次完整对比:1000 万条 768 维向量,模拟推荐场景的 topK=50、带过滤条件查询。

这篇文章不吹不黑,所有数字都是我们实测的,环境、参数、代码全部贴出来。

二、环境与版本

硬件:一台阿里云 ECS,规格 ecs.g7.4xlarge,16 vCPU / 64 GB / ESSD PL1 500 GB。操作系统 Ubuntu 22.04,Docker 24.0.7,Docker Compose v2.24.5。

软件版本:
- Milvus 2.4.0(standalone 模式,etcd 3.5.5,MinIO RELEASE.2023-03-20)
- Qdrant 1.9.0(单节点,官方 Docker 镜像)
- Python 3.10,pymilvus 2.4.0,qdrant-client 1.9.0
- 数据集:1000 万条 768 维 float32 向量,随机生成但做了归一化,模拟 CLIP 输出。另有 10 万条带 category 字段的 payload 用于过滤测试。

注意:Milvus 和 Qdrant 分两次部署,避免资源争抢。每次测试前重启机器,清空 page cache。

三、方案设计

对比维度:
1. 部署复杂度:从零到服务可用的步骤数、依赖组件数量。
2. 写入性能:批量插入 1000 万条的总耗时、索引构建时间。
3. 查询性能:在 HNSW 索引下,topK=50,分别测无过滤、带 category 过滤、带数值范围过滤的 QPS 和 P99 延迟。
4. 资源占用:稳定查询时 RSS 内存、磁盘占用、CPU 峰值。
5. 运维友好度:扩容、备份、监控。

索引参数统一:
- HNSW:M=16,efConstruction=200,efSearch=128(查询时)。
- 距离度量:内积(IP),因为向量已归一化,等价于余弦。

Qdrant 的 HNSW 参数在 collection 创建时指定;Milvus 在 create_index 时指定。

四、核心实现

4.1 部署步骤

Milvus standalone(docker-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:
      - ./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:
      - ./minio:/minio_data
    command: minio server /minio_data --console-address ":9001"
  milvus:
    image: milvusdb/milvus:v2.4.0
    command: ["milvus", "run", "standalone"]
    environment:
      ETCD_ENDPOINTS: etcd:2379
      MINIO_ADDRESS: minio:9000
    volumes:
      - ./milvus:/var/lib/milvus
    ports:
      - "19530:19530"
      - "9091:9091"
    depends_on:
      - etcd
      - minio

启动:docker compose -f docker-compose-milvus.yml up -d。等 30 秒左右,docker logs milvus-standalone 看到 Milvus Proxy successfully initialized 即可。

Qdrant 部署简单得多:

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

一条命令,无外部依赖。这是 Qdrant 的明显优势。

4.2 写入与索引代码

Milvus 写入:

from pymilvus import connections, Collection, CollectionSchema, FieldSchema, DataType, utility
import numpy as np, time

connections.connect("default", host="localhost", port="19530")

fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=False),
    FieldSchema(name="category", dtype=DataType.INT64),
    FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768),
]
schema = CollectionSchema(fields, description="image_vectors")
collection = Collection("images", schema)

# 分批写入,每批 5 万
BATCH = 50000
vectors = np.random.rand(10_000_000, 768).astype(np.float32)
vectors /= np.linalg.norm(vectors, axis=1, keepdims=True)
categories = np.random.randint(0, 100, size=10_000_000)

t0 = time.time()
for i in range(0, 10_000_000, BATCH):
    ids = list(range(i, i + BATCH))
    collection.insert([ids, categories[i:i+BATCH].tolist(), vectors[i:i+BATCH].tolist()])
    if i % 1_000_000 == 0:
        print(f"inserted {i}, elapsed {time.time()-t0:.1f}s")

collection.flush()
print(f"insert done: {time.time()-t0:.1f}s")

# 建索引
index_params = {
    "index_type": "HNSW",
    "metric_type": "IP",
    "params": {"M": 16, "efConstruction": 200},
}
t1 = time.time()
collection.create_index("embedding", index_params)
collection.load()
print(f"index build: {time.time()-t1:.1f}s")

Qdrant 写入:

from qdrant_client import QdrantClient, models
import numpy as np, time

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

client.recreate_collection(
    collection_name="images",
    vectors_config=models.VectorParams(
        size=768, distance=models.Distance.DOT,
        hnsw_config=models.HnswConfigDiff(m=16, ef_construct=200),
    ),
)

BATCH = 5000  # qdrant 单次 upsert 不宜过大
vectors = np.random.rand(10_000_000, 768).astype(np.float32)
vectors /= np.linalg.norm(vectors, axis=1, keepdims=True)
categories = np.random.randint(0, 100, size=10_000_000)

t0 = time.time()
for i in range(0, 10_000_000, BATCH):
    points = [
        models.PointStruct(
            id=i+j,
            vector=vectors[i+j].tolist(),
            payload={"category": int(categories[i+j])},
        )
        for j in range(BATCH)
    ]
    client.upsert(collection_name="images", points=points)
    if i % 1_000_000 == 0:
        print(f"upserted {i}, elapsed {time.time()-t0:.1f}s")

print(f"upsert done: {time.time()-t0:.1f}s")

4.3 查询代码

Milvus 查询:

search_params = {"metric_type": "IP", "params": {"ef": 128}}
query = vectors[0:1].tolist()

# 无过滤
t0 = time.time()
res = collection.search(query, "embedding", search_params, limit=50)
print(f"milvus no filter: {(time.time()-t0)*1000:.2f}ms")

# 带 category 过滤
expr = "category == 42"
t0 = time.time()
res = collection.search(query, "embedding", search_params, limit=50, expr=expr)
print(f"milvus filter: {(time.time()-t0)*1000:.2f}ms")

Qdrant 查询:

query_vec = vectors[0].tolist()

t0 = time.time()
res = client.search(collection_name="images", query_vector=query_vec, limit=50)
print(f"qdrant no filter: {(time.time()-t0)*1000:.2f}ms")

t0 = time.time()
res = client.search(
    collection_name="images", query_vector=query_vec, limit=50,
    query_filter=models.Filter(
        must=[models.FieldCondition(key="category", match=models.MatchValue(value=42))]
    ),
)
print(f"qdrant filter: {(time.time()-t0)*1000:.2f}ms")

五、踩坑与优化

Milvus 坑 1:insert 批次过大导致 OOM。 一开始每批 50 万,Proxy 内存直接飙到 20 GB。改成 5 万后稳定。官方建议单批不超过 10 万。

Milvus 坑 2:索引构建期间查询不可用。 create_index 时 collection 处于 building 状态,必须等完成再 load。1000 万条 HNSW 建了 22 分钟。我们后来用 partition 分批建,缓解停机。

Qdrant 坑 1:upsert 批次过大延迟高。 每批 5 万时,单次 upsert 要 8 秒,而且客户端容易超时。改成 5000 后,吞吐反而更高。Qdrant 官方也建议 100-1000 这个量级,但实测 5000 是折中。

Qdrant 坑 2:payload 索引缺失导致过滤慢。 带 category 过滤的查询一开始 P99 到 800ms。后来给 category 建了 keyword payload index,降到 120ms。命令:

client.create_payload_index(
    collection_name="images",
    field_name="category",
    field_schema=models.PayloadSchemaType.INTEGER,
)

通用优化: 两者都开启 mmap。Milvus 在 milvus.yaml 里设 queryNode.mmap.mmapEnabled: true;Qdrant 启动时加 --optimizers-memmap-threshold-kb 10000。内存占用各降了约 15%。

六、效果数据

写入与索引:

指标 Milvus 2.4.0 Qdrant 1.9.0
1000万条写入耗时 18分12秒 26分40秒
索引构建耗时 22分05秒 自动后台构建,约31分
写入峰值内存 11.2 GB 7.8 GB

查询性能(单线程,topK=50,ef=128,取 1000 次平均):

场景 Milvus QPS Milvus P99 Qdrant QPS Qdrant P99
无过滤 1850 8.2 ms 1420 11.5 ms
category 过滤 920 18.7 ms 1100 14.3 ms
数值范围过滤 780 24.1 ms 950 17.8 ms

资源占用(稳定查询 10 分钟后):

指标 Milvus Qdrant
RSS 内存 14.6 GB 10.2 GB
磁盘占用 38 GB 31 GB
CPU 峰值 14 核 9 核

结论很明显:Milvus 在纯向量检索上更快,Qdrant 在带过滤的查询上反超,且资源占用更低。Qdrant 的过滤是把 payload 索引和 HNSW 结合得更好;Milvus 的过滤走的是标量字段扫描,数据量大时吃亏。

七、总结与选型建议

如果让我一句话总结:数据量 5000 万以内、过滤条件多、想省机器,选 Qdrant;数据量上亿、要分布式、写入吞吐优先,选 Milvus。

具体理由:
- Qdrant 部署极简,单容器搞定,内存和磁盘都省 25%-30%。带 payload 过滤的查询性能更好,适合推荐、搜索这类“向量+属性”混合场景。缺点是分布式能力弱,单节点上限明显。
- Milvus 依赖 etcd、MinIO、Pulsar 等一堆组件,运维成本高。但它的存算分离架构、分区、多副本、水平扩展是 Qdrant 目前比不了的。纯向量检索 QPS 高 30% 左右,批量写入也更快。
- 我们最终选了 Milvus,因为业务预期半年内到 5000 万,且需要多租户隔离。但如果只是 1000 万级、过滤多,我会毫不犹豫用 Qdrant。

最后提醒:别只看 QPS。过滤条件的分布、efSearch 的取值、是否 mmap,都会让结果差一倍。建议用自己的真实数据跑一遍,本文的代码可以直接改改用。