一、问题背景:粗排链路为什么让我重新审视向量数据库
上个月我们做广告召回后的粗排改造,需要把用户行为序列Embedding化后,从1000万广告向量中找Top50相关候选。业务特点很明确:标量过滤重(广告行业标签、价格区间、投放状态),写多读多(每小时全量更新,QPS峰值1.2万),资源受限(只能给到8C16G的物理机)。
我提前调研了Milvus和Qdrant。Milvus是老牌选手,架构重但功能全;Qdrant是Rust写的,轻量且性能激进。但网上水文太多,我决定自己部署压测,用真实数据说话。
二、环境与版本:裸机Docker Compose部署
服务器配置:Intel Xeon 3114 (8核16线程),32G内存(限制容器使用16G),SSD NVMe 500G。OS为Ubuntu 22.04 LTS。
版本选择:
- Milvus 2.3.3(standalone模式,含etcd和minio)
- Qdrant 1.7.3(单节点模式)
Docker Compose部署Milvus:
version: '3.5'
services:
etcd:
image: quay.io/coreos/etcd:v3.5.5
environment:
- ETCD_AUTO_COMPACTION_MODE=revision
- ETCD_AUTO_COMPACTION_RETENTION=1000
minio:
image: minio/minio:RELEASE.2023-03-20T20-16-18Z
environment:
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
milvus:
image: milvusdb/milvus:v2.3.3
command: ["milvus", "run", "standalone"]
ports:
- "19530:19530"
volumes:
- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9091/healthz"]
interval: 30s
start_period: 90s
Qdrant部署极其简单:
docker run -d --name qdrant \
-p 6333:6333 -p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage \
qdrant/qdrant:v1.7.3
三、方案设计:为什么用Glove-200d和混合查询压测
我用GloVe-200d真实词向量(100万条,每条向量200维,带8个标量字段),模拟广告向量场景。
压测场景设计:
1. 纯向量查询:只按向量相似度取Top50,测试基础性能
2. 混合查询:带category=electronics、price<100的标量过滤,再向量检索——这是我们的真实业务场景
关键配置差异:
- Milvus创建Collection时需显式定义标量字段的索引类型(我用autoindex)
- Qdrant需要在创建Collection时指定payload字段的索引(keyword和integer类型)
四、核心实现:Python初始化代码与查询对比
Milvus 2.3.3初始化与混合查询
from pymilvus import (
connections, Collection, FieldSchema, CollectionSchema,
DataType, utility
)
connections.connect(alias="default", host="localhost", port="19530")
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True),
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=200),
FieldSchema(name="category", dtype=DataType.VARCHAR, max_length=64), # 标量过滤
FieldSchema(name="price", dtype=DataType.FLOAT) # 范围过滤
]
schema = CollectionSchema(fields, "ad_vectors")
col = Collection("ad_collection", schema)
# 关键:为标量字段建索引,否则过滤性能极差
col.create_index("category", {"index_type": "Trie"})
col.create_index("price", {"index_type": "StructuredSort"})
col.create_index("embedding", {"index_type": "HNSW", "params": {"M": 16, "efConstruction": 200}})
# 混合查询
res = col.search(
data=[query_vector],
anns_field="embedding",
param={"metric_type": "IP", "params": {"ef": 100}},
limit=50,
expr='category == "electronics" and price < 100.0', # 标量过滤表达式
output_fields=["id", "price"]
)
Qdrant 1.7.3初始化与混合查询
from qdrant_client import QdrantClient
from qdrant_client.models import (
Distance, VectorParams, PointStruct, Filter,
FieldCondition, Range, KeywordCondition
)
import numpy as np
client = QdrantClient(host="localhost", port=6333)
client.recreate_collection(
collection_name="ad_vectors",
vectors_config=VectorParams(size=200, distance=Distance.DOT),
# 关键:必须提前定义payload索引,否则过滤会全表扫描
payload_schema={
"category": PayloadSchemaType.KEYWORD,
"price": PayloadSchemaType.FLOAT
}
)
# 混合查询
results = client.search(
collection_name="ad_vectors",
query_vector=query_vector,
query_filter=Filter(
must=[
FieldCondition(key="category", match={"value": "electronics"}),
FieldCondition(key="price", range=Range(lt=100.0))
]
),
limit=50,
with_payload=["id", "price"]
)
五、踩坑与优化:那些文档没写明白的细节
Milvus的坑:
1. Segment合并延迟:默认segmentSealProportion=0.3,意味着写入30%就封存segment,导致大量小segment。我改成0.6后,查询性能提升20%。需要在milvus.yaml里改common.retentionDuration和dataCoord.segment.sealProportion。
2. HNSW内存爆炸:efConstruction从200降到128,内存占用从2.8G降到1.8G,召回率只损失0.3%。
3. 标量过滤必须用expr:Milvus的布尔表达式解析有点慢,过滤字段越多越明显。实测3个标量条件时,P95从15ms涨到28ms。
Qdrant的坑:
1. Payload索引必须显式创建:很多人漏了这一步,结果过滤查询直接全表扫描,P95飙到800ms。
2. 默认内存映射:on_disk_payload默认是false,导致所有payload都进内存。我改成true后,内存降到620M,但查询P95增加了3ms。
3. Rust的极限压榨:Qdrant的optimizers线程占用CPU很凶,部署时建议限制--optimizers-threads 2,否则会干扰查询线程。
六、效果数据:性能与资源的真实对比
用100万条向量、100个并发线程、每个线程查询100次,压测结果如下:
| 指标 | Milvus 2.3.3 | Qdrant 1.7.3 |
|---|---|---|
| 纯向量查询P95 (ms) | 18 | 12 |
| 混合查询P95 (ms) | 26 | 31 |
| 混合查询吞吐量 (QPS) | 8500 | 6300 |
| 常驻内存 (GB) | 1.8 | 0.62 |
| 数据导入耗时 (min) | 12 | 9 |
| 索引构建耗时 (min) | 6 | 4 |
结论:如果你的场景是纯向量检索(比如问答相似度),无脑选Qdrant,又轻又快。但像我们这种重标量过滤的广告场景,Milvus的表达式查询优化更好,吞吐量高出35%。不过Qdrant的内存优势太明显,后续如果扩展数据量到亿级,我可能会把Qdrant的on_disk_payload打开,用磁盘换内存。
七、总结
最后说点实际的。选型别只看benchmark,一定要拿自己的真实数据压测。我们最终选了Milvus,因为广告业务的过滤条件会越来越复杂。但如果你做的是纯向量检索,Qdrant的轻量部署和性能会让你惊喜。另外提一句,Milvus 2.3+的restful接口比之前好用多了,但和Qdrant的OpenAPI规范比起来还是不够优雅。下次如果数据量到5000万,我可能会再测一下Qdrant的分片能力,毕竟它支持分布式后性能提升会更明显。