一、问题背景
我们做的是一个内容平台的图文素材库,用户上传的图片和文案需要做相似度检索,用于去重、推荐和版权比对。向量由 CLIP 类模型产出,维度 768,float32。当前存量约 1200 万条,每天新增 20 万左右。
需求很明确:
- 支持 top-10 近邻检索,召回率(对比暴力检索)不低于 95%
- P99 延迟 50ms 以内,QPS 峰值 200
- 单机能跑,运维成本低,团队没有专职的向量数据库 DBA
- 支持按业务标签做标量过滤(比如只搜某个类目下的内容)
候选就是 Milvus 和 Qdrant。这两个在社区里讨论度最高,但网上的对比文章大多是跑个 demo 就下结论,缺少同机器、同数据、同脚本的横向数字。所以我干脆自己做了一轮,把过程记录下来。
二、环境与版本
硬件:阿里云 ECS,16 vCPU / 64GB 内存 / 1TB NVMe SSD(ESSD PL1),Ubuntu 22.04,内核 5.15。
软件版本:
- Milvus 2.4.4,standalone 模式,Docker Compose 部署,依赖 etcd 3.5.5 + MinIO
- Qdrant 1.9.2,单容器部署
- Python 3.10,pymilvus 2.4.4,qdrant-client 1.9.2
- 压测脚本用 asyncio + 固定并发,预热 30 秒后统计 5 分钟
数据:1200 万条 768 维 float32 向量,随机生成但做了聚类(用 1000 个中心做高斯分布),模拟真实 embedding 的分布,避免纯随机向量导致索引效果失真。另带一个 category 整型标量字段,取值 0-99。
三、方案设计
两边的索引配置都按官方推荐的中大规模参数来:
- Milvus:
IVF_FLAT,nlist=4096,nprobe=32;同时测了HNSW(M=16, efConstruction=200, ef=64)。metric 用IP(内积),因为我们的 embedding 已归一化,内积等价余弦。 - Qdrant:
HNSW,m=16,ef_construct=200,查询hnsw_ef=64;也测了带标量过滤的场景(category == 42)。
压测指标:QPS、P50/P95/P99 延迟、Recall@10(用 1000 条查询对 500 万子集做暴力检索作为 ground truth)、进程 RSS 内存。
四、核心实现
4.1 部署
Milvus standalone 用官方 compose:
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 / etcd / minio
docker compose ps
Qdrant 就一行:
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
4.2 写入与建索引
Milvus 侧,注意 index_type 和 metric_type 要匹配,写入用 flush 后再建索引,否则会报 collection 未加载:
from pymilvus import connections, Collection, CollectionSchema, FieldSchema, DataType, utility
connections.connect(host="127.0.0.1", port="19530")
fields = [
FieldSchema("id", DataType.INT64, is_primary=True, auto_id=False),
FieldSchema("category", DataType.INT64),
FieldSchema("emb", DataType.FLOAT_VECTOR, dim=768),
]
schema = CollectionSchema(fields, description="image_emb")
col = Collection("image_emb", schema)
index_params = {
"index_type": "IVF_FLAT",
"metric_type": "IP",
"params": {"nlist": 4096},
}
col.create_index("emb", index_params)
col.load()
# 分批写入,每批 5000
BATCH = 5000
for i in range(0, len(vectors), BATCH):
col.insert([ids[i:i+BATCH], cats[i:i+BATCH], vectors[i:i+BATCH]])
col.flush()
Qdrant 侧,create_collection 时直接指定 HNSW 参数,写入用 upload_points 批量走 gRPC,比 HTTP 快不少:
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct, HnswConfigDiff
client = QdrantClient(host="127.0.0.1", port=6333, prefer_grpc=True)
client.recreate_collection(
collection_name="image_emb",
vectors_config=VectorParams(size=768, distance=Distance.DOT),
hnsw_config=HnswConfigDiff(m=16, ef_construct=200),
)
BATCH = 5000
for i in range(0, len(vectors), BATCH):
points = [
PointStruct(id=int(ids[j]), vector=vectors[j].tolist(),
payload={"category": int(cats[j])})
for j in range(i, min(i + BATCH, len(vectors)))
]
client.upsert(collection_name="image_emb", points=points, wait=False)
4.3 查询压测
统一用 asyncio,并发 64,跑 5 分钟,记录每条查询的耗时:
import asyncio, time, numpy as np
from qdrant_client import AsyncQdrantClient
async def bench_qdrant(queries, concurrency=64, duration=300):
cli = AsyncQdrantClient(host="127.0.0.1", port=6333, prefer_grpc=True)
lat, stop = [], time.time() + duration
sem = asyncio.Semaphore(concurrency)
async def one(q):
async with sem:
t0 = time.perf_counter()
await cli.search(collection_name="image_emb", query_vector=q.tolist(),
limit=10, search_params={"hnsw_ef": 64})
lat.append((time.perf_counter() - t0) * 1000)
i = 0
while time.time() < stop:
await asyncio.gather(*[one(queries[(i + k) % len(queries)]) for k in range(concurrency)])
i += concurrency
lat = np.array(lat)
print(f"QPS={len(lat)/duration:.1f} P50={np.percentile(lat,50):.1f}ms "
f"P99={np.percentile(lat,99):.1f}ms")
Milvus 的对应写法用 collection.search(..., search_params={"nprobe": 32}),逻辑一致,这里不重复。
五、踩坑与优化
坑 1:Milvus 首次 load 很慢。 1200 万条 IVF_FLAT 建完索引后 load() 花了将近 4 分钟,内存直接冲到 40GB+。原因是 Milvus 会把索引和原始数据都加载进 querynode。如果内存不够会反复 swap,延迟飙到几百毫秒。建议 standalone 场景内存至少给到数据量的 1.5 倍。
坑 2:Qdrant 的 wait=False。 批量 upsert 时如果不加 wait=False,每批都同步等索引落盘,1200 万条写了快 40 分钟。改成异步后降到 12 分钟,但要注意最后手动 client.count() 确认写入完成再压测,否则数据没齐召回率会偏低。
坑 3:nprobe 和 hnsw_ef 的取舍。 我们一开始 Milvus 用 nprobe=16,QPS 好看但 Recall@10 只有 91%。调到 32 后召回 96%,延迟涨了约 40%。Qdrant 的 hnsw_ef 从 32 调到 64 也是类似曲线。这个参数没有银弹,必须按业务召回要求调。
优化:Qdrant 开启标量索引。 我们过滤场景多,给 category 建了 payload index 后,带过滤的查询延迟从 38ms 降到 11ms。Milvus 侧对应的是给标量字段建索引并在 search 时用表达式过滤,效果也有但配置更繁琐。
六、效果数据
同一批 1200 万数据,1000 条查询,top-10:
| 指标 | Milvus 2.4.4 (IVF_FLAT) | Milvus 2.4.4 (HNSW) | Qdrant 1.9.2 (HNSW) |
|---|---|---|---|
| QPS (并发64) | 412 | 286 | 538 |
| P50 延迟 | 12ms | 19ms | 9ms |
| P99 延迟 | 47ms | 61ms | 34ms |
| Recall@10 | 96.1% | 97.8% | 97.2% |
| 进程 RSS 峰值 | 43GB | 46GB | 21GB |
| 1200万写入耗时 | 18min | 22min | 12min |
| 带标量过滤 P99 | 55ms | 68ms | 12ms |
几个观察:
- Qdrant 在单机中小规模下内存效率明显更好,21GB 对 43GB,几乎是两倍差距。原因是 Qdrant 的 HNSW 图结构和向量存储做了更紧凑的内存布局,而 Milvus 的 querynode 会同时持有索引和原始 segment 数据。
- Milvus 的 HNSW 召回最高但延迟和内存都上去了,IVF_FLAT 是性价比更均衡的选择。Qdrant 只提供 HNSW 系索引,但调参空间够用。
- 标量过滤场景 Qdrant 优势很大,payload index 是原生设计;Milvus 的表达式过滤在 standalone 下走的是后过滤逻辑,数据量大时延迟抖得厉害。
- 写入速度 Qdrant 更快,但 Milvus 的分段式存储在持续增量写入和数据 compaction 上更成熟,长期跑大规模增量我们更放心。
七、总结
选型没有绝对答案,看你的约束条件。
如果像我这次一样,单机、千万级、内存有限、过滤查询多,Qdrant 1.9.2 是更省心的选择,部署一个容器、内存省一半、P99 34ms、过滤延迟 12ms,运维几乎零成本。代价是索引类型单一,未来数据涨到亿级要自己考虑分片。
如果预期数据会快速涨到亿级以上、需要多种索引类型(GPU 索引、DiskANN)、团队有分布式运维能力,Milvus 2.4.4 的生态和扩展性更稳,standalone 也能平滑迁到 cluster。但要提前把内存预算打足,至少按数据量 1.5 倍准备。
我们最终的生产选型是 Qdrant,把 1200 万存量迁了过去,单机跑得很稳,峰值 QPS 380 时 P99 仍在 40ms 以内。等数据量过 5000 万再回头评估要不要上 Milvus 集群。以上数字都是同一台机器实测,脚本可以复用,欢迎拿自己的数据复现。