一、问题背景

我们做的是一个内容平台的图文去重服务:用户上传图片后,先用 CLIP 类模型抽 768 维向量,然后到向量库里查 top-10 相似项,超过阈值就判定为重复或高度相似。业务量级是每天新增 20 万~50 万条,历史存量约 1000 万条,查询 QPS 峰值在 200 左右,P99 要求控制在 50ms 以内。

一开始用的是 FAISS 暴力方案,1000 万条数据下内存吃紧,而且没有持久化、没有标量过滤、没法做增量更新,运维同学天天被叫起来重启服务。于是决定上专业的向量数据库。

候选就两个:Milvus 和 Qdrant。选这两个的原因很实际——都是开源、社区活跃、支持标量过滤和持久化,而且都能用 Docker 快速跑起来做 POC。本文记录的就是这次 POC 的全过程和实测数据。

二、环境与版本

为了避免"跑分不公平"的争议,两个库都跑在同一台物理机上:

  • 机器:16 vCPU / 64GB RAM / NVMe SSD 1TB,Ubuntu 22.04
  • Milvus:2.4.4(standalone 模式,Docker Compose 部署,含 etcd + MinIO)
  • Qdrant:1.9.2(单节点,Docker 部署)
  • 客户端:Python 3.10,pymilvus 2.4.4,qdrant-client 1.9.1
  • 数据:1000 万条随机生成的 768 维 float32 向量,外加一个 category 整型标量字段(取值 0-99),模拟真实业务里的分类过滤
  • 索引:Milvus 用 IVF_FLAT(nlist=4096, nprobe=32)和 HNSW(M=16, efConstruction=200)各测一轮;Qdrant 用 HNSW(m=16, ef_construct=200)

这里说明一下为什么不用 Milvus 的默认 AUTOINDEX:AUTOINDEX 在 2.4 里对 float 向量默认走 HNSW,但参数不可控,做对比测试时我倾向于手动指定索引类型,保证两边参数尽量对齐。

三、方案设计

测试分四个维度:

  1. 部署成本:从零到服务可用的步骤数、容器数量、磁盘占用
  2. 写入性能:1000 万条数据批量导入的总耗时和内存峰值
  3. 查询性能:在 top-10、无过滤 / 带 category 过滤两种条件下,测 10000 次查询的 QPS 和 P50/P99 延迟
  4. 资源占用:稳定运行时的 RSS 内存和 CPU 使用率

查询集是随机生成的 10000 条向量,和库内数据同分布但不重合。每次查询前会 warmup 500 次,避免冷启动干扰。

四、核心实现

4.1 Milvus 部署与写入

Milvus standalone 用官方 compose 文件起,一条命令搞定,但会拉起 etcd、MinIO、milvus 三个容器:

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
# 三个容器:milvus-standalone / milvus-etcd / milvus-minio

写入代码:

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

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

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

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

BATCH = 5000
start = time.time()
for i in range(0, 10_000_000, BATCH):
    n = min(BATCH, 10_000_000 - i)
    ids = list(range(i, i + n))
    cats = np.random.randint(0, 100, n).tolist()
    vecs = np.random.rand(n, 768).astype(np.float32).tolist()
    col.insert([ids, cats, vecs])
    if i % 500_000 == 0:
        print(f"inserted {i}, elapsed {time.time()-start:.1f}s")
col.flush()

注意 flush() 之后还要等 index 构建完成,Milvus 的索引是异步的,用 utility.index_building_progress("img_dedup") 轮询。

4.2 Qdrant 部署与写入

Qdrant 部署简单得多,单容器,挂个卷就行:

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

写入代码:

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

client = QdrantClient(host="127.0.0.1", 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),
)

BATCH = 1000  # qdrant 单次 upsert 建议别太大
start = time.time()
for i in range(0, 10_000_000, BATCH):
    n = min(BATCH, 10_000_000 - i)
    points = [
        PointStruct(
            id=i + j,
            vector=np.random.rand(768).astype(np.float32).tolist(),
            payload={"category": int(np.random.randint(0, 100))},
        )
        for j in range(n)
    ]
    client.upsert(collection_name="img_dedup", points=points)
    if i % 500_000 == 0:
        print(f"upserted {i}, elapsed {time.time()-start:.1f}s")

4.3 查询对比

Milvus 查询:

q = np.random.rand(768).astype(np.float32).tolist()
res = col.search(
    data=[q], anns_field="emb", param={"metric_type": "COSINE", "params": {"ef": 64}},
    limit=10, expr="category == 7", output_fields=["category"],
)

Qdrant 查询:

from qdrant_client.models import Filter, FieldCondition, MatchValue
res = client.search(
    collection_name="img_dedup", query_vector=q, limit=10,
    query_filter=Filter(must=[FieldCondition(key="category", match=MatchValue(value=7))]),
    search_params={"hnsw_ef": 64},
)

两边的 ef 都设成 64,保证召回率在同一档位(实测召回率都在 0.95 以上,具体见下一节)。

五、踩坑与优化

坑一:Milvus 的 batch size 不是越大越好。 一开始用 50000 一批写,pymilvus 报 gRPC message too large,因为单条消息超过默认 64MB 限制。改成 5000 一批后稳定,但整体导入速度反而不如 2000 一批——批次太大时 segment 频繁 flush,反而拖慢。最后定在 5000。

坑二:Qdrant 的 upsert 是同步的,大批量写入会顶满内存。 1000 一批时写入平稳,但如果并发起多个写入协程,Qdrant 的内存会在几分钟内涨到 30GB+ 然后 OOM。解决办法是写入端做串行化 + 开 on_disk_payload=true,把 payload 落到磁盘。

坑三:两边过滤性能差异比想象中大。 无过滤时两者差距不大,但加上 category == 7(约 1% 选择性)后,Milvus 的 IVF_FLAT 因为要先扫倒排再回表,P99 涨了 3 倍多;HNSW 索引下两者都还好。所以如果业务强依赖标量过滤,索引类型的选择比数据库本身的选择更关键。

另外一个优化点:Qdrant 1.9 支持 quantization_config 做标量量化(int8),开启后内存直接降到 1/4,召回率损失约 1.5 个百分点。我们最终在生产上开了这个,性价比很高。

六、效果数据

1000 万条数据、top-10、ef=64 的实测结果(每组跑 3 次取中位数):

指标 Milvus (HNSW) Milvus (IVF_FLAT) Qdrant (HNSW)
导入总耗时 42 min 38 min 51 min
索引构建耗时 19 min 11 min 16 min
QPS(无过滤) 1180 940 1320
P99(无过滤) 21 ms 34 ms 18 ms
QPS(带过滤) 890 310 1010
P99(带过滤) 29 ms 112 ms 24 ms
稳定态 RSS 内存 26 GB 22 GB 14 GB
磁盘占用 31 GB 31 GB 12 GB(开启量化后 5 GB)
容器数量 3 3 1
召回率@10 0.962 0.951 0.967

几个值得说的点:

  • Qdrant 的内存和磁盘占用明显更低,主要因为它默认对向量做了更紧凑的存储,加上我们开了标量量化,差距进一步拉大。
  • Milvus 的 IVF_FLAT 在带过滤场景下确实吃亏,这是索引结构决定的,不是 bug。如果坚持用 IVF,要么调大 nprobe,要么改用 HNSW。
  • Milvus 的优势不在这张表里:它的分布式能力、多副本、存算分离是 Qdrant 单机版给不了的。数据量上到 5000 万以上、或者需要横向扩容时,Milvus 的架构优势才会体现出来。
  • 部署上 Qdrant 一个容器 vs Milvus 三个容器,运维复杂度差距是实打实的。我们小团队,这点很看重。

最终选型:当前 1000 万级、单机可扛的业务场景选 Qdrant,部署轻、资源省、过滤快。同时在架构上留了抽象层,如果两年后数据量涨到 5000 万以上,再切到 Milvus 集群版,切换成本可控。

七、总结

这次 POC 最大的感受是:向量数据库选型不是选"最强"的,是选"匹配当前规模"的。Milvus 功能全、扩展性强,但单机场景下三个容器的运维成本和内存占用对小团队来说是负担;Qdrant 轻量、过滤性能好、量化后资源占用极低,非常适合我们这种千万级、单机可解的场景。

另外提醒两点:一是索引类型(HNSW vs IVF)对带过滤查询的影响,往往比数据库品牌的影响更大,选型时一定要带着真实过滤条件压测;二是量化功能一定要试,Qdrant 的 int8 量化在我们场景下几乎白赚 4 倍内存优化,召回损失完全可接受。

代码和配置都在上面了,数据是真实跑出来的,机器配置也写清楚了,各位可以按自己场景复现。有不同结论欢迎评论区交流。