一、问题背景:为什么需要向量数据库选型

去年下半年我们团队接手了一个电商图片搜索功能,要求对100万张商品图提取特征向量(512维),实现以图搜图。最初用Faiss本地方案,但维护索引版本、支持增量更新、高并发查询都很痛苦。于是开始调研主流的向量数据库。

业务核心需求:单机部署(初期数据量不大)、支持高并发读(QPS 100+)、增量数据实时可见、内存占用可控。最终候选锁定Milvus和Qdrant,两者都是当前社区活跃度高的开源方案。

二、环境与版本

测试服务器配置:
- CPU: Intel Xeon Platinum 8260 4核
- 内存: 8GB
- 磁盘: 100GB SSD(云盘)
- OS: Ubuntu 22.04 LTS, Kernel 5.15
- Docker: 24.0.7
- Docker Compose: v2.23.0

软件版本:
- Milvus: 2.3.0(standalone模式)
- Qdrant: 1.7.3(单节点模式)
- Python客户端: pymilvus 2.3.0 / qdrant-client 1.7.3
- 测试数据:100,000条512维float向量,随机生成

三、方案设计:两套部署对比

3.1 Milvus部署

Milvus 2.3采用了存算分离架构,但standalone模式把所有组件打包在一个容器里。直接上docker-compose.yml:

version: '3.5'
services:
  etcd:
    image: quay.io/coreos/etcd:v3.5.5
    container_name: milvus-etcd
    environment:
      - ETCD_AUTO_COMPACTION_MODE=revision
      - ETCD_AUTO_COMPACTION_RETENTION=1000
      - ETCD_QUOTA_BACKEND_BYTES=4294967296
    volumes:
      - /data/milvus/etcd:/etcd

  minio:
    image: minio/minio:RELEASE.2023-03-20T20-16-18Z
    container_name: milvus-minio
    environment:
      MINIO_ACCESS_KEY: minioadmin
      MINIO_SECRET_KEY: minioadmin
    volumes:
      - /data/milvus/minio:/data
    command: minio server /data

  standalone:
    image: milvusdb/milvus:v2.3.0
    container_name: milvus-standalone
    command: ["milvus", "run", "standalone"]
    environment:
      ETCD_ENDPOINTS: etcd:2379
      MINIO_ADDRESS: minio:9000
    ports:
      - "19530:19530"
    volumes:
      - /data/milvus/data:/var/lib/milvus

启动:docker-compose up -d。注意首次启动需要拉三个镜像,约2GB。

3.2 Qdrant部署

Qdrant就轻量多了,单容器搞定:

version: '3.5'
services:
  qdrant:
    image: qdrant/qdrant:v1.7.3
    container_name: qdrant
    ports:
      - "6333:6333"
      - "6334:6334"
    volumes:
      - /data/qdrant/storage:/qdrant/storage
    environment:
      - QDRANT__SERVICE__GRPC_PORT=6334
    command: ["./qdrant", "--uri", "http://0.0.0.0:6333"]

启动:docker-compose up -d。镜像约200MB,启动速度比Milvus快一倍。

四、核心实现:数据写入与查询

4.1 数据准备

生成100,000条512维float向量,每条关联一个随机商品ID。下面是用Python批量写入Qdrant的代码:

from qdrant_client import QdrantClient
from qdrant_client.http import models
import numpy as np

client = QdrantClient(host="localhost", port=6333)

# 创建集合,使用Cosine距离
client.recreate_collection(
    collection_name="products",
    vectors_config=models.VectorParams(
        size=512,
        distance=models.Distance.COSINE
    ),
    optimizers_config=models.OptimizersConfigDiff(
        default_segment_number=2,  # 控制内存分段数
    )
)

# 批量写入100,000条
BATCH_SIZE = 256
for i in range(0, 100000, BATCH_SIZE):
    vectors = np.random.rand(BATCH_SIZE, 512).astype(np.float32)
    payloads = [{"product_id": f"P{j:06d}"} for j in range(i, i+BATCH_SIZE)]
    ids = list(range(i, i+BATCH_SIZE))

    client.upsert(
        collection_name="products",
        points=models.Batch(
            ids=ids,
            vectors=vectors.tolist(),
            payloads=payloads
        )
    )
    if i % 10000 == 0:
        print(f"Inserted {i} records")

Milvus的写法类似,但需要先创建索引。注意Milvus默认不会自动建索引,必须手动调用collection.create_index(),否则查询会全量扫描。

4.2 查询性能测试

写一个简单的压测函数,分别查询1000次,记录延迟分布:

import time
from pymilvus import connections, Collection

# Milvus查询示例
connections.connect(host="localhost", port="19530")
collection = Collection("products")
collection.load()  # 加载到内存

query_vector = np.random.rand(512).astype(np.float32).tolist()
latencies = []

for _ in range(1000):
    start = time.perf_counter()
    results = collection.search(
        data=[query_vector],
        anns_field="embedding",
        param={"metric_type": "COSINE", "params": {"nprobe": 8}},
        limit=10,
    )
    latencies.append((time.perf_counter() - start) * 1000)  # ms

print(f"Milvus p50: {np.percentile(latencies, 50):.2f}ms, p99: {np.percentile(latencies, 99):.2f}ms")

Qdrant的查询代码类似,这里不重复贴了。

五、踩坑与优化

5.1 Milvus的「内存暴涨」问题

第一次测试时,往Milvus写入50万向量后,内存直接从2GB飙升到7.9GB,然后OOM被杀。排查发现Milvus的load()操作会把整个集合加载到内存,且默认的index_type是IVF_FLAT,内存占用高。解决办法:

  • 改用HNSW索引,内存占用降低40%
  • 设置collection.load(replica_number=1),控制加载副本数
  • 核心配置:index_param = {"M": 16, "efConstruction": 200}

5.2 Qdrant的「分段管理」

Qdrant默认每个分段(segment)会独立构建索引,如果分段数过多(>10),查询时会遍历所有分段,导致延迟飙升。优化方式:

  • 写入后手动触发client.update_collection(optimizers_config={"default_segment_number": 1})合并分段
  • 或者设置optimizers_config中的memmap_threshold_kb为0,强制使用mmap减少内存占用

5.3 数据一致性差异

一个坑:Milvus在插入后立即查询可能查不到最新数据,因为数据先写入消息队列再批量刷盘。而Qdrant写入时默认wait=True则保证实时可见。这对我们的增量更新场景很重要,最终选择了Qdrant。

六、效果数据:实测数字说话

测试条件:100,000条512维向量,查询top-10,并发10线程,持续压测5分钟。

指标 Milvus 2.3.0 (HNSW) Qdrant 1.7.3 (HNSW)
部署镜像大小 2.1 GB (3容器) 198 MB (1容器)
内存占用 (空闲) 1.2 GB 380 MB
内存占用 (加载后) 3.8 GB 2.6 GB
QPS (并发10) 2350 1980
p50 延迟 4.1 ms 5.0 ms
p99 延迟 42.7 ms 68.3 ms
写入速度 (1000条/秒) 2.8秒 1.2秒
增量数据实时可见 需等待2-5秒 实时(写后即可查)

关键发现:
1. Qdrant在资源受限场景更友好:内存占用低30%,镜像体积小一个数量级
2. Milvus高并发下更稳:p99延迟控制在50ms内,Qdrant偶有毛刺到100ms+
3. 写入速度Qdrant快一倍:因为Milvus有etcd和消息队列的写入开销
4. 部署复杂度:Qdrant一个docker-compose文件搞定,Milvus需要三个服务配合

七、总结与选型建议

经过实测,我给出的选型建议:

  • 选Qdrant:如果你的场景是中小规模(<500万向量)、单机部署、对内存敏感、需要增量数据实时可见。Rust写的,启动快、部署简单。
  • 选Milvus:如果你有大规模集群需求(千万级以上)、需要高QPS稳定服务、团队有Kubernetes运维能力。Milvus的分布式架构在水平扩展上更成熟。

我们最终选择了Qdrant,因为初期数据量只有100万,云服务器成本敏感,而且需要支持运营后台实时上传图片后立即能搜到。Qdrant 1.7.3版本在单个4核8G服务器上跑得很稳,后续如果数据涨到500万以上,可以考虑迁移到Milvus集群。

最后一句:选型没有银弹,拿自己真实的数据量跑一遍,比看一百篇对比文章都有用。