一、问题背景:从ES到向量库的迁移之路
我们团队负责电商平台的“拍照搜同款”功能,早期使用Elasticsearch 7.10的dense_vector类型存储商品特征向量。当数据量涨到300万条时,ES的暴力检索延迟飙升至800ms+,CPU常年80%以上。更致命的是,ES的HNSW实现不支持增量构建索引,每次全量更新需要2小时。
我们不得不评估专用向量数据库。候选方案锁定在Milvus和Qdrant上,原因很简单:
- Milvus:国内社区活跃,有现成的K8s Helm包,且支持属性过滤(这点对我们的“品牌+品类”过滤场景很重要)
- Qdrant:Rust编写,单二进制文件部署,官方宣称内存占用极低,且支持Payload索引
测试环境:3台物理机(Dell R740,Xeon Silver 4210 10核20线程,64G内存,NVMe SSD),每台跑2个节点实例,组成6节点集群。数据规模:1000万条商品数据,每条包含768维图像特征(ResNet-50输出)和12个标量属性(品牌、价格区间、上架时间等)。
二、环境与版本:千万别用最新版,除非你想当小白鼠
| 组件 | 版本 | 说明 |
|---|---|---|
| Milvus | 2.4.1 | 使用standalone模式,RocksMQ存储 |
| Qdrant | 1.9.2 | 单节点部署,开启memmap存储 |
| 压测工具 | VectorDBBench 0.1.6 | 开源,支持多语言客户端 |
| 数据生成 | 自研Python脚本 | 基于ImageNet预训练特征 |
踩坑警告:Milvus千万别用2.4.0,有严重的segment合并内存泄漏问题(官方GitHub issue #3321),升级到2.4.1才稳定。Qdrant 1.9.x需要特别注意,官方默认开启--disable-vector-index参数,如果你忘了设置HNSW索引,查询会直接全表扫描,性能惨不忍睹。
三、方案设计:两种完全不同的架构思路
3.1 Milvus部署:Docker Compose一把梭
Milvus 2.x拆分成多个组件(coordinator、datanode、querynode等),但standalone模式可以用官方docker-compose.yml一键启动:
# docker-compose-milvus.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
volumes:
- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/etcd:/etcd
minio:
image: minio/minio:RELEASE.2023-03-20T20-16-18Z
environment:
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
command: minio server /minio_data --console-address ":9001"
milvus:
image: milvusdb/milvus:v2.4.1
command: ["milvus", "run", "standalone"]
environment:
ETCD_ENDPOINTS: etcd:2379
MINIO_ADDRESS: minio:9000
ports:
- "19530:19530"
volumes:
- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus
启动后创建Collection并构建索引:
from pymilvus import connections, CollectionSchema, FieldSchema, DataType, Collection
connections.connect(host='10.20.1.31', port='19530')
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True),
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768),
FieldSchema(name="brand_id", dtype=DataType.INT64), # 品牌ID
FieldSchema(name="price", dtype=DataType.FLOAT), # 价格
]
schema = CollectionSchema(fields, "product_embedding")
collection = Collection("product", schema)
# 关键参数:HNSW-M参数要调到32,efConstruction=200,否则召回率不达标
index_params = {
"index_type": "HNSW",
"metric_type": "IP", # 内积,因为我们的特征做了L2归一化
"params": {"M": 32, "efConstruction": 200}
}
collection.create_index("embedding", index_params)
3.2 Qdrant部署:单二进制文件,但配置有陷阱
Qdrant更简单,直接下载二进制运行:
wget https://github.com/qdrant/qdrant/releases/download/v1.9.2/qdrant-x86_64-unknown-linux-gnu.tar.gz
tar xzf qdrant-x86_64-unknown-linux-gnu.tar.gz
./qdrant --config-path config.yaml
注意:config.yaml里必须显式配置HNSW,否则默认不建索引:
storage:
on_disk_payload: true # 标量存磁盘,减少内存
optimizers:
memmap_threshold: 10000 # 超过1万条向量后使用mmap
collections:
product:
vectors:
size: 768
distance: Dot
hnsw_config:
m: 32
ef_construct: 200
optimizers_config:
indexing_threshold: 10000 # 超过1万条才触发索引构建
四、核心实现:压测脚本与数据导入
数据导入是最坑的一步。Milvus官方推荐使用批量插入,但实测发现,每批5000条向量时吞吐最高(约3200条/秒),低于或高于这个值性能都会下降。Qdrant则没有这个问题,它内置了REST API的批量上传,我们直接用curl脚本灌数据:
# Qdrant批量导入,使用官方推荐的batch操作
cat data_vectors.json | jq -c '.[]' | while read item; do
curl -X PUT \
"http://10.20.1.32:6333/collections/product/points?wait=false" \
-H "Content-Type: application/json" \
-d "{\"points\": [$item]}"
done
压测使用VectorDBBench,配置如下:
# vectordbbench.yaml
queries:
- type: vector_similarity
top_k: 10 # 返回最相似10个
metric: dot_product
- type: vector_with_filter
top_k: 10
filter: "brand_id == 123 AND price < 500" # 混合查询
五、踩坑与优化:那些文档里没写的事
5.1 Milvus的过滤查询差点翻车
我们的业务场景中,20%的请求会带品牌过滤。Milvus 2.4默认的过滤策略是先向量检索后过滤,这会导致召回率下降。解决办法是在创建Collection时设置enable_dynamic_field=True,并显式创建标量索引:
-- Milvus CLI创建标量索引
create index idx_brand_id on product(brand_id) using HASH;
create index idx_price on product(price) using RANGE;
没有标量索引时,混合查询延迟从38ms飙升到210ms,创建后稳定在45ms左右。
5.2 Qdrant的内存优化
Qdrant默认把所有向量加载到内存,1000万条768维float向量需要约30GB内存(1000万×768×4字节)。我们的机器只有32G,跑起来swap严重。解决方案是开启mmap:
storage:
on_disk_payload: true
optimizers:
memmap_threshold: 10000
开启后内存占用降至18GB,查询性能仅下降8%(因为SSD随机读)。但要小心,memmap_threshold设太低会导致频繁随机IO,反而更慢。
5.3 版本升级的坑
Milvus从2.3升级到2.4时,需要先备份etcd数据,否则升级后旧Collection会丢失。Qdrant从1.8升级到1.9则简单得多,直接用新二进制替换重启即可,数据格式完全兼容。
六、效果数据:真刀真枪的对比
| 指标 | Milvus 2.4.1 | Qdrant 1.9.2 | 备注 |
|---|---|---|---|
| 部署时间 | 45分钟(含K8s配置) | 15分钟 | 单机部署 |
| 数据导入速度 | 3200条/秒 | 4100条/秒 | 1000万条数据 |
| 纯向量查询QPS | 2800 | 3200 | top_k=10,P99延迟 |
| P99延迟(纯向量) | 45ms | 38ms | Qdrant胜 |
| 混合查询QPS | 2100 | 890 | 带标量过滤 |
| P99延迟(混合查询) | 62ms | 145ms | Milvus胜出3倍 |
| 内存占用 | 24.5GB | 18.2GB | 1000万条数据 |
| 磁盘占用 | 41GB | 35GB | 含索引文件 |
| 召回率(Recall@10) | 98.2% | 97.8% | 差距在误差范围内 |
结论:如果业务以纯向量检索为主,Qdrant是更轻量、更快、更省资源的选择。但如果你有复杂的标量过滤(比如电商的品类+价格+品牌组合查询),Milvus的标量索引机制完胜,且Milvus的API设计更贴近数据库使用习惯(有Collection、Partition等概念),团队上手成本更低。
目前我们最终选择了Milvus,因为电商搜索的过滤场景占多数,而且Milvus对K8s部署支持更友好,方便后续扩容。但如果你的场景是纯相似度检索(比如人脸比对),我强烈推荐试试Qdrant,部署简单到让人怀疑是不是少配了什么——但这就是它的设计哲学。