一、问题背景:为什么又要换向量数据库?
我们团队维护的智能客服系统,最近把Embedding模型从text2vec-large换成了bge-m3,向量维度从512升到了768,数据量也从300万涨到了1200万。原先用的FAISS单机版本开始吃力了——不是查询慢,而是构建索引时内存直接爆掉(OutOfMemoryError)。
于是我们开始调研真正的向量数据库。候选名单里有Milvus、Qdrant、Weaviate。因为团队里Java和Go的人各半,而Weaviate的Go客户端维护成本高,最后只留下了Milvus和Qdrant。
这次选型不是做Demo,是直接上生产。我们的核心场景是:文章实时写入,用户提问后2秒内必须返回Top-30相关段落。别问为什么是2秒,产品经理拍脑袋定的。
二、环境与版本:先亮底牌
测试机是公司淘汰下来的物理机:
- CPU:Intel Xeon Gold 6248R @ 3.0GHz (32核)
- 内存:64GB DDR4
- 存储:1TB NVMe SSD (RAID 1)
- OS:Ubuntu 22.04 LTS
- Docker:24.0.7
数据库版本(都是当时稳定版):
- Milvus:v2.4.11 (standalone模式,含etcd + minio)
- Qdrant:v1.9.7 (单节点模式)
数据规模:12,000,000条,每条包含id (uint64)、vector (float[768])、payload (title, category, timestamp)。数据是从真实业务日志里脱敏抽样的。
三、方案设计:部署与索引策略
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
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"
standalone:
image: milvusdb/milvus:v2.4.11
command: ["milvus", "run", "standalone"]
ports:
- "19530:19530"
volumes:
- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus
depends_on:
- etcd
- minio
Qdrant部署(单行命令搞定,这点确实比Milvus清爽):
docker run -d --name qdrant \
-p 6333:6333 -p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage \
qdrant/qdrant:v1.9.7
索引参数(这是关键,直接影响查询性能):
- Milvus:
HNSW,M=32,efConstruction=400,efSearch=128(我们测试后觉得efSearch=128是性价比平衡点,再高延迟翻倍但召回率只涨0.3%)。 - Qdrant:
HNSW,m=32,ef_construct=400,全量payload索引。
四、核心实现:数据灌入与查询压测
我们写了个Java压测工具(Spring Boot 3.2 + REST API),但核心逻辑在Python里调客户端库。注意,千万别用官方Benchmark工具,那是神仙配置,和自己业务差太远。
Milvus写入代码片段(pymilvus 2.4.x):
from pymilvus import (connections, CollectionSchema, FieldSchema,
DataType, Collection, utility)
from pymilvus import MilvusClient
# 连接
client = MilvusClient(uri="http://localhost:19530")
# 建集合,启用动态字段
schema = client.create_schema(auto_id=False, enable_dynamic_field=True)
schema.add_field(field_name="id", datatype=DataType.INT64, is_primary=True)
schema.add_field(field_name="title", datatype=DataType.VARCHAR, max_length=512)
schema.add_field(field_name="category", datatype=DataType.VARCHAR, max_length=64)
schema.add_field(field_name="vector", datatype=DataType.FLOAT_VECTOR, dim=768)
# 创建HNSW索引
index_params = client.prepare_index_params()
index_params.add_index(field_name="vector", index_type="HNSW",
metric_type="COSINE", params={"M": 32, "efConstruction": 400})
client.create_collection(collection_name="article_v2",
schema=schema, index_params=index_params)
# 批量插入(这里用批量,单条插入会慢到怀疑人生)
batch_size = 10000
for i in range(0, 12000000, batch_size):
batch_data = [
{"id": j, "title": f"title_{j}", "category": f"cat_{j % 10}",
"vector": generate_vector()} for j in range(i, i+batch_size)
]
client.insert(collection_name="article_v2", data=batch_data)
print(f"Inserted {i+batch_size} records")
Qdrant写入代码片段(qdrant-client 1.9.7):
from qdrant_client import QdrantClient, models
client = QdrantClient(host="localhost", port=6333)
# 建集合
client.recreate_collection(
collection_name="article_v2",
vectors_config=models.VectorParams(size=768, distance=models.Distance.COSINE),
optimizers_config=models.OptimizersConfigDiff(
indexing_threshold=20000, # 每2万条自动建索引
memmap_threshold=50000
)
)
# 批量写入(Qdrant对批量大小敏感,实测1500-2000一条批最佳)
for i in range(0, 12000000, 2000):
points = []
for j in range(i, i+2000):
points.append(models.PointStruct(
id=j,
vector=generate_vector(),
payload={"title": f"title_{j}", "category": f"cat_{j % 10}"}
))
client.upsert(collection_name="article_v2", points=points, wait=False)
if i % 100000 == 0:
print(f"Upserted {i+2000} records")
压测方式:模拟真实查询,生成1000条随机查询向量,分别跑纯向量检索和向量+Payload过滤(category="cat_5"且timestamp在近7天内)。
五、踩坑与优化:血泪教训
Milvus的坑:
- 磁盘占用超预期:Milvus的minio存储是完整副本,1200万条向量占用了41GB磁盘,而Qdrant只要19GB。原因是Milvus默认把原始数据+索引都存了,Qdrant只存索引。
- 批量插入必须调大
client_timeout:默认10秒,插入1万条必超时。我改成了client = MilvusClient(uri="http://...", timeout=300)。 efConstruction不是越大越好:调到512后,索引构建时间从3小时涨到5.5小时,但查询召回率只提升0.1%。400足够。
Qdrant的坑:
- 内存控制是真强,但慢在写入:Qdrant的写入瓶颈在
memmap映射。实测单条插入延迟2ms,但批量1000条要1.8秒,比Milvus慢4倍。 - Payload过滤必须建索引:一开始没给
category加索引,带过滤的查询直接跑全表,延迟飙到8秒。加了索引后降到116ms。 wait=False能显著提升吞吐:异步写入时,Qdrant能达到1.2k/s,但同步写入只有300/s。Milvus批量写入一直稳在5k/s。
六、效果数据:最终用数字说话
| 指标 | Milvus 2.4.11 | Qdrant 1.9.7 |
|---|---|---|
| 数据导入耗时(1200万条) | 42分钟 | 3小时17分钟 |
| 索引构建耗时 | 1小时10分钟 | 48分钟 |
| 纯向量检索 P99 延迟 | 42ms | 58ms |
| 带过滤检索 P99 延迟 | 187ms | 116ms |
| 单机 QPS(纯检索,32并发) | 1850 | 960 |
| 单机 QPS(带过滤,32并发) | 320 | 510 |
| 内存占用(稳定后) | 22.4GB | 10.1GB |
| 磁盘占用 | 41GB | 19GB |
| 召回率(Top-30,与暴力搜索对比) | 99.2% | 99.1% |
结论:
- 如果你们是写多读少(比如每天批量灌数据,查询集中在白天),无脑选Milvus。它的批量写入和纯向量检索性能太强了。
- 如果你们是读多写多(比如实时增量同步+复杂过滤),且对内存敏感,Qdrant更合适。它的Payload过滤查询性能几乎是Milvus的两倍,内存只占一半。
- 我们最终选了Qdrant。原因是我们的业务场景里,客服提问大概率带分类过滤(比如“退款政策”会强制过滤
category="after_sales"),这个场景Qdrant赢麻了。至于写入慢,我们通过Kafka异步削峰解决了。
七、总结与后续计划
最后说点真心话:向量数据库选型,别信知乎吹的,也别信官方Benchmark。一定要拿自己的真实数据和真实查询模式去测。我们这轮对比花了整整两周,但排除了一个未来必然会炸的雷(如果当初选了Milvus,我们的内存只有64G,跑满带过滤的查询肯定会OOM)。
后续我们打算测试Qdrant的分布式模式(虽然单节点目前够了),以及对比一下Milvus的GPU_CAGRA索引(这是2.4版本的新特性,但需要A100)。等有结果了再来更新。
如果你也在纠结这两个库,欢迎评论区交流。特别是如果你用过Milvus的DiskANN或者Qdrant的BinaryQuantization,跪求分享经验。