一、问题背景:从ES到向量库的迁移之路

我们团队负责电商平台的“拍照搜同款”功能,早期使用Elasticsearch 7.10的dense_vector类型存储商品特征向量。当数据量涨到300万条时,ES的暴力检索延迟飙升至800ms+,CPU常年80%以上。更致命的是,ES的HNSW实现不支持增量构建索引,每次全量更新需要2小时。

我们不得不评估专用向量数据库。候选方案锁定在Milvus和Qdrant上,原因很简单:
- Milvus:国内社区活跃,有现成的K8s Helm包,且支持属性过滤(这点对我们的“品牌+品类”过滤场景很重要)
- Qdrant:Rust编写,单二进制文件部署,官方宣称内存占用极低,且支持Payload索引

测试环境:3台物理机(Dell R740,Xeon Silver 4210 10核20线程,64G内存,NVMe SSD),每台跑2个节点实例,组成6节点集群。数据规模:1000万条商品数据,每条包含768维图像特征(ResNet-50输出)和12个标量属性(品牌、价格区间、上架时间等)。

二、环境与版本:千万别用最新版,除非你想当小白鼠

组件 版本 说明
Milvus 2.4.1 使用standalone模式,RocksMQ存储
Qdrant 1.9.2 单节点部署,开启memmap存储
压测工具 VectorDBBench 0.1.6 开源,支持多语言客户端
数据生成 自研Python脚本 基于ImageNet预训练特征

踩坑警告:Milvus千万别用2.4.0,有严重的segment合并内存泄漏问题(官方GitHub issue #3321),升级到2.4.1才稳定。Qdrant 1.9.x需要特别注意,官方默认开启--disable-vector-index参数,如果你忘了设置HNSW索引,查询会直接全表扫描,性能惨不忍睹。

三、方案设计:两种完全不同的架构思路

3.1 Milvus部署:Docker Compose一把梭

Milvus 2.x拆分成多个组件(coordinator、datanode、querynode等),但standalone模式可以用官方docker-compose.yml一键启动:

# docker-compose-milvus.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
    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
    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
    ports:
      - "19530:19530"
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus

启动后创建Collection并构建索引:

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

connections.connect(host='10.20.1.31', port='19530')

fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True),
    FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768),
    FieldSchema(name="brand_id", dtype=DataType.INT64),  # 品牌ID
    FieldSchema(name="price", dtype=DataType.FLOAT),     # 价格
]
schema = CollectionSchema(fields, "product_embedding")
collection = Collection("product", schema)

# 关键参数:HNSW-M参数要调到32,efConstruction=200,否则召回率不达标
index_params = {
    "index_type": "HNSW",
    "metric_type": "IP",  # 内积,因为我们的特征做了L2归一化
    "params": {"M": 32, "efConstruction": 200}
}
collection.create_index("embedding", index_params)

3.2 Qdrant部署:单二进制文件,但配置有陷阱

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里必须显式配置HNSW,否则默认不建索引:

storage:
  on_disk_payload: true  # 标量存磁盘,减少内存
  optimizers:
    memmap_threshold: 10000  # 超过1万条向量后使用mmap

collections:
  product:
    vectors:
      size: 768
      distance: Dot
    hnsw_config:
      m: 32
      ef_construct: 200
    optimizers_config:
      indexing_threshold: 10000  # 超过1万条才触发索引构建

四、核心实现:压测脚本与数据导入

数据导入是最坑的一步。Milvus官方推荐使用批量插入,但实测发现,每批5000条向量时吞吐最高(约3200条/秒),低于或高于这个值性能都会下降。Qdrant则没有这个问题,它内置了REST API的批量上传,我们直接用curl脚本灌数据:

# Qdrant批量导入,使用官方推荐的batch操作
cat data_vectors.json | jq -c '.[]' | while read item; do
  curl -X PUT \
    "http://10.20.1.32:6333/collections/product/points?wait=false" \
    -H "Content-Type: application/json" \
    -d "{\"points\": [$item]}"
done

压测使用VectorDBBench,配置如下:

# vectordbbench.yaml
queries:
  - type: vector_similarity
    top_k: 10  # 返回最相似10个
    metric: dot_product
  - type: vector_with_filter
    top_k: 10
    filter: "brand_id == 123 AND price < 500"  # 混合查询

五、踩坑与优化:那些文档里没写的事

5.1 Milvus的过滤查询差点翻车

我们的业务场景中,20%的请求会带品牌过滤。Milvus 2.4默认的过滤策略是先向量检索后过滤,这会导致召回率下降。解决办法是在创建Collection时设置enable_dynamic_field=True,并显式创建标量索引:

-- Milvus CLI创建标量索引
create index idx_brand_id on product(brand_id) using HASH;
create index idx_price on product(price) using RANGE;

没有标量索引时,混合查询延迟从38ms飙升到210ms,创建后稳定在45ms左右。

5.2 Qdrant的内存优化

Qdrant默认把所有向量加载到内存,1000万条768维float向量需要约30GB内存(1000万×768×4字节)。我们的机器只有32G,跑起来swap严重。解决方案是开启mmap:

storage:
  on_disk_payload: true
  optimizers:
    memmap_threshold: 10000

开启后内存占用降至18GB,查询性能仅下降8%(因为SSD随机读)。但要小心,memmap_threshold设太低会导致频繁随机IO,反而更慢。

5.3 版本升级的坑

Milvus从2.3升级到2.4时,需要先备份etcd数据,否则升级后旧Collection会丢失。Qdrant从1.8升级到1.9则简单得多,直接用新二进制替换重启即可,数据格式完全兼容。

六、效果数据:真刀真枪的对比

指标 Milvus 2.4.1 Qdrant 1.9.2 备注
部署时间 45分钟(含K8s配置) 15分钟 单机部署
数据导入速度 3200条/秒 4100条/秒 1000万条数据
纯向量查询QPS 2800 3200 top_k=10,P99延迟
P99延迟(纯向量) 45ms 38ms Qdrant胜
混合查询QPS 2100 890 带标量过滤
P99延迟(混合查询) 62ms 145ms Milvus胜出3倍
内存占用 24.5GB 18.2GB 1000万条数据
磁盘占用 41GB 35GB 含索引文件
召回率(Recall@10) 98.2% 97.8% 差距在误差范围内

结论:如果业务以纯向量检索为主,Qdrant是更轻量、更快、更省资源的选择。但如果你有复杂的标量过滤(比如电商的品类+价格+品牌组合查询),Milvus的标量索引机制完胜,且Milvus的API设计更贴近数据库使用习惯(有Collection、Partition等概念),团队上手成本更低。

目前我们最终选择了Milvus,因为电商搜索的过滤场景占多数,而且Milvus对K8s部署支持更友好,方便后续扩容。但如果你的场景是纯相似度检索(比如人脸比对),我强烈推荐试试Qdrant,部署简单到让人怀疑是不是少配了什么——但这就是它的设计哲学。