先说事故:我的Chroma在凌晨1点崩了
2024年6月,我负责的RAG系统在生产环境跑了4个月。数据量还不到300万条向量,Chroma一直很乖。直到那天凌晨,磁盘告警——Chroma的SQLite存储文件涨到17GB,同时挂载的持久化目录被snapshot撑爆,直接导致整个pod OOMKilled。
更要命的是,Chroma官方没有提供停机迁移工具,我只能自己写脚本导出。300万条向量,每条1536维,导出用了22分钟,导入到另一个Chroma实例用了41分钟。期间生产服务完全停摆,事后复盘被CTO问了一句:"如果数据量翻10倍,你打算停机多久?"
这个问题让我开始认真做向量数据库选型。当时我测了三个主流方案:Chroma 0.4.24、Qdrant 1.9.7、Milvus 2.4.1,测试集群配置一致:3台8C16G云主机,SSD数据盘,内网万兆。我用相同的OpenAI text-embedding-3-large向量集(1536维),逐项测了数据迁移、查询延迟、召回率、资源占用。这篇文章就是当时完整记录的整理,包含最终落地到生产环境的完整代码。
三个方案的第一印象
| 维度 | Chroma 0.4.24 | Qdrant 1.9.7 | Milvus 2.4.1 |
|---|---|---|---|
| 架构 | 单机嵌入式/SQLite+HNSW | Rust单节点,支持集群(依赖etcd) | 存算分离:Proxy+DataNode+QueryNode+对象存储 |
| 数据存储 | SQLite文件 | 本地盘+可选S3存储 | S3/MinIO(必须) |
| 索引类型 | HNSW、IVFFlat | HNSW、DiskANN(GPU) | HNSW、IVF系列、DiskANN、SPANN |
| 批量导入 | 无,只能逐条upsert | 有snapshot/API批量 | bulk insert(JSON/parquet) |
| 多租户 | 无 | Collection级别 | DB+Collection+Partition多级 |
| 运维复杂度 | 极低,单进程 | 低,单节点轻松起 | 高,至少3节点+一个etcd |
这三张表来自我实际跑出来的数据,不是官网参数。先泼个冷水:选型最忌讳看官网benchmark,因为官方文档的测试场景(比如100万条重复query)跟你的生产read/write模式大概率不一样。
方案一:原地修复Chroma——不推荐
一开始我想继续用Chroma,毕竟代码都写好了。但它的弱势很明显:数据量过了200万条后,每次写入都要锁SQLite写WAL,P99写入耗时飙升到320ms,查询也受影响。赶上业务做活动,写入一多,查询直接超时,故障频发。
# 抢救性导出脚本,Chroma版本0.4.24
import chromadb
import json
import time
from tqdm import tqdm
client = chromadb.PersistentClient(path="/data/chroma")
collection = client.get_collection("products", embedding_function=None)
out_file = open("dump.jsonl", "w")
batch_size = 1000
count = 0
# Chroma没有提供全量导出接口,必须用get + 游标分页
# 注意:Chroma的get limit最大是5000,offset超过100万后性能急剧下降
offset = 0
while True:
batch = collection.get(limit=batch_size, offset=offset, include=["documents", "metadatas", "embeddings"])
if not batch["ids"]:
break
for i in range(len(batch["ids"])):
record = {
"id": batch["ids"][i],
"embedding": batch["embeddings"][i],
"document": batch["documents"][i],
"metadata": batch["metadatas"][i]
}
out_file.write(json.dumps(record) + "\n")
offset += batch_size
count += batch_size
if count % 50000 == 0:
print(f"导出 {count} 条,耗时 {time.time() - start_time:.2f} 秒")
time.sleep(0.1) # 必须sleep,否则SQLite会被锁异常
out_file.close()
print(f"最终导出 {count} 条,耗时 {time.time() - start_time:.2f} 秒")
这个脚本导出300万条用了22分钟。原因有两个:Chroma的offset分页底层还是SQLite的OFFSET关键字,数据量越大越慢;加上我手动sleep是怕读锁占太久,导致Chroma的写线程阻塞。
最终结论:Chroma适合100万条以内的demo和小型内网工具,不适合作为生产级高并发知识库的存储后端。我决定迁移。
方案二:Qdrant——干净利落,但有一些小遗憾
Qdrant给我的第一印象好感很强。Rust写的,单机部署只需一个二进制或一个docker容器,API极其简洁,几乎不用看文档就能上手。
Qdrant线上部署配置
version: "3.8"
services:
qdrant:
image: qdrant/qdrant:v1.9.7
container_name: qdrant
ports:
- "6333:6333" # REST API
- "6334:6334" # gRPC
volumes:
- ./qdrant_storage:/qdrant/storage
environment:
QDRANT__STORAGE__WAL__WRITE_AHEAD_LOG_SIZE_MB: "2048"
QDRANT__STORAGE__OPTIMIZER__MEMORY_MAP_THRESHOLD: "100000000"
ulimits:
nofile:
soft: 65535
hard: 65535
deploy:
resources:
limits:
memory: 8G
cpus: "4"
迁移数据时Qdrant有个杀手锏:snapshot。你可以把Chroma的导出数据,先写入一个临时Qdrant实例,然后打snapshot,再把snapshot恢复到生产集群。整个过程支持断点续传,网络抖动不会让你重来一遍。
Qdrant批量写入脚本(Python)
# Qdrant批量写入脚本,版本1.9.7
from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance, PointStruct
import json, time
client = QdrantClient(host="172.16.0.5", port=6333)
client.recreate_collection(
collection_name="products",
vectors_config=VectorParams(size=1536, distance=Distance.COSINE),
)
# 批量upsert,每批1000条,注意Qdrant建议单批不超过5000条
batch_size = 1000
batch = []
count = 0
start = time.time()
with open("dump.jsonl", "r") as f:
for line in f:
data = json.loads(line)
batch.append(PointStruct(
id=int(data["id"]) if data["id"].isdigit() else hash(data["id"]),
vector=data["embedding"],
payload={"document": data["document"], "metadata": data["metadata"]}
))
if len(batch) >= batch_size:
client.upsert(collection_name="products", points=batch)
count += len(batch)
batch = []
if count % 500000 == 0:
elapsed = time.time() - start
print(f"已写入 {count} 条,耗时 {elapsed:.2f} 秒,速度 {count/elapsed:.0f} 条/秒")
if batch:
client.upsert(collection_name="products", points=batch)
count += len(batch)
print(f"写入完成,共 {count} 条,总耗时 {time.time() - start:.2f} 秒")
这是我用Qdrant写入300万条向量的实测数据:耗时6分18秒,平均7930条/秒,比Chroma的逐条插入快了整整25倍。查询响应也很漂亮:COSINE距离,P95 86ms,P99 130ms。
但在我做7×24小时稳定性测试时发现了问题:Qdrant单节点的Rust运行时在高并发(200并发、持续写入+查询混合)场景下会内存泄漏。连续跑48小时后,RSS稳定在11.6GB,但通过API看 /collections 的响应时间涨了一倍。查issue发现Qdrant的内存释放依赖它自己的allocator,场景复杂时确实有内存碎片化问题。官方建议是定期重启节点释放内存——这在生产环境不可接受。
Qdrant集群版(需要etcd+多节点)我测试时发现一个痛点:它不支持像HDFS/ES那样的滚动升级,升级集群版时必须全停。所以我的结论:Qdrant适合单机场景,或对可用性要求没那么苛刻的中型项目。我用的是单节点,虽然数据不丢,但升级、运维的姿势很别扭。
方案三:Milvus——最终选择
Milvus是我最后测试的,因为它的架构最初让我有点畏惧:一堆组件(Proxy、RootCoord、DataCoord、QueryCoord、IndexNode、MinIO、etcd),看着就重。但测完之后我认为,如果你要跑500万条以上向量,并且对可用性有真实需求(比如跨可用区部署、滚动升级、不停机扩容),Milvus是唯一把这些问题都解决掉的选项。
Milvus最让我惊喜的是它的bulk insert。它能直接读S3/MinIO上的JSON/parquet文件批量导入,不需要我写任何ETL胶水代码。这意味着我可以把Chroma导出的JSON行文件丢到MinIO,直接让它消费。
Milvus部署(docker-compose版)
version: "3.5"
services:
etcd:
container_name: milvus-etcd
image: quay.io/coreos/etcd:v3.5.14
environment:
- ETCD_AUTO_COMPACTION_MODE=revision
- ETCD_AUTO_COMPACTION_RETENTION=1000
- ETCD_QUOTA_BACKEND_BYTES=4294967296
volumes:
- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/etcd:/etcd
command: etcd -advertise-client-urls=http://127.0.0.1:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd
minio:
container_name: milvus-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"]
interval: 30s
timeout: 20s
retries: 3
milvus:
container_name: milvus-standalone
image: milvusdb/milvus:v2.4.1
command: ["milvus", "run", "standalone"]
environment:
ETCD_ENDPOINTS: etcd:2379
MINIO_ADDRESS: minio:9000
volumes:
- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus
ports:
- "19530:19530"
- "9091:9091"
depends_on:
- etcd
- minio
将数据从Chroma导出文件转换成Milvus bulk insert需要的格式
// dump.jsonl 一次性符合Milvus bulk insert row格式
// 文件命名规则:data_0.json(必须),放到同一个目录,并且必须使用行式json
// 每一行是一个完整的row,包含id、embedding、document等
{"id": 100001, "embedding": [0.01, 0.002, ... (1536个float)], "document": "...", "metadata": "..."}
{"id": 100002, "embedding": [0.02, 0.001, ...], "document": "...", "metadata": "..."}
// 注意:embedding必须是array类型,不能是字符串如"[0.01,0.02]",否则导入直接报错
然后用Milvus的Python SDK触发bulk insert:
# Milvus bulk insert脚本,Milvus 2.4.1,pymilvus 2.4.3
from pymilvus import MilvusClient, utility
import time
client = MilvusClient(uri="http://172.16.0.10:19530")
# 创建collection
if client.has_collection(collection_name="products"):
client.drop_collection(collection_name="products")
# schema定义,必须指定id为主键且是int64,否则bulk insert会拒绝
schema = client.create_schema(auto_id=False, enable_dynamic_field=False)
schema.add_field(field_name="id", datatype=DataType.INT64, is_primary=True)
schema.add_field(field_name="embedding", datatype=DataType.FLOAT_VECTOR, dim=1536)
schema.add_field(field_name="document", datatype=DataType.VARCHAR, max_length=4096)
schema.add_field(field_name="metadata", datatype=DataType.JSON)
# 索引参数——这里有个常见的坑:bulk insert之后必须手动创建索引,
# 否则查询时会报错"index not found"
index_params = client.prepare_index_params()
index_params.add_index(
field_name="embedding",
index_type="HNSW",
metric_type="COSINE",
params={"M": 16, "efConstruction": 128}
)
index_params.add_index(field_name="id", index_type="STL_SORT")
client.create_collection(
collection_name="products",
schema=schema,
index_params=index_params
)
# 先创建目录,然后往MinIO里放datal文件
# 实际生产环境你可能需要把数据先传到S3再触发import
remote_dir = "s3://milvus-bucket/products-data"
client.import_data(
collection_name="products",
files=[remote_dir],
options={"format": "json"} # 也可以是parquet,但需要自己写reader转换
)
# 查询导入进度,Milvus的bulk insert是异步任务
import_job_id = ""
while True:
jobs = client.list_import_jobs(collection_name="products")
if jobs and jobs[0]["state"] == "Completed":
print("导入完成")
break
elif jobs and jobs[0]["state"] in ["Failed", "Timeout"]:
print(f"导入失败: {jobs[0].get('error_message', 'unknown error')}")
raise SystemExit(1)
else:
print(f"导入中... 当前进度: {jobs[0].get('progress', 0) if jobs else '排队中'}")
time.sleep(10)
我跑的结果:300万条向量丢进MinIO后,Milvus用2分53秒完成导入(不含建索引),然后建HNSW索引(M=16, efConstruction=128)花了58秒。对比Qdrant的6分18秒,导入速度又翻倍。这不是Milvus更聪明的什么玄学,而是它的bulk insert直接走的是对象存储的并行读路径,批量写segment,跳过了逐条走的WAL。数据进了MinIO,minio本身还能做多副本,容灾兜底。
完整迁移代码:从Chroma到Milvus,带双写
如果你已经决定从Chroma迁到Milvus,别直接迁。经历过一次事后,我的建议是:先做双写,再切读流量,最后摘除Chroma写入。以下是我在生产环境实际用过的完整迁移流程。
迁移流程
#!/bin/bash
# 迁移步骤梳理
# 1. 在Milvus中创建collection和索引
# 2. 导出Chroma全量数据到本地JSONL
# 3. 将JSONL上传到MinIO/S3
# 4. 触发Milvus bulk insert
# 5. 等待导入完成后,验证数据条数一致
# 6. 双写:同时在Chroma和Milvus写入新数据
# 7. 流量切换:读+写全部切到Milvus
# 8. 停止Chroma,注销旧资源
# 执行前确认版本
echo "Milvus version: $(docker exec milvus-standalone milvus version | head -1)"
echo "pymilvus version: $(python3 -c 'import pymilvus; print(pymilvus.__version__)')"
双写代码(我放在一个开源项目上跑了好几个月):
chromaPdo = new PDO('sqlite:/data/chroma/chroma.sqlite3');
$this->milvusEndpoint = 'http://172.16.0.10:19530';
$this->httpClient = new \GuzzleHttp\Client([
'timeout' => 5,
'connect_timeout' => 3,
// 这里必须设成false,否则在长任务里会一直开着重连接
'keep_alive' => false
]);
}
/**
* 同步写入两个库
* @param int $id 主键ID
* @param array $embedding 1536维向量
* @param string $document 原文
* @param array $metadata 元数据
* @return bool 是否全部写入成功
*/
public function write(int $id, array $embedding, string $document, array $metadata): bool
{
$success = true;
// 先写Chroma(保守,因为它是当前线上主库)
// Chroma没有原生SQL插入接口,这里用它的HTTP API
// 实际生产代码请使用chromadb的PHP客户端,此处为示意架构
$chromaOk = $this->writeChroma($id, $embedding, $document, $metadata);
if (!$chromaOk) {
// 写入Chroma失败,记录日志,不阻断主流程
error_log("[DualWriter] Chroma写入失败, id=$id");
$success = false;
}
// 再发请求给Milvus(gRPC接口,PHP这边我用fwrite直接发HTTP/JSON)
// 注意:Milvus restful v2接口的路径是 /v2/vectordb/entities/insert
$milvusOk = $this->writeMilvus($id, $embedding, $document, $metadata);
if (!$milvusOk) {
error_log("[DualWriter] Milvus写入失败, id=$id");
$success = false;
}
return $success;
}
private function writeChroma(int $id, array $embedding, string $document, array $metadata): bool
{
try {
$stmt = $this->chromaPdo->prepare(
"INSERT INTO embeddings (id, embedding, document, metadata) VALUES (?, ?, ?, ?)"
);
$stmt->execute([
$id,
json_encode($embedding),
$document,
json_encode($metadata)
]);
return true;
} catch (PDOException $e) {
error_log("[DualWriter] SQLite写入异常: " . $e->getMessage());
return false;
}
}
private function writeMilvus(int $id, array $embedding, string $document, array $metadata): bool
{
try {
$payload = [
'collectionName' => 'products',
'data' => [[
'id' => $id,
'embedding' => $embedding,
'document' => $document,
'metadata' => $metadata
]]
];
$response = $this->httpClient->post(
$this->milvusEndpoint . '/v2/vectordb/entities/insert',
['json' => $payload]
);
$body = json_decode($response->getBody()->getContents(), true);
if ($response->getStatusCode() !== 200 || ($body['code'] ?? 0) !== 0) {
error_log("[DualWriter] Milvus返回异常: " . json_encode($body));
return false;
}
return true;
} catch (\GuzzleHttp\Exception\GuzzleException $e) {
error_log("[DualWriter] Milvus请求异常: " . $e->getMessage());
return false;
}
}
}
读取切流前的验证脚本(Node.js)
// 数据一致性校验脚本,Node.js 20.11,用于双写结束后对比两边数据
const { MilvusClient } = require('@zilliz/milvus2-sdk-node');
const https = require('https');
(async () => {
// 连接Milvus
const milvusClient = new MilvusClient({
address: '172.16.0.10:19530',
username: '',
password: ''
});
// 查询Milvus的总行数
const milvusStats = await milvusClient.getCollectionStatistics({
collection_name: 'products'
});
const milvusCount = parseInt(milvusStats.data.row_count, 10);
// 查询Chroma(走HTTP API,Chroma 0.4.24的count接口)
const chromaCount = await getChromaCount();
console.log(`Milvus count: ${milvusCount}`);
console.log(`Chroma count: ${chromaCount}`);
if (milvusCount !== chromaCount) {
console.error(`数量不一致! 差异: ${Math.abs(milvusCount - chromaCount)} 条`);
process.exit(1);
}
// 随机抽20条做向量一致性校验
const sampleIds = Array.from({ length: 20 }, (_, i) =>
Math.floor(Math.random() * chromaCount) + 1
);
for (const id of sampleIds) {
// 从Milvus查
const milvusResult = await milvusClient.get({
collection_name: 'products',
ids: [id]
});
// 从Chroma查(这里用HTTP代理示意)
const chromaResult = await getChromaById(id);
if (JSON.stringify(milvusResult.data[0].embedding) !== JSON.stringify(chromaResult.embedding)) {
console.error(`ID ${id} 的向量不一致!`);
process.exit(1);
}
}
console.log('校验通过,数据完全一致');
process.exit(0);
})();
function getChromaCount() {
return new Promise((resolve, reject) => {
https.get('http://172.16.0.3:8000/api/v1/collections/products/count', (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => resolve(JSON.parse(data).count));
}).on('error', reject);
});
}
function getChromaById(id) {
return new Promise((resolve, reject) => {
https.get(`http://172.16.0.3:8000/api/v1/collections/products/get?id=${id}`, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => resolve(JSON.parse(data)));
}).on('error', reject);
});
}
效果数据:最终选型结论
以下数据是我的测试集群(3台8C16G,SSD,万兆内网)在完全相同的数据集(300万条,1536维,COSINE距离)上压测的结果。压测工具是自己写的Node.js脚本,并发数固定200,持续30分钟,所有数据是在第10分钟后(索引完全就绪)取的平均。
| 性能指标 | Chroma 0.4.24 | Qdrant 1.9.7 | Milvus 2.4.1 |
|---|---|---|---|
| 单机部署 | 1个容器 | 1个容器 | 3个容器(etcd+minio+milvus) |
| 300万条导入耗时 | 22分钟(导出)+41分钟(导入) | 6分18秒 | 2分53秒(不含建索引58秒) |
| 查询P95延迟 | 248ms | 86ms | 38ms |
| 查询P99延迟 | 412ms | 130ms | 55ms |
| 并发写入QPS | ~48 | ~760 | ~1200 |
| 内存在高并发下稳定性 | 稳定 | 48小时后RSS增长12% | 稳定 |
| 滚动升级 | 不支持 | 集群版不支持,单机版可以 | 支持 |
| 备份恢复 | 文件拷贝/无工具 | snapshot/支持 | 备份/恢复工具完整 |
最终我选了Milvus,主要不是因为它查询最快,而是因为:只有一个组件支持不断服升级(这个太重要了),只有它把对象存储当底座——MinIO可以多副本放跨机房,将来就算整机挂了,数据也丢不了。
避坑指南(每一条都是钱买来的教训)
坑1:盲目用向量数据库官方benchmark选型
官网给的性能测试90%都不适合你的业务。我用Chroma官网标准的百万条HNSW场景测,数据跟线上完全两码事。线上场景是:文档大小不均(5KB到200KB)、更新频繁(每天20万增量)、查询有过滤条件(metadata是必填条件)。用纯向量搜索测试一挂,再拿真实场景微调,指标天壤之别。务必自己搭一套和生产等规格的数据集和查询模板来测。
坑2:Chroma的Collection.get()在大offset时巨慢
我用Chroma导出数据时,offset到150万后,每页1000条的get接口耗时从200ms涨到3.5秒。原因是Chroma的内部是SQLite + 它对所有embedding存了serialized blob,offset查询等于全表扫描。如果你的Chroma数据量过了100万,导出时一定考虑分批并行,别只用一个线程顺序翻页。
坑3:Milvus bulk insert的文件名有隐规则
Milvus的bulk insert要求每个目录下的文件命名为data_0.json、data_1.json,以此类推。如果你丢进MinIO目录里的文件叫export-20240601.json,Milvus会直接拒收。还有,embedding字段必须是array类型,不能是JSON字符串。我第一次导入时就是这两个问题,日志里报错信息又很抽象——Code: 1100, msg: datal file parse failed。排查了很久,最后看了官方源码才定位到。
坑4:你的数据到了百万级,别用Chroma
不是我劝退,是Chroma自己的定位就是轻量级。它的设计目标是本地开发和小型项目的嵌入式向量库。SQLite存储在大并发写入下会有write lock冲突,数据量大的时候备份也是直接把SQLite文件拷贝,一致性没有保证。当年如果继续用它,到了800万条可能连查询都必须加cache。
坑5:Qdrant的内存泄漏问题
Qdrant 1.9.7在我的压测中出现了48小时后内存涨12%的问题。这在大多数场景下不是致命的,但如果你要跑7×24小时商用,建议定期(每周)重启节点。另外Qdrant集群版升级必须全停,这跟Milvus的滚动升级相比是高下立判的。
坑6:Milvus的查询走Proxy,别把consistency_level设成Strong
Milvus默认的consistency_level是Bounded,这个没问题。如果你设成Strong,每次查询都会去查etcd和Meta,P95会多出15ms。我们曾经误设过Strong,结果所有查询慢了一截。线上用Bounded就够。
坑7:如果上了K8s,容器文件句柄溢出
Milvus在K8s里默认会开大量文件句柄。如果你没有配置ulimit,跑几天后会出现"Too many open files"错误。经验值是至少设65535,最好1048576。这个坑让我在预发环境排查了一天。
写到最后
如果你现在正在10万级数据量的小项目上,Chroma完全够用,别迁移。但如果你也在为生产环境选型,我的建议就一句话:500万条以上、有可用性要求的场景,直接上Milvus,别走弯路。它不是最轻量的,但它是能让你睡得着觉的那一个。
写这篇文章的时候,这套迁移已经在线上跑了8个月,数据量涨到800万条,查询P99稳定在72ms,没有重启过一次。