向量数据库选型实战:500万向量规模下的性能对比
2023年下半年,公司要做一个大规模知识库检索系统,预估向量数量500万+,QPS要求200+。为了选型,我花了两周时间对比测试了Milvus、Qdrant、Weaviate、Pinecone四个方案。这篇文章记录完整的测试过程和结论。
背景:我们的需求
项目是一个企业级知识问答系统,具体需求:
- 向量规模:初期500万,一年内可能到2000万
- 向量维度:1024维(BGE-large)
- QPS要求:日常100,峰值200
- 召回精度:Top-10召回率要求95%以上
- 延迟要求:P99 < 100ms
- 部署方式:私有化部署,不能用SaaS
候选方案
| 数据库 | 开源 | 语言 | 索引算法 | 部署复杂度 |
|---|---|---|---|---|
| Milvus 2.3 | ✅ Apache 2.0 | Go/C++ | IVF, HNSW, DiskANN | 高 |
| Qdrant 1.6 | ✅ Apache 2.0 | Rust | HNSW | 低 |
| Weaviate 1.22 | ✅ BSD-3 | Go | HNSW | 中 |
| Pinecone | ❌ SaaS | - | 自研 | 最低(托管) |
Pinecone因为不能私有化部署,只作为性能参考。
测试环境
硬件配置
# 测试服务器 (3台相同配置)
CPU: AMD EPYC 7742 64核
内存: 256GB DDR4
存储: 2TB NVMe SSD
网络: 25Gbps
# 客户端服务器
同上配置,用于发压
测试数据
import numpy as np
import h5py
def generate_test_data(num_vectors: int, dim: int, output_path: str):
"""
生成测试向量数据
使用正态分布,模拟真实embedding
"""
# 生成向量
vectors = np.random.randn(num_vectors, dim).astype(np.float32)
# L2归一化(模拟真实embedding)
norms = np.linalg.norm(vectors, axis=1, keepdims=True)
vectors = vectors / norms
# 生成查询向量(从数据中随机抽取1000个)
query_indices = np.random.choice(num_vectors, 1000, replace=False)
queries = vectors[query_indices]
# Ground truth (brute force计算)
# 对于大规模数据,这一步很耗时
print("Computing ground truth...")
ground_truth = compute_ground_truth(vectors, queries, k=100)
# 保存
with h5py.File(output_path, 'w') as f:
f.create_dataset('vectors', data=vectors)
f.create_dataset('queries', data=queries)
f.create_dataset('ground_truth', data=ground_truth)
print(f"Generated {num_vectors} vectors, saved to {output_path}")
def compute_ground_truth(vectors, queries, k=100):
"""暴力计算ground truth"""
import faiss
index = faiss.IndexFlatIP(vectors.shape[1]) # Inner product
index.add(vectors)
distances, indices = index.search(queries, k)
return indices
# 生成500万向量测试集
generate_test_data(
num_vectors=5_000_000,
dim=1024,
output_path="test_data_5m.h5"
)
测试一:数据导入性能
Milvus导入
from pymilvus import connections, Collection, FieldSchema, CollectionSchema, DataType, utility
def milvus_import(vectors, batch_size=10000):
connections.connect("default", host="localhost", port="19530")
# 定义Schema
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True),
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=1024)
]
schema = CollectionSchema(fields)
collection = Collection("test_collection", schema)
# 批量导入
start_time = time.time()
for i in range(0, len(vectors), batch_size):
batch = vectors[i:i+batch_size]
ids = list(range(i, i+len(batch)))
collection.insert([ids, batch.tolist()])
if (i // batch_size) % 100 == 0:
print(f"Imported {i+len(batch)}/{len(vectors)}")
# 创建索引
index_params = {
"metric_type": "IP",
"index_type": "HNSW",
"params": {"M": 16, "efConstruction": 200}
}
collection.create_index("embedding", index_params)
collection.load()
elapsed = time.time() - start_time
return elapsed
Qdrant导入
from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance, PointStruct
def qdrant_import(vectors, batch_size=1000):
client = QdrantClient(host="localhost", port=6333)
# 创建Collection
client.recreate_collection(
collection_name="test_collection",
vectors_config=VectorParams(
size=1024,
distance=Distance.COSINE,
hnsw_config={"m": 16, "ef_construct": 200}
)
)
# 批量导入
start_time = time.time()
for i in range(0, len(vectors), batch_size):
batch = vectors[i:i+batch_size]
points = [
PointStruct(id=i+j, vector=vec.tolist())
for j, vec in enumerate(batch)
]
client.upsert(collection_name="test_collection", points=points)
if (i // batch_size) % 100 == 0:
print(f"Imported {i+len(batch)}/{len(vectors)}")
elapsed = time.time() - start_time
return elapsed
导入性能对比
| 数据库 | 500万向量导入时间 | 导入速度 | 索引构建时间 |
|---|---|---|---|
| Milvus | 47分钟 | ~1800 vec/s | 包含在内 |
| Qdrant | 63分钟 | ~1320 vec/s | 包含在内 |
| Weaviate | 82分钟 | ~1015 vec/s | 包含在内 |
测试二:查询性能
测试脚本
import asyncio
import aiohttp
import time
from typing import List
import numpy as np
async def benchmark_search(
client,
queries: np.ndarray,
top_k: int = 10,
num_threads: int = 50,
duration_seconds: int = 60
):
"""
压测搜索性能
Returns:
latencies: 所有请求的延迟列表
qps: 实际QPS
"""
latencies = []
query_count = 0
start_time = time.time()
async def single_search(query):
nonlocal query_count
req_start = time.time()
await client.search(query, top_k)
latency = (time.time() - req_start) * 1000
latencies.append(latency)
query_count += 1
# 创建worker
async def worker():
while time.time() - start_time < duration_seconds:
query = queries[np.random.randint(len(queries))]
await single_search(query)
# 并发执行
tasks = [asyncio.create_task(worker()) for _ in range(num_threads)]
await asyncio.gather(*tasks)
elapsed = time.time() - start_time
qps = query_count / elapsed
return {
"qps": qps,
"latency_avg": np.mean(latencies),
"latency_p50": np.percentile(latencies, 50),
"latency_p95": np.percentile(latencies, 95),
"latency_p99": np.percentile(latencies, 99),
"total_queries": query_count
}
查询性能对比 (500万向量, top_k=10)
| 数据库 | QPS | P50 (ms) | P95 (ms) | P99 (ms) |
|---|---|---|---|---|
| Milvus (HNSW) | 487 | 8.2 | 23.5 | 45.8 |
| Milvus (IVF_FLAT) | 312 | 12.8 | 38.2 | 72.3 |
| Qdrant | 423 | 9.5 | 27.8 | 52.1 |
| Weaviate | 285 | 14.2 | 42.5 | 89.6 |
| Pinecone (p2) | ~600 | ~6 | ~18 | ~35 |
Pinecone作为托管服务,性能确实最好,但价格也最贵(500万向量月费约$350)。在自建方案中,Milvus+HNSW性能最佳。
测试三:召回精度
精度测试代码
def compute_recall(retrieved: List[int], ground_truth: List[int], k: int) -> float:
"""
计算Recall@k
retrieved: 召回的ID列表
ground_truth: 真实最近邻ID列表
"""
retrieved_set = set(retrieved[:k])
gt_set = set(ground_truth[:k])
return len(retrieved_set & gt_set) / k
def benchmark_recall(client, queries, ground_truth, k=10):
"""测试召回率"""
recalls = []
for i, query in enumerate(queries):
results = client.search(query, k)
retrieved_ids = [r.id for r in results]
recall = compute_recall(retrieved_ids, ground_truth[i].tolist(), k)
recalls.append(recall)
return {
"recall_avg": np.mean(recalls),
"recall_min": np.min(recalls),
"recall_max": np.max(recalls)
}
召回精度对比
| 数据库/索引 | Recall@10 | Recall@100 | ef_search |
|---|---|---|---|
| Milvus HNSW (ef=64) | 96.8% | 98.2% | 64 |
| Milvus HNSW (ef=128) | 98.5% | 99.1% | 128 |
| Milvus IVF_FLAT (nprobe=32) | 94.2% | 96.8% | - |
| Qdrant HNSW (ef=64) | 96.5% | 98.0% | 64 |
| Weaviate HNSW | 95.8% | 97.5% | 默认 |
-
M: 每个节点的最大连接数,越大精度越高,但内存和构建时间也越大,推荐16-64-
efConstruction: 构建时的搜索宽度,越大索引质量越高,推荐100-500-
ef_search: 查询时的搜索宽度,越大精度越高但速度越慢,推荐64-256
测试四:内存和磁盘占用
| 数据库 | 内存占用 | 磁盘占用 | 备注 |
|---|---|---|---|
| Milvus | 52GB | 23GB | HNSW索引全部加载到内存 |
| Qdrant | 48GB | 21GB | 支持mmap模式可降低内存 |
| Weaviate | 67GB | 28GB | 内存占用较高 |
500万×1024维向量的原始大小约19GB。HNSW索引会额外占用内存存储图结构。Weaviate内存占用较高,如果机器内存有限需要注意。
测试五:运维复杂度
Milvus部署
# Milvus需要依赖etcd和MinIO
# docker-compose.yml
version: '3.5'
services:
etcd:
image: quay.io/coreos/etcd:v3.5.5
# ... etcd配置
minio:
image: minio/minio:latest
# ... minio配置
milvus:
image: milvusdb/milvus:v2.3.0
depends_on:
- etcd
- minio
# ... milvus配置
# 生产环境建议:
# - etcd: 3节点集群
# - MinIO: 4节点以上
# - Milvus: 分离coordinator和node
Qdrant部署
# Qdrant单机部署很简单
docker run -p 6333:6333 -v $(pwd)/qdrant_storage:/qdrant/storage qdrant/qdrant
# 集群模式
docker run -p 6333:6333 \
-e QDRANT__CLUSTER__ENABLED=true \
-e QDRANT__CLUSTER__P2P__PORT=6335 \
qdrant/qdrant
运维复杂度评分
| 维度 | Milvus | Qdrant | Weaviate |
|---|---|---|---|
| 单机部署 | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| 集群部署 | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
| 扩缩容 | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
| 监控告警 | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ |
| 文档质量 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
混合检索支持
除了向量检索,我们还需要支持过滤条件(如按时间范围、按分类)。这就需要支持hybrid search。
Milvus过滤
# Milvus支持标量过滤
collection.search(
data=[query_vector],
anns_field="embedding",
param={"metric_type": "IP", "params": {"ef": 64}},
limit=10,
expr='category == "tech" and timestamp > 1700000000' # 过滤表达式
)
Qdrant过滤
# Qdrant的过滤语法更直观
client.search(
collection_name="test",
query_vector=query_vector,
query_filter=models.Filter(
must=[
models.FieldCondition(
key="category",
match=models.MatchValue(value="tech")
),
models.FieldCondition(
key="timestamp",
range=models.Range(gt=1700000000)
)
]
),
limit=10
)
过滤性能对比
| 过滤比例 | Milvus QPS | Qdrant QPS |
|---|---|---|
| 无过滤 | 487 | 423 |
| 过滤50% | 412 | 385 |
| 过滤90% | 298 | 312 |
| 过滤99% | 145 | 178 |
过滤比例越高,性能下降越明显。Qdrant在高过滤比例下表现更好,可能是因为它的过滤实现更高效。
最终选择:Milvus
决策过程
综合考虑后,我们选择了Milvus,原因:
- 性能最好:QPS和延迟都是最优
- 功能完整:支持多种索引类型,可以根据场景切换
- 生态成熟:有Attu管理界面,监控完善
- 企业背书:Zilliz提供商业支持,有问题可以找人
没选Qdrant的原因:虽然部署简单,但我们担心在大规模场景下的稳定性(当时1.6版本还比较新)。另外Qdrant的社区相对较小,遇到问题可能找不到解决方案。
- 向量 < 100万,团队小,追求简单 → 选Qdrant
- 向量 100万-1000万,需要稳定性 → 选Milvus
- 需要多模态(图文混合) → 选Weaviate
- 不想自运维,预算充足 → 选Pinecone
上线后的经验
遇到的问题
问题1:Compaction导致的查询抖动
Milvus后台的compaction会合并segment,期间可能导致P99延迟飙升。解决方案:把compaction放到凌晨低峰期执行。
# 手动触发compaction
from pymilvus import utility
utility.do_compaction("test_collection")
问题2:etcd磁盘写满
etcd的WAL日志默认不自动清理,跑了一个月磁盘满了。解决方案:配置auto-compaction。
# etcd配置
ETCD_AUTO_COMPACTION_MODE=revision
ETCD_AUTO_COMPACTION_RETENTION=1000
问题3:批量更新慢
每天有约10万向量需要更新(文档修改),但Milvus的upsert比较慢。解决方案:改成delete+insert。
监控指标
# Prometheus指标(重点关注)
milvus_proxy_search_latency_bucket # 搜索延迟分布
milvus_querynode_sq_latency_bucket # 查询节点延迟
milvus_datanode_flush_duration # 刷盘耗时
milvus_memory_usage_bytes # 内存使用
# 告警规则
- alert: MilvusSearchLatencyHigh
expr: histogram_quantile(0.99, milvus_proxy_search_latency_bucket) > 100
for: 5m
labels:
severity: warning
参考资料
总结
向量数据库选型没有绝对的"最好",要根据具体场景:
- 先明确需求:规模、QPS、延迟、精度、预算
- 用真实数据测试:官方benchmark不代表你的场景
- 关注运维复杂度:性能再好,运维不起来也白搭
- 预留扩展空间:数据增长往往超预期
更新记录:
2023-12-05: 初版发布
2024-02-15: 更新Milvus 2.3测试数据
2024-05-10: 补充上线后遇到的问题
2024-08-20: 更新Qdrant 1.9性能数据(比1.6提升约15%)