1. 业务背景:为什么我们不得不迁移向量数据库
我们电商平台的以图搜图功能在2024年初面临巨大压力。商品库从500万暴涨到2000万,原来的FAISS本地索引方案开始崩溃——单机内存128GB被打满,每次全量重建需要11小时,增量更新时会出现查询阻塞。
在调研了pgvector、Elasticsearch、Milvus和Qdrant后,我们锁定了后两者。原因很直接:pgvector在2000万数据量下查询延迟超过200ms,ES的HNSW实现内存占用高得离谱。而Milvus和Qdrant都原生支持分布式和磁盘索引,理论上能扛住这个量级。
关键决策点在于:我们的查询QPS峰值预计3000+,同时要支持商品属性过滤(如价格区间、品牌ID),这要求向量检索必须与标量过滤深度耦合。
2. 测试环境与版本锁定
为了避免“版本差异导致测试结果失真”,我们统一使用Docker部署,硬件配置如下:
- 服务器:3台裸金属,2路Intel Xeon 8380(80核),512GB DDR5内存
- 存储:NVMe SSD RAID0,顺序读2.8GB/s
- 网络:25GbE内网
- 数据集:2000万个768维float32向量(来自CLIP ViT-L/14),外加商品价格、品牌ID等6个标量字段
- 版本:Milvus v2.4.1(standalone模式)、Qdrant v1.9.2(单节点)
3. 部署步骤:从Docker Compose到生产参数
3.1 Milvus部署
Milvus的部署比我想象中繁琐。官方推荐的milvus-standalone-docker-compose.yml默认配置偏向开发,生产必须调整。我们最终用的关键参数:
# docker-compose.yml 关键片段
services:
etcd:
image: quay.io/coreos/etcd:v3.5.5
environment:
- ETCD_AUTO_COMPACTION_MODE=revision
- ETCD_AUTO_COMPACTION_RETENTION=1000
milvus:
image: milvusdb/milvus:v2.4.1
command: ["milvus", "run", "standalone"]
environment:
- ETCD_USE_EMBED=true
- ETCD_DATA_DIR=/var/lib/milvus/etcd
- COMMON_STORAGETYPE=local
volumes:
- ./milvus_data:/var/lib/milvus
- ./milvus_config:/milvus/configs
ports:
- "19530:19530"
- "9091:9091"
ulimits:
memlock: -1
nofile: 65535
启动后需要手动调整milvus.yaml中的queryNode资源限制,默认的queryNode.gracefulStopTimeout只有5秒,高负载下会导致节点频繁重启。
# 启动并验证
docker-compose up -d
curl -X POST http://localhost:9091/metrics | grep milvus_querynode_sql_processed
3.2 Qdrant部署
Qdrant的部署简单到令人感动,一个docker run就能跑起来。但生产环境需要认真配置config.yaml:
# qdrant_config.yaml
storage:
optimizers:
default_segment_number: 10 # 关键:并行构建段数
memmap_threshold: 10000 # 超过1万点用mmap
on_disk_payload: true # 标量字段落盘,控制内存
service:
max_request_size_mb: 64
grpc_timeout_sec: 30
启动命令:
docker run -d --name qdrant \
-p 6333:6333 -p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage \
-v $(pwd)/qdrant_config.yaml:/qdrant/config/config.yaml \
qdrant/qdrant:v1.9.2
3.3 数据导入
两个系统都支持批量导入。Milvus用pymilvus批量插入,Qdrant用grpc并发上传。这里有个细节:Milvus默认的producer并发度是10,导入2000万条数据用了47分钟;Qdrant默认并发上传,同样数据量只用了28分钟。
4. 查询性能实测:参数调优的血泪史
4.1 查询场景定义
我们模拟线上真实查询:
# 查询请求示例:找出与目标商品相似且价格在100-500元的商品
query_vector = get_embedding("红色连衣裙")
filter_condition = {
"price": {"$gte": 100, "$lte": 500},
"brand_id": {"$in": [1024, 2048, 4096]}
}
top_k = 50
4.2 Milvus查询优化
Milvus的查询性能极大依赖索引参数。我们最初用默认的HNSW参数(M=16, efConstruction=200),P99延迟高达35ms。经过反复调参,最终锁定以下参数:
from pymilvus import connections, Collection, FieldSchema, CollectionSchema, DataType, utility
connections.connect(alias="default", host="localhost", port="19530")
# 关键索引参数
index_params = {
"index_type": "HNSW",
"metric_type": "IP",
"params": {
"M": 32, # 每层最大连接数,16->32提升召回率
"efConstruction": 400, # 构建时动态列表大小
"ef": 128 # 查询时动态列表大小
}
}
collection.create_index(field_name="embedding", index_params=index_params)
# 带有标量过滤的查询
search_params = {"metric_type": "IP", "params": {"ef": 128}}
results = collection.search(
data=[query_vector],
anns_field="embedding",
param=search_params,
limit=50,
expr='price >= 100 and price <= 500 and brand_id in [1024, 2048, 4096]'
)
重要发现:Milvus的expr过滤是在向量检索后执行的(即post-filter),如果过滤条件命中率低(<5%),会导致大量无效计算。我们通过将price从float类型改为int64,并建立metric_type为INT64的标量索引,将过滤性能提升了40%。
4.3 Qdrant查询优化
Qdrant的查询API设计更直观,但要注意with_payload和with_vectors的默认行为——默认会返回完整向量,2000万数据下payload传输成为瓶颈。
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct, Filter, FieldCondition, Range, MatchAny
client = QdrantClient(host="localhost", port=6333, prefer_grpc=True)
# 创建集合时指定hnsw_config
from qdrant_client.models import HnswConfigDiff
client.create_collection(
collection_name="products",
vectors_config=VectorParams(size=768, distance=Distance.COSINE),
hnsw_config=HnswConfigDiff(
m=32,
ef_construct=400,
full_scan_threshold=10000 # 小于1万点全扫描
),
optimizers_config=OptimizersConfigDiff(
indexing_threshold=20000 # 2万点后开始构建索引
)
)
# 带过滤查询
search_result = client.query_points(
collection_name="products",
query=query_vector,
query_filter=Filter(
must=[
FieldCondition(key="price", range=Range(gte=100, lte=500)),
FieldCondition(key="brand_id", match_any=MatchAny(any=[1024, 2048, 4096]))
]
),
limit=50,
with_payload=False, # 关键:不返回payload,减少网络开销
with_vectors=False # 关键:不返回向量
)
Qdrant的过滤是pre-filter,在执行向量距离计算前就已经过滤掉不满足条件的点,这使得带有严格过滤条件的查询Qdrant比Milvus快得多。
4.4 性能对比数据
在2000万数据量下,1000次查询压测结果(并发32,数据均512GB内存,SSD存储):
| 指标 | Milvus 2.4.1 | Qdrant 1.9.2 |
|---|---|---|
| 无过滤P99延迟 | 12ms | 18ms |
| 带过滤P99延迟 | 28ms | 15ms |
| 内存占用(空闲) | 86GB | 28GB |
| 内存占用(峰值) | 142GB | 45GB |
| 启动时间 | 76秒 | 3.2秒 |
| 索引构建时间 | 39分钟 | 52分钟 |
数据解读:在纯向量检索场景,Milvus的HNSW实现效率确实更高(12ms vs 18ms),但一旦加入标量过滤,Qdrant的pre-filter优势就体现出来了(15ms vs 28ms)。内存方面Qdrant的memmap机制让索引直接走磁盘映射,内存占用仅为Milvus的三分之一。
5. 踩坑记录:那些文档没告诉你的
5.1 Milvus的磁盘索引陷阱
Milvus 2.4支持DiskANN索引,我们尝试用它来降低内存占用,结果悲剧了。DiskANN在build阶段的耗时为HNSW的5倍,查询延迟飙升到80ms+。后来查了GitHub issue才发现,DiskANN的search_list_size参数必须手动调优,默认值100在2000万数据下严重不足。
# DiskANN索引参数(不推荐,仅供避坑参考)
index_params = {
"index_type": "DISKANN",
"metric_type": "L2",
"params": {
"search_list_size": 2000, # 默认100,必须调到1500+
"build_list_size": 100 # 默认100
}
}
5.2 Qdrant的段合并风暴
Qdrant的段(segment)合并机制在数据导入阶段会触发资源竞争。我们的导入线程数设置过高(24),导致段合并频繁,CPU飙到100%,导入速度反而下降。最终将导入并发限制在8,并设置optimizers_config的max_optimization_threads=4才稳定下来。
5.3 网络层的坑
两边的gRPC客户端默认都有max_send_message_length限制。在导入大向量时(我们的batch size是1000),Qdrant默认的4MB限制直接报错,需要显式设置:
# Qdrant gRPC配置
from grpc import ssl_channel_credentials
from qdrant_client import QdrantClient
client = QdrantClient(
host="localhost",
port=6333,
prefer_grpc=True,
grpc_options={
"grpc.max_send_message_length": 128 * 1024 * 1024 # 128MB
}
)
6. 最终选择与架构演进
我们的结论是:两者没有绝对的优劣,取决于你的查询模式。
- 如果查询以纯向量召回为主(比如相似图推荐),且对延迟极致敏感,选 Milvus。它的HNSW实现更成熟,查询延迟在无过滤条件下比Qdrant低30%以上。
- 如果查询经常带多重标量过滤(比如电商的类目+价格+品牌),且内存资源有限,选 Qdrant。pre-filter机制在过滤场景下性能反超Milvus一倍,且内存占用仅为1/3。
最终我们选择了 Qdrant。原因很简单:我们的业务查询90%带标量过滤,而且Qdrant的部署和运维复杂度远低于Milvus(我们不需要单独维护etcd和对象存储)。迁移到Qdrant后,服务器从5台缩减到3台,硬件成本直接降低40%。
在实际生产环境中,我们还做了进一步优化:将高热度商品(top 10%流量)的向量加载到内存缓存,其余走mmap,查询延迟进一步稳定在P99 10ms以内。
如果你们也在做向量数据库选型,建议先用自己真实的业务数据跑一遍压测,重点关注过滤条件下的查询性能,而不是纯粹的向量检索延迟。毕竟业务场景很少会不带filter做查询。