1. 业务背景与选型困境

今年Q2我们团队接手了一个服装电商平台的“以图搜图”功能。业务要求:现有商品图库约120万张,每张图用ResNet50提取1024维浮点向量,需要实现基于余弦相似度的Top-10检索,响应时间控制在100ms以内(含网络开销)。

技术栈初步筛选时,VectorDB赛道主流选型是Milvus和Qdrant。作为团队里负责基础组件的,我决定不盲从社区热度,而是自建一套测试环境,从部署体验、写入性能、查询延迟、资源占用四个维度做AB对比。

需要提前说明:两个库都是优秀的开源向量数据库,但定位不同——Milvus更像一个分布式数据库系统,Qdrant更偏向轻量级嵌入式引擎。我在选型对比中用了单机部署模式,因为业务初期数据量不大,暂时不需要分布式。

2. 测试环境与版本锁定

为了避免版本差异导致的偏差,我统一使用Docker部署,机器配置如下:
- 云服务器:4C8G(实际通过swap扩展到16G内存,但尽量压测真实物理内存边界)
- 磁盘:200GB SSD(IOPS约5000)
- 操作系统:Ubuntu 20.04 LTS,内核5.4

选用的具体版本(均为本文发布时的最新稳定版):
- Milvus:v2.4.0-rc.1(standalone模式)
- Qdrant:v1.8.4(单节点模式)
- Python SDK:milvus 2.3.6 / qdrant-client 1.9.1

向量维度统一为1024,距离度量使用余弦相似度。数据集:120万条商品向量,每个向量附带3个标量字段(商品ID、类目ID、上架时间)。

3. 部署步骤:Docker Compose完整配置

Milvus部署(Standalone模式)

Milvus 2.4引入了新的消息存储架构,但单机部署依然推荐官方提供的milvus.yaml配置。我修改了关键参数以适配4核环境:

# docker-compose-milvus.yaml
version: '3.5'
services:
  etcd:
    image: quay.io/coreos/etcd:v3.5.5
    environment:
      - ETCD_AUTO_COMPACTION_MODE=revision
      - ETCD_AUTO_COMPACTION_RETENTION=1000
      - ETCD_QUOTA_BACKEND_BYTES=4294967296
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/etcd:/etcd
  minio:
    image: minio/minio:RELEASE.2023-03-20T20-16-18Z
    environment:
      MINIO_ACCESS_KEY: minioadmin
      MINIO_SECRET_KEY: minioadmin
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/minio:/minio_data
    command: minio server /minio_data
  standalone:
    image: milvusdb/milvus:v2.4.0-rc.1
    command: ["milvus", "run", "standalone"]
    environment:
      ETCD_ENDPOINTS: etcd:2379
      MINIO_ADDRESS: minio:9000
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus
    ports:
      - "19530:19530"
    depends_on:
      - etcd
      - minio

启动命令:docker-compose -f docker-compose-milvus.yaml up -d
坑点:首次启动etcd会拉取镜像较慢,建议提前docker pull

Qdrant部署(单节点模式)

Qdrant的配置文件更简洁,官方提供的config.yaml几乎不需要修改,只需调整内存用量:

# docker-compose-qdrant.yaml
version: '3.5'
services:
  qdrant:
    image: qdrant/qdrant:v1.8.4
    ports:
      - "6333:6333"
      - "6334:6334"
    volumes:
      - ./qdrant_storage:/qdrant/storage
    environment:
      - QDRANT__SERVICE__GRPC_PORT=6334
      - QDRANT__STORAGE__OPTIMIZER__DEFAULT_SEGMENT_NUMBER=2
      - QDRANT__STORAGE__OPTIMIZER__MEMORY_LIMIT=4294967296
    command: ["./qdrant", "--config", "./config/production.yaml"]

注意MEMORY_LIMIT设置为4GB,这能防止Qdrant在写入时过度占用内存。启动同样简单:docker-compose -f docker-compose-qdrant.yaml up -d

4. 核心实现:数据写入与查询代码

Milvus SDK写入示例

from pymilvus import connections, Collection, FieldSchema, CollectionSchema, DataType, utility

# 1. 连接
connections.connect(host='localhost', port='19530')

# 2. 创建集合
fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True),
    FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=1024),
    FieldSchema(name="product_id", dtype=DataType.VARCHAR, max_length=64),
    FieldSchema(name="category_id", dtype=DataType.INT64)
]
schema = CollectionSchema(fields, "商品向量集合")
collection = Collection(name="product_vectors", schema=schema)

# 3. 创建索引(HNSW参数:M=16, efConstruction=200)
index_params = {
    "metric_type": "COSINE",
    "index_type": "HNSW",
    "params": {"M": 16, "efConstruction": 200}
}
collection.create_index(field_name="embedding", index_params=index_params)
collection.load()

# 4. 批量写入(每次1000条)
import numpy as np
batch_size = 1000
vectors = np.random.rand(batch_size, 1024).astype(np.float32)
product_ids = [f"prod_{i}" for i in range(batch_size)]
categories = [i % 50 for i in range(batch_size)]

entities = [
    vectors.tolist(),
    product_ids,
    categories
]
insert_result = collection.insert(entities)
print(f"Milvus inserted {len(insert_result.primary_keys)} vectors")

Qdrant SDK写入示例

from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance, PointStruct
import numpy as np

# 1. 连接(使用gRPC端口)
client = QdrantClient(host="localhost", port=6334, prefer_grpc=True)

# 2. 创建集合
client.recreate_collection(
    collection_name="product_vectors",
    vectors_config=VectorParams(size=1024, distance=Distance.COSINE),
    optimizers_config={
        "default_segment_number": 2,  # 减少段数,降低内存碎片
        "memmap_threshold_kb": 128   # 磁盘映射阈值
    }
)

# 3. 批量写入(Python SDK自动分片)
batch_size = 1000
points = []
for i in range(batch_size):
    point = PointStruct(
        id=i,
        vector=np.random.rand(1024).astype(np.float32).tolist(),
        payload={
            "product_id": f"prod_{i}",
            "category_id": i % 50
        }
    )
    points.append(point)
client.upsert(collection_name="product_vectors", points=points)
print(f"Qdrant upserted {len(points)} vectors")

5. 踩坑与优化:段合并死锁与HNSW参数调优

Milvus的“段合并死锁”

在写入约50万条数据后,Milvus的查询突然卡死,日志显示segment merge stuck。排查发现是Milvus的自动段合并机制在高负载写入下产生了死锁——默认的segmentRowLimit=4096导致小段过多,合并线程无法获取锁。

解决方案
- 在milvus.yaml中增大segmentRowLimit到16384
- 手动设置indexBuildingTaskPoolSize=2(4核机器不宜过高)
- 写入时使用collection.flush()强制落盘,减少内存中的未合并段

调整后写入稳定,但代价是索引构建时间从2小时延长到3.5小时。

Qdrant的HNSW参数调优

Qdrant默认的HNSW参数m=16, ef_construct=100在召回率上勉强能达到95%,但Top-10查询中偶尔出现无效结果(余弦相似度低于0.7)。我将ef_construct提升到200,召回率提升到98.5%,但索引构建时间增加了40%。

关键参数

client.create_payload_index(
    collection_name="product_vectors",
    field_name="category_id",
    field_schema="integer"
)
# HNSW索引参数在创建集合时指定
vectors_config=VectorParams(
    size=1024, 
    distance=Distance.COSINE,
    hnsw_config={
        "m": 16,
        "ef_construct": 200,
        "full_scan_threshold": 10000  # 当候选数少于1万时全扫描
    }
)

6. 性能实测数据(120万向量)

指标 Milvus v2.4.0 Qdrant v1.8.4
批量写入吞吐量(1000条/批) 4200 QPS 2800 QPS
单条写入延迟(P99) 8ms 12ms
Top-10查询延迟(P50) 18ms 12ms
Top-10查询延迟(P99) 45ms 28ms
内存占用(进程) 12.3GB 4.8GB
磁盘占用 18.7GB 22.1GB
召回率(Recall@10) 97.2% 98.5%

解读
1. 写入性能:Milvus凭借其日志结构的合并树(LSM-like)架构,批量写入远快于Qdrant。但代价是内存占用飙升,12GB已经逼近物理内存上限导致swap频繁。
2. 查询性能:Qdrant在低内存下表现更好,因为它的向量索引直接使用mmap映射到磁盘,而Milvus需要将索引全部加载到内存。在P99延迟上Qdrant明显更稳定。
3. 召回率:两个库在调优后都能达到97%以上,但Qdrant的HNSW实现默认参数更保守,稍作调整就能达到98.5%。

7. 总结与选型建议

如果你是资源受限的单机部署(内存5000 QPS),Milvus的分布式能力(依赖etcd和MinIO)更具优势。但要做好心理准备:Milvus的学习曲线更陡,内存优化需要精细化配置,建议至少32GB内存起步。

最终的选型结果:我们团队选择了Qdrant,因为业务初期单机足以应对,并且运维成本低。后续如果数据量突破500万,再考虑迁移到Milvus集群或基于Qdrant的分片方案。

最后提一句:不要迷信任何VectorDB的Benchmark数字,用自己的数据、在自己的机器上跑一遍才是真理。毕竟,向量检索的性能跟数据分布、维度、索引参数、硬件配置强相关,别人的数字只是参考。