1. 问题背景:RAG应用扛不住并发,先别急着加机器

上个月我们给电商搜索团队做一个“以图搜图”功能,底层是标准的RAG架构:CLIP模型把商品图变成768维向量,丢进向量数据库,然后根据用户上传图做相似度检索。初期数据量只有200万,用ES的dense_vector凑合跑,延迟在80ms左右。

但业务方突然说要接入全量商品库——1000万条,ES直接崩了,查询P99飙到1.2秒。CTO给我一周时间选型并落地。我当时在Milvus和Qdrant之间犹豫,因为两者都支持HNSW索引、都提供Python SDK、都能部署在K8s上。但网上对比文章大多是“部署体验”层面的,没有我这种真实业务场景的压测数据。

所以我自己搭了两个集群,跑了三天三夜的压测。本文所有数据均来自我的测试环境,不是官方benchmark,因为官方测的是纯向量检索,而我需要的是“标签过滤 + 向量检索”这种混合查询。

2. 环境与版本:别用最新版,用稳定版

硬件配置(两台物理机,避免云上网络抖动干扰测试):
- CPU:Intel Xeon Gold 6330,32核64线程
- 内存:256GB DDR4
- 磁盘:NVMe SSD,顺序读2.8GB/s
- 网络:万兆内网

软件版本:
- Milvus 2.4.1(官方推荐稳定版,不是2.5那个beta)
- Qdrant 1.9.2(带--force-sync参数,因为单机模式)
- Python 3.10 + pymilvus 2.4.4 + qdrant-client 1.12.1
- 数据:1000万条,768维float32,每条附带category(10类)和price(浮点)两个过滤字段

部署方式都是Docker Compose,没用K8s。原因很简单:K8s operator的版本兼容问题太多,我上周刚被Milvus-operator 0.1.4坑过(它会强制拉取旧版etcd镜像)。生产环境如果是K8s,建议直接用Helm,但测试阶段Docker足够。

3. 方案设计:同样架构,不同配置

我的核心查询模式是:

SELECT id, vector FROM items WHERE category = 'electronics' AND price = 10000:
        client.upsert(collection_name='product_vectors', points=points, wait=False)
        points = []

性能差异初现:导入1000万条,Milvus耗时1小时52分钟(28k QPS),Qdrant耗时2小时17分钟(21k QPS)。但Milvus在导入过程中内存峰值到98GB,Qdrant只有72GB。因为Milvus的shards_num=4会创建4个独立的HNSW图,每个图维护独立的visit_list,内存开销是单图的4倍。

5. 踩坑与优化:一个让服务“假死”的Bug

压测查询是重点。我写了个脚本模拟50并发,混合查询(30%纯向量检索,70%带过滤条件)。跑了30分钟后,Qdrant突然所有查询超时(10秒无响应),但CPU只有45%——典型的“假死”现象。

排查发现:Qdrant的ef参数(搜索时动态调整HNSW探索宽度)默认是None,它会根据查询向量和索引规模自动调整。但我的过滤条件category='electronics'命中率只有10%(约100万条),Qdrant会退化成长表扫描——它要先过滤出100万条候选,再在这些候选里跑HNSW。

解决方案:在client.search手动设置ef=64,并利用Qdrant的payload_selector字段把过滤下推到索引层:

# 优化后的Qdrant查询
from qdrant_client.models import Filter, FieldCondition, MatchValue, Range

filter_condition = Filter(
    must=[
        FieldCondition(key='category', match=MatchValue(value='electronics')),
        FieldCondition(key='price', range=Range(lt=500))
    ]
)

results = client.search(
    collection_name='product_vectors',
    query_vector=query_vec,
    query_filter=filter_condition,  # 关键:把过滤下推到索引
    limit=10,
    ef=64,  # 手动设置,避免长表扫描
    with_payload=False
)

Milvus这边没遇到这种问题,因为它的partition by category天然做了数据分片,而price上的RangeIndex(默认就是)能快速过滤。但Milvus也有坑:如果efSearch参数设置过大(比如超过256),内存会暴涨。官方文档说efSearch范围是1-65535,我设了512后,单个查询的内存峰值从200MB涨到1.2GB,而且P99延迟从30ms涨到90ms——收益是召回率只提升0.8%,完全没必要。

最终调优参数:
- Milvus:efSearch=128metric_type=IPshards_num=4
- Qdrant:ef=128payload_mapping="category"(对高基数字段建映射索引)

6. 效果数据:延迟、吞吐、资源占用全对比

压测结果(50并发,混合查询,运行2小时,P99统计):

指标 Milvus 2.4.1 Qdrant 1.9.2 差异
纯向量检索 P99 28ms 35ms Milvus快25%
过滤+向量检索 P99 42ms 105ms Qdrant慢150%
纯向量检索 QPS 1800 1500 Milvus高20%
过滤+向量 QPS 950 420 Milvus高126%
内存占用(稳态) 87GB 63GB Milvus高38%
磁盘占用(HNSW+原始) 112GB 98GB Milvus高14%

结论很明显:如果你和我一样,查询里离不开metadata过滤(比如商品类目、价格区间、用户标签),选Milvus。它的分区+标量索引组合拳更成熟。但如果你做的是纯向量检索(比如人脸比对、重复图片检测),Qdrant更省内存,单机就能扛住大模型,还能省一台服务器。

最后补充一个资源占用的细节:Milvus的querynode进程在空闲时也会保持约40GB的常驻内存(因为HNSW图常驻),而Qdrant在空闲时会释放到20GB(缓存LRU策略)。如果你的业务有明显的波峰波谷(比如白天高并发、晚上低流量),Qdrant更适合——它可以缩容到一台机器,Milvus做不到这么灵活。

7. 总结:别迷信官方benchmark,拿自己的数据说话

这次选型花了3天,但值得。我踩过的坑希望你避开:
1. Milvus的内存配置shards_num不是越大越好,我试过8分片,导入吞吐没提升,内存却翻倍了。
2. Qdrant的过滤查询:一定搞清楚query_filter会不会走索引,用explain方法验证。
3. 压测脚本要混合查询:纯向量检索的benchmark没有参考价值,业务里一定带过滤。
4. 版本锁死:Milvus 2.4和Qdrant 1.9是当前最稳的组合,别急着升级2.5或1.10,等社区把坑填平再说。

如果你的数据量在500万以下,用Qdrant单机就够了;如果千万级且过滤查询多,直接上Milvus集群。至于官方说的“Qdrant比Milvus快3倍”,那是在他们自己的测试集上,我这边结论是反过来的。所以——拿自己的数据测,别信任何人的“性能对比”。