一、问题背景:百万素材的以图搜图,该选谁?
上个月,我们团队接到一个影视素材管理平台的需求,核心功能是将100万张电影剧照和视频关键帧用CLIP模型提成768维向量,然后做“以图搜图”和相似素材推荐。技术选型时,我们锁定了两个当前最热门的开源向量数据库:Milvus(老牌,功能全)和 Qdrant(Rust写的,号称内存效率高)。
我们的业务场景很具体:写多读多,要求P95延迟低于50ms,服务器配置为4核16G内存、NVMe SSD。这个内存配置比较尴尬,跑Milvus官方推荐的Docker Compose(etcd + minio + standalone)有点吃力。本文就是在这个资源受限的真实业务下,对两者进行的一次完整PK。
二、环境与版本:Docker Compose一把梭
测试机配置:4核CPU(Intel Xeon Platinum 8269CY),16GB RAM,500GB SSD。操作系统为Ubuntu 22.04,Docker版本24.0.7。
Milvus版本:2.4.1(standalone模式,含etcd和minio)
Qdrant版本:1.9.2(单节点模式)
数据规模:1,000,000条向量,维度为 768 dims(CLIP ViT-L/14输出),距离度量方式为 COSINE。数据集使用开源数据集LAION-5B的子集,随机抽取并归一化。
部署步骤Milvus(关键节点):
# 下载standalone的docker-compose.yml
wget https://github.com/milvus-io/milvus/releases/download/v2.4.1/milvus-standalone-docker-compose.yml -O docker-compose.yml
# 修改配置:限制内存使用,防止OOM
# 在docker-compose.yml中为standalone服务添加:
# environment:
# - GOGC=200
# - CACHE_SIZE=1024 # MB
docker compose up -d
部署步骤Qdrant(关键节点):
# 直接运行容器,挂载数据卷
docker run -d --name qdrant \
-p 6333:6333 -p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage \
qdrant/qdrant:v1.9.2
# 初始化collection时需要指定量化配置
curl -X PUT 'http://localhost:6333/collections/movies' \
-H 'Content-Type: application/json' \
-d '{
"vectors": {
"size": 768,
"distance": "Cosine",
"quantization_config": {
"scalar": { "type": "int8", "always_ram": true }
}
}
}'
三、方案设计:写入与查询的策略差异
Milvus侧设计:使用pymilvus批量写入,每批256条,开启flush策略。索引选择HNSW,参数M=16、efConstruction=200。创建索引时必须先创建collection并插入数据,再创建索引,否则索引构建会失败。
Qdrant侧设计:使用官方Python客户端。由于Qdrant的HNSW默认参数(m=16, ef_construct=100)偏低,我们进行了调整。同时开启了int8量化(如上代码所示),这是Qdrant在16G内存下能扛住100万768维向量的关键。
核心实现代码块(Milvus写入与查询):
# milvus_test.py
from pymilvus import connections, Collection, CollectionSchema, FieldSchema, DataType, utility
import numpy as np
import time
connections.connect(alias="default", host="localhost", port="19530")
# 定义schema
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True),
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768)
]
schema = CollectionSchema(fields)
collection = Collection(name="movies", schema=schema)
# 批量写入(模拟100万条)
vectors = np.random.rand(1000000, 768).astype(np.float32)
t0 = time.time()
for i in range(0, 1000000, 256):
batch_ids = list(range(i, i+256))
collection.insert([batch_ids, vectors[i:i+256]])
print(f"Milvus插入耗时: {time.time()-t0:.2f}s")
# 创建HNSW索引
index_params = {"index_type": "HNSW", "metric_type": "COSINE", "params": {"M": 16, "efConstruction": 200}}
collection.create_index(field_name="embedding", index_params=index_params)
collection.load()
# 查询
query_vec = vectors[0].reshape(1, -1)
t0 = time.time()
results = collection.search(data=query_vec, anns_field="embedding", param={"metric_type": "COSINE", "params": {"ef": 64}}, limit=10)
print(f"Milvus单次查询延迟: {(time.time()-t0)*1000:.2f}ms")
四、核心实现:Qdrant的批量写入与搜索
Qdrant核心代码块:
# qdrant_test.py
from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance, PointStruct, HnswConfigDiff
import numpy as np
import time
client = QdrantClient(host="localhost", port=6333)
# 调整HNSW参数并开启量化
client.update_collection(
collection_name="movies",
hnsw_config=HnswConfigDiff(m=16, ef_construct=200)
)
# 批量upsert(每批512条,Qdrant对批量大小更宽容)
vectors = np.random.rand(1000000, 768).astype(np.float32)
points = [
PointStruct(id=i, vector=vectors[i].tolist()) for i in range(1000) # 示例前1000条
]
t0 = time.time()
for i in range(0, 1000000, 512):
batch_points = [
PointStruct(id=j, vector=vectors[j].tolist()) for j in range(i, min(i+512, 1000000))
]
client.upsert(collection_name="movies", points=batch_points)
print(f"Qdrant插入耗时: {time.time()-t0:.2f}s")
# 查询
t0 = time.time()
hits = client.search(
collection_name="movies",
query_vector=vectors[0].tolist(),
limit=10,
search_params={"ef": 64}
)
print(f"Qdrant单次查询延迟: {(time.time()-t0)*1000:.2f}ms")
五、踩坑与优化:内存溢出与参数调优的血泪史
Milvus的坑:默认配置下,Milvus standalone会预分配大量内存给etcd和minio。在16G机器上直接OOM。解决办法是修改docker-compose.yml,给standalone容器设置CACHE_SIZE=1024,并将etcd和minio的内存限制在512M以内。即便如此,查询时如果ef参数设置过大(比如128),内存占用会瞬间飙升2GB。建议查询时动态调整ef,不要全局设置过高。
Qdrant的坑:Qdrant默认不开启量化,如果直接用float32存100万768维,内存需要 1000000 * 768 * 4 / 1024^3 ≈ 2.86GB,看似不大,但加上HNSW图结构(每个节点有16个邻居),内存会翻倍到6GB以上。开启int8量化后,向量内存直接降到0.72GB,加上索引也能控制在2GB以内。务必开启量化*。另外,Qdrant的ef_construct默认只有100,构建索引速度虽快,但召回率下降到95%以下,调到200后召回率提升到99.2%,索引构建仅慢了15%。
六、效果数据:百万级实测对比
经过三轮测试取平均值(每轮查询1000次随机向量),数据如下:
| 指标 | Milvus 2.4.1 | Qdrant 1.9.2 |
|---|---|---|
| 批量写入吞吐(条/秒) | 4,200 | 3,100 |
| 单次查询P50延迟(ms) | 8.5 | 6.2 |
| 单次查询P95延迟(ms) | 21.3 | 12.0 |
| 峰值内存占用(GB) | 7.8 | 4.6 |
| 索引构建时间(分钟) | 4.2 | 3.5 |
| 召回率(Recall@10) | 99.1% | 98.7% |
结论:在16G内存的受限环境下,Qdrant是更优选。内存占用仅为Milvus的59%,P95查询延迟低43%,且部署简单(单容器搞定)。Milvus的优势在于批量写入吞吐高35%,且生态更全(支持S3存储、Feder索引等),如果你有32G以上内存且对写入吞吐有极致要求,可以选Milvus。
最终决策:我们用了Qdrant,因为我们的瓶颈是内存和查询延迟,写入可以通过离线批处理弥补。如果你的场景是实时高并发写入,建议选Milvus但务必加内存。
总结
没有绝对的好坏,只有适不适合。选型前建议先压测,特别是小内存机器上,Qdrant的量化机制简直是救命稻草。Milvus功能强但吃资源,适合大企业;Qdrant轻量高效,适合我们这种中型团队。希望这份实测数据能帮你少走弯路。