一、问题背景
我们做的是电商「以图搜同款」和「语义搜商品」,向量规模大概 1200 万,维度 768(CLIP ViT-L/14 的 image embedding)。原来图省事,直接拿 Elasticsearch 8.x 的 dense_vector 字段顶了一阵子。问题在于:
- ES 的 HNSW 索引构建慢,1200 万条重建一次要 6 个多小时;
- 内存吃不住,光向量索引就占了快 40G,还得跟全文检索抢资源;
- 过滤 + 向量混合查询(比如「类目=女装 AND 价格<300」再排序)性能掉得厉害,P99 直接飙到 800ms。
于是决定迁到专用向量库。候选就两个:Milvus 和 Qdrant。选它们的原因很实际——社区活跃、都支持 HNSW、都有标量过滤、都有 Go/Rust 写的高性能内核,而且都能私有化部署。
业务侧的硬指标是:
- 单条查询 P99 < 100ms(TopK=50,带标量过滤)
- 写入吞吐 ≥ 2000 条/s
- 召回率 Recall@50 ≥ 0.95
- 单机内存不超过 32G
二、环境与版本
测试机器统一用同一批,避免硬件差异干扰:
| 项目 | 配置 |
|---|---|
| 单机 | 8 vCPU / 32G RAM / 500G NVMe SSD |
| 集群 | 3 × (8 vCPU / 32G RAM) |
| OS | Ubuntu 22.04 LTS |
| Docker | 26.1.1 |
| Milvus | 2.4.1(standalone + cluster) |
| Qdrant | 1.9.2 |
| 客户端 | pymilvus 2.4.3 / qdrant-client 1.9.1 |
| 数据集 | 1200 万条 768 维 float32 向量 + 类目/价格标量 |
三、方案设计
Milvus 方案:standalone 模式用 docker-compose 起全套(etcd + minio + milvus),集群模式用官方 Helm chart 起 3 个 querynode。索引统一用 HNSW,参数 M=16, efConstruction=200,查询 ef=128。距离度量用 IP(内积,向量已归一化,等价余弦)。
Qdrant 方案:单机直接用官方镜像,集群用 3 节点 Raft。索引用 HNSW,m=16, ef_construct=200,查询 hnsw_ef=128。同样 Cosine 距离。
两边都开标量过滤:category_id 建整型索引,price 建范围索引。
四、核心实现
4.1 Milvus 部署(docker-compose)
# milvus-standalone-docker-compose.yml
version: '3.5'
services:
etcd:
image: quay.io/coreos/etcd:v3.5.5
environment:
- ETCD_AUTO_COMPACTION_MODE=revision
- ETCD_AUTO_COMPACTION_RETENTION=1000
- ETCD_QUOTA_BACKEND_BYTES=4294967296
volumes:
- ./volumes/etcd:/etcd
command: etcd -advertise-client-urls=http://127.0.0.1:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd
minio:
image: minio/minio:RELEASE.2023-03-20T20-16-18Z
environment:
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
volumes:
- ./volumes/minio:/minio_data
command: minio server /minio_data --console-address ":9001"
standalone:
image: milvusdb/milvus:v2.4.1
command: ["milvus", "run", "standalone"]
environment:
ETCD_ENDPOINTS: etcd:2379
MINIO_ADDRESS: minio:9000
volumes:
- ./volumes/milvus:/var/lib/milvus
ports:
- "19530:19530"
- "9091:9091"
depends_on:
- etcd
- minio
4.2 建表 + 插入 + 查询(pymilvus)
from pymilvus import (
connections, FieldSchema, CollectionSchema,
DataType, Collection, utility
)
import numpy as np, time
connections.connect("default", host="localhost", port="19530")
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True),
FieldSchema(name="category_id", dtype=DataType.INT32),
FieldSchema(name="price", dtype=DataType.FLOAT),
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768),
]
schema = CollectionSchema(fields, description="product_vectors")
col = Collection("products", schema)
# HNSW 索引
index_params = {
"index_type": "HNSW",
"metric_type": "IP",
"params": {"M": 16, "efConstruction": 200},
}
col.create_index("embedding", index_params)
col.create_index("category_id", {"index_type": "INVERTED"})
col.create_index("price", {"index_type": "STL_SORT"})
col.load()
# 批量写入 1200 万
BATCH = 10000
total = 12_000_000
for start in range(0, total, BATCH):
n = min(BATCH, total - start)
ids = list(range(start, start + n))
cats = np.random.randint(0, 200, n).tolist()
prices = (np.random.rand(n) * 1000).round(2).tolist()
vecs = np.random.rand(n, 768).astype(np.float32)
vecs /= np.linalg.norm(vecs, axis=1, keepdims=True)
col.insert([ids, cats, prices, vecs.tolist()])
# 查询:带过滤的 TopK
query_vec = np.random.rand(1, 768).astype(np.float32)
query_vec /= np.linalg.norm(query_vec)
search_params = {"metric_type": "IP", "params": {"ef": 128}}
t0 = time.time()
res = col.search(
data=query_vec.tolist(),
anns_field="embedding",
param=search_params,
limit=50,
expr="category_id == 12 && price < 300",
output_fields=["category_id", "price"],
)
print(f"Milvus latency: {(time.time()-t0)*1000:.2f} ms")
4.3 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.9.2
from qdrant_client import QdrantClient
from qdrant_client.models import (
VectorParams, Distance, PointStruct,
HnswConfigDiff, Filter, FieldCondition,
MatchValue, Range
)
import numpy as np, time
client = QdrantClient(host="localhost", port=6333)
client.recreate_collection(
collection_name="products",
vectors_config=VectorParams(size=768, distance=Distance.COSINE),
hnsw_config=HnswConfigDiff(m=16, ef_construct=200),
optimizers_config={"memmap_threshold": 20000},
)
BATCH = 1000
total = 12_000_000
for start in range(0, total, BATCH):
n = min(BATCH, total - start)
vecs = np.random.rand(n, 768).astype(np.float32)
vecs /= np.linalg.norm(vecs, axis=1, keepdims=True)
points = [
PointStruct(
id=start + i,
vector=vecs[i].tolist(),
payload={
"category_id": int(np.random.randint(0, 200)),
"price": float(np.random.rand() * 1000),
},
)
for i in range(n)
]
client.upsert(collection_name="products", points=points)
# 建 payload 索引
client.create_payload_index("products", "category_id", "integer")
client.create_payload_index("products", "price", "float")
qvec = np.random.rand(768).astype(np.float32)
qvec /= np.linalg.norm(qvec)
flt = Filter(must=[
FieldCondition(key="category_id", match=MatchValue(value=12)),
FieldCondition(key="price", range=Range(lt=300)),
])
t0 = time.time()
hits = client.search(
collection_name="products",
query_vector=qvec.tolist(),
query_filter=flt,
limit=50,
search_params={"hnsw_ef": 128},
)
print(f"Qdrant latency: {(time.time()-t0)*1000:.2f} ms")
五、踩坑与优化
坑 1:Milvus 默认 ef 太小导致召回掉。 一开始用默认 ef=64,Recall@50 只有 0.89,调到 128 后到 0.965,延迟从 38ms 升到 55ms,可接受。
坑 2:Qdrant 单段写入过大触发强制 flush。 每批 10000 条时,单 segment 涨到 2G 以上,后台优化线程疯狂合并,写入吞吐从 3500 掉到 1200。把 optimizers_config.memmap_threshold 设成 20000、批大小降到 1000 后稳定。
坑 3:Milvus 集群 querynode 内存不均。 3 节点默认按 segment 均分,热点类目查询全打到同一节点。开启 queryNode.loadMemoryUsageFactor 并配合多副本(replica_number=2)后 P99 从 210ms 降到 88ms。
坑 4:过滤字段没建索引。 两边都一样——category_id 不建索引时,Milvus 走全量扫描,延迟直接 1.2s;建了 INVERTED 后降到 60ms。Qdrant 同理,payload 索引必建。
六、效果数据
统一压测:wrk 风格并发 32,持续 5 分钟,TopK=50,带过滤。
| 指标 | Milvus 单机 | Milvus 3 节点 | Qdrant 单机 | Qdrant 3 节点 |
|---|---|---|---|---|
| 写入吞吐 (条/s) | 2800 | 7200 | 3500 | 6800 |
| 查询 QPS | 1420 | 3900 | 2020 | 3600 |
| P50 延迟 (ms) | 21 | 8 | 14 | 9 |
| P99 延迟 (ms) | 68 | 41 | 47 | 44 |
| Recall@50 | 0.965 | 0.965 | 0.971 | 0.971 |
| 索引内存 (GB) | 18.4 | 6.3/节点 | 13.1 | 4.8/节点 |
| 建索引耗时 | 42 min | — | 31 min | — |
结论很清晰:
- 单机场景:Qdrant 全面占优,QPS 高 42%,P99 低 30%,内存省 29%,部署还简单(一个容器搞定,不用 etcd + minio)。
- 集群场景:Milvus 反超,QPS 高 8%,写入吞吐高 6%,且它的存算分离架构在节点故障恢复上更成熟。
- 运维成本:Milvus 依赖多(etcd、对象存储、pulsar/kafka 可选),Qdrant 单二进制,小团队更友好。
七、总结
最终我们选了 Qdrant 1.9.2 单机 + 冷备,因为当前 1200 万规模单机完全扛得住(P99 47ms,内存 13G),没必要上集群增加复杂度。如果未来涨到 5000 万以上、或者需要多租户强隔离,再切 Milvus 集群。
给同行的选型建议:
- 千万级以下、单机够用 → 优先 Qdrant,部署和调优都省心。
- 亿级以上、要横向扩展、要存算分离 → Milvus,集群能力更成熟。
- 无论选哪个,标量过滤字段一定要建索引,这是最容易踩的坑。
- HNSW 的
ef/ef_construct别用默认值,召回率和延迟的平衡点要自己压测找。
向量库没有银弹,规模、团队运维能力、预算三者一权衡,答案自然就出来了。