1. 业务背景与选型困境
今年Q2我们团队接手了一个服装电商平台的“以图搜图”功能。业务要求:现有商品图库约120万张,每张图用ResNet50提取1024维浮点向量,需要实现基于余弦相似度的Top-10检索,响应时间控制在100ms以内(含网络开销)。
技术栈初步筛选时,VectorDB赛道主流选型是Milvus和Qdrant。作为团队里负责基础组件的,我决定不盲从社区热度,而是自建一套测试环境,从部署体验、写入性能、查询延迟、资源占用四个维度做AB对比。
需要提前说明:两个库都是优秀的开源向量数据库,但定位不同——Milvus更像一个分布式数据库系统,Qdrant更偏向轻量级嵌入式引擎。我在选型对比中用了单机部署模式,因为业务初期数据量不大,暂时不需要分布式。
2. 测试环境与版本锁定
为了避免版本差异导致的偏差,我统一使用Docker部署,机器配置如下:
- 云服务器:4C8G(实际通过swap扩展到16G内存,但尽量压测真实物理内存边界)
- 磁盘:200GB SSD(IOPS约5000)
- 操作系统:Ubuntu 20.04 LTS,内核5.4
选用的具体版本(均为本文发布时的最新稳定版):
- Milvus:v2.4.0-rc.1(standalone模式)
- Qdrant:v1.8.4(单节点模式)
- Python SDK:milvus 2.3.6 / qdrant-client 1.9.1
向量维度统一为1024,距离度量使用余弦相似度。数据集:120万条商品向量,每个向量附带3个标量字段(商品ID、类目ID、上架时间)。
3. 部署步骤:Docker Compose完整配置
Milvus部署(Standalone模式)
Milvus 2.4引入了新的消息存储架构,但单机部署依然推荐官方提供的milvus.yaml配置。我修改了关键参数以适配4核环境:
# docker-compose-milvus.yaml
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:
- ${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
volumes:
- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/minio:/minio_data
command: minio server /minio_data
standalone:
image: milvusdb/milvus:v2.4.0-rc.1
command: ["milvus", "run", "standalone"]
environment:
ETCD_ENDPOINTS: etcd:2379
MINIO_ADDRESS: minio:9000
volumes:
- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus
ports:
- "19530:19530"
depends_on:
- etcd
- minio
启动命令:docker-compose -f docker-compose-milvus.yaml up -d
坑点:首次启动etcd会拉取镜像较慢,建议提前docker pull。
Qdrant部署(单节点模式)
Qdrant的配置文件更简洁,官方提供的config.yaml几乎不需要修改,只需调整内存用量:
# docker-compose-qdrant.yaml
version: '3.5'
services:
qdrant:
image: qdrant/qdrant:v1.8.4
ports:
- "6333:6333"
- "6334:6334"
volumes:
- ./qdrant_storage:/qdrant/storage
environment:
- QDRANT__SERVICE__GRPC_PORT=6334
- QDRANT__STORAGE__OPTIMIZER__DEFAULT_SEGMENT_NUMBER=2
- QDRANT__STORAGE__OPTIMIZER__MEMORY_LIMIT=4294967296
command: ["./qdrant", "--config", "./config/production.yaml"]
注意MEMORY_LIMIT设置为4GB,这能防止Qdrant在写入时过度占用内存。启动同样简单:docker-compose -f docker-compose-qdrant.yaml up -d。
4. 核心实现:数据写入与查询代码
Milvus SDK写入示例
from pymilvus import connections, Collection, FieldSchema, CollectionSchema, DataType, utility
# 1. 连接
connections.connect(host='localhost', port='19530')
# 2. 创建集合
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True),
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=1024),
FieldSchema(name="product_id", dtype=DataType.VARCHAR, max_length=64),
FieldSchema(name="category_id", dtype=DataType.INT64)
]
schema = CollectionSchema(fields, "商品向量集合")
collection = Collection(name="product_vectors", schema=schema)
# 3. 创建索引(HNSW参数:M=16, efConstruction=200)
index_params = {
"metric_type": "COSINE",
"index_type": "HNSW",
"params": {"M": 16, "efConstruction": 200}
}
collection.create_index(field_name="embedding", index_params=index_params)
collection.load()
# 4. 批量写入(每次1000条)
import numpy as np
batch_size = 1000
vectors = np.random.rand(batch_size, 1024).astype(np.float32)
product_ids = [f"prod_{i}" for i in range(batch_size)]
categories = [i % 50 for i in range(batch_size)]
entities = [
vectors.tolist(),
product_ids,
categories
]
insert_result = collection.insert(entities)
print(f"Milvus inserted {len(insert_result.primary_keys)} vectors")
Qdrant SDK写入示例
from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance, PointStruct
import numpy as np
# 1. 连接(使用gRPC端口)
client = QdrantClient(host="localhost", port=6334, prefer_grpc=True)
# 2. 创建集合
client.recreate_collection(
collection_name="product_vectors",
vectors_config=VectorParams(size=1024, distance=Distance.COSINE),
optimizers_config={
"default_segment_number": 2, # 减少段数,降低内存碎片
"memmap_threshold_kb": 128 # 磁盘映射阈值
}
)
# 3. 批量写入(Python SDK自动分片)
batch_size = 1000
points = []
for i in range(batch_size):
point = PointStruct(
id=i,
vector=np.random.rand(1024).astype(np.float32).tolist(),
payload={
"product_id": f"prod_{i}",
"category_id": i % 50
}
)
points.append(point)
client.upsert(collection_name="product_vectors", points=points)
print(f"Qdrant upserted {len(points)} vectors")
5. 踩坑与优化:段合并死锁与HNSW参数调优
Milvus的“段合并死锁”
在写入约50万条数据后,Milvus的查询突然卡死,日志显示segment merge stuck。排查发现是Milvus的自动段合并机制在高负载写入下产生了死锁——默认的segmentRowLimit=4096导致小段过多,合并线程无法获取锁。
解决方案:
- 在milvus.yaml中增大segmentRowLimit到16384
- 手动设置indexBuildingTaskPoolSize=2(4核机器不宜过高)
- 写入时使用collection.flush()强制落盘,减少内存中的未合并段
调整后写入稳定,但代价是索引构建时间从2小时延长到3.5小时。
Qdrant的HNSW参数调优
Qdrant默认的HNSW参数m=16, ef_construct=100在召回率上勉强能达到95%,但Top-10查询中偶尔出现无效结果(余弦相似度低于0.7)。我将ef_construct提升到200,召回率提升到98.5%,但索引构建时间增加了40%。
关键参数:
client.create_payload_index(
collection_name="product_vectors",
field_name="category_id",
field_schema="integer"
)
# HNSW索引参数在创建集合时指定
vectors_config=VectorParams(
size=1024,
distance=Distance.COSINE,
hnsw_config={
"m": 16,
"ef_construct": 200,
"full_scan_threshold": 10000 # 当候选数少于1万时全扫描
}
)
6. 性能实测数据(120万向量)
| 指标 | Milvus v2.4.0 | Qdrant v1.8.4 |
|---|---|---|
| 批量写入吞吐量(1000条/批) | 4200 QPS | 2800 QPS |
| 单条写入延迟(P99) | 8ms | 12ms |
| Top-10查询延迟(P50) | 18ms | 12ms |
| Top-10查询延迟(P99) | 45ms | 28ms |
| 内存占用(进程) | 12.3GB | 4.8GB |
| 磁盘占用 | 18.7GB | 22.1GB |
| 召回率(Recall@10) | 97.2% | 98.5% |
解读:
1. 写入性能:Milvus凭借其日志结构的合并树(LSM-like)架构,批量写入远快于Qdrant。但代价是内存占用飙升,12GB已经逼近物理内存上限导致swap频繁。
2. 查询性能:Qdrant在低内存下表现更好,因为它的向量索引直接使用mmap映射到磁盘,而Milvus需要将索引全部加载到内存。在P99延迟上Qdrant明显更稳定。
3. 召回率:两个库在调优后都能达到97%以上,但Qdrant的HNSW实现默认参数更保守,稍作调整就能达到98.5%。
7. 总结与选型建议
如果你是资源受限的单机部署(内存5000 QPS),Milvus的分布式能力(依赖etcd和MinIO)更具优势。但要做好心理准备:Milvus的学习曲线更陡,内存优化需要精细化配置,建议至少32GB内存起步。
最终的选型结果:我们团队选择了Qdrant,因为业务初期单机足以应对,并且运维成本低。后续如果数据量突破500万,再考虑迁移到Milvus集群或基于Qdrant的分片方案。
最后提一句:不要迷信任何VectorDB的Benchmark数字,用自己的数据、在自己的机器上跑一遍才是真理。毕竟,向量检索的性能跟数据分布、维度、索引参数、硬件配置强相关,别人的数字只是参考。