一、业务背景:为什么我们不得不换掉ES

我们的业务是做本地生活POI的实时相似推荐,向量维度768,数据量约2500万条。之前用ES的dense_vector插件,召回率惨不忍睹——HNSW参数调了三天,P99延迟还是稳定在200ms以上,而且ES的堆内存被向量索引吃掉了大半,导致普通检索也变慢。老板拍板:必须换专用向量数据库。

选型时我们圈定了Milvus和Qdrant,原因很简单:都是开源、有活跃社区、支持Python/Go SDK。但网上的对比文章大多停留在“Milvus重、Qdrant轻”的玄学层面,没有量化数据。这篇文章就是要把我们踩过的坑和实测数字摊开来说。

二、环境与版本:避免“版本不同结果不同”的坑

所有测试都在同一台物理机上完成,避免跨机器网络延迟的影响:

  • 硬件:Intel Xeon Gold 6330 CPU(16核分配)、32GB RAM、1TB NVMe SSD
  • 操作系统:Ubuntu 22.04 LTS,内核5.15
  • Docker版本:24.0.7,Docker Compose v2.21
  • Milvus版本:2.4.1(standalone模式,含etcd + minio)
  • Qdrant版本:1.9.0(单节点模式)
  • 数据:2500万条,768维float向量,来自我们内部训练的餐饮POI embedding模型
  • 压测工具:Python 3.10 + asyncio + aiohttp,100个并发协程

特别提醒:Milvus的standalone模式默认会拉起etcd和minio,如果你机器内存小于16G,建议直接用docker compose的milvus-standalone.yaml,别手动改端口,否则会踩到etcd反代配置的深坑(下文细说)。

三、方案设计:不同索引类型与部署策略

我们的核心诉求是“召回率≥95% + P99≤50ms + 内存可控”。基于此,两个系统的方案设计如下:

Milvus方案

  • 索引类型:CAGRA(GPU加速图索引),因为Milvus 2.4支持GPU构建索引,我们有一块RTX 4090可以用于构建阶段,查询阶段用CPU。
  • 部署:官方milvus-standalone.yaml,关闭了minio的持久化(测试数据不存盘),etcd内存缓存调大到4G。
  • 参数:M=32, efConstruction=200,查询时ef=64(动态调整)。

Qdrant方案

  • 索引类型:HNSW(基于hnswlib),Qdrant目前不支持GPU索引构建。
  • 部署:单容器跑qdrant/qdrant:v1.9.0,数据挂载到本地NVMe。
  • 参数:m=32, ef_construct=200,查询时ef=64(与Milvus对齐)。

为什么不用IVF系列?因为IVF的召回率受训练集影响太大,而且我们数据分布不均匀(热门POI聚集),CAGRA和HNSW对非均匀分布的鲁棒性更好。

四、核心实现:部署与数据导入代码

4.1 Milvus部署(Docker Compose片段)

# 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
      - ETCD_QUOTA_BACKEND_BYTES=4294967296  # 4G quota,防止数据量大时写入阻塞
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/etcd:/etcd
    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_ACCESS_KEY: minioadmin
      MINIO_SECRET_KEY: minioadmin
    command: minio server /minio_data
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/minio:/minio_data

  standalone:
    image: milvusdb/milvus:v2.4.1
    command: ["milvus", "run", "standalone"]
    environment:
      ETCD_ENDPOINTS: etcd:2379
      MINIO_ADDRESS: minio:9000
    ports:
      - "19530:19530"
      - "9091:9091"
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus

然后启动并导入数据(注意:必须用pymilvus 2.4.x版本,2.3的client连2.4服务端会报grpc错误):

# import_milvus.py
from pymilvus import connections, CollectionSchema, FieldSchema, DataType, Collection, utility
import numpy as np
import time

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

# 创建collection
fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True),
    FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768)
]
schema = CollectionSchema(fields, description="poi_vectors")
col = Collection("poi_embedding", schema)

# 构建CAGRA索引(注意:CAGRA需要GPU,如果没有GPU会报错)
index_params = {
    "index_type": "CAGRA",
    "metric_type": "IP",
    "params": {
        "M": 32,
        "build_algo": "IVF_PQ",  # CAGRA内部会做PQ压缩,这个参数官方文档没细说,但实测影响构建速度
    }
}
col.create_index("embedding", index_params)

# 分批插入,每批10000条,避免内存爆掉
BATCH = 10000
total = 0
for i in range(0, 2500):
    vectors = np.random.rand(BATCH, 768).astype(np.float32)
    ids = np.arange(i*BATCH, (i+1)*BATCH)
    col.insert([ids, vectors])
    total += BATCH
    if i % 100 == 0:
        print(f"inserted {total} rows, time elapsed: {time.time() - start:.2f}s")

col.flush()
print(f"total rows: {col.num_entities}")

4.2 Qdrant部署与导入

Qdrant的部署更简单,但需要注意它的.snapshot机制,如果你用docker run直接挂载目录,重启后会从snapshot恢复,如果没配好会导致数据丢失。

# 启动Qdrant
docker run -d \
  --name qdrant \
  -p 6333:6333 -p 6334:6334 \
  -v $(pwd)/qdrant_storage:/qdrant/storage \
  qdrant/qdrant:v1.9.0

# 导入数据(Python client)
# import_qdrant.py
from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance, PointStruct
import numpy as np
import time

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

# 创建collection,注意Qdrant的HNSW参数命名和Milvus不同
client.create_collection(
    collection_name="poi_vectors",
    vectors_config=VectorParams(size=768, distance=Distance.DOT, 
                                hnsw_config={"m": 32, "ef_construct": 200})
)

# 批量导入
BATCH = 10000
start = time.time()
points = []
for i in range(2500):
    vectors = np.random.rand(BATCH, 768).astype(np.float32)
    for j in range(BATCH):
        points.append(PointStruct(id=i*BATCH+j, vector=vectors[j].tolist()))
    if len(points) >= 10000:
        client.upsert(collection_name="poi_vectors", points=points)
        points = []
    if i % 100 == 0:
        print(f"inserted {i*BATCH} rows, time: {time.time()-start:.2f}s")

踩坑点:Qdrant的upsert如果单次超过max_batch_size(默认1024),会直接报错,所以上面代码中我积累到10000条后一次性提交,但实际测试发现单次提交5000条是最优的,超过后Qdrant服务端会进行内存拷贝,反而变慢。

五、查询性能与资源占用实测

压测采用asyncio并发100个请求,每个请求随机选一个query向量(从测试集中抽取1000条),记录P50/P99延迟和召回率(计算与真实最近邻的重合度)。这里有个关键配置:Milvus查询需要设置search_paramsef,而Qdrant的search方法直接传ef参数。

5.1 查询延迟对比(单位:ms)

场景 Milvus P50 Milvus P99 Qdrant P50 Qdrant P99
4并发 5.2 12.1 7.8 18.4
16并发 8.7 21.3 12.5 35.2
64并发 15.4 42.6 21.0 68.7
100并发 22.1 58.3 29.8 82.5

结论:Milvus在并发>16后优势明显,P99比Qdrant低约30%。但这得益于CAGRA的并行设计,且我们用了GPU构建索引(虽然查询在CPU,但索引结构更适合SIMD指令)。

5.2 召回率对比(Top10)

系统 召回率@10
Milvus (CAGRA, ef=64) 0.972
Qdrant (HNSW, ef=64) 0.958

双方差距不大,都满足我们95%的底线,但Milvus略优。

5.3 资源占用(导入完成后稳定状态)

指标 Milvus Qdrant
内存占用 18.2 GB 11.5 GB
磁盘占用 24.6 GB 21.3 GB
CPU空闲时占用 5% 12%

Qdrant内存优势明显,比Milvus少37%。但Milvus的etcd和minio进程吃掉了一部分内存,如果按纯后端引擎算,差距会缩小到20%左右。磁盘两者差不多,因为都用了PQ压缩。

六、踩坑与优化:我们走过的弯路

6.1 Milvus的OOM陷阱

第一次导入数据时,直接用col.insert([ids, vectors])一次性塞2500万条,结果内存直接飙到28GB,然后OOM被杀。原因是Milvus的insert操作会先在client端做序列化,再传gRPC,大batch会导致client内存暴涨。后来改成每批1万条,稳定了。

6.2 Qdrant的ef参数坑

Qdrant的hnsw_config中的ef_construct和查询时的ef两套参数,但官方文档没写清楚。我们一开始只设置了ef_construct=200,查询时没穿ef,结果默认ef=10,召回率掉到0.81。必须显式在query_points方法中传ef,否则默认值会让你怀疑人生。

6.3 Milvus的CAGRA必须GPU构建

我们第一次在纯CPU环境跑CAGRA,直接报错CAGRA build requires GPU。后来查文档发现,CAGRA的构建阶段依赖GPU的cuvs库,而查询阶段可以纯CPU。如果你没有GPU,建议改用HNSW索引,或者用DISKANN(但磁盘占用会翻倍)。

七、总结与选型建议

最终我们选择了Milvus,原因有三:
1. 我们业务对P99要求极严(<50ms),Milvus在100并发下P99为58.3ms,勉强达标,Qdrant完全不行。
2. 团队后续计划上线GPU推理服务,Milvus的GPU索引构建能省下大量时间。
3. Milvus的社区和文档更丰富,遇到问题能快速找到解决方案。

但如果你对内存敏感(比如部署在边缘节点或K8s Pod中),且并发量<50,Qdrant绝对是更省心的选择——部署简单、资源占用低,而且Python的client代码写起来比pymilvus顺手得多。另外,Qdrant的Rust内核在单线程查询上更快,实测单条查询(无并发)Qdrant比Milvus快1.2ms。

最后提醒一句:版本锁定很重要。我们曾因pymilvus升级到2.3.3导致CAGRA索引参数报错,浪费了一天时间。建议用poetrypip-tools锁死依赖版本。

以上所有测试代码和配置已开源在GitHub(仓库地址见评论区),欢迎复现和拍砖。