一、问题背景

上个月接手一个图文去重项目,需要把 1000 万条商品图文向量(768 维,来自 BGE-base)做近邻检索,用于判断新上传图片是否和历史库重复。业务侧要求:单次查询 P99 控制在 50ms 以内,QPS 峰值 200,支持按 category_id 做标量过滤。

候选方案锁定两个:Milvus 和 Qdrant。网上文章要么是官方 benchmark 的「跑分图」,要么是「Hello World」级别的入门,缺少同机器、同数据、同索引参数下的真实对比。所以我干脆自己搭一套,把所有变量控制住,跑一遍实测。

这篇文章记录完整过程:部署、建索引、压测、踩坑、数据。所有版本号和配置都写清楚,你可以直接复现。

二、环境与版本

  • 机器:阿里云 ecs.g7.4xlarge,16 vCPU / 64GB / ESSD PL1 500GB
  • OS:Ubuntu 22.04,内核 5.15
  • Docker:24.0.7,Docker Compose v2.23.0
  • Milvus:2.4.1(standalone 模式,etcd + MinIO 内嵌)
  • Qdrant:1.9.2(单节点,无额外依赖)
  • 客户端:Python 3.10,pymilvus 2.4.3,qdrant-client 1.9.1
  • 数据集:1000 万条 768 维 float32 向量,随机生成但加了聚类结构(模拟真实分布),另带 category_id(0-99)标量字段

注意:Milvus standalone 虽然叫「单机」,但它依赖 etcd 和 MinIO 两个组件,实际是三个容器。Qdrant 是真正的单进程。这一点在资源占用对比里很关键。

三、部署步骤

3.1 Milvus 2.4.1

官方推荐用 docker-compose。我用的 milvus-standalone-docker-compose.yml,只改了一处:把 MILVUS_MEM_LIMIT 去掉,让它吃满机器内存,方便对比真实占用。

wget https://github.com/milvus-io/milvus/releases/download/v2.4.1/milvus-standalone-docker-compose.yml -O docker-compose-milvus.yml
docker compose -f docker-compose-milvus.yml up -d

启动后三个容器:milvus-standalone、milvus-etcd、milvus-minio。健康检查:

curl http://localhost:9091/healthz
# OK

3.2 Qdrant 1.9.2

Qdrant 简单得多,一条命令:

docker run -d --name qdrant \
  -p 6333:6333 -p 6334:6334 \
  -v /data/qdrant:/qdrant/storage \
  qdrant/qdrant:v1.9.2

健康检查 curl http://localhost:6333/healthz 返回 healthz check passed。

3.3 建集合与索引

Milvus 侧,我用 IVF_SQ8 和 HNSW 各建一次对比,最终选 HNSW(M=16, efConstruction=200),因为业务对延迟敏感。

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

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

fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=False),
    FieldSchema(name="category_id", dtype=DataType.INT64),
    FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768),
]
schema = CollectionSchema(fields, description="image dedup")
col = Collection("img_dedup", schema, consistency_level="Bounded")

index_params = {
    "index_type": "HNSW",
    "metric_type": "COSINE",
    "params": {"M": 16, "efConstruction": 200},
}
col.create_index("embedding", index_params)
col.create_index("category_id", {"index_type": "INVERTED"})  # 标量过滤加速
col.load()

Qdrant 侧,HNSW 参数对应 m=16, ef_construct=200,并在 payload 上建 integer 索引:

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

client = QdrantClient(host="localhost", port=6333)
client.recreate_collection(
    collection_name="img_dedup",
    vectors_config=VectorParams(size=768, distance=Distance.COSINE),
    hnsw_config=HnswConfigDiff(m=16, ef_construct=200),
)
client.create_payload_index("img_dedup", "category_id", PayloadSchemaType.INTEGER)

两者都开 on_disk 与否我也分别测了,下面数据默认内存模式。

四、核心实现:写入与压测

写入我用批量 1000 条,分 10000 批灌完 1000 万条。压测用 500 个真实分布的 query 向量,串行 + 并发两种模式。

Milvus 查询:

import time, numpy as np
from pymilvus import Collection

col = Collection("img_dedup")
col.load()

def milvus_search(q, topk=10, ef=64, cat=None):
    expr = f"category_id == {cat}" if cat is not None else None
    t0 = time.perf_counter()
    res = col.search(
        data=[q], anns_field="embedding",
        param={"metric_type": "COSINE", "params": {"ef": ef}},
        limit=topk, expr=expr, output_fields=["category_id"],
    )
    return (time.perf_counter() - t0) * 1000, res

Qdrant 查询:

import time
from qdrant_client import QdrantClient
from qdrant_client.models import Filter, FieldCondition, MatchValue

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

def qdrant_search(q, topk=10, ef=64, cat=None):
    qfilter = None
    if cat is not None:
        qfilter = Filter(must=[FieldCondition(key="category_id", match=MatchValue(value=cat))])
    t0 = time.perf_counter()
    res = client.search(
        collection_name="img_dedup", query_vector=q.tolist(),
        limit=topk, search_params={"hnsw_ef": ef}, query_filter=qfilter,
    )
    return (time.perf_counter() - t0) * 1000, res

压测脚本对每个 query 跑 10 次取均值,去掉首尾各 5% 极值算 P50/P99。并发用 32 线程,模拟 200 QPS 压力。

五、踩坑与优化

坑一:Milvus 的 ef 参数在 search 里叫 ef,但建索引时叫 efConstruction,两个不是一个东西。我一开始把 ef 设成 200,延迟直接飙到 80ms。降到 64 后 P99 回到 40ms 内,召回率(Recall@10)只掉 0.8%。

坑二:Qdrant 的 search 在 1.9 里 deprecated,官方推荐 query_points。我一开始用 search 还能跑,但日志一直 warning。切到 query_points 后 API 更清晰,过滤写法也统一了。

坑三:Milvus standalone 的 etcd 默认 2GB 内存限制,灌到 800 万条时 etcd 开始 OOM,写入报 etcdserver: request timed out。改 ETCD_QUOTA_BACKEND_BYTES=8589934592 才稳住。Qdrant 没这个问题,但它的 WAL 默认 32MB,高并发写入时建议调到 128MB。

坑四:标量过滤。Milvus 的 expr 过滤在 100 万级数据上很快,但 1000 万级且 category 分布均匀时,P99 会从 38ms 涨到 52ms。Qdrant 的 payload index 效果更明显,同样条件下只涨到 41ms。这点 Qdrant 赢。

六、效果数据

所有数据为 5 轮压测中位数,单位 ms(延迟)和 GB(内存)。

指标 Milvus 2.4.1 Qdrant 1.9.2
索引构建耗时 42 min 28 min
内存占用(加载后) 38.6 GB 22.4 GB
磁盘占用(向量+索引) 31.2 GB 24.7 GB
P50 延迟(无过滤) 12 ms 9 ms
P99 延迟(无过滤) 38 ms 20 ms
P99 延迟(category 过滤) 52 ms 41 ms
单机 QPS(32 并发) 310 480
Recall@10 0.982 0.978

几个观察:

  1. 内存差距最大。Qdrant 22.4GB vs Milvus 38.6GB,差 42%。原因是 Milvus 的 etcd + MinIO + 内部 segment 缓存有固定开销,Qdrant 是单进程直接 mmap。
  2. 延迟 Qdrant 全面领先,P99 低 18ms。HNSW 实现上 Qdrant 的图遍历更紧凑,缓存命中率更高。
  3. 过滤场景 Qdrant 优势扩大。它的 payload index 和向量索引是同一套存储,Milvus 的标量索引走独立路径,跨 segment 时开销更大。
  4. 召回率两者几乎一样,都在 0.98 左右,差异在统计误差内。
  5. 扩展性 Milvus 完胜。Qdrant 单机到 5000 万条以上开始吃力,Milvus 可以加 query node 水平扩。如果你的数据会涨到亿级,这点要提前想。

七、总结

回到选型。我的结论是:

  • 数据量 5000 万以内、追求低延迟低内存、过滤条件复杂 → 选 Qdrant。部署简单,单机性能强,运维成本低。
  • 数据量亿级、需要水平扩展、团队已有 K8s 和对象存储 → 选 Milvus。生态成熟,分布式能力是它的护城河。
  • 如果只是做 PoC 或小规模业务,Qdrant 的启动成本低到可以忽略,先跑起来再说。

最终这个项目我选了 Qdrant,因为 1000 万条在单机范围内,Qdrant 的内存和延迟优势直接省了一台机器的钱。但如果明年数据涨到 5000 万以上,我会重新评估,可能迁到 Milvus 集群。

没有银弹,只有场景。把变量控制住跑一遍实测,比看十篇 benchmark 文章都有用。