1. 问题背景:RAG系统卡在召回环节,我决定换个向量库

上个月我们团队在做一个电商以图搜图+属性过滤的功能。数据量不大——就100万张商品主图,每张图用CLIP-ViT-B/32抽成768维向量,但业务有个硬性要求:所有查询必须支持“品牌+价格区间+颜色”的标量预过滤。当时线上用的是某云厂商的向量服务,单次带过滤查询平均要380ms,P99直接飙到1.2秒,用户反馈“点一下等半天”。

我花了三天时间调研,最终锁定Milvus和Qdrant两个开源方案。为什么不用ElasticSearch?因为它的向量插件KNN性能衰减太厉害,100万规模下召回率掉到92%以下,业务方不接受。为什么不用Weaviate?因为它的混合检索(BM25+向量)在过滤场景下需要额外维护倒排索引,而我们已经有独立的ES做文本搜索了,不想引入重复组件。

所以这篇博客只聊Milvus和Qdrant,用数据说话,给你一个能直接参考的选型结论。

2. 环境与版本:裸机部署,拒绝云厂商“美化版”数据

测试机器是我工位下的淘汰服务器,配置如下:
- CPU:Intel i5-13400(6P+4E,16线程)
- 内存:32GB DDR4 3200MHz(实际可用约28GB)
- 磁盘:1TB NVMe SSD(顺序读3500MB/s)
- 操作系统:Ubuntu 22.04 LTS,内核5.15.0

版本选择刻意拉开差距
- Milvus:2.4.1(Docker Compose方式,etcd+minio+standalone三个容器)
- Qdrant:1.9.7(单机二进制,直接./qdrant启动,没用Docker)

为什么不用Milvus的Milvus Lite?因为团队生产环境是K8s,Lite只适合本地调试。Qdrant之所以不用Docker,是因为官方二进制对NUMA内存分配更友好,实测能多压出几百MB可用内存。

数据集模拟:100万条商品记录,向量是随机生成的768维float32数组,标量字段有brand(50个枚举值)、price(整数,分布偏斜)、color(10个枚举值)。写入时故意做乱序插入,模拟真实业务的数据流。

3. 方案设计:压测脚本与关键指标

在设计对比方案时,我定了三个“必须踩”的坑:
1. 过滤查询:SQL里的WHERE brand='Nike' AND price BETWEEN 100 AND 500,对应到向量库就是“先按标量过滤再算向量距离”。
2. 纯向量查询:不带任何过滤,只按L2距离排序取TopK=100,用于测试裸检索性能。
3. 批量写入:模拟每天凌晨的全量刷新,100万条数据分10个线程并发写入。

压测工具用Python的concurrent.futures + requests库,每个请求独立计时,结果收集到CSV里再分析。关键指标就看三个:QPS(每秒查询数)、P99延迟、内存驻留集(RSS)

为了公平,两个数据库的索引参数都做了适配:
- Milvus:HNSW,M=16, efConstruction=200,查询时ef=128
- Qdrant:HNSW,m=16, ef_construct=200,查询时ef=128

代码块1:Qdrant创建collection的Python脚本(注意Payload索引的创建)

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

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

# 创建collection,显式指定HNSW参数
client.recreate_collection(
    collection_name="products",
    vectors_config=VectorParams(size=768, distance=Distance.L2, hnsw_config={"m": 16, "ef_construct": 200}),
    optimizers_config={"indexing_threshold": 10000}  # 超过1万条自动建索引
)

# 关键:对标量字段创建索引,否则过滤查询会全表扫描
client.create_payload_index(collection_name="products", field_name="brand", field_schema=PayloadSchemaType.KEYWORD)
client.create_payload_index(collection_name="products", field_name="price", field_schema=PayloadSchemaType.INTEGER)
client.create_payload_index(collection_name="products", field_name="color", field_schema=PayloadSchemaType.KEYWORD)

这步最容易被忽略——如果你不创建payload索引,即使数据量只有10万条,带过滤的查询也会慢到怀疑人生(实测慢30倍以上)。Qdrant的过滤机制是先走payload索引拿到候选ID集合,再在这些ID里做向量搜索,所以索引命中率直接影响性能。

4. 核心实现:写入与查询的代码对比

Milvus这边我用的是PyMilvus 2.4.x,写入时强制开启批量插入模式,避免逐条insert的开销。Qdrant则用官方client的upload_collection方法,内部自动做batch。

代码块2:Milvus批量写入与过滤查询代码片段

```python
from pymilvus import connections, Collection, FieldSchema, CollectionSchema, DataType, utility
import random, time

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

定义schema

fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True),
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768),
FieldSchema(name="brand", dtype=DataType.VARCHAR, max_length=64),
FieldSchema(name="price", dtype=DataType.INT64),
FieldSchema(name="color", dtype=DataType.VARCHAR, max_length=16)
]
schema = CollectionSchema(fields, "product vectors")
collection = Collection("products_milvus", schema)

创建HNSW索引(必须先建索引再插入才能自动构建)

index_params = {"index_type": "HNSW", "metric_type": "L2", "params": {"M": 16, "efConstruction": 200}}
collection.create_index("embedding", index_params)

批量写入:每次5000条,共200次

batch_size = 5000
for i in range(200):
data = [
[j for j in range(ibatch_size, (i+1)batch_size)], # id
[[random.random() for _ in range(768)] for _ in range(batch_size)], # vector
[random.choice(["Nike", "Adidas", "Puma", "NB"]) for _ in range(batch_size)],
[random.randint(50, 1500) for _ in range(batch_size)],
[random.choice(["Red", "Blue", "Black", "White"]) for _ in range(batch_size)]
]
collection.insert(data)
if i % 20 == 0:
print(f"Inserted {i*batch_size} rows")

collection.flush()

带过滤查询:expr语法类似SQL

query_vector = [random.random() for _ in range(768)]
start = time.time()
results = collection.search(
data=[query_vector],
anns_field="embedding",
param={"metric_type": "L2", "params": {"ef": 128}},
limit=100,
expr='brand in ["Nike", "Adidas"] and price >= 100 and price = 0`这种“看似无害”的条件,结果P99从80ms涨到600ms,排查了两小时才发现是过滤条件太宽泛。

5. 踩坑与优化:两边的“隐形地雷”

先说Milvus的坑:
- 内存碎片化:Milvus的写入段(segment)默认是1GB一个,如果删除频繁,会产生大量小段。我们用compaction命令合并后,查询性能回升了40%。但注意compaction期间CPU会飙到90%+,建议凌晨执行。
- Docker的资源限制:默认的Docker Compose没有限制内存,但minio和etcd会抢内存。我后来在compose文件里给每个容器加了mem_limit: 4g,否则跑满100万数据时,standalone进程会被OOM Killer干掉。

再说Qdrant的坑:
- WAL日志膨胀:Qdrant的预写日志(WAL)默认是1GB轮转,但高并发写入时文件句柄数会暴涨。我加了--storage.wal-size-limit 512MB参数才压住,否则会触发Too many open files错误。
- CPU亲和性:Qdrant默认用所有核,但过滤查询是CPU密集型的,如果机器上有其他服务(比如ES),会导致严重的上下文切换。用taskset -c 0-7 ./qdrant绑定物理核后,P99下降18%。

优化后的最终参数:
- Milvus:cache_size=4GB(在Milvus config中设置),index_building_interval=5s
- Qdrant:--storage.optimizers.cpu-budget=4--service.http-port=6333--service.grpc-port=6334

6. 效果数据:实测结果与选型结论

压测持续跑了12小时,每轮测试前重启服务以排除缓存干扰。最终数据如下(100万条,768维,TopK=100):

指标 Milvus 2.4.1 Qdrant 1.9.7 差异
纯向量QPS(无过滤) 2450 1920 Milvus高28%
纯向量P99延迟 62ms 85ms Milvus低27%
过滤查询QPS(brand+price) 110 178 Qdrant高62%
过滤查询P99延迟 340ms 210ms Qdrant低38%
批量写入吞吐(条/秒) 5800 7200 Qdrant高24%
峰值内存RSS 9.8GB 6.2GB Qdrant省37%
容器/进程数 3容器+1进程 1进程 Qdrant更轻

结论很清晰:
- 如果你的查询全是“纯向量相似度搜索”,比如人脸识别、相似图片推荐,选Milvus。它的HNSW实现更成熟,索引构建速度也更快(100万条全量构建耗时Milvus 18分钟,Qdrant 23分钟)。
- 如果业务有强标量过滤,比如电商筛选、权限过滤,选Qdrant。它的Payload索引和过滤算子在向量搜索前做预筛选,代价要小得多。我们最终把线上服务换成了Qdrant,过滤查询P99从原来云厂商的1.2秒降到210ms,直接达标。

最后说个玄学问题:Qdrant内存占用低,是因为它把HNSW图做了内存映射(mmap),而不是像Milvus那样全量加载到堆内。但这也意味着如果数据量超过物理内存,Qdrant会频繁换页导致性能骤降。在我们的场景下,32GB内存跑100万条768维没问题,但如果涨到500万条,建议给Qdrant加到64GB或改用Milvus的分布式模式。

以上,就是这次选型的全部记录。如果你也在踩向量数据库的坑,欢迎评论区交流。别问我为什么不用pgvector,问就是“标量过滤性能打不过Qdrant”。