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=16ef_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_nodecache_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. 总结:选型建议

  1. 数据量5000万需要分布式:必须上Milvus。Qdrant的分布式功能(v1.8+)尚不成熟,而Milvus的存算分离、多副本机制在亿级场景更稳定。
  2. 预算敏感场景:Qdrant单节点只需一台机器,Milvus需要至少三台(etcd+minio+standalone)。
  3. 运维复杂度:Qdrant一个二进制文件搞定,Milvus依赖组件过多,建议用Milvus Operator上K8s。

最后的建议:不要听信任何“银弹”。我们用Milvus跑了三个月,因为内存开销大被迫升配;换Qdrant后同样满足需求,每年省了5万云资源费。向量数据库选型本质是数据规模、硬件成本和延迟容忍度的三角博弈。