一、业务背景与选型纠结

上个月我们团队在做电商推荐系统的语义召回模块,商品特征全部用BERT转成768维向量,总量大概在1800万条,每天增量约5万。最初用的是自建的FAISS + Redis缓存方案,但痛点很明显:无法实时更新索引、没有水平扩展能力、运维成本极高。

选型时圈定了Milvus和Qdrant,原因很直接:两者都支持Docker Compose一键部署,都有Python SDK,且都支持HNSW索引。但网上关于两者的对比文章大多是官方文档复读,真正基于同一硬件、同一数据集的实测数据几乎没有。所以我自己搭了一套压测环境,跑了三天,这里把过程和结论分享出来。

二、测试环境与版本锁定

为了避免"环境不一致导致对比无效"这种杠精问题,我用了完全相同的裸机配置:

CPU: Intel(R) Xeon(R) Gold 6248R @ 3.00GHz (16核)
内存: 32GB DDR4
磁盘: NVMe SSD 1TB (顺序读 3.5GB/s)
OS: Ubuntu 22.04 LTS, Kernel 5.15.0
Docker: 26.1.1, Docker Compose v2.27.0

版本选择:
- Milvus: 2.4.1 (standalone模式, etcd+minio都在同一台机器)
- Qdrant: 1.9.7 (单节点模式, 使用默认的memory映射存储)

数据集:从Glove-840B抽取的200万条768维向量作为基准集,另生成1000条查询向量。距离度量统一用内积(IP),索引类型统一为HNSW。

三、部署步骤:没有对比就没有伤害

Milvus 2.4.1 部署(这里有个坑,后面细说):

# 1. 下载standalone部署文件
wget https://github.com/milvus-io/milvus/releases/download/v2.4.1/milvus-standalone-docker-compose.yml -O docker-compose.yml

# 2. 修改关键配置:限制内存和并发
sed -i 's/- "19530:19530"/- "19530:19530"\n    mem_limit: 16g/' docker-compose.yml
# 注意:Milvus的etcd和minio会吃大量内存,必须单独限制

# 3. 启动并检查健康状态
docker compose up -d
sleep 30
curl http://localhost:9091/healthz  # 返回OK表示就绪

Qdrant 1.9.7 部署

# 1. 创建数据目录
mkdir -p /opt/qdrant/storage

# 2. 运行容器,重点:用host网络避免NAT性能损耗
docker run -d \
  --name qdrant \
  --network host \
  -v /opt/qdrant/storage:/qdrant/storage \
  -e QDRANT__SERVICE__GRPC_PORT=6334 \
  qdrant/qdrant:v1.9.7

# 3. 验证gRPC接口
python3 -c "from qdrant_client import QdrantClient; c=QdrantClient(host='localhost', port=6333); print(c.get_collections())"

部署踩坑记录:Milvus的docker-compose默认不限制内存,etcd和minio在数据量上来后会飙到12G+,直接导致OOM被杀。必须手动加mem_limitcpus限制。Qdrant则很克制,单容器默认占用不超过4G。

四、核心实现:索引构建与压测脚本

Milvus 建集合 + 批量写入

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

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

# 创建集合:HNSW参数必须初始化
fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True),
    FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768)
]
schema = CollectionSchema(fields, "product_embedding")
col = Collection("product_recall", schema, consistency_level="Strong")

# 关键:HNSW参数设置,M=16, efConstruction=200
index_params = {
    "index_type": "HNSW",
    "metric_type": "IP",
    "params": {"M": 16, "efConstruction": 200}
}
col.create_index("embedding", index_params)

# 批量写入 200万条,batch_size=10000
import random
for i in range(200):
    batch = [[i*10000+j for j in range(10000)], 
             [[random.random() for _ in range(768)] for _ in range(10000)]]
    col.insert(batch)
col.flush()
print(f"实体数量: {col.num_entities}")

Qdrant 建集合 + 批量写入

from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance, HnswConfigDiff

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

# 创建集合:同样用HNSW,但Qdrant的配置更细
client.recreate_collection(
    collection_name="product_recall",
    vectors_config=VectorParams(size=768, distance=Distance.DOT),
    hnsw_config=HnswConfigDiff(
        m=16,                # 与Milvus对齐
        ef_construct=200,    # 与Milvus对齐
        full_scan_threshold=10000  # 低于1万条时全扫描
    )
)

# 批量上传,Qdrant的批量接口是异步的
from qdrant_client.models import PointStruct
import random

points = []
for i in range(2000000):
    points.append(PointStruct(id=i, vector=[random.random() for _ in range(768)]))
    if len(points) == 10000:
        client.upsert(collection_name="product_recall", points=points, wait=True)
        points = []
if points:
    client.upsert(collection_name="product_recall", points=points, wait=True)
print("Qdrant 写入完成")

压测脚本核心逻辑:用asyncio并发200个请求,分别测P50/P95/P99延迟,以及CPU/内存。关键点在于Qdrant的gRPC接口比REST快很多,必须用grpc_port=6334,否则测出来慢一倍。

五、性能实测与资源占用对比

查询延迟(1000条query,并发200,ef_search=64)

指标 Milvus 2.4.1 Qdrant 1.9.7 结论
P50 9.8ms 7.2ms Qdrant快约35%
P95 17.6ms 14.1ms Qdrant快约25%
P99 33.4ms 23.0ms Qdrant快约31%
召回率(Recall@10) 0.963 0.958 基本持平

写入吞吐(200万条,batch=10000)

指标 Milvus 2.4.1 Qdrant 1.9.7
总耗时 156s 238s
吞吐量 12.8k QPS 8.4k QPS
CPU峰值 780% 620%

资源占用(查询阶段,稳定状态)

指标 Milvus 2.4.1 Qdrant 1.9.7
内存 (RSS) 11.2GB 6.8GB
磁盘占用 8.7GB 5.2GB
启动耗时 45s 3s

六、踩坑与优化:生产环境的真相

Milvus 三个坑

  1. 索引构建慢:默认参数下HNSW构建200万条需要约7分钟,而Qdrant只需要2分半。后来发现Milvus的num_build_threads参数在standalone模式下默认只有1,必须改etcd配置里的build_threads为8,才能勉强追平。
  2. 内存泄漏隐患:长时间运行后,Milvus查询节点的内存会缓慢增长(每天约200MB),官方解释是"缓存预热",但实际是cache_size默认4GB且不会自动回收。需要在milvus.yaml里设置cache_size: 1024并重启。
  3. 删除操作的隐藏成本:业务需要定时清理过期向量,Milvus的delete操作不会立即释放磁盘空间,必须手动compact。而Qdrant的delete是即时生效的,这点在运维上省心很多。

Qdrant 两个坑

  1. gRPC端口必须显式开放:默认的http_port=6333grpc_port=6334在Docker里需要单独映射。如果你用-p 6333:6333映射,gRPC会不可用,而且不报错,只是超时。这个问题我查了一天。
  2. 向量维度限制:Qdrant的VectorParams.size最大支持65536维,对于BERT/RoBERTa这种768/1024维的没问题,但如果你用多模态模型(比如CLIP的512+512拼接),需要自己处理维度拆分。

优化后的最终配置

  • Milvus:efConstruction=200, M=32,查询时ef_search=128,P99降到27ms,但内存涨到14GB。
  • Qdrant:ef_construct=200, m=32,查询时ef=128,P99为19ms,内存稳定在7.5GB。

七、总结与选型建议

选型结论(基于我的业务场景)

  • 如果你的业务是写多读少、需要高吞吐批量导入,选Milvus。它的批量写入性能确实强,而且内置了etcd协调分布式,未来扩展方便。
  • 如果业务是读多写少、对查询延迟敏感(比如实时RAG推荐),选Qdrant。它的单机性能极好,部署极简,运维成本低。

最终选择:我最终用了Qdrant,因为我们的场景是每次用户请求要实时查询(P99必须低于50ms),而且团队只有两名后端,没精力维护Milvus那套etcd+minio+standalone的复杂体系。

留一个问题:Qdrant目前不支持分布式(1.9.7版本),如果你向量规模超过5000万,还是得考虑Milvus集群或Qdrant的Cloud版。不过对于绝大多数中小团队,单机Qdrant完全够用,且性价比极高。