1. 业务场景与选型背景

我们做的是电商拍照购,图搜链路需要支持千万级商品图向量检索。业务有三个硬性指标:P99延迟小于20ms,QPS不低于2000,支持每日百万级增量更新。之前用Faiss单机部署,内存吃紧,扩容困难。于是我们决定在Milvus和Qdrant之间做个PK。

选型考量点很具体:
- 部署复杂度:我们团队只有两个后端,没有专职运维,K8s用得也不熟。所以单机Docker部署的友好度很重要。
- 资源占用:线上机器预算有限,8C32G是上限。需要对比相同数据量下的内存与磁盘占用。
- 过滤能力:图搜需要带类目、品牌等标量过滤。不能只比纯向量检索,混合查询能力必须测。
- 数据导入效率:冷启动要导入1000万条数据,导入时间直接决定上线进度。

2. 测试环境与版本

组件 版本 配置
CPU Intel Xeon Platinum 8255C 8核
内存 DDR4 32GB
磁盘 NVMe SSD 500GB
OS Ubuntu 20.04.6 LTS -
Docker 20.10.25 -
Milvus 2.4.1(standalone) 8C16G
Qdrant 1.9.2(单节点) 8C16G
数据集 1000万条 4096维float32
客户端 Python 3.10 pymilvus 2.4.3 / qdrant-client 1.9.1

数据是ResNet50提取的4096维特征,每个向量约16KB,1000万条原始数据约160GB,量化后控制在40GB以内。

3. 部署步骤与参数调优

3.1 Milvus Standalone部署

Milvus 2.4用etcd存储元数据,MinIO存binlog。standalone模式用docker-compose一键起,但注意内存分配。

# docker-compose.yml (Milvus 2.4.1)
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
    command: etcd -advertise-client-urls=http://etcd:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd

  minio:
    image: minio/minio:RELEASE.2023-03-20T20-16-18Z
    environment:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: minioadmin
    command: minio server /minio_data --console-address ":9001"

  milvus:
    image: milvusdb/milvus:v2.4.1
    command: ["milvus", "run", "standalone"]
    environment:
      ETCD_ENDPOINTS: etcd:2379
      MINIO_ADDRESS: minio:9000
      # 关键:限制内存,防止OOM
      KNOWHERE_GPU_MEM_SIZE: 0
      MILVUS_MSG_SIZE: 512
    ports:
      - "19530:19530"
    depends_on:
      - etcd
      - minio

启动后创建collection,索引参数是关键:

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

connections.connect(alias="default", host="localhost", port="19530")

fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True),
    FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=4096),
    FieldSchema(name="category", dtype=DataType.INT64),  # 标量过滤字段
    FieldSchema(name="brand", dtype=DataType.VARCHAR, max_length=64)
]
schema = CollectionSchema(fields, "product image vector")
collection = Collection("product_search", schema)

# index参数直接影响查询性能
index_params = {
    "index_type": "IVF_FLAT",  # 不用IVF_SQ8,4096维下精度损失大
    "metric_type": "IP",
    "params": {"nlist": 4096}
}
collection.create_index("vector", index_params)
collection.load()

踩坑记录:Milvus 2.4默认的KNOWHERE_GPU_MEM_SIZE不设的话,如果你机器上有GPU但显存不够,会直接崩。另外MILVUS_MSG_SIZE控制消息队列大小,默认512KB,高并发下千万量级建议调大到1024。我们实测调大后P99下降3ms。

3.2 Qdrant单节点部署

Qdrant部署更简单,一个二进制文件搞定,官方镜像也就40MB。

# Qdrant 1.9.2 单节点部署
docker run -d \
  --name qdrant \
  -p 6333:6333 \
  -p 6334:6334 \
  -v $(pwd)/qdrant_storage:/qdrant/storage \
  -e QDRANT__STORAGE__OPTIMIZER__MAX_SEGMENT_SIZE=200MB \
  -e QDRANT__STORAGE__OPTIMIZER__MEMORY_INDEX_THRESHOLD=10000 \
  -e QDRANT__SERVICE__GRPC_PORT=6334 \
  qdrant/qdrant:v1.9.2

创建collection并建索引:

from qdrant_client import QdrantClient
from qdrant_client.models import (
    Distance, VectorParams, CollectionCreateParams, OptimizersConfigDiff
)

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

client.recreate_collection(
    collection_name="product_search",
    vectors_config=VectorParams(size=4096, distance=Distance.DOT),
    optimizers_config=OptimizersConfigDiff(
        indexing_threshold=10000,  # 10000条后自动建索引
        memmap_threshold=20000,
        max_segment_size=200 * 1024 * 1024  # 200MB
    ),
    shard_number=4,  # 单机多分片提升并发
    replication_factor=1
)

关键调优:Qdrant默认的max_segment_size是50MB,对于4096维向量,段太小会导致段数量过多,查询随机寻址开销大。我们调到200MB后,RPS提升约25%。另外shard_number设为4,充分利用多核。

4. 查询性能实测对比

4.1 纯向量TopK查询

测试方式:随机生成1000个查询向量,每个查询跑10次取平均。数据量1000万,TopK=10。

指标 Milvus 2.4.1 Qdrant 1.9.2
QPS(单客户端并发64) 2100 3200
P99延迟(ms) 19.2 12.8
平均延迟(ms) 7.6 5.2
内存占用(used) 18.7GB 14.3GB
磁盘占用 38GB 35GB

Qdrant胜在HNSW索引,Milvus用IVF_FLAT,虽然导入快但查询慢。如果用Milvus的HNSW(index_type: HNSW),QPS能到2800,但索引构建时间从25分钟增加到2小时,且内存多占3GB。

4.2 带标量过滤的混合查询

业务场景:category=1001 AND brand="nike" 过滤后查TopK。

过滤条件 Milvus P99(ms) Qdrant P99(ms)
无过滤 19.2 12.8
过滤后剩10万条 24.5 15.2
过滤后剩5000条 31.8 22.6

Milvus的filter是后置过滤,先向量检索再标量过滤,过滤条件苛刻时耗时暴增。Qdrant的filter是前置的,在segment遍历时就排除不满足条件的向量,效率高很多。这一点在业务场景里非常关键——我们很多查询过滤后只剩几千条,Qdrant的优势非常明显。

5. 数据导入与更新实测

5.1 冷启动导入1000万条

阶段 Milvus耗时 Qdrant耗时
数据预处理(BLOB转向量) 38分钟 38分钟
批量导入+建索引 42分钟 1小时15分钟
总量 80分钟 1小时53分钟

Milvus的IVF_FLAT建索引是分段的,导入速度优势明显。Qdrant的HNSW边插入边建索引,速度慢但这是实时的——导入完就能查,Milvus还要等load。

5.2 增量更新

模拟业务场景:每天新增10万条,删除5万条旧数据。

操作 Milvus Qdrant
插入10万条 12秒 18秒
删除5万条 25秒 8秒
更新向量 不支持(需删+插) 原生支持upsert

Milvus 2.4的delete是标记删除,数据还在磁盘上,要等compact才真正释放空间。我们测试时连续删了3天,磁盘占用只增不减,必须手动触发collection.compact()。Qdrant的delete是即时生效的,segment直接重写。这个差异在频繁删除更新场景下非常致命。

6. 资源占用与稳定性

6.1 内存对比

  • Milvus:常驻内存18.7GB,其中数据段缓存占大头。空闲时内存不释放,通过MILVUS_MSG_SIZEKNOWHERE_ALPHA可以微调。长时间运行有内存碎片问题,我们跑了5天后内存从18GB涨到21GB,只能重启。
  • Qdrant:14.3GB,且空闲时自动释放部分缓存。跑了7天内存稳定在14.5GB左右。memmap_threshold参数控制多少条后切换到mmap模式,有效控制内存峰值。

6.2 CPU占用

  • 查询压力下:Milvus 8核吃满,Qdrant约6核。Qdrant的HNSW图遍历是CPU密集型的,但优化得更好。
  • 导入时:Milvus 4核,Qdrant 7核。Qdrant建HNSW索引时CPU打满。

6.3 稳定性

  • Milvus:出现了两次etcd lease过期导致节点假死,需要重启etcd。社区issue里也有类似反馈。
  • Qdrant:没遇到崩溃,但有一次segment损坏,好在有WAL日志恢复回来了。

7. 总结与选型建议

我们最终选了Qdrant,核心原因是:

  1. 混合查询性能强:带过滤的查询P99稳定在20ms内,Milvus在过滤条件苛刻时会飙到30ms+。
  2. 资源占用更可控:内存稳定,不会像Milvus那样持续涨。
  3. 实时删除更新:upsert原生支持,不用手动compact。
  4. 部署简单:单二进制,没有etcd/MinIO这些外部依赖。

但Milvus不是没有优势:
- 冷导入速度快,适合一次性导入大数据的场景。
- 生态更丰富,有GUI管理界面,监控指标完善。
- 分布式扩展成熟,Qdrant单机版到分布式要换架构。

如果你跟我一样是中小团队,业务以混合查询为主,Qdrant值得一试。 如果你要处理PB级数据且需要流式写入,Milvus的分布式能力更靠谱。另外提一句,Qdrant的RESTful API非常友好,排查问题比Milvus的gRPC舒服多了。