← 返回首页

向量数据库选型实战:500万向量规模下的性能对比

2023-12-05 | 向量数据库 RAG Milvus Qdrant
2023年下半年,公司要做一个大规模知识库检索系统,预估向量数量500万+,QPS要求200+。为了选型,我花了两周时间对比测试了Milvus、Qdrant、Weaviate、Pinecone四个方案。这篇文章记录完整的测试过程和结论。

背景:我们的需求

项目是一个企业级知识问答系统,具体需求:

候选方案

数据库 开源 语言 索引算法 部署复杂度
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 包含在内
⚠️ 踩坑: Qdrant的batch_size不能太大,超过5000会OOM。后来发现是客户端序列化的问题,1000是比较安全的值。

测试二:查询性能

测试脚本

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% 默认
💡 HNSW参数说明:
- 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,原因:

  1. 性能最好:QPS和延迟都是最优
  2. 功能完整:支持多种索引类型,可以根据场景切换
  3. 生态成熟:有Attu管理界面,监控完善
  4. 企业背书: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

参考资料

总结

向量数据库选型没有绝对的"最好",要根据具体场景:

  1. 先明确需求:规模、QPS、延迟、精度、预算
  2. 用真实数据测试:官方benchmark不代表你的场景
  3. 关注运维复杂度:性能再好,运维不起来也白搭
  4. 预留扩展空间:数据增长往往超预期

更新记录:
2023-12-05: 初版发布
2024-02-15: 更新Milvus 2.3测试数据
2024-05-10: 补充上线后遇到的问题
2024-08-20: 更新Qdrant 1.9性能数据(比1.6提升约15%)