一、问题背景:以图搜图业务的向量检索选型困境

我们团队负责电商平台的“拍照购”功能,商品库有1000万张图片,使用CLIP模型提取为768维向量。业务要求:单次查询延迟<50ms(P99),支持每日增量更新10万条,且服务器成本敏感(初期只有3台裸金属)。

起初我们打算用ES的dense_vector插件,但在1000万规模下ES的内存占用直接打爆(堆外内存+向量缓存),查询延迟飙到800ms+。于是转向专用向量数据库,主要候选是Milvus和Qdrant。两者都支持HNSW索引,但架构理念差异很大:Milvus是“重”分布式,依赖etcd、MinIO等组件;Qdrant是“轻”单二进制,支持嵌入式模式。本文记录了我们在一台物理机上分别部署两者的完整过程及压测结果。

二、环境与版本:物理机配置与软件版本锁定

为了避免网络干扰,压测全部走内网。硬件配置如下:
- CPU:Intel Xeon Gold 6230R(26核52线程)
- 内存:128GB DDR4 ECC
- 存储:1.5TB NVMe SSD(顺序读2.8GB/s)
- OS:Ubuntu 22.04 LTS,内核5.15

软件版本:
- Milvus:2.4.1(standalone模式,Docker Compose部署)
- Qdrant:1.9.2(单二进制运行,不使用Docker)
- 压测工具:wrk(HTTP压测)+ 自研Go客户端(批量插入与查询)
- 数据集:CLIP ViT-L/14提取的1000万条768维float32向量,文件格式npy(约28GB)

关键版本说明:Milvus 2.4.x开始将Knowhere升级为2.x,HNSW索引构建速度比旧版快40%左右;Qdrant 1.9.x引入了on_disk索引存储,允许索引不驻留内存。

三、方案设计:两种部署架构与索引参数

3.1 Milvus部署(Standalone模式)

Milvus standalone其实是个“伪单机”,内部仍包含etcd、minio、rootcoord、datanode等进程。我们使用官方docker-compose.yml,但修改了内存限制:

# docker-compose.yml 关键片段
services:
  standalone:
    image: milvusdb/milvus:v2.4.1
    command: ["milvus", "run", "standalone"]
    environment:
      - ETCD_USE_EMBED=true
      - MINIO_USE_EMBED=true
      - KNOWHERE_INDEX_BUILD_THREADS=16   # 关键:索引构建线程数
    mem_limit: 48g
    volumes:
      - ./milvus-data:/var/lib/milvus

3.2 Qdrant部署(单二进制+SSD)

Qdrant更简单,下载二进制直接跑:

# 下载与启动
wget https://github.com/qdrant/qdrant/releases/download/v1.9.2/qdrant-x86_64-unknown-linux-gnu.tar.gz
tar xzf qdrant-x86_64-unknown-linux-gnu.tar.gz
./qdrant --config-path config.yaml

config.yaml中关键配置:

storage:
  on_disk: true           # 索引与向量存磁盘
  optimize_overwrite: true
collection:
  hnsw_config:
    m: 32                 # HNSW连接数
    ef_construct: 200     # 建索引时的ef
    on_disk: true         # 索引放磁盘

四、核心实现:数据灌入与查询压测代码

4.1 数据灌入(Go代码片段)

// 使用官方SDK批量插入
// Milvus版本:github.com/milvus-io/milvus-sdk-go/v2
func MilvusInsert(client *milvus.Client, vectors [][]float32, ids []int64) error {
    col, _ := client.GetCollection(context.Background(), "products")
    // 每批1000条,开启自动flush
    _, err := col.Insert(ctx, []string{"id", "vector"}, []any{ids, vectors})
    return err
}

// Qdrant版本:github.com/qdrant/go-client
func QdrantInsert(client *qdrant.Client, vectors [][]float32, ids []uint64) error {
    points := make([]*qdrant.PointStruct, 0, 1000)
    for i := range ids {
        points = append(points, &qdrant.PointStruct{
            Id:      qdrant.NewIDNum(ids[i]),
            Vectors: qdrant.NewVectors(vectors[i]),
        })
    }
    _, err := client.Upsert(context.Background(), &qdrant.UpsertPoints{
        CollectionName: "products",
        Points: points,
    })
    return err
}

实测插入速度:Qdrant单线程批量插入(batch=1000)能达到4.2万条/秒(受限于CPU),Milvus在同样batch下只有2.8万条/秒。但Milvus的auto-flush机制导致磁盘写入更平滑,而Qdrant在插入时会产生大量小文件合并,如果SSD的IOPS不够会明显掉速。

4.2 查询压测(wrk脚本)

# 压测Milvus RESTful API(v2端点)
wrk -t8 -c64 -d60s --latency -s query.lua http://milvus-host:19530/v2/vectordb/entities/search

# 压测Qdrant REST API
wrk -t8 -c64 -d60s --latency -s qdrant_query.lua http://qdrant-host:6333/collections/products/points/search

Lua脚本中构造请求体:{"collectionName":"products","vector":[0.1,0.2,...],"limit":10},其中向量用固定随机数(保证结果集大小一致)。

五、踩坑与优化:那些文档没写清楚的细节

5.1 Milvus的索引构建内存崩溃

第一次用默认参数建HNSW索引(M=64, efConstruction=500),直接OOM killed。排查发现Milvus的索引构建内存占用大约是数据量 * 维度 * 4字节 * 2.5倍。1000万条768维float32,基础数据28GB,HNSW图结构额外需要约70GB。解决办法:将M降至32,efConstruction降至200,并发线程数从默认的30降到16。

5.2 Qdrant的“隐式”段合并

Qdrant在数据灌入时,默认每5万个点形成一个segment,查询时多segment会拖慢延迟。官方推荐使用optimizer定期合并。我们踩坑:压测时发现延迟从10ms飙到50ms,排查发现是后台optimizer在跑合并任务,占满了CPU。优化:将optimizers_config.max_segment_size设为500000,并在数据灌入完成后手动触发一次合并。

5.3 查询参数的“隐藏”差异

Milvus的HNSW查询参数ef(search_ef)默认为64,Qdrant的ef默认为10。如果不调整参数,对比不公平。我们统一设置为ef=128。但注意:Qdrant的ef是搜索时动态调整的,而Milvus的search_ef是collection级参数,修改需要重建索引?实际上Milvus 2.4支持动态调整无需重建。

六、效果数据:延迟、吞吐与资源占用对比

压测结果(1000万向量,768维,HNSW M=32, ef=128, topK=10):

指标 Milvus 2.4.1 Qdrant 1.9.2
P50延迟 8.2ms 5.1ms
P99延迟 31ms 23ms
最大吞吐 4,800 QPS 6,200 QPS
内存占用(空闲) 12.4GB(含etcd+minio) 3.2GB(SSD模式)
内存占用(压测高峰) 28.7GB 9.6GB
索引构建时间 1小时52分 49分钟
磁盘占用(含索引) 41GB 38GB

关键发现
1. Qdrant延迟更稳定,P99/P50比值约4.5;Milvus为3.8,但在高并发下延迟抖动更明显(GC暂停)。
2. Milvus的内存占用是Qdrant的3倍,主要因为etcd缓存+minio块缓存。如果业务要求内存小于8GB,Milvus基本不可用。
3. Qdrant的SSD模式性能超出预期,虽然索引在磁盘上,但NVMe顺序读+内存页缓存,P99仍控制在23ms。
4. Milvus的增量导入体验更差:每次插入都要走flusher,小批量(<100条)插入时延迟高达200ms;Qdrant的wal机制对批量插入更友好。

七、总结与选型建议

如果业务是单机起步、内存受限、要求低延迟,无脑选Qdrant。它的单二进制部署、低内存占用、稳定的P99延迟,非常适合中小团队。我们最终选择Qdrant,将服务器从3台缩减到1台,内存成本降低60%。

如果业务是超大规模(亿级+)、需要多副本高可用、且接受分布式运维成本,Milvus的成熟生态(有配套的Attu管理界面、监控面板)更合适。但要做好心理准备:Milvus的运维复杂度远高于Qdrant,etcd和minio的故障排查会让运维同学“很爽”。

最后提醒:向量数据库的选型一定要用真实业务数据压测。我们曾在官方benchmark上看到Milvus性能更好,但实际场景(CLIP向量分布、批量插入模式)下结论完全反转。把压测代码跑在自己的机器上,比看任何博客都管用。


后记:以上数据来自我们2024年3月的实测,具体版本可能已更新。文中提到的KNOWHERE_INDEX_BUILD_THREADS环境变量在Milvus 2.4.x有效,后续版本可能调整。如果你有不同的压测结果,欢迎在评论区讨论。