一、问题背景
我们做的是一个电商商品以图搜图 + 语义检索的业务。商品库大约 1200 万条,向量维度 128,未来半年可能涨到 3000 万。需求很明确:
- 单次查询返回 Top 50,P99 控制在 50ms 以内
- 支持按类目、价格区间做过滤(payload filter)
- 部署要简单,团队没有专职运维
- 单机资源预算:8 核 16G,SSD 500G
候选方案锁定了 Milvus 和 Qdrant。网上文章要么是官方 benchmark,要么是玩具级 demo,跟自己业务差距太大。所以我干脆自己搭环境跑了一遍,把数据记录下来。
二、环境与版本
| 项目 | 配置 |
|---|---|
| 机器 | 阿里云 ecs.g7.2xlarge,8 vCPU / 16GB / ESSD PL1 |
| 系统 | Ubuntu 22.04 LTS,内核 5.15 |
| Docker | 24.0.7 |
| Milvus | 2.4.0(standalone 模式) |
| Qdrant | 1.8.2 |
| 客户端 | pymilvus 2.4.0 / qdrant-client 1.8.2 |
| 数据集 | 1000 万条,128 维 float32,随机生成 + 类目字段 |
说明一下:Milvus standalone 内部依赖 etcd 和 MinIO,Qdrant 是单二进制。这点在部署阶段差异就很大。
三、方案设计与部署
3.1 Milvus 部署
Milvus 官方推荐用 docker-compose 起 standalone。下载 2.4.0 的 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
起来之后会有三个容器:milvus-standalone、milvus-etcd、milvus-minio。默认端口 19530(gRPC)和 9091(metrics)。
几个关键配置我改了 milvus.yaml:
queryNode:
cache:
cacheSize: 4 # GB,默认按内存比例,我手动压到 4G
segcore:
chunkRows: 32768
dataCoord:
segment:
maxSize: 512 # MB
首次启动大约 40 秒,etcd 和 MinIO 各占 100MB 左右内存。
3.2 Qdrant 部署
Qdrant 简单得多:
docker run -d --name qdrant \
-p 6333:6333 -p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage \
-e QDRANT__SERVICE__GRPC_PORT=6334 \
qdrant/qdrant:v1.8.2
一个容器搞定,启动 3 秒。默认使用 mmap 存储,磁盘即内存的延伸。
3.3 建集合与灌数据
Qdrant 侧:
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct
import numpy as np
client = QdrantClient(host="localhost", port=6333)
client.recreate_collection(
collection_name="products",
vectors_config=VectorParams(size=128, distance=Distance.COSINE),
hnsw_config={"m": 16, "ef_construct": 200},
optimizers_config={"memmap_threshold": 20000},
)
BATCH = 2000
for i in range(0, 10_000_000, BATCH):
vecs = np.random.rand(BATCH, 128).astype(np.float32)
points = [
PointStruct(
id=i + j,
vector=vecs[j].tolist(),
payload={"cate": int(np.random.randint(0, 50)),
"price": float(np.random.rand() * 1000)},
)
for j in range(BATCH)
]
client.upsert(collection_name="products", points=points)
Milvus 侧:
from pymilvus import (connections, CollectionSchema, FieldSchema,
DataType, Collection, utility)
import numpy as np
connections.connect("default", host="localhost", port="19530")
fields = [
FieldSchema("id", DataType.INT64, is_primary=True),
FieldSchema("vec", DataType.FLOAT_VECTOR, dim=128),
FieldSchema("cate", DataType.INT32),
FieldSchema("price", DataType.FLOAT),
]
schema = CollectionSchema(fields)
col = Collection("products", schema)
col.create_index("vec", {
"index_type": "HNSW",
"metric_type": "COSINE",
"params": {"M": 16, "efConstruction": 200},
})
col.create_index("cate", {"index_type": "INVERTED"})
BATCH = 2000
for i in range(0, 10_000_000, BATCH):
vecs = np.random.rand(BATCH, 128).astype(np.float32)
ids = list(range(i, i + BATCH))
cates = np.random.randint(0, 50, BATCH).tolist()
prices = (np.random.rand(BATCH) * 1000).tolist()
col.insert([ids, vecs.tolist(), cates, prices])
if i % 200000 == 0:
col.flush()
col.flush()
灌 1000 万条数据,Qdrant 用时约 22 分钟,Milvus 约 35 分钟。Milvus 慢主要是 flush 和索引构建阶段开销大。
四、核心实现:查询对比
查询侧我都测了三类:纯向量 Top50、带类目过滤、带价格范围过滤。
import time
import numpy as np
# ---- Qdrant ----
q = np.random.rand(128).astype(np.float32).tolist()
lat_q = []
for _ in range(200):
t = time.perf_counter()
client.search(
collection_name="products",
query_vector=q,
limit=50,
query_filter={
"must": [{"key": "cate", "match": {"value": 3}},
{"key": "price", "range": {"gte": 100, "lte": 500}}]
},
search_params={"hnsw_ef": 128},
)
lat_q.append((time.perf_counter() - t) * 1000)
# ---- Milvus ----
from pymilvus import Collection
col = Collection("products")
col.load()
lat_m = []
for _ in range(200):
t = time.perf_counter()
col.search(
data=[q], anns_field="vec",
param={"metric_type": "COSINE", "params": {"ef": 128}},
limit=50,
expr="cate == 3 and price >= 100 and price <= 500",
output_fields=["cate", "price"],
)
lat_m.append((time.perf_counter() - t) * 1000)
注意 Milvus 的过滤表达式是字符串 DSL,Qdrant 是 JSON 结构。前者更灵活但拼字符串容易出错,后者类型安全。
五、踩坑与优化
坑 1:Milvus 首次 load 极慢。 1000 万条数据 col.load() 花了 6 分钟,把 segment 全加载进内存。如果内存不够会触发 swapping。后来我把 cacheSize 设成 4GB,靠磁盘兜底,但查询 P99 涨到 60ms+。Qdrant 用 mmap,load 几乎瞬时。
坑 2:Qdrant 的 memmap_threshold。 默认值比较大,小集合会用内存模式。数据量过千万必须显式调,否则 OOM。我设成 20000(点数量),超过就落盘。
坑 3:Milvus 过滤字段要建倒排索引。 不建 INVERTED 索引,带过滤的查询会退化成暴力扫描,P99 直接从 27ms 飙到 400ms+。这个坑卡了我半天。
坑 4:Qdrant 的 ef 参数。 默认 hnsw_ef=128 是查询时参数,跟构建的 ef_construct 是两个东西。调大召回率高但延迟上升。我在召回率 0.96 时把 ef 定在 128,ef 提到 256 召回 0.98 但延迟 +40%。
六、效果数据
1000 万条、128 维、200 次查询取 P50/P99:
| 指标 | Qdrant 1.8.2 | Milvus 2.4.0 |
|---|---|---|
| 纯向量 P50 | 6.8ms | 9.2ms |
| 纯向量 P99 | 18.1ms | 27.4ms |
| 带过滤 P50 | 9.4ms | 14.7ms |
| 带过滤 P99 | 24.6ms | 41.3ms |
| 召回率@50 | 0.96 | 0.97 |
| 常驻内存 | 6.2GB | 9.8GB |
| 磁盘占用 | 5.4GB | 7.1GB |
| 冷启动到可用 | 3s | 45s |
| 灌数 1000 万耗时 | 22min | 35min |
另外做了个压力测试,16 并发持续查询,Qdrant P99 涨到 58ms,Milvus 涨到 112ms 并出现少量超时。Milvus 在 standalone 下并发能力明显是瓶颈,要上集群才行。
七、总结
如果按我们业务的需求(单机、过滤多、并发中等、运维简单),Qdrant 是更合适的选择:
- 部署成本几乎为零,一个容器搞定
- 内存占用低 36%,mmap 让磁盘当内存用
- 延迟低 30% 左右,过滤场景优势更明显
- JSON 过滤接口比 Milvus DSL 更友好
Milvus 的优势在于生态和分布式能力。如果数据量过亿、要上集群、要接 Flink/Spark 做流式写入,Milvus 的架构更成熟,多租户和一致性也更强。单机场景下它有点重。
我的建议是:单机 5000 万以内选 Qdrant,超过或需要分布式选 Milvus。 别迷信官方 benchmark,自己按业务数据跑一遍才是真的。