一、问题背景:为什么要在 Milvus 和 Qdrant 之间纠结

先说业务。我们做的是一个内容平台的图文去重系统:用户上传图片后,用 CLIP 提取 768 维向量,去向量库里找相似度 > 0.92 的历史内容,命中就拦截。日均新增约 40 万条,历史存量 1200 万条,峰值 QPS 大概 300,P99 要求 100ms 以内。

需求很明确:高维向量 + 标量过滤(按业务线、时间范围)+ 中等规模 + 成本敏感。

一开始用的是 FAISS 自建,但过滤和增量更新太痛苦,决定换专业的向量数据库。调研一圈后剩两个候选:

  • Milvus 2.4.4:国内生态好,文档全,支持多种索引(HNSW、IVF、DiskANN),分布式架构成熟,但组件多(etcd、MinIO、Pulsar),运维重。
  • Qdrant 1.9.2:Rust 写的,单二进制部署,过滤性能是官方主打卖点,内存占用低,但分布式版本(集群模式)需要额外配置,生态相对小。

网上评测大多是"玩具数据"跑出来的,我决定自己动手,用真实数据量测一遍。

二、环境与版本

硬件:3 台 16C 64G,SSD 1TB,万兆内网,Ubuntu 22.04。

软件版本固定如下,避免版本差异导致结论失真:

组件 版本
Milvus 2.4.4 (standalone + 3 节点集群各测一轮)
Qdrant 1.9.2
Python SDK pymilvus 2.4.4 / qdrant-client 1.9.1
数据集 1200 万条 768 维 float32 向量 + 3 个标量字段
压测工具 自写 asyncio 脚本 + locust

Milvus 用官方 docker-compose 起 standalone,Qdrant 用 docker run 单机起,先保证公平。

三、方案设计

测试分四块:

  1. 部署复杂度:记录从零到可用的步骤数和踩坑。
  2. 写入性能:1200 万条分批灌入,batch=1000,记录总耗时和内存峰值。
  3. 查询性能:随机抽 1 万条 query,测 TopK=10 的 P50/P99;再测带 business_id == X AND ts > T 过滤的查询。
  4. 资源占用:用 docker stats 和 pidstat 采样,记录稳定态 CPU/内存。

索引参数统一用 HNSW,M=16,efConstruction=200,查询 ef=64。Milvus 用 MILVUS 索引类型,Qdrant 用默认 HNSW 配置。

四、核心实现(含部署与压测代码)

4.1 Milvus 部署

# 下载官方 compose
wget https://github.com/milvus-io/milvus/releases/download/v2.4.4/milvus-standalone-docker-compose.yml -O docker-compose.yml

# 关键:调大 etcd 和 minio 的资源限制,默认配置在 1200 万数据下会 OOM
# 修改 docker-compose.yml 中 standalone 的 deploy.resources.limits.memory: 32G
docker compose up -d

# 验证
docker compose ps
curl http://localhost:9091/healthz

建集合和索引:

from pymilvus import connections, Collection, CollectionSchema, FieldSchema, DataType

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

fields = [
    FieldSchema("id", DataType.INT64, is_primary=True, auto_id=False),
    FieldSchema("vec", DataType.FLOAT_VECTOR, dim=768),
    FieldSchema("business_id", DataType.INT64),
    FieldSchema("ts", DataType.INT64),
]
schema = CollectionSchema(fields, description="image_dedup")
col = Collection("image_dedup", schema)

# HNSW 索引
index_params = {
    "index_type": "HNSW",
    "metric_type": "COSINE",
    "params": {"M": 16, "efConstruction": 200},
}
col.create_index("vec", index_params)
col.load()

4.2 Qdrant 部署

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

建集合:

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

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

client.recreate_collection(
    collection_name="image_dedup",
    vectors_config=VectorParams(size=768, distance=Distance.COSINE),
    hnsw_config={"m": 16, "ef_construct": 200},
)

4.3 压测脚本(关键部分)

import asyncio, random, time, numpy as np
from qdrant_client import AsyncQdrantClient
from pymilvus import AsyncMilvusClient

N_QUERY = 10000
TOPK = 10

async def bench_qdrant():
    cli = AsyncQdrantClient(host="127.0.0.1", port=6333)
    lat = []
    for _ in range(N_QUERY):
        q = np.random.rand(768).astype(np.float32).tolist()
        t0 = time.perf_counter()
        await cli.search(
            collection_name="image_dedup",
            query_vector=q,
            limit=TOPK,
            query_filter={"must": [{"key": "business_id", "match": {"value": 7}}]},
        )
        lat.append(time.perf_counter() - t0)
    lat.sort()
    print(f"Qdrant P50={lat[len(lat)//2]*1000:.1f}ms P99={lat[int(len(lat)*0.99)]*1000:.1f}ms")

async def bench_milvus():
    cli = AsyncMilvusClient(uri="http://127.0.0.1:19530")
    lat = []
    for _ in range(N_QUERY):
        q = np.random.rand(768).astype(np.float32).tolist()
        t0 = time.perf_counter()
        await cli.search(
            collection_name="image_dedup",
            data=[q],
            limit=TOPK,
            filter='business_id == 7',
            output_fields=["id"],
        )
        lat.append(time.perf_counter() - t0)
    lat.sort()
    print(f"Milvus P50={lat[len(lat)//2]*1000:.1f}ms P99={lat[int(len(lat)*0.99)]*1000:.1f}ms")

asyncio.run(bench_qdrant())
asyncio.run(bench_milvus())

五、踩坑与优化

坑 1:Milvus standalone 默认内存不够。 compose 里 standalone 默认 limit 8G,灌到 800 万条时开始频繁 GC,写入吞吐从 3500 条/s 掉到 800 条/s。调到 32G 后恢复。生产环境建议直接上集群。

坑 2:Qdrant 的 payload 索引。 一开始过滤查询 P99 高达 400ms,原因是 business_id 没建 payload index。加上 client.create_payload_index("image_dedup", "business_id", "integer") 后降到 60ms 以内。这是 Qdrant 过滤快的前提,官方文档写得不显眼。

坑 3:两者 batch size 差异大。 Milvus 单次 insert 超过 5000 条容易触发 segment flush 抖动,建议 1000-2000;Qdrant 的 upsert 可以到 5000,再大反而慢。压测时统一 batch=1000 才公平。

坑 4:Milvus 的 ef 参数在 search 时要显式传。 不传会用默认值,和索引里的 efConstruction 不是一回事,我一开始漏了,P99 虚高 30ms。

六、效果数据

1200 万条数据、单机部署下的实测结果:

指标 Milvus 2.4.4 Qdrant 1.9.2
写入总耗时(batch=1000) 58 min 25 min
写入吞吐峰值 3500 条/s 8200 条/s
纯向量查询 P50 / P99 18ms / 62ms 12ms / 48ms
带过滤查询 P50 / P99 22ms / 71ms 15ms / 55ms
500 并发 P99 110ms 240ms(开始抖动)
稳定态内存占用 41 GB 24 GB
稳定态 CPU(空载) 6% 2%
部署步骤 5 步(含 3 个依赖组件) 1 步

结论有点反直觉:低并发下 Qdrant 全面占优,高并发下 Milvus 更稳。Qdrant 单机在 500 并发时 P99 从 55ms 飙到 240ms,原因是单节点没有分片,请求排队;Milvus 因为 segment 并行查询,反而扛住了。

过滤查询这块,我原本以为 Qdrant 会碾压,实际差距没那么大(1.3 倍左右),前提是 payload index 建对了。Milvus 2.4 的标量过滤优化比 2.3 好不少。

七、总结

选型建议按场景分:

  • 数据量 300 或有强过滤需求、需要水平扩展:选 Milvus。集群模式成熟,高并发更稳,代价是运维复杂度。
  • 成本敏感:Qdrant 内存占用低 40%,长期跑下来电费和机器成本差距明显。

我们最后选了 Qdrant,因为业务峰值 QPS 实际只有 180,且团队没有专职运维。但如果你在做的是搜索类高并发场景,Milvus 的分布式能力更值得投入。

最后提醒一句:所有评测都要用自己的数据和查询模式跑一遍,向量维度和过滤字段的选择性对结果影响极大,别直接抄别人的 benchmark。