一、问题背景

我们做的是一个图文内容平台,每天新增约 30 万条图文素材,需要做「相似图片/文案去重」和「推荐召回」。向量维度 768(CLIP ViT-L/14 图像向量 + BGE-base 文本向量各一套),预计一年内单集合规模到 500 万条。

候选方案很快收敛到 Milvus 和 Qdrant 两个:

  • Milvus:社区大、生态全、支持存算分离和多种索引,公司其他团队已有运维经验。
  • Qdrant:Rust 写的,单机性能口碑好,filter 语法舒服,部署轻。

网上的对比文章大多停留在「Milvus 功能多、Qdrant 轻量」这种定性描述,缺少同机同数据下的数字。所以干脆自己搭一套压测环境,用数据说话。

二、环境与版本

项目 配置
服务器 16 vCPU / 64GB RAM / 1TB NVMe SSD
OS Ubuntu 22.04,内核 5.15
Docker 24.0.7
Milvus 2.4.1 standalone(etcd + minio 内置)
Qdrant 1.9.2
客户端 pymilvus 2.4.3 / qdrant-client 1.9.1
数据集 500 万条 × 768 维 float32,约 14.3GB 原始向量

两份服务不要同时跑,压测时轮流启动,避免资源互相干扰。数据落盘都用 NVMe,关闭 swap。

三、方案设计

两边都使用 HNSW 索引,尽量对齐参数:

  • Milvus:M=16, efConstruction=200,查询 ef=64,metric 用 COSINE。
  • Qdrant:m=16, ef_construct=200,查询 hnsw_ef=64,distance 用 Cosine。

测试三类负载:

  1. 纯向量 TopK=10 检索,1000 次取 P50/P95/P99。
  2. 带标量过滤(category 字段等值过滤,过滤后约 10% 数据)的检索。
  3. 批量写入 100 万条,观察吞吐和内存曲线。

四、部署与核心实现

4.1 Milvus 2.4.1 部署

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

# 关键调参:给足内存,避免 mmap 抖动
cat >> docker-compose.yml <<'EOF'
# 在 standalone 服务的 environment 中追加
# QUERY_NODE_GRACEFUL_STOP_TIMEOUT: 60
EOF

docker compose up -d
docker compose ps   # 确认 milvus-standalone / etcd / minio 三个容器 healthy

建集合与索引:

from pymilvus import MilvusClient

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

client.create_collection(
    collection_name="img_vec",
    dimension=768,
    metric_type="COSINE",
    auto_id=False,
)

index_params = client.prepare_index_params()
index_params.add_index(
    field_name="vector",
    index_type="HNSW",
    metric_type="COSINE",
    params={"M": 16, "efConstruction": 200},
)
client.create_index("img_vec", index_params)
client.load_collection("img_vec")

4.2 Qdrant 1.9.2 部署

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

建集合与索引:

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

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

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

4.3 压测脚本(两边共用逻辑)

import time, numpy as np, statistics

def bench_search(search_fn, queries, topk=10, rounds=1000):
    lat = []
    for i in range(rounds):
        q = queries[i % len(queries)]
        t0 = time.perf_counter()
        search_fn(q, topk)
        lat.append((time.perf_counter() - t0) * 1000)  # ms
    lat.sort()
    return {
        "p50": statistics.median(lat),
        "p95": lat[int(len(lat) * 0.95)],
        "p99": lat[int(len(lat) * 0.99)],
    }

# Milvus 侧
def milvus_search(q, topk):
    return mclient.search(
        collection_name="img_vec",
        data=[q.tolist()],
        limit=topk,
        search_params={"metric_type": "COSINE", "params": {"ef": 64}},
    )

# Qdrant 侧
def qdrant_search(q, topk):
    return qclient.search(
        collection_name="img_vec",
        query_vector=q.tolist(),
        limit=topk,
        search_params={"hnsw_ef": 64},
    )

五、踩坑与优化

  1. Milvus standalone 的 mmap 陷阱:默认配置下 querynode 会积极 mmap,压测时 RSS 看起来不高但 page cache 飙到 40GB,导致 P99 抖动。后来显式限制 queryNode.cache.cacheSize 并观察 docker stats 的 RSS + cache 总和才准确。

  2. Qdrant 的 ef 参数名不一致:建索引时是 ef_construct,查询时是 hnsw_ef,文档里分散在两处,第一次写错直接静默用了默认值,延迟数据偏乐观,重跑才修正。

  3. 批量写入的分批大小:Milvus 用 insert 时单批 1 万条、并发 4 路最稳;超过 5 万条单批会触发 flush 阻塞。Qdrant 用 upload_points,batch_size 设 256 反而比 1024 吞吐更高,因为 gRPC 单帧太大时序列化开销明显。

  4. 过滤查询的索引:Milvus 需要为标量字段单独建 INVERTED 索引,否则过滤走全扫;Qdrant 1.9 对标量字段有自动 payload index,但要手动 create_payload_index 才会生效,别忘了。

六、效果数据

500 万条数据全部导入后(Milvus 索引构建约 22 分钟,Qdrant 约 17 分钟),稳态压测结果:

指标 Milvus 2.4.1 Qdrant 1.9.2
纯向量 P50 8.6 ms 5.2 ms
纯向量 P99 27.4 ms 16.9 ms
过滤查询 P99 41.2 ms 58.7 ms
写入 100 万条耗时 6 分 40 秒 9 分 15 秒
稳态 RSS(含 cache) 21.3 GB 13.2 GB
磁盘占用 18.9 GB 15.4 GB

结论很清晰:纯向量检索 Qdrant 更快更省内存,P99 领先约 38%,内存只有 Milvus 的 62%;但带标量过滤的查询 Milvus 更稳,因为它对倒排索引和向量索引的融合执行做了优化,Qdrant 在过滤比例 10% 时 P99 反而被反超。

七、总结

如果业务是「纯语义召回 + 单机规模、内存敏感」,Qdrant 1.9.2 是更划算的选择,部署一个容器就完事,延迟和资源占用都更好。

如果业务有大量「向量 + 复杂标量过滤」的组合查询,或者未来要上分布式、存算分离、多租户,Milvus 2.4.1 的生态和稳定性更值得投入,代价是多吃 8GB 左右内存。

我们最终选了 Qdrant 做主召回,因为它 90% 的查询都是纯向量;把需要复杂过滤的分析型查询单独走离线任务。选型没有银弹,用自己业务的数据跑一遍,比看十篇评测都管用。