1. 问题背景:为什么需要向量数据库选型?
去年团队接手了一个电商图片搜索项目,需要从1.2亿张商品图中快速找到相似商品。传统Elasticsearch的文本匹配无法满足语义相似度需求,我们决定采用向量数据库。
初选时锁定了两个明星项目:Milvus(云原生分布式)和Qdrant(轻量级Rust实现)。但项目要求单节点方案(初期数据约800万张),且服务器只有64GB内存、16核CPU,必须精确评估谁更合适。
2. 环境与版本
- 硬件:腾讯云CVM,4台配置相同:16 vCPU (Intel Xeon Platinum 8255C) / 64G RAM / 500G SSD
- 软件栈:
- Milvus:
milvus-2.3.0(standalone模式),依赖etcd和minio - Qdrant:
qdrant/qdrant:v1.7.3(single node) - 向量生成器:ResNet50提取的512维特征,归一化后float32
- 客户端:Python 3.10 +
pymilvus==2.3.0+qdrant-client==1.7.3
3. 方案设计:部署与索引策略
3.1 Milvus部署(Docker Compose)
# milvus/docker-compose.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
minio:
image: minio/minio:RELEASE.2023-03-20T20-16-18Z
environment:
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
standalone:
image: milvusdb/milvus:v2.3.0
command: ["milvus", "run", "standalone"]
depends_on: [etcd, minio]
ports:
- "19530:19530"
启动命令:
docker-compose up -d
3.2 Qdrant部署(Docker)
docker run -d \
--name qdrant \
-p 6333:6333 \
-p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage \
qdrant/qdrant:v1.7.3 \
--disable-telemetry
3.3 索引参数选择
- Milvus:IVF_FLAT索引,
nlist=1024,搜索时nprobe=16 - Qdrant:HNSW索引,
m=16,ef_construct=200,搜索时ef=64
4. 核心实现:插入与检索代码
4.1 批量插入800万向量
Milvus插入:
from pymilvus import connections, Collection, CollectionSchema, FieldSchema, DataType
import numpy as np
connections.connect(host='localhost', port='19530')
dim = 512
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=False),
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=dim),
FieldSchema(name="category", dtype=DataType.INT64)
]
schema = CollectionSchema(fields, "product images")
collection = Collection("products", schema)
# 创建IVF_FLAT索引
index_params = {
"metric_type": "L2",
"index_type": "IVF_FLAT",
"params": {"nlist": 1024}
}
collection.create_index("embedding", index_params)
collection.load()
# 分批插入,每批5000条
batch_size = 5000
total = 8_000_000
for i in range(0, total, batch_size):
vectors = np.random.randn(batch_size, dim).astype(np.float32)
ids = list(range(i, i+batch_size))
categories = np.random.randint(0, 100, batch_size).tolist()
entities = [ids, vectors, categories]
collection.insert(entities)
print(f"Milvus插入完成,共{collection.num_entities}条")
Qdrant插入:
from qdrant_client import QdrantClient
from qdrant_client.http import models
import numpy as np
client = QdrantClient("localhost", port=6333)
# 创建集合
client.recreate_collection(
collection_name="products",
vectors_config=models.VectorParams(size=512, distance=models.Distance.COSINE),
optimizers_config=models.OptimizersConfigDiff(memmap_threshold=20000)
)
# 创建HNSW索引
client.update_collection(
collection_name="products",
optimizer_config=models.OptimizersConfigDiff(indexing_threshold=10000)
)
# 分批插入
batch_size = 5000
total = 8_000_000
for i in range(0, total, batch_size):
vectors = np.random.randn(batch_size, dim).astype(np.float32)
ids = list(range(i, i+batch_size))
points = [
models.PointStruct(id=idx, vector=vec.tolist(), payload={"category": int(np.random.randint(0,100))})
for idx, vec in zip(ids, vectors)
]
client.upsert(collection_name="products", points=points)
print("Qdrant插入完成")
4.2 性能测试代码
import time
# 测试搜索延迟
def test_search(db_type):
query = np.random.randn(512).astype(np.float32)
top_k = 100
if db_type == 'milvus':
collection.load()
start = time.perf_counter()
results = collection.search(
data=[query],
anns_field="embedding",
param={"metric_type": "L2", "params": {"nprobe": 16}},
limit=top_k
)
elapsed = time.perf_counter() - start
return elapsed*1000 # ms
elif db_type == 'qdrant':
start = time.perf_counter()
results = client.search(
collection_name="products",
query_vector=query.tolist(),
limit=top_k,
search_params=models.SearchParams(hnsw_ef=64)
)
elapsed = time.perf_counter() - start
return elapsed*1000
# 运行100次取平均
for db in ['milvus', 'qdrant']:
times = [test_search(db) for _ in range(100)]
avg = sum(times)/len(times)
print(f"{db} 平均搜索延迟: {avg:.2f}ms")
5. 踩坑与优化记录
5.1 Milvus的“不动明王”问题
插入800万条后,collection.load()耗时高达47秒!解决方案:
- 使用replica_number参数预加载:collection.load(replica_number=1)
- 或将query_node的cache_capacity从默认4G提升到16G(milvus.yaml中修改)
5.2 Qdrant的内存爆炸
(踩坑)默认配置下,Qdrant会把所有向量加载到内存,800万5124B ≈ 16GB,加上HNSW图结构,实际占用35GB。优化方案:
# qdrant_config.yaml
storage:
optimizers:
default_segment_number: 5
memmap_threshold_kb: 20000 # 超过20MB的段使用mmap
重启后内存占用降至8.2GB,但查询延迟从3ms升到7ms。
5.3 索引构建时间对比
| 阶段 | Milvus | Qdrant |
|---|---|---|
| 插入800万向量 | 18min22s | 12min07s |
| 索引构建/合并 | 9min15s (后台自动) | 4min30s (主动合并) |
| 首次加载 | 47s | 即时查询(无需显式加载) |
6. 效果数据:延迟与资源占用
6.1 查询延迟对比
| 数据量 | Milvus (nprobe=16) | Qdrant (ef=64) |
|---|---|---|
| 10万 | 2.1ms | 1.8ms |
| 100万 | 5.4ms | 3.9ms |
| 800万 | 18.7ms | 7.2ms |
| 亿级(模拟) | 45ms (需要分布式) | 单节点无法支持 |
6.2 资源占用(800万数据稳态)
| 指标 | Milvus (standalone) | Qdrant (single) |
|---|---|---|
| CPU平均 | 45% (4核) | 22% (2核) |
| 内存占用 | 28.6GB | 8.2GB (优化后) |
| 磁盘占用 | 6.8GB (含etcd+minio) | 4.2GB |
| 响应P99 | 32ms | 12ms |
6.3 召回率
在相同数据集上测试top-100召回率:
- Milvus IVF_FLAT (nprobe=16) : 0.943
- Qdrant HNSW (ef=64) : 0.957
(以暴力搜索Brute Force结果为ground truth)
7. 总结:选型建议
- 数据量5000万需要分布式:必须上Milvus。Qdrant的分布式功能(v1.8+)尚不成熟,而Milvus的存算分离、多副本机制在亿级场景更稳定。
- 预算敏感场景:Qdrant单节点只需一台机器,Milvus需要至少三台(etcd+minio+standalone)。
- 运维复杂度:Qdrant一个二进制文件搞定,Milvus依赖组件过多,建议用Milvus Operator上K8s。
最后的建议:不要听信任何“银弹”。我们用Milvus跑了三个月,因为内存开销大被迫升配;换Qdrant后同样满足需求,每年省了5万云资源费。向量数据库选型本质是数据规模、硬件成本和延迟容忍度的三角博弈。