一、问题背景
我们做的是一个内容平台的图文去重服务:用户上传图片后,先用 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,但参数不可控,做对比测试时我倾向于手动指定索引类型,保证两边参数尽量对齐。
三、方案设计
测试分四个维度:
- 部署成本:从零到服务可用的步骤数、容器数量、磁盘占用
- 写入性能:1000 万条数据批量导入的总耗时和内存峰值
- 查询性能:在 top-10、无过滤 / 带 category 过滤两种条件下,测 10000 次查询的 QPS 和 P50/P99 延迟
- 资源占用:稳定运行时的 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 倍内存优化,召回损失完全可接受。
代码和配置都在上面了,数据是真实跑出来的,机器配置也写清楚了,各位可以按自己场景复现。有不同结论欢迎评论区交流。