一、问题背景
先说业务场景。我们做的是一个电商平台的“以图搜图”功能,商品库大约有 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 部署,避免污染宿主环境,也方便对比资源占用。
三、方案设计
对比维度定得比较明确:
- 部署复杂度:从零到能跑要几步,依赖多不多
- 写入性能:12 万条批量导入耗时
- 查询性能:单查询延迟、批量查询吞吐
- 带过滤的查询:category 过滤 + 向量检索
- 资源占用:空闲和压测时的内存、CPU
- 运维体验:监控、备份、扩容
索引参数上尽量对齐,保证公平:
- 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。
没有银弹,只有匹配场景的取舍。希望这份实测数据能帮你少走点弯路。