1. 业务背景:为什么我们不得不迁移向量数据库

我们电商平台的以图搜图功能在2024年初面临巨大压力。商品库从500万暴涨到2000万,原来的FAISS本地索引方案开始崩溃——单机内存128GB被打满,每次全量重建需要11小时,增量更新时会出现查询阻塞。

在调研了pgvector、Elasticsearch、Milvus和Qdrant后,我们锁定了后两者。原因很直接:pgvector在2000万数据量下查询延迟超过200ms,ES的HNSW实现内存占用高得离谱。而Milvus和Qdrant都原生支持分布式和磁盘索引,理论上能扛住这个量级。

关键决策点在于:我们的查询QPS峰值预计3000+,同时要支持商品属性过滤(如价格区间、品牌ID),这要求向量检索必须与标量过滤深度耦合。

2. 测试环境与版本锁定

为了避免“版本差异导致测试结果失真”,我们统一使用Docker部署,硬件配置如下:

  • 服务器:3台裸金属,2路Intel Xeon 8380(80核),512GB DDR5内存
  • 存储:NVMe SSD RAID0,顺序读2.8GB/s
  • 网络:25GbE内网
  • 数据集:2000万个768维float32向量(来自CLIP ViT-L/14),外加商品价格、品牌ID等6个标量字段
  • 版本:Milvus v2.4.1(standalone模式)、Qdrant v1.9.2(单节点)

3. 部署步骤:从Docker Compose到生产参数

3.1 Milvus部署

Milvus的部署比我想象中繁琐。官方推荐的milvus-standalone-docker-compose.yml默认配置偏向开发,生产必须调整。我们最终用的关键参数:

# docker-compose.yml 关键片段
services:
  etcd:
    image: quay.io/coreos/etcd:v3.5.5
    environment:
      - ETCD_AUTO_COMPACTION_MODE=revision
      - ETCD_AUTO_COMPACTION_RETENTION=1000

  milvus:
    image: milvusdb/milvus:v2.4.1
    command: ["milvus", "run", "standalone"]
    environment:
      - ETCD_USE_EMBED=true
      - ETCD_DATA_DIR=/var/lib/milvus/etcd
      - COMMON_STORAGETYPE=local
    volumes:
      - ./milvus_data:/var/lib/milvus
      - ./milvus_config:/milvus/configs
    ports:
      - "19530:19530"
      - "9091:9091"
    ulimits:
      memlock: -1
      nofile: 65535

启动后需要手动调整milvus.yaml中的queryNode资源限制,默认的queryNode.gracefulStopTimeout只有5秒,高负载下会导致节点频繁重启。

# 启动并验证
docker-compose up -d
curl -X POST http://localhost:9091/metrics | grep milvus_querynode_sql_processed

3.2 Qdrant部署

Qdrant的部署简单到令人感动,一个docker run就能跑起来。但生产环境需要认真配置config.yaml

# qdrant_config.yaml
storage:
  optimizers:
    default_segment_number: 10  # 关键:并行构建段数
    memmap_threshold: 10000     # 超过1万点用mmap
  on_disk_payload: true         # 标量字段落盘,控制内存
service:
  max_request_size_mb: 64
  grpc_timeout_sec: 30

启动命令:

docker run -d --name qdrant \
  -p 6333:6333 -p 6334:6334 \
  -v $(pwd)/qdrant_storage:/qdrant/storage \
  -v $(pwd)/qdrant_config.yaml:/qdrant/config/config.yaml \
  qdrant/qdrant:v1.9.2

3.3 数据导入

两个系统都支持批量导入。Milvus用pymilvus批量插入,Qdrant用grpc并发上传。这里有个细节:Milvus默认的producer并发度是10,导入2000万条数据用了47分钟;Qdrant默认并发上传,同样数据量只用了28分钟。

4. 查询性能实测:参数调优的血泪史

4.1 查询场景定义

我们模拟线上真实查询:

# 查询请求示例:找出与目标商品相似且价格在100-500元的商品
query_vector = get_embedding("红色连衣裙")
filter_condition = {
    "price": {"$gte": 100, "$lte": 500},
    "brand_id": {"$in": [1024, 2048, 4096]}
}
top_k = 50

4.2 Milvus查询优化

Milvus的查询性能极大依赖索引参数。我们最初用默认的HNSW参数(M=16, efConstruction=200),P99延迟高达35ms。经过反复调参,最终锁定以下参数:

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

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

# 关键索引参数
index_params = {
    "index_type": "HNSW",
    "metric_type": "IP",
    "params": {
        "M": 32,           # 每层最大连接数,16->32提升召回率
        "efConstruction": 400,  # 构建时动态列表大小
        "ef": 128           # 查询时动态列表大小
    }
}

collection.create_index(field_name="embedding", index_params=index_params)

# 带有标量过滤的查询
search_params = {"metric_type": "IP", "params": {"ef": 128}}
results = collection.search(
    data=[query_vector],
    anns_field="embedding",
    param=search_params,
    limit=50,
    expr='price >= 100 and price <= 500 and brand_id in [1024, 2048, 4096]'
)

重要发现:Milvus的expr过滤是在向量检索后执行的(即post-filter),如果过滤条件命中率低(<5%),会导致大量无效计算。我们通过将price从float类型改为int64,并建立metric_typeINT64的标量索引,将过滤性能提升了40%。

4.3 Qdrant查询优化

Qdrant的查询API设计更直观,但要注意with_payloadwith_vectors的默认行为——默认会返回完整向量,2000万数据下payload传输成为瓶颈。

from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct, Filter, FieldCondition, Range, MatchAny

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

# 创建集合时指定hnsw_config
from qdrant_client.models import HnswConfigDiff
client.create_collection(
    collection_name="products",
    vectors_config=VectorParams(size=768, distance=Distance.COSINE),
    hnsw_config=HnswConfigDiff(
        m=32,
        ef_construct=400,
        full_scan_threshold=10000  # 小于1万点全扫描
    ),
    optimizers_config=OptimizersConfigDiff(
        indexing_threshold=20000   # 2万点后开始构建索引
    )
)

# 带过滤查询
search_result = client.query_points(
    collection_name="products",
    query=query_vector,
    query_filter=Filter(
        must=[
            FieldCondition(key="price", range=Range(gte=100, lte=500)),
            FieldCondition(key="brand_id", match_any=MatchAny(any=[1024, 2048, 4096]))
        ]
    ),
    limit=50,
    with_payload=False,   # 关键:不返回payload,减少网络开销
    with_vectors=False    # 关键:不返回向量
)

Qdrant的过滤是pre-filter,在执行向量距离计算前就已经过滤掉不满足条件的点,这使得带有严格过滤条件的查询Qdrant比Milvus快得多。

4.4 性能对比数据

在2000万数据量下,1000次查询压测结果(并发32,数据均512GB内存,SSD存储):

指标 Milvus 2.4.1 Qdrant 1.9.2
无过滤P99延迟 12ms 18ms
带过滤P99延迟 28ms 15ms
内存占用(空闲) 86GB 28GB
内存占用(峰值) 142GB 45GB
启动时间 76秒 3.2秒
索引构建时间 39分钟 52分钟

数据解读:在纯向量检索场景,Milvus的HNSW实现效率确实更高(12ms vs 18ms),但一旦加入标量过滤,Qdrant的pre-filter优势就体现出来了(15ms vs 28ms)。内存方面Qdrant的memmap机制让索引直接走磁盘映射,内存占用仅为Milvus的三分之一。

5. 踩坑记录:那些文档没告诉你的

5.1 Milvus的磁盘索引陷阱

Milvus 2.4支持DiskANN索引,我们尝试用它来降低内存占用,结果悲剧了。DiskANN在build阶段的耗时为HNSW的5倍,查询延迟飙升到80ms+。后来查了GitHub issue才发现,DiskANN的search_list_size参数必须手动调优,默认值100在2000万数据下严重不足。

# DiskANN索引参数(不推荐,仅供避坑参考)
index_params = {
    "index_type": "DISKANN",
    "metric_type": "L2",
    "params": {
        "search_list_size": 2000,  # 默认100,必须调到1500+
        "build_list_size": 100      # 默认100
    }
}

5.2 Qdrant的段合并风暴

Qdrant的段(segment)合并机制在数据导入阶段会触发资源竞争。我们的导入线程数设置过高(24),导致段合并频繁,CPU飙到100%,导入速度反而下降。最终将导入并发限制在8,并设置optimizers_configmax_optimization_threads=4才稳定下来。

5.3 网络层的坑

两边的gRPC客户端默认都有max_send_message_length限制。在导入大向量时(我们的batch size是1000),Qdrant默认的4MB限制直接报错,需要显式设置:

# Qdrant gRPC配置
from grpc import ssl_channel_credentials
from qdrant_client import QdrantClient
client = QdrantClient(
    host="localhost",
    port=6333,
    prefer_grpc=True,
    grpc_options={
        "grpc.max_send_message_length": 128 * 1024 * 1024  # 128MB
    }
)

6. 最终选择与架构演进

我们的结论是:两者没有绝对的优劣,取决于你的查询模式。

  • 如果查询以纯向量召回为主(比如相似图推荐),且对延迟极致敏感,选 Milvus。它的HNSW实现更成熟,查询延迟在无过滤条件下比Qdrant低30%以上。
  • 如果查询经常带多重标量过滤(比如电商的类目+价格+品牌),且内存资源有限,选 Qdrant。pre-filter机制在过滤场景下性能反超Milvus一倍,且内存占用仅为1/3。

最终我们选择了 Qdrant。原因很简单:我们的业务查询90%带标量过滤,而且Qdrant的部署和运维复杂度远低于Milvus(我们不需要单独维护etcd和对象存储)。迁移到Qdrant后,服务器从5台缩减到3台,硬件成本直接降低40%。

在实际生产环境中,我们还做了进一步优化:将高热度商品(top 10%流量)的向量加载到内存缓存,其余走mmap,查询延迟进一步稳定在P99 10ms以内。

如果你们也在做向量数据库选型,建议先用自己真实的业务数据跑一遍压测,重点关注过滤条件下的查询性能,而不是纯粹的向量检索延迟。毕竟业务场景很少会不带filter做查询。