一、问题背景

我们团队做的是一个内容平台的图文去重服务。简单说,用户上传图片或文章后,系统要用 CLIP 模型抽 768 维向量,去库里找相似度超过阈值的历史内容,命中就拦截。业务量不大但增长快:从最初的 80 万条涨到现在的 1000 万条,原来的方案是 FAISS + 自己写的内存索引,问题很明显——进程重启要重建索引 40 分钟,多副本之间数据不同步,运维同学半夜被叫起来重启服务已经三次了。

痛定思痛,决定换成正经的向量数据库。候选就两个:Milvus 和 Qdrant。选这两个的原因很实际——社区活跃、文档齐全、都支持 HNSW 和标量过滤,而且我们团队之前都没深度用过,不存在路径依赖。

网上很多对比文章要么是官方 benchmark(数据太理想),要么是「Hello World」级别的体验(跑 10 万条就下结论)。我想要的是一份能回答「我们这 1000 万条到底该选谁」的实测。所以自己搭环境跑了一遍,把过程和数据整理成这篇。

二、环境与版本

硬件不是土豪配置,就是三台云主机:

项目 配置
机器 3 台,4C16G,500GB SSD
OS Ubuntu 22.04.4 LTS
Docker 24.0.7
Milvus 2.4.4(standalone + 分布式各测一轮)
Qdrant 1.9.2(单机集群模式)
Python SDK pymilvus 2.4.4 / qdrant-client 1.9.1
压测工具 自写 asyncio 脚本 + locust 交叉验证

数据集:1000 万条 768 维 float32 向量,从线上脱敏导出,附带一个 category 整型标量字段(0-19)用于过滤测试。向量已做 L2 归一化,统一用 COSINE 距离。查询集是 2000 条真实线上 query 向量,每条只取 top-10。

这里先说明一个容易忽略的点:Milvus standalone 和集群模式的性能差异很大,很多人拿 standalone 的数据去对比 Qdrant 单机,然后说「Milvus 慢」,这不公平。我的做法是两种模式都测,数据都放出来。

三、方案设计

对比维度定四个:部署成本、写入吞吐、查询延迟、资源占用。每个维度都给出可复现的配置和命令。

索引参数统一如下,保证公平:

  • Milvus:index_type=HNSW,M=16,efConstruction=200,查询时 ef=64
  • Qdrant:hnsw_config: {m: 16, ef_construct: 200},查询 hnsw_ef=64

过滤场景测两种:无过滤,以及 category == 7(约占 5% 数据量)。

四、核心实现

4.1 Qdrant 部署

Qdrant 的部署是真的省心,单机一个 docker run 就完事:

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

集群模式用官方 compose,改三份配置即可,这里不展开。建 collection 和写入的代码如下:

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

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

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

# 分批写入,每批 2000 条
BATCH = 2000
for i in range(0, len(vectors), BATCH):
    points = [
        PointStruct(
            id=i + j,
            vector=vectors[i + j].tolist(),
            payload={"category": int(cats[i + j])},
        )
        for j in range(min(BATCH, len(vectors) - i))
    ]
    client.upsert(collection_name="dedup", points=points, wait=False)

# 建标量索引,过滤查询必需
client.create_payload_index(
    collection_name="dedup",
    field_name="category",
    field_schema="integer",
)

注意 wait=False 是关键,否则每批都要等落盘,1000 万条能写到你怀疑人生。等全部写完再调一次 client.get_collection() 看 points_count 确认。

4.2 Milvus 部署

Milvus 用官方 docker-compose,standalone 版本一条命令:

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

但它实际起了 etcd、minio、milvus 三个容器,资源占用要一起算。集群模式更复杂,需要独立的 etcd 集群、MinIO 集群、pulsar,我用了官方 helm chart 在 k8s 上部署,配置略过。

建表与写入:

from pymilvus import MilvusClient, DataType

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

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

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

client.create_collection(
    collection_name="dedup",
    schema=schema,
    index_params=index_params,
)

BATCH = 2000
for i in range(0, len(vectors), BATCH):
    rows = [
        {"id": i + j, "vector": vectors[i + j].tolist(), "category": int(cats[i + j])}
        for j in range(min(BATCH, len(vectors) - i))
    ]
    client.insert(collection_name="dedup", data=rows)

Milvus 的坑在于:插入后必须 client.flush() 或等 compaction,否则数据在 growing segment 里查不到。我一开始没 flush 就压测,结果召回率只有 30%,排查了半天。

五、踩坑与优化

坑 1:Qdrant 的 memmap 阈值。 默认 memmap_threshold_kb 是 200000,1000 万条向量全在内存里,16G 机器直接 OOM 被 kill 两次。调成 20000 后,冷数据走 mmap,内存降到 9.2GB,代价是首次查询 P99 会飙到 200ms+,预热后恢复正常。生产上建议预留 15 分钟预热窗口。

坑 2:Milvus 的 segment 大小。 默认 dataCoord.segment.maxSize 是 1024MB,1000 万条 768 维大概是 30GB,会被切成 30 个 segment。查询时要扫所有 segment,延迟上不去。我把 maxSize 调到 2048,并开启 growingSegments 的自动 compaction,P99 从 45ms 降到 27ms。代价是 compaction 期间 CPU 打满,建议放低峰期。

坑 3:过滤查询的性能陷阱。 Qdrant 在 category == 7 过滤下,如果没建 payload index,会退化成全量扫描,P99 直接 800ms。建了 integer 索引后回到 42ms。Milvus 的 INVERTED 索引同样必要,不建的话过滤查询会走 brute force。

优化项: 两边都把 ef 从 64 调到 64 是折中,实测 ef=32 时召回率掉到 0.91,ef=128 时延迟涨 40% 但召回只到 0.993。业务上 0.95 召回够用,最终定 ef=64。

六、效果数据

1000 万条数据,2000 次查询取平均,单位 ms:

场景 Qdrant P50 Qdrant P99 Milvus(standalone) P50 Milvus(集群) P50 Milvus(集群) P99
无过滤 top-10 12 38 19 9 27
category=7 过滤 15 42 24 11 31
召回率@10 0.962 - 0.958 0.961 -

写入吞吐(2000 条/批,并发 8):

指标 Qdrant Milvus standalone Milvus 集群
1000 万条总耗时 52 min 68 min 41 min
峰值内存 9.2 GB 21 GB 单节点 14 GB
磁盘占用 31 GB 34 GB 34 GB

部署成本上,Qdrant 单机 5 分钟搞定,Milvus 集群从零到可用花了我们将近一天(主要是 pulsar 和 minio 的坑)。运维复杂度差距明显。

七、总结

回到「我们该选谁」这个问题,我的结论是分场景:

  • 数据量 5000 万以下、团队没有专职中间件运维:选 Qdrant。部署简单、单机性能足够、内存可控,我们的 1000 万条场景 P99 38ms 完全满足业务(阈值 100ms)。最终我们生产环境选的就是 Qdrant 集群。
  • 数据量上亿、需要水平扩展、有 k8s 运维能力:选 Milvus。集群模式 P99 27ms 确实更快,扩展性也好,但代价是组件多、调参复杂,segment 和 compaction 的坑要踩一遍。
  • 别信官方 benchmark 的绝对数字,硬件、索引参数、数据分布影响太大,一定要拿自己的数据跑一轮。我这份数据也只能说明「4C16G 三节点、1000 万条 768 维」这个特定场景。

最后提醒一句:两个项目迭代都很快,Milvus 2.4 之后上了 GPU 索引,Qdrant 1.10 也在优化多租户。选型不是一锤子买卖,建议每半年重新评估一次。代码和压测脚本我放在内网 GitLab 了,有需要的同学可以找我要。