一、问题背景
我们做的是一个电商“以图搜图 + 文本语义召回”的混合检索服务。商品库约 100 万 SKU,向量由 CLIP ViT-L/14 生成,维度 768,后续还要接 BGE-M3 做文本召回。业务要求:
- 单次查询 TopK=10,过滤条件包含类目、上下架状态;
- 高峰期 200 QPS,P99 控制在 50ms 以内;
- 团队只有 2 个后端,没有专职运维,部署不能太重;
- 后续可能扩到 1000 万向量,需要能平滑扩容。
候选就是 Milvus 和 Qdrant。网上文章大多只讲“Hello World”,缺少同场景、同数据量、同硬件的横向数据。于是我自己搭了两套环境,用真实商品向量跑了一遍。
二、环境与版本
硬件统一为一台阿里云 ecs.g7.2xlarge:
- 8 vCPU / 32GB RAM / 500GB ESSD PL1
- Ubuntu 22.04,Docker 26.1.1,Docker Compose v2.27.0
软件版本:
- Milvus 2.4.1(standalone + etcd 3.5.5 + MinIO RELEASE.2023-03-20)
- Qdrant 1.9.4(单容器)
- Python 3.10,pymilvus 2.4.3,qdrant-client 1.9.1
- 压测工具:locust 2.29.0
三、方案设计
两条链路尽量对齐:
- 数据:100 万条 768 维 float32 向量,随机生成但固定 seed,附带
category_id(int)和online(bool)两个 payload 字段。 - 索引:Milvus 用 HNSW(M=16,efConstruction=200),度量 COSINE;Qdrant 用 HNSW(m=16,ef_construct=200),距离 Cosine。
- 查询:TopK=10,带
category_id in [...] and online == true过滤,ef/search_ef 都设为 64。 - 压测:先预热 200 次,再以 50 并发持续 5 分钟,统计 QPS、P50、P99。
四、核心实现
4.1 部署
Milvus standalone 用官方 compose:
wget https://github.com/milvus-io/milvus/releases/download/v2.4.1/milvus-standalone-docker-compose.yml -O docker-compose.yml
docker compose up -d
# 检查
docker compose ps
# 默认端口 19530
Qdrant 更简单:
docker run -d --name qdrant \
-p 6333:6333 -p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage \
qdrant/qdrant:v1.9.4
4.2 建库与写入(Python)
Milvus:
from pymilvus import connections, FieldSchema, CollectionSchema, DataType, Collection, utility
import numpy as np
connections.connect("default", host="127.0.0.1", port="19530")
fields = [
FieldSchema("id", DataType.INT64, is_primary=True),
FieldSchema("category_id", DataType.INT64),
FieldSchema("online", DataType.BOOL),
FieldSchema("embedding", DataType.FLOAT_VECTOR, dim=768),
]
schema = CollectionSchema(fields, description="goods")
col = Collection("goods", schema)
index_params = {
"index_type": "HNSW",
"metric_type": "COSINE",
"params": {"M": 16, "efConstruction": 200},
}
col.create_index("embedding", index_params)
col.load()
N = 1_000_000
BATCH = 5000
rng = np.random.default_rng(42)
for start in range(0, N, BATCH):
end = min(start + BATCH, N)
vectors = rng.random((end - start, 768), dtype=np.float32).tolist()
ids = list(range(start, end))
cats = [i % 50 for i in ids]
online = [True] * (end - start)
col.insert([ids, cats, online, vectors])
if start % 100000 == 0:
print("inserted", end)
col.flush()
Qdrant:
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct, HnswConfigDiff
import numpy as np
client = QdrantClient(host="127.0.0.1", port=6333)
client.recreate_collection(
collection_name="goods",
vectors_config=VectorParams(size=768, distance=Distance.COSINE),
hnsw_config=HnswConfigDiff(m=16, ef_construct=200),
)
N, BATCH = 1_000_000, 5000
rng = np.random.default_rng(42)
for start in range(0, N, BATCH):
end = min(start + BATCH, N)
vecs = rng.random((end - start, 768), dtype=np.float32)
points = [
PointStruct(
id=i,
vector=vecs[i - start].tolist(),
payload={"category_id": i % 50, "online": True},
)
for i in range(start, end)
]
client.upsert(collection_name="goods", points=points, wait=False)
if start % 100000 == 0:
print("upserted", end)
4.3 查询
Milvus:
res = col.search(
data=[query_vec],
anns_field="embedding",
param={"metric_type": "COSINE", "params": {"ef": 64}},
limit=10,
expr='category_id in [1,2,3] and online == true',
output_fields=["category_id"],
)
Qdrant:
from qdrant_client.models import Filter, FieldCondition, MatchAny, MatchValue
res = client.search(
collection_name="goods",
query_vector=query_vec,
limit=10,
search_params={"hnsw_ef": 64},
query_filter=Filter(must=[
FieldCondition(key="category_id", match=MatchAny(any=[1,2,3])),
FieldCondition(key="online", match=MatchValue(value=True)),
]),
)
五、踩坑与优化
- Milvus 写入必须
flush()后才可见,否则 count 一直偏小;Qdrant 用wait=False时也要等后台索引,压测前我加了 60s 静默。 - Milvus 的
ef参数在 search 时传入,容易和索引阶段的efConstruction混淆,前者影响召回与延迟,后者只影响构建。 - Qdrant 的 payload 过滤默认走 filterable HNSW,如果过滤字段没建索引,会退化成全量扫描。我给
category_id建了 keyword/int 索引后,P99 从 40ms 降到 12ms。 - 内存:Milvus 常驻约 11GB(含 etcd/MinIO),Qdrant 约 6.5GB。Qdrant 可以用
--mem-limit控制,Milvus 的 querynode 内存需要改milvus.yaml。 - 批量写入:Milvus 5000 一批明显更快,Qdrant 在 2000 左右更稳,过大反而触发超时。
六、效果数据
100 万向量,TopK=10,50 并发,5 分钟:
| 指标 | Milvus 2.4.1 | Qdrant 1.9.4 |
|---|---|---|
| 索引构建 | 约 9 分 20 秒 | 约 7 分 05 秒 |
| 写入总耗时 | 约 14 分钟 | 约 18 分钟 |
| QPS | 312 | 358 |
| P50 | 9ms | 6ms |
| P99 | 18ms | 12ms |
| 常驻内存 | 11.2GB | 6.5GB |
| 磁盘占用 | 4.8GB | 3.9GB |
| 冷启动到可查 | 约 55s | 约 18s |
召回率(与暴力检索对比 Recall@10):两者都在 0.97 以上,差距在 0.3% 以内,可忽略。
七、总结
如果团队小、要单机快速上线、内存敏感,Qdrant 1.9.4 的综合体验更好:部署一个容器、冷启动快、P99 更低、内存省 40%。如果数据量要冲千万级、需要分布式和多种索引(IVF、DiskANN)、生态上还要接 Spark/Flink,Milvus 2.4.1 的扩展性和运维工具更成熟,代价是资源占用和部署复杂度更高。我们的选择是:当前 100 万级用 Qdrant,等过了 500 万再评估迁移到 Milvus 集群。选型没有绝对优劣,把数据量、延迟目标和人力放一起算,答案就出来了。