一、问题背景:为什么我们要换掉Faiss
我们做的业务是电商场景下的“以图搜图”和“语义相似商品推荐”。早期直接用Faiss做离线索引,每天全量重建一次。问题很快暴露:
- 商品库每天新增约8万条向量,Faiss重建索引期间查询服务不可用
- 无法做实时增删改,运营下架商品后向量还在
- 多租户场景下,不同商家需要物理隔离,Faiss单索引做不到
于是开始评估专用向量数据库。候选有Milvus、Qdrant、Weaviate和PgVector。PgVector在50万向量以上时查询延迟明显抖动,Weaviate的GraphQL接口团队不喜欢,最终聚焦在Milvus和Qdrant。
选型核心诉求:
- 单节点能扛住100万条768维向量
- 支持实时增删改,延迟<100ms
- 内存占用可控,8GB机器能跑起来
- 过滤条件(如品类、价格区间)能和向量检索混合
二、环境与版本
所有测试在同一台机器上完成:
- 云主机:AWS EC2 c6i.2xlarge(8 vCPU,16GB内存,GP3 500GB)
- 操作系统:Ubuntu 22.04 LTS
- Docker:24.0.7,Docker Compose v2.23.0
- Milvus:2.4.1(standalone模式,etcd 3.5.5 + MinIO RELEASE.2023-03-20)
- Qdrant:1.9.2(单节点,官方镜像)
- 客户端:Python 3.10,pymilvus 2.4.3,qdrant-client 1.9.1
- 测试数据集:100万条768维向量,随机生成,附带10万个过滤标签
两者都通过Docker Compose部署,避免污染宿主机。
三、方案设计
3.1 部署方案
Milvus standalone 需要三个容器:etcd、MinIO、milvus-standalone。官方compose文件直接可用,但要注意etcd和MinIO的健康检查顺序。
Qdrant 单容器即可,数据卷挂载到宿主机。没有外部依赖,部署难度低一个数量级。
3.2 索引与参数
为了公平对比,两者都使用HNSW索引:
- Milvus:
index_type=HNSW,M=16,efConstruction=200,查询时ef=128 - Qdrant:
hnsw_config.m=16,ef_construct=200,查询时hnsw_ef=128
距离度量统一用COSINE。
3.3 测试方法
- 写入:分批插入,每批5000条,记录总耗时和内存峰值
- 查询:随机选1000条向量做top-10检索,计算P50/P95/P99延迟和QPS
- 过滤查询:在向量检索基础上加
category_id in [1,2,3]条件 - 资源占用:用
docker stats每10秒采样一次,取稳定后的平均值
四、核心实现(含代码)
4.1 Milvus部署与查询
部署命令:
wget https://github.com/milvus-io/milvus/releases/download/v2.4.1/milvus-standalone-docker-compose.yml -O docker-compose.yml
docker compose up -d
Python写入与查询:
from pymilvus import connections, Collection, CollectionSchema, FieldSchema, DataType, utility
import numpy as np, time
connections.connect(host="localhost", port="19530")
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=False),
FieldSchema(name="category_id", dtype=DataType.INT64),
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768),
]
schema = CollectionSchema(fields, description="product_vectors")
collection = Collection(name="products", schema=schema)
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()
# 批量写入
vectors = np.random.rand(1000000, 768).astype(np.float32)
ids = list(range(1000000))
cats = np.random.randint(0, 50, 1000000).tolist()
t0 = time.time()
batch = 5000
for i in range(0, 1000000, batch):
collection.insert([
ids[i:i+batch],
cats[i:i+batch],
vectors[i:i+batch].tolist(),
])
collection.flush()
print(f"Milvus insert 1M vectors: {time.time()-t0:.1f}s")
# 查询
query_vec = vectors[0].tolist()
search_params = {"metric_type": "COSINE", "params": {"ef": 128}}
t0 = time.time()
res = collection.search(
data=[query_vec],
anns_field="embedding",
param=search_params,
limit=10,
expr="category_id in [1,2,3]",
output_fields=["category_id"],
)
print(f"Milvus search: {(time.time()-t0)*1000:.1f}ms")
4.2 Qdrant部署与查询
部署命令:
docker run -d --name qdrant \
-p 6333:6333 -p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage \
qdrant/qdrant:v1.9.2
Python写入与查询:
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct, Filter, FieldCondition, MatchAny
import numpy as np, time
client = QdrantClient(host="localhost", port=6333)
client.recreate_collection(
collection_name="products",
vectors_config=VectorParams(size=768, distance=Distance.COSINE),
hnsw_config={"m": 16, "ef_construct": 200},
)
vectors = np.random.rand(1000000, 768).astype(np.float32)
cats = np.random.randint(0, 50, 1000000).tolist()
t0 = time.time()
batch = 5000
for i in range(0, 1000000, batch):
points = [
PointStruct(id=j, vector=vectors[j].tolist(), payload={"category_id": cats[j]})
for j in range(i, i+batch)
]
client.upsert(collection_name="products", points=points)
print(f"Qdrant upsert 1M vectors: {time.time()-t0:.1f}s")
# 查询
query_vec = vectors[0].tolist()
t0 = time.time()
res = client.search(
collection_name="products",
query_vector=query_vec,
limit=10,
query_filter=Filter(
must=[FieldCondition(key="category_id", match=MatchAny(any=[1,2,3]))]
),
search_params={"hnsw_ef": 128},
)
print(f"Qdrant search: {(time.time()-t0)*1000:.1f}ms")
五、踩坑与优化
Milvus的坑:
- 首次查询慢:collection.load()后第一次查询要等段加载,P99会飙到2秒。解决办法是预热:load后先跑100次空查询。
- etcd磁盘写入:默认etcd数据在容器内,重启丢元数据。必须挂载volume,否则collection schema全丢。
- 内存溢出:100万×768维float32约3GB原始数据,Milvus加载索引后实测占用7.2GB。8GB机器跑100万向量很勉强,建议16GB起步。
- 过滤性能:
expr里的标量过滤在Milvus 2.4中走的是“先过滤再检索”,当过滤命中率低于10%时延迟反而比纯向量检索高。可以开启partition_key做分区裁剪。
Qdrant的坑:
- upsert不是幂等覆盖:相同id会覆盖,但payload是整体替换不是合并。要合并得用
set_payload。 - 内存映射:Qdrant默认把向量存磁盘并mmap,内存占用看着低,但首次查询会触发磁盘IO。设置
on_disk: false强制常驻内存后,100万向量占用约4.8GB。 - 过滤索引:category_id这种低基数字段,建payload索引反而慢。高基数字段(如商家id)建索引后过滤查询快3倍。
共同优化:
- 批量写入用5000-10000条一批,太小网络开销大,太大客户端内存涨
- 查询时ef/hnsw_ef从64开始调,128是召回率和延迟的平衡点,256以上延迟翻倍但召回只涨0.3%
六、效果数据
100万条768维向量,top-10检索,1000次查询统计:
| 指标 | Milvus 2.4.1 | Qdrant 1.9.2 |
|---|---|---|
| 写入总耗时 | 412s | 287s |
| 索引构建时间 | 186s | 94s |
| P50延迟(纯向量) | 8.2ms | 6.7ms |
| P99延迟(纯向量) | 24ms | 18ms |
| QPS(纯向量) | 892 | 1120 |
| P99延迟(带过滤) | 41ms | 29ms |
| 内存占用(稳定) | 7.2GB | 4.8GB |
| 磁盘占用 | 5.1GB | 3.9GB |
| 部署容器数 | 3 | 1 |
结论:
- 单节点100万向量以内,Qdrant在延迟、内存、部署复杂度上全面占优
- Milvus的优势在分布式:我们后来压到500万向量时,Milvus集群版QPS到3400,Qdrant单节点掉到480且P99到120ms
- 如果业务量在200万向量以下且不想运维复杂中间件,选Qdrant
- 如果预期半年内破千万向量,或者需要多租户物理隔离、一致性快照,直接上Milvus分布式
最终我们选了Qdrant,因为当前商品库只有80万向量,且团队只有2个后端,不想维护etcd和MinIO。等破500万再迁Milvus,Qdrant的snapshot可以导出,迁移成本可控。