一、问题背景:从ES暴力搜索到向量数据库
我们团队在做一个服装电商的以图搜图功能,最初用Elasticsearch的dense_vector插件,索引100万条128维特征时,单次查询延迟已经飙到800ms+,更致命的是内存占用直接吃满16G的测试机。于是开始调研专门的向量数据库,目标锁定在Milvus和Qdrant——前者是开源社区最活跃的,后者是Rust写的,性能口碑很好。
业务场景:500万条商品图特征(ResNet50提取的128维向量),需要支持“颜色=红色 AND 价格95%(相对暴力计算)。
二、环境与版本
- 操作系统:Ubuntu 22.04 LTS(内核5.15)
- Docker版本:24.0.5
- Milvus:2.3.3(standalone模式,含etcd和minio)
- Qdrant:1.7.3(单节点模式)
- Python客户端:pymilvus 2.3.x / qdrant-client 1.9.x
- 数据集:500万条随机生成的128维float向量,每条附带color(string)、price(float)两个标量字段
这里有个坑:Milvus 2.3.x的standalone模式默认会启动etcd和minio两个依赖容器,内存基线占用直接干到3.2G(含cache)。Qdrant单节点就一个容器,内存占用2.1G。这是后话,先部署。
三、方案设计:Docker Compose部署对比
两个系统都支持Docker部署,为了公平,统一用Docker Compose管理。Milvus的standalone部署需要三个服务编排:
# docker-compose-milvus.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
- ETCD_SNAPSHOT_COUNT=50000
volumes:
- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/etcd:/etcd
command: etcd -advertise-client-urls=http://etcd:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd
minio:
image: minio/minio:RELEASE.2023-03-20T20-16-18Z
environment:
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
volumes:
- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/minio:/minio_data
command: minio server /minio_data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
standalone:
image: milvusdb/milvus:v2.3.3
command: ["milvus", "run", "standalone"]
ports:
- "19530:19530"
volumes:
- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus
depends_on:
- etcd
- minio
Qdrant这边就简单粗暴了,一个服务完事:
# docker-compose-qdrant.yml
version: '3.5'
services:
qdrant:
image: qdrant/qdrant:v1.7.3
ports:
- "6333:6333"
- "6334:6334" # gRPC端口
volumes:
- ./qdrant_storage:/qdrant/storage
command: ["qdrant", "--config-path", "/qdrant/config/production.yaml"]
部署完成后,用docker stats看基线内存:Milvus全家桶吃掉3.2G,Qdrant单容器2.1G。注意Milvus的minio是独立进程,会占额外内存。
四、核心实现:数据写入与查询对比
先看Milvus的写入和查询代码。这里有个关键参数index_type和metric_type的选择,直接决定性能和召回率:
# milvus_test.py
from pymilvus import (Collection, CollectionSchema, DataType, FieldSchema,
connections, utility)
import numpy as np
import time
# 连接Milvus
connections.connect(host='localhost', port='19530')
# 定义schema,注意标量字段要加index才能做过滤
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True),
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=128),
FieldSchema(name="color", dtype=DataType.VARCHAR, max_length=32),
FieldSchema(name="price", dtype=DataType.FLOAT),
]
schema = CollectionSchema(fields=fields, description="product search")
collection = Collection(name="products", schema=schema)
# 创建索引:IVF_SQ8在精度和内存间平衡,nlist=1024
collection.create_index(field_name="embedding",
index_params={"index_type": "IVF_SQ8", "metric_type": "L2", "params": {"nlist": 1024}})
# 写入500万条数据
BATCH_SIZE = 10000
for i in range(0, 5000000, BATCH_SIZE):
vectors = np.random.randn(BATCH_SIZE, 128).astype(np.float32)
colors = np.random.choice(['red', 'blue', 'green', 'black', 'white'], BATCH_SIZE)
prices = np.random.uniform(10, 1000, BATCH_SIZE).astype(np.float32)
data = [vectors, colors, prices]
collection.insert(data)
if i % 100000 == 0:
print(f"inserted {i} rows")
collection.flush()
print(f"total rows: {collection.num_entities}")
# 带标量过滤的混合查询
collection.load()
query_vector = np.random.randn(128).astype(np.float32)
start = time.time()
results = collection.search(data=[query_vector], anns_field="embedding",
param={"metric_type": "L2", "params": {"nprobe": 32}},
limit=10,
expr='color == "red" and price < 500',
output_fields=["color", "price"])
print(f"query latency: {(time.time()-start)*1000:.1f} ms")
print(f"result count: {len(results[0])}")
Qdrant这边用Rust写的,Python客户端是异步的,写法不太一样。注意Qdrant的payload索引需要显式创建,否则标量过滤会全表扫描:
# qdrant_test.py
from qdrant_client import QdrantClient
from qdrant_client.models import (Distance, VectorParams, PointStruct,
Filter, FieldCondition, MatchValue, Range)
import numpy as np
import time
client = QdrantClient(host='localhost', port=6333)
# 创建collection,指定向量维度和距离函数
client.create_collection(
collection_name="products",
vectors_config=VectorParams(size=128, distance=Distance.L2),
)
# 创建标量索引,关键步骤!
client.create_payload_index(collection_name="products", field_name="color", field_schema="keyword")
client.create_payload_index(collection_name="products", field_name="price", field_schema="float")
# 批量写入,注意Qdrant的upsert接口
BATCH_SIZE = 10000
for i in range(0, 5000000, BATCH_SIZE):
vectors = np.random.randn(BATCH_SIZE, 128).astype(np.float32)
colors = np.random.choice(['red', 'blue', 'green', 'black', 'white'], BATCH_SIZE)
prices = np.random.uniform(10, 1000, BATCH_SIZE).astype(np.float32)
points = []
for j in range(BATCH_SIZE):
points.append(PointStruct(
id=i+j,
vector=vectors[j].tolist(),
payload={"color": colors[j], "price": prices[j]}
))
client.upsert(collection_name="products", points=points)
if i % 100000 == 0:
print(f"upserted {i} rows")
# 带标量过滤的查询
query_vector = np.random.randn(128).astype(np.float32)
query_filter = Filter(
must=[
FieldCondition(key="color", match=MatchValue(value="red")),
FieldCondition(key="price", range=Range(lt=500))
]
)
start = time.time()
results = client.search(
collection_name="products",
query_vector=query_vector,
query_filter=query_filter,
limit=10,
with_payload=True
)
print(f"query latency: {(time.time()-start)*1000:.1f} ms")
print(f"result count: {len(results)}")
五、踩坑与优化:三个血泪教训
第一个坑:Milvus的标量filter不建索引会拖垮查询。 刚开始我没给color和price加索引,查询延迟直接飙到1.2秒。后来用collection.create_index给标量字段加索引,延迟降到280ms。Qdrant这边更狠,直接拒绝执行不带索引的过滤查询(返回400错误),逼着你建索引。
第二个坑:Qdrant的WAL日志和段合并。 写入500万条数据时,Qdrant的磁盘占用一度膨胀到21G(因为WAL未合并),而Milvus通过minio存储,稳定在18G。后来发现Qdrant需要手动触发optimizer的flush操作,或者调整optimizers_config的default_segment_number参数。把段大小调大到200MB后,磁盘降到11G。
第三个坑:Milvus的nprobe参数是召回率的命根子。 默认nprobe=8,召回率只有87%,调到32后召回率到95%,但延迟从180ms涨到280ms。Qdrant的HNSW索引没有这个参数,但有个ef参数(查询时的搜索宽度),默认是128,调到256后召回率从92%涨到96%,延迟从150ms涨到240ms。两者调参思路类似,都是拿延迟换精度。
六、效果数据:500万向量实测对比
经过三轮调优后,最终性能对比如下(每轮跑1000次查询取P99):
| 指标 | Milvus 2.3.3 | Qdrant 1.7.3 |
|---|---|---|
| 写入吞吐(条目/秒) | 12,500 | 15,800 |
| 纯向量查询P99 | 210ms | 180ms |
| 混合查询(标量过滤)P99 | 285ms | 240ms |
| 召回率(@10,相对暴力计算) | 95.2% | 95.8% |
| 内存占用(docker stats) | 3.2G(含etcd+minio) | 2.1G |
| 磁盘占用 | 18.5G | 11.2G(调优后) |
| 标量过滤支持 | 完整SQL表达式 | 支持,但语法受限 |
| 集群扩展 | 内置分片+副本 | 需独立部署分布式版 |
实际业务中我们选了Qdrant,理由是:内存占用少40%,纯查询性能快15%,而且部署运维简单——一个容器搞定。Milvus的标量过滤表达式更强大(支持in、like等),但我们的过滤逻辑就两种(颜色+价格区间),Qdrant够用了。
但如果你要上生产环境且数据量超过亿级,或者需要复杂的标量过滤逻辑,Milvus的集群方案更成熟。Qdrant的分布式版(v1.7+)还比较新,生产案例少,我们不敢赌。
最后提醒一句:向量数据库的性能和硬件强相关,我们测试的8核16G机器只是入门配置,如果你用SSD和更大的内存,数字会更好看。建议选型时用自己的业务数据和真实查询模式做benchmark,别直接抄别人的结论。