一、问题背景: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抖动的。