一、问题背景

我们做的是一个内容平台的图文素材库,用户上传的图片和文案需要做相似度检索,用于去重、推荐和版权比对。向量由 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

几个观察:

  1. Qdrant 在单机中小规模下内存效率明显更好,21GB 对 43GB,几乎是两倍差距。原因是 Qdrant 的 HNSW 图结构和向量存储做了更紧凑的内存布局,而 Milvus 的 querynode 会同时持有索引和原始 segment 数据。
  2. Milvus 的 HNSW 召回最高但延迟和内存都上去了,IVF_FLAT 是性价比更均衡的选择。Qdrant 只提供 HNSW 系索引,但调参空间够用。
  3. 标量过滤场景 Qdrant 优势很大,payload index 是原生设计;Milvus 的表达式过滤在 standalone 下走的是后过滤逻辑,数据量大时延迟抖得厉害。
  4. 写入速度 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 集群。以上数字都是同一台机器实测,脚本可以复用,欢迎拿自己的数据复现。