一、问题背景

先说业务场景。我们做的是一个电商平台的“以图搜图”功能,商品库大约有 12 万条图文向量,向量维度 768(用的是 CLIP ViT-L/14 输出的图像 embedding)。每天新增和更新大约 3000~5000 条,QPS 峰值大概 40~60,要求 P99 查询延迟控制在 50ms 以内。机器资源有限,单节点部署,配置是 8 核 16G 的云主机,SSD 云盘。

之前用的是 FAISS 暴力检索,10 万级别还能扛,但数据量一涨、还要支持过滤条件(类目、价格区间)和实时增删,就有点力不从心了。于是决定上专门的向量数据库。候选就是 Milvus 和 Qdrant,两个都比较主流,社区活跃,Python SDK 也成熟。

网上很多文章要么是官方 benchmark 的搬运,要么是玩具数据集跑一跑,参考价值有限。所以我干脆自己在真实数据上做了一轮对比,把部署、写入、查询、资源占用都测了一遍,记录如下。

二、环境与版本

  • 操作系统:Ubuntu 22.04 LTS
  • 硬件:8 vCPU / 16GB RAM / 500GB SSD
  • Docker:24.0.7,Docker Compose v2.24.5
  • Milvus:2.4.0(standalone 模式,etcd + MinIO 依赖)
  • Qdrant:1.9.2(单节点)
  • Python:3.11.6
  • 客户端:pymilvus 2.4.0,qdrant-client 1.9.2
  • 数据集:12 万条 768 维 float32 向量,附带 category(int)、price(float)两个 payload 字段

两者都用 Docker 部署,避免污染宿主环境,也方便对比资源占用。

三、方案设计

对比维度定得比较明确:

  1. 部署复杂度:从零到能跑要几步,依赖多不多
  2. 写入性能:12 万条批量导入耗时
  3. 查询性能:单查询延迟、批量查询吞吐
  4. 带过滤的查询:category 过滤 + 向量检索
  5. 资源占用:空闲和压测时的内存、CPU
  6. 运维体验:监控、备份、扩容

索引参数上尽量对齐,保证公平:

  • Milvus:HNSW,M=16,efConstruction=200,查询 ef=64;距离度量 COSINE
  • Qdrant:HNSW,m=16,ef_construct=200,查询 hnsw_ef=64;距离度量 Cosine

四、核心实现

4.1 Milvus 部署与写入

Milvus standalone 用官方 compose 文件,一条命令拉起:

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

写入和建索引的代码:

from pymilvus import MilvusClient, DataType
import numpy as np, time

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

# 建 collection
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)
schema.add_field("price", DataType.FLOAT)

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

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

# 批量写入
vectors = np.random.rand(120000, 768).astype(np.float32)
# 归一化,COSINE 下建议先归一化
vectors /= np.linalg.norm(vectors, axis=1, keepdims=True)

t0 = time.time()
batch = 2000
for i in range(0, len(vectors), batch):
    rows = [
        {"id": j, "vector": vectors[j].tolist(),
         "category": int(j % 20), "price": float(j % 500)}
        for j in range(i, min(i + batch, len(vectors)))
    ]
    client.insert(collection_name="goods", data=rows)
client.flush("goods")
print(f"Milvus 写入耗时: {time.time() - t0:.1f}s")

实测写入 12 万条耗时 约 96 秒,平均每分钟约 7.5 万条。注意 Milvus 是“先写日志再建索引”的架构,flush 之后才有索引,查询才快。

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="localhost", port=6333)

client.recreate_collection(
    collection_name="goods",
    vectors_config=VectorParams(size=768, distance=Distance.COSINE),
    hnsw_config=HnswConfigDiff(m=16, ef_construct=200),
)

vectors = np.random.rand(120000, 768).astype(np.float32)
vectors /= np.linalg.norm(vectors, axis=1, keepdims=True)

t0 = time.time()
batch = 2000
for i in range(0, len(vectors), batch):
    points = [
        PointStruct(
            id=j,
            vector=vectors[j].tolist(),
            payload={"category": int(j % 20), "price": float(j % 500)},
        )
        for j in range(i, min(i + batch, len(vectors)))
    ]
    client.upsert(collection_name="goods", points=points, wait=False)
client.wait_for_pending_updates()
print(f"Qdrant 写入耗时: {time.time() - t0:.1f}s")

实测写入耗时 约 71 秒,比 Milvus 快一些。Qdrant 的 upsert 默认 wait=False,异步落盘,所以写入吞吐更好。

五、查询性能与资源实测

查询代码两边逻辑一致:随机取 1000 条向量做单查询,记录 P50、P99;再做一次带 category 过滤的查询。

查询结果(单位 ms):

指标 Milvus 2.4.0 Qdrant 1.9.2
单查询 P50 12 8
单查询 P99 31 18
带过滤 P99 47 23
批量 100 并发 QPS 210 340

Qdrant 在延迟上全面领先,尤其是带过滤的场景,因为它的 payload 索引和向量索引结合得更紧,过滤是“检索时下推”的。Milvus 的过滤在 standalone 下走的是标量字段扫描,数据量大时开销明显。

资源占用(压测 5 分钟,40 QPS 稳定):

指标 Milvus Qdrant
空闲内存 约 1.8GB 约 420MB
压测内存峰值 约 3.6GB 约 1.1GB
压测 CPU 峰值 约 480% 约 260%
磁盘占用 约 1.2GB 约 780MB

这里差距挺明显的。Milvus 因为要跑 etcd、MinIO、pulsar(standalone 内嵌),光是依赖组件就吃掉不少内存。Qdrant 是 Rust 写的,单进程,内存控制好很多。对于 16G 的机器,Milvus 能跑,但余量不大;Qdrant 就非常从容。

六、踩坑与优化

坑 1:Milvus 的 flush 时机。 一开始我没调 flush(),插完直接查,结果召回率极低,因为数据还在 growing segment 里,没建索引。生产环境要么手动 flush,要么等它自动 sealed,但自动 sealed 有延迟。

坑 2:归一化。 COSINE 距离下,如果向量没归一化,Milvus 内部会自己算,但会有额外开销。Qdrant 同样建议先归一化。统一在客户端归一化后,两边 P99 都降了 3~5ms。

坑 3:Qdrant 的 wait 参数。 写入时 wait=False 吞吐高,但如果写完立刻查,可能查不到。压测时我加了 wait_for_pending_updates() 保证一致性,生产上可以按业务容忍度取舍。

优化点: Milvus 把 ef 从 64 提到 128,召回率从 0.92 到 0.98,但 P99 涨到 45ms,最后折中在 96。Qdrant 的 hnsw_ef 同样调到 96,P99 约 22ms,性价比更高。

七、总结

回到选型。如果你的场景是:

  • 资源紧张、单节点、追求低延迟:选 Qdrant。部署简单,内存占用小,带过滤查询快,Rust 实现稳定。
  • 数据量上亿、需要分布式、生态成熟:选 Milvus。它的分布式架构、多索引类型、和 Spark/Flink 的集成更完善,但代价是资源占用和运维复杂度。

我们最终选了 Qdrant,因为 12 万级数据、单节点、16G 内存的约束下,它的性价比明显更高。但如果业务涨到千万级、要横向扩展,我会毫不犹豫换 Milvus。

没有银弹,只有匹配场景的取舍。希望这份实测数据能帮你少走点弯路。