一、问题背景:百万素材的以图搜图,该选谁?

上个月,我们团队接到一个影视素材管理平台的需求,核心功能是将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=16efConstruction=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轻量高效,适合我们这种中型团队。希望这份实测数据能帮你少走弯路。