一、问题背景:RAG业务上线前夜,我被向量库卡住了脖子
我们做的是企业知识库RAG系统,文档切片后embedding为768维向量,总量约2500万条。业务要求:
- 单次查询TopK=10,QPS峰值800
- 支持按租户ID过滤(filter字段为int64数组)
- 数据增量更新,每日约50万条
一开始用的FAISS暴力检索,单机扛不住,而且过滤逻辑要靠后置代码处理,内存翻倍。于是决定换分布式向量库,候选就两个:Milvus和Qdrant。当时网上的对比文章大多是“跑个hello world”,没有压测数据。所以我花了三天时间,在同样的物理机上把两者都部署了一遍,用真实业务数据做对比。
二、环境与版本:同一台机器,同样的配置
- 服务器:裸金属,16核Intel Xeon Gold 6248,32GB RAM,SSD(顺序读1.8GB/s)
- 操作系统:Ubuntu 22.04 LTS,内核5.15
- Milvus版本:2.4.1(standalone模式,内置etcd和minio)
- Qdrant版本:1.9.2(单节点模式,使用默认的rocksdb存储)
- 数据:2500万条随机生成的768维float32向量,每条附带一个租户ID(范围1-1000)
- 压测工具:自研Go脚本,模拟300个并发goroutine,持续压测30分钟
部署方式都采用Docker Compose,避免环境差异。
三、方案设计:两个库的部署步骤与基础配置
3.1 Milvus部署
Milvus standalone模式需要三个组件:etcd、minio和milvus主服务。docker-compose.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
minio:
image: minio/minio:RELEASE.2023-03-20T20-16-18Z
environment:
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
milvus:
image: milvusdb/milvus:v2.4.1
command: ["milvus", "run", "standalone"]
environment:
ETCD_ENDPOINTS: etcd:2379
MINIO_ADDRESS: minio:9000
volumes:
- ./milvus_data:/var/lib/milvus
启动后,用Python SDK创建集合并建索引:
from pymilvus import connections, CollectionSchema, FieldSchema, DataType, Collection, utility
connections.connect(host='localhost', port='19530')
# 创建集合
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True),
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768),
FieldSchema(name="tenant_id", dtype=DataType.INT64)
]
schema = CollectionSchema(fields, description="rag_vectors")
collection = Collection(name="knowledge_base", schema=schema)
# 创建IVF_PQ索引,关键参数
index_params = {
"index_type": "IVF_PQ",
"metric_type": "L2",
"params": {"nlist": 4096, "m": 32, "nbits": 8}
}
collection.create_index("embedding", index_params)
3.2 Qdrant部署
Qdrant单节点部署更简单,只有一个镜像:
version: '3.5'
services:
qdrant:
image: qdrant/qdrant:v1.9.2
ports:
- "6333:6333"
volumes:
- ./qdrant_storage:/qdrant/storage
创建集合并配置量化:
from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance, ScalarQuantization, ScalarType
client = QdrantClient(host="localhost", port=6333)
# 创建集合,启用scalar量化
client.create_collection(
collection_name="knowledge_base",
vectors_config=VectorParams(size=768, distance=Distance.COSINE),
quantization_config=ScalarQuantization(
scalar=ScalarType.INT8,
always_ram=True
)
)
这里有个细节:Milvus的IVF_PQ把向量压缩成32字节(m=32, nbits=8),而Qdrant的scalar量化是INT8,即每个维度1字节,768维变成768字节。理论上Milvus的内存占用应该更低,但实际测试结果恰恰相反,原因后面说。
四、核心实现:数据灌库与压测脚本
两个库都写同样的数据,2500万条,每条格式是(id, vector, tenant_id)。数据灌库时发现Milvus的批量插入吞吐明显高于Qdrant(Milvus 12k条/秒,Qdrant 8.5k条/秒),但Qdrant的CPU占用更平稳。
压测脚本核心逻辑(Go语言):
func benchQuery(client interface{}, wg *sync.WaitGroup, results chan time.Duration) {
defer wg.Done()
queryVector := make([]float32, 768)
// 随机生成查询向量
for i := range queryVector {
queryVector[i] = rand.Float32()
}
start := time.Now()
// 这里根据client类型调用对应的搜索API
// Milvus: collection.Search(...)
// Qdrant: client.Search(...)
results <- time.Since(start)
}
关键查询参数:
- Milvus:search_params = {"metric_type": "L2", "params": {"nprobe": 64}}
- Qdrant:search_params = SearchParams(hnsw_ef=128, quantization=QuantizationSearchParams(rescore=True))
注意Qdrant的rescore=True很重要,先用量化结果粗筛,再用原始向量精排,否则召回率会掉到85%以下。
五、踩坑与优化:那些文档里没写的坑
坑1:Milvus的段文件合并(compaction)直接吃满IO
灌库完成后,Milvus后台自动合并小段文件,此时查询P99从8ms飙到38ms。必须手动触发合并并等待完成:
collection.compact()
# 等待合并完成
while collection.get_compaction_state().state != "Completed":
time.sleep(5)
而Qdrant的rocksdb是LSM结构,天然支持在线合并,没有这个问题。
坑2:Qdrant的filter字段必须建索引
业务里有租户过滤,Qdrant不建索引时,过滤+向量检索耗时会从5ms涨到120ms。必须显式创建:
client.create_payload_index(
collection_name="knowledge_base",
field_name="tenant_id",
field_schema=PayloadSchemaType.INTEGER,
)
Milvus则需要在插入数据前声明字段类型,因为Milvus是列式存储,过滤效率天然高一些。
坑3:Milvus的query node内存管理
Milvus默认会把所有segment的索引加载到内存,2500万×32字节≈800MB,这没问题。但如果用HNSW索引,内存直接翻倍到1.6GB。后来换回IVF_PQ才把内存压下来。Qdrant的scalar量化支持always_ram=False,可以把量化数据留在磁盘,只把原始向量放内存用于rescore,内存占用可以再降30%。
六、效果数据:同一批数据,同一个并发压力
最终压测30分钟,取稳定期数据:
| 指标 | Milvus 2.4.1 | Qdrant 1.9.2 |
|---|---|---|
| QPS(稳定期) | 845 | 782 |
| P99延迟 | 9.2ms | 14.7ms |
| P50延迟 | 3.8ms | 6.1ms |
| 内存占用(RSS) | 11.2GB | 8.4GB |
| 磁盘占用 | 38GB | 15.6GB |
| 召回率(相对暴力检索) | 98.7% | 99.1% |
分析:
- Milvus的查询性能确实强,尤其在nprobe=64时,P99控制在10ms以内。但后台任务(compaction、etcd快照)会周期性抢资源,导致P99抖动。
- Qdrant的磁盘占用优势非常明显,15.6GB vs 38GB,因为rocksdb自带压缩,而Milvus的minio存的是原始段文件+索引,没有压缩。
- 内存上Qdrant的always_ram=False配置下,8.4GB包含OS页缓存,实际专属内存只有5GB左右。
七、总结与选型建议
如果查询模式是高QPS、低延迟、过滤条件简单(比如纯ID过滤),Milvus是更好的选择,它的列式存储和向量索引分离设计在并发场景下更稳。但要注意Milvus的运维复杂度——etcd、minio、query node、index node,任何一个组件出问题都会影响整个集群。
如果业务有复杂的元数据过滤(比如多条件组合、范围查询),或者磁盘空间敏感,Qdrant更合适。它的单二进制部署太香了,而且payload索引做得好,过滤+向量混合检索命中率更高。
最后提一句,如果你用的是小数据集(100万以下),别纠结,两个库随便选。真正决定选型的是数据规模过千万后的资源瓶颈。我这次测试最大的收获是:不要轻信官网的Benchmark文章,用自己的数据、自己的过滤条件、自己的并发模型去压测,才靠谱。
有问题欢迎评论区交流,特别是同样踩过Milvus compaction坑的同学,我想知道你们是怎么处理P99抖动的。