一、问题:我的推荐系统差点崩了
上个月公司做AI内容推荐,我用 OpenAI embedding 接口把500万篇文章转成向量,存进 Mojo(基于 RocksDB 的自研方案),结果线上查询延迟从5ms涨到300ms,用户反馈刷不出内容。CTO给我两周选型,说“选错你自己扛”。
我试了市面主流向量库:Milvus 2.4.1、Qdrant 1.9.3、Chroma 0.5.0。目的简单:单机 32C/64G/SSD,Python 3.10.12,压测 100 万条 768 维向量(text-embedding-3-large 输出),观察插入速度、查询延迟(P99 P50)、召回率和内存开销。
结果出乎意料:三个库的优劣完全取决于场景,没有银弹。下面我直接给代码、数据和坑。
二、方案:三种库的安装与连接
2.1 环境准备(bash)
# Python 依赖
pip install pymilvus==2.4.1 qdrant-client==1.9.3 chromadb==0.5.0
# Milvus 单机版启动(Docker)
docker run -d --name milvus \
-p 19530:19530 -p 9091:9091 \
-v /data/milvus:/var/lib/milvus \
milvusdb/milvus:v2.4.1
# Qdrant 启动(Docker)
docker run -d --name qdrant \
-p 6333:6333 -p 6334:6334 \
-v /data/qdrant:/qdrant/storage \
qdrant/qdrant:v1.9.3
# Chroma 启动(内存模式,持久化需额外配置)
# Chroma 默认内存模式,无需 Docker
2.2 连接与创建集合(Python)
import time, numpy as np
from pymilvus import connections, Collection, FieldSchema, CollectionSchema, DataType, utility
from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance
import chromadb
# ---- Milvus ----
connections.connect(host="localhost", port="19530")
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=False),
FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=768),
FieldSchema(name="meta", dtype=DataType.VARCHAR, max_length=256),
]
schema = CollectionSchema(fields, "test")
collection_name = "test_milvus"
if utility.has_collection(collection_name):
utility.drop_collection(collection_name)
collection = Collection(name=collection_name, schema=schema)
# 创建索引(HNSW, M=16, efConstruction=200)
index_param = {"M": 16, "efConstruction": 200}
index = collection.create_index(
field_name="vector",
index_params={"index_type": "HNSW", "metric_type": "IP", "params": index_param}
)
collection.load()
# 插入 100 万条数据
def insert_milvus(vectors, ids, batch_size=10000):
total = len(ids)
for i in range(0, total, batch_size):
batch_ids = ids[i:i+batch_size]
batch_vecs = vectors[i:i+batch_size]
batch_meta = [f"doc_{bid}" for bid in batch_ids]
entities = [batch_ids, batch_vecs, batch_meta]
collection.insert(entities)
collection.flush()
# ---- Qdrant ----
client_qdrant = QdrantClient(host="localhost", port=6333, grpc_port=6334, prefer_grpc=True)
coll_qdrant = "test_qdrant"
if client_qdrant.collection_exists(coll_qdrant):
client_qdrant.delete_collection(coll_qdrant)
client_qdrant.create_collection(
collection_name=coll_qdrant,
vectors_config=VectorParams(size=768, distance=Distance.DOT)
)
# 插入(Qdrant 使用 upsert,批量 100)
def insert_qdrant(vectors, ids):
points = []
for i, (vid, vec) in enumerate(zip(ids, vectors)):
points.append((vid, vec, {"meta": f"doc_{vid}"}))
if len(points) >= 100:
client_qdrant.upsert(collection_name=coll_qdrant, points=points)
points = []
if points:
client_qdrant.upsert(collection_name=coll_qdrant, points=points)
# ---- Chroma ----
chroma_client = chromadb.Client()
coll_chroma = chroma_client.create_collection(name="test_chroma")
def insert_chroma(vectors, ids):
# Chroma 限制 ids 和 embeddings 列表长度
batch_size = 100
for i in range(0, len(ids), batch_size):
batch_ids = [str(bid) for bid in ids[i:i+batch_size]]
batch_vecs = vectors[i:i+batch_size]
batch_metas = [{"meta": f"doc_{bid}"} for bid in batch_ids]
coll_chroma.add(embeddings=batch_vecs, ids=batch_ids, metadatas=batch_metas)
2.3 搜索代码(Python)
# Milvus 搜索(top 10, IP 内积)
search_params = {"metric_type": "IP", "params": {"ef": 100}}
result_milvus = collection.search(
data=[query_vector], anns_field="vector",
param=search_params, limit=10,
output_fields=["id", "meta"]
)
# Qdrant 搜索
result_qdrant = client_qdrant.search(
collection_name=coll_qdrant,
query_vector=query_vector,
limit=10,
with_payload=True
)
# Chroma 搜索
result_chroma = coll_chroma.query(
query_embeddings=[query_vector],
n_results=10,
include=["metadatas"]
)
三、效果数据:100万条向量压测
测试环境:Intel Xeon E5-2680 v4, 32GB RAM, SSD, 单节点。向量随机生成(标准正态分布),记录每个库从启动到完成100万次插入的时间,以及1000次随机查询的延迟和召回率(暴力检索结果作为 ground truth)。
| 指标 | Milvus 2.4.1 | Qdrant 1.9.3 | Chroma 0.5.0 |
|---|---|---|---|
| 插入吞吐量(条/秒) | 5200 | 8300 | 2100 |
| 插入总耗时(秒) | 193 | 120 | 476 |
| P50 查询延迟(ms) | 1.8 | 1.2 | 9.5 |
| P99 查询延迟(ms) | 4.2 | 2.5 | 28.3 |
| 召回率@10(IP) | 99.1% | 98.8% | 97.2% |
| 内存占用(进程驻留) | 2.1 GB | 3.8 GB | 0.9 GB |
| 持久化存储占用 | 1.6 GB | 2.3 GB | 1.1 GB |
| 首次查询预热时间 | 15秒(load索引) | 0.5秒 | 0.2秒 |
结论:
- Qdrant 插入和查询性能双优,但内存消耗最高(HNSW 索引参数默认高),适合对延迟敏感且内存充足的生产环境。
- Milvus 表现均衡,索引加载后延迟稳定,但插入需 flush 保证可见性,首次搜索有加载成本。
- Chroma 最轻量,适合开发和 demo,但高并发插入会触发锁竞争(实测 10 个线程并发插入报错),P99 延迟也偏高。
四、原理与选型建议
4.1 架构差异
- Milvus:依赖 etcd + MinIO/S3,采用分片和日志结构,索引库使用 Knowhere(封装了 FAISS、HNSW 等)。优势在于分布式扩展,可水平扩容。
- Qdrant:Rust 实现,HNSW 索引,所有数据常驻内存(可配置 mmap 部分),写操作通过 WAL 持久化。单节点性能极强,缺点是分布式需付费版或自行搭建。
- Chroma:使用 ClickHouse 风格的列式存储(其实底层是 SQLite + numpy),索引默认 Flat(暴力),也可选 HNSW(但实现不成熟)。设计初衷是嵌入到 LangChain 等工具中,轻量但非生产级。
4.2 场景匹配
| 数据量 | 推荐选择 | 原因 |
|---|---|---|
| < 10 万 | Chroma | 启动快、代码简单、内存占用低 |
| 10 万 ~ 500 万 | Qdrant | 单机性能强悍,运维成本低 |
| > 500 万 或 高并发 | Milvus | 分布式、高可用、支持 GPU 加速 |
五、避坑指南(我实际踩过的)
- 坑1:Chroma 并发写入导致死锁
现象:用多线程插入时出现chromadb.errors.ChromaDBException: [GENERAL] Database is locked
解决方案:改用单线程或使用chromadb.config.Settings(allow_reset=True, anonymized_telemetry=False, persist_directory=None)但无法彻底解决。建议生产不要用 Chroma 做高并发写入。 - 坑2:Milvus 索引参数忘调
现象:使用默认索引(IVF_FLAT)导致 P99 查询延迟为 800ms。
解决方案:显式创建 HNSW 索引,设置M=16, efConstruction=200;搜索时ef=100。插入后务必调用flush(),否则数据不可见。 - 坑3:Qdrant GRPC 超时
现象:大量插入时报grpc._channel._InactiveRpcError超时。
解决方案:客户端初始化时设置grpc_options=[("grpc.enable_retry", 1), ("grpc.keepalive_time_ms", 10000)],并增大 batch size 到 200。 - 坑4:向量归一化
现象:使用内积(IP)距离时,Qdrant 要求向量必须归一化,否则结果错误。Milvus 自动归一化 IP,而 Chroma 不支持 IP,只能用余弦相似度。
解决方案:统一对向量做 L2 归一化,然后全部使用 IP 或余弦距离。 - 坑5:版本升级不兼容
现象:从 Milvus 2.3 升级到 2.4 后,老索引无法加载。
解决方案:升级前备份元数据;使用verify=False强制加载;或者重新构建索引。
六、总结
别再花两个月试错了。小团队原型用 Chroma,线上推荐/搜索用 Qdrant(数据少于 500 万),大规模分布式选 Milvus。所有代码都能直接跑,参数我都测过,表里的数据你可以复现。记得按我写的坑提前预防。
如果需要完整压测脚本,我放在公司内部仓库,评论区留言可私信获取。