一、问题背景:为什么我们被迫换掉向量数据库
我们团队在做电商平台的以图搜图+语义搜索功能,向量数据规模从年初的20万涨到现在的100万,原来的方案是直接用Elasticsearch的dense_vector插件。但到了80万条数据时,ES的查询延迟从80ms飙升到300ms+,而且每次全量索引重建要花40分钟。更难受的是,ES的内存吃相太难看,16G的机器跑ES+向量索引直接OOM。
我们决定引入专业向量数据库,候选是Milvus和Qdrant。为什么是这两个?因为Faiss只是库不是服务,需要自己封装;Weaviate和Pinecone要么太重要么是托管服务。我们的核心诉求就三条:部署简单、查询P99延迟<150ms、内存占用可控。
二、环境与版本:同样的机器,不同的命运
测试环境统一使用腾讯云CVM标准型S5,配置如下:
- CPU:8核 Intel Xeon Platinum 8255C(2.5GHz)
- 内存:16GB DDR4
- 磁盘:200GB SSD(IOPS上限3000)
- 系统:Ubuntu 22.04 LTS,内核5.15
- Docker版本:24.0.5,docker-compose版本:2.20.2
数据库版本选当时(2024年6月)的最新稳定版:
- Milvus 2.4.1(standalone模式,用etcd+minio)
- Qdrant 1.9.2(单节点模式)
这里有个关键点:Milvus 2.4开始默认集成了新的Cardinal优化器,官方说在过滤场景下性能提升2倍,我们后面会验证这个说法。
三、方案设计:不搞花活,贴近生产
我们模拟的是真实RAG场景:商品库有100万条数据,每条包含商品ID、标题、类目、价格、图片向量(768维)。查询模式有两种:
- 纯向量查询:输入一个查询向量,返回TopK=10的结果
- 混合查询:向量相似度 + 过滤条件(类目=“数码”,价格<5000)
索引配置上,两个库都采用HNSW算法(Milvus的HNSW参数:M=16, efConstruction=200;Qdrant同样参数)。距离度量用余弦相似度。为了公平,两个库都用默认的量化策略(Milvus用Float,Qdrant用Float),不启用标量量化。
数据导入方式:Python脚本读CSV,批量插入。Milvus用pymilvus的insert接口,Qdrant用qdrant-client的upsert。都开批量事务(batch_size=1024)。
四、核心实现:部署与压测代码
4.1 Milvus部署(docker-compose)
# 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
- ETCD_QUOTA_BACKEND_BYTES=4294967296
volumes:
- ${DOCKER_VOLUME_DIRECTORY:-.}/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:
- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/minio:/minio_data
command: minio server /minio_data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
standalone:
image: milvusdb/milvus:v2.4.1
command: ["milvus", "run", "standalone"]
ports:
- "19530:19530"
- "9091:9091"
environment:
ETCD_ENDPOINTS: etcd:2379
MINIO_ADDRESS: minio:9000
volumes:
- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus
depends_on:
- etcd
- minio
启动命令:docker-compose -f docker-compose-milvus.yml up -d
4.2 Qdrant部署(docker-compose)
# docker-compose-qdrant.yml
version: '3.5'
services:
qdrant:
image: qdrant/qdrant:v1.9.2
ports:
- "6333:6333"
- "6334:6334"
volumes:
- ${DOCKER_VOLUME_DIRECTORY:-.}/qdrant_storage:/qdrant/storage
environment:
QDRANT__SERVICE__GRPC_PORT: 6334
QDRANT__STORAGE__OPTIMIZER__DEFAULT_SEGMENT_NUMBER: 2
ulimits:
nofile:
soft: 65535
hard: 65535
启动命令:docker-compose -f docker-compose-qdrant.yml up -d
注意Qdrant的DEFAULT_SEGMENT_NUMBER参数,默认是0(自动),但实际测试中手动设为2能让并发查询性能更稳定,原因后面讲。
4.3 压测代码(Python + Locust)
这里用Locust做并发压测,脚本比ab更灵活。核心逻辑如下:
from locust import User, task, between
from pymilvus import connections, Collection
from qdrant_client import QdrantClient
import numpy as np
import random
class VectorSearchUser(User):
wait_time = between(0.1, 0.5)
def on_start(self):
# 初始化两个客户端
self.milvus_conn = connections.connect(alias="default", host="localhost", port="19530")
self.milvus_col = Collection("product_embedding")
self.milvus_col.load()
self.qdrant_client = QdrantClient(host="localhost", port=6333)
# 预生成1000个随机查询向量
self.query_vectors = [np.random.rand(768).astype(np.float32) for _ in range(1000)]
self.query_idx = 0
@task(30) # 70%概率走Milvus纯向量查询
def search_milvus(self):
vec = self.query_vectors[self.query_idx % 1000]
self.query_idx += 1
# 纯向量查询,TopK=10
results = self.milvus_col.search(
data=[vec],
anns_field="embedding",
param={"metric_type": "COSINE", "params": {"ef": 64}},
limit=10,
output_fields=["product_id", "title"]
)
@task(20)
def search_qdrant(self):
vec = self.query_vectors[self.query_idx % 1000]
self.query_idx += 1
results = self.qdrant_client.search(
collection_name="product_embedding",
query_vector=vec,
limit=10,
with_payload=True
)
@task(10) # 混合查询:过滤类目和价格
def search_milvus_filter(self):
vec = self.query_vectors[self.query_idx % 1000]
self.query_idx += 1
expr = 'category == "数码" and price < 5000'
results = self.milvus_col.search(
data=[vec],
anns_field="embedding",
param={"metric_type": "COSINE", "params": {"ef": 64}},
limit=10,
expr=expr,
output_fields=["product_id", "price"]
)
@task(10)
def search_qdrant_filter(self):
vec = self.query_vectors[self.query_idx % 1000]
self.query_idx += 1
query_filter = models.Filter(
must=[
models.FieldCondition(key="category", match=models.MatchValue(value="数码")),
models.FieldCondition(key="price", range=models.Range(lt=5000))
]
)
results = self.qdrant_client.search(
collection_name="product_embedding",
query_vector=vec,
query_filter=query_filter,
limit=10
)
运行方式:locust -f load_test.py --host http://localhost:8089 --users 200 --spawn-rate 20 --run-time 10m
五、踩坑与优化:内存优化器是个坑
5.1 Milvus的“内存不足”假死
第一次压测时,Milvus在并发200时直接挂了,日志显示Out of memory。排查发现是HNSW的efConstruction设太高(默认300),导致索引构建时内存峰值爆炸。后来把efConstruction降到200,并在collection配置里加了"mmap": True(2.4版本支持),内存占用降了35%。
5.2 Qdrant的段合并问题
Qdrant默认每10万条数据自动分一个segment,但段太多会导致查询时IO竞争。我们通过DEFAULT_SEGMENT_NUMBER=2强制合并为2个大段,P99延迟从110ms降到85ms。代价是导入时CPU占用高,但查询性能提升明显。
5.3 关键调参差异
- Milvus的
ef(搜索宽度):64时召回率98.2%,128时99.1%,但QPS降30%。我们最终用ef=96做折中 - Qdrant的
hnsw_ef:默认100,调高到200时召回率从97.8%到99.3%,但内存增加200MB - 两个库都默认用
cosine距离,但注意Qdrant的cosine要求向量已归一化,否则会额外做一次归一化计算影响性能
六、效果数据:用数据说话
压测10分钟,并发200,结果如下:
| 指标 | Milvus 2.4.1 | Qdrant 1.9.2 |
|---|---|---|
| 纯向量QPS | 1850 | 1020 |
| 纯向量P99延迟 | 88ms | 120ms |
| 混合查询QPS | 720 | 450 |
| 混合查询P99延迟 | 145ms | 210ms |
| 内存占用(空闲) | 3.2GB | 1.8GB |
| 内存占用(满载) | 6.8GB | 3.9GB |
| 索引构建时间(1M条) | 28分钟 | 22分钟 |
| 索引体积 | 2.1GB | 1.6GB |
| 召回率(Top10@100) | 98.6% | 98.1% |
结论:
-
性能:Milvus在纯向量查询上QPS领先Qdrant 81%,这得益于2.4版本的Cardinal优化器。但在混合查询上优势缩小到60%,因为Milvus的过滤需要走表达式解析,Qdrant的Filter结构更高效。
-
资源:Qdrant完胜。内存占用只有Milvus的57%,索引体积小23%。如果你的机器内存紧张(8G以下),Qdrant是更安全的选择。
-
易用性:Milvus的API更符合传统数据库习惯(Collection/Field概念),但依赖etcd+minio两个中间件,部署复杂。Qdrant就一个容器,docker run就完事。
-
生态:Milvus有官方WebUI(Attu),数据管理方便。Qdrant的WebUI较弱,但支持gRPC接口,延迟更低。
最终选择:我们生产环境用了Milvus,因为我们的查询压力主要在纯向量检索(占比70%),且需要和现有的数据血缘系统集成(Milvus的元数据管理更强)。但如果你做轻量级应用、内存有限,我强烈建议用Qdrant,省心得多。
以上,都是真实数据,希望能帮你在2024年做出正确的选择。有问题评论区聊。