Milvus 折腾记
一、为什么要换
我的 RAG 系统一开始用的是 Redis Stack,RediSearch 的 HNSW 做向量检索,再搭上 ReJSON 存元数据。刚开始只有几万条文档 chunk,延时 5-8ms,完全够用。
等到文档库涨到 30 万条的时候,问题来了:查询延迟开始往 30-50ms 走。到了 50 万条,冷查询偶尔飙到 100ms 以上。最要命的是 HNSW 索引内存占用——1536 维向量,M=16,一条向量大概 147KB,50 万条就是 73GB 内存。Redis 是纯内存数据库,这个趋势下去迟早扛不住。
对比了一圈:
| 方案 | 50万向量内存 | 百万级延迟 | 能扛到多大规模 |
|---|---|---|---|
| Redis Stack (HNSW) | ~73GB | 30-100ms | ~100万,再往上内存吃不消 |
| FAISS (内存) | ~12GB (IVF) | <5ms | 受限于单机内存 |
| Chroma | — | — | 10万左右就不行了 |
| Milvus | ~6GB (IVF_SQ8) | <10ms | 亿级 |
Milvus 的核心优势就一点:向量不一定要全放内存。它可以落盘(MinIO/S3),内存只放索引。而且支持量化压缩(SQ8、PQ),1536 维的向量从浮点数压到 8bit,内存直接砍到 1/8。
二、安装
用的 Docker Compose 单机版,三个容器——etcd(存元数据)、MinIO(存向量数据)、Milvus 本身:
# docker-compose.yml
services:
etcd:
image: quay.io/coreos/etcd:v3.5.5
environment:
ETCD_AUTO_COMPACTION_MODE: revision
ETCD_AUTO_COMPACTION_RETENTION: "1000"
ETCD_QUOTA_BACKEND_BYTES: "4294967296"
volumes:
- etcd_data:/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:
image: minio/minio:latest
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
volumes:
- minio_data:/minio_data
command: minio server /minio_data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
milvus:
image: milvusdb/milvus:v2.4.0
depends_on:
etcd:
condition: service_started
minio:
condition: service_healthy
environment:
ETCD_ENDPOINTS: etcd:2379
MINIO_ADDRESS: minio:9000
ports:
- "19530:19530"
- "9091:9091"
volumes:
- milvus_data:/var/lib/milvus
command: milvus run standalone
volumes:
etcd_data:
minio_data:
milvus_data:
docker compose up -d
# 等大概半分钟
curl localhost:9091/healthz # 返回 OK
如果只是本地开发测试,Milvus 2.4 开始支持 Lite 模式,pip install milvus 之后一行代码就能跑,数据存在本地 SQLite 文件里,不需要 Docker、不需要 etcd、不需要 MinIO。开发者体验终于追上来了。
三、Schema 设计和数据迁移
我的 RAG 文档 Collection 的 schema:
from pymilvus import connections, Collection, CollectionSchema, FieldSchema, DataType
connections.connect(host="localhost", port="19530")
fields = [
FieldSchema(name="id", dtype=DataType.VARCHAR, max_length=64, is_primary=True),
FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=8192),
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=1536),
FieldSchema(name="doc_id", dtype=DataType.VARCHAR, max_length=64), # 所属文档
FieldSchema(name="chunk_type", dtype=DataType.VARCHAR, max_length=32), # text/figure/table
FieldSchema(name="created_at", dtype=DataType.INT64), # unix timestamp
]
schema = CollectionSchema(fields, description="RAG document chunks")
collection = Collection(name="rag_chunks", schema=schema)
这里有两点是我踩坑之后的教训:
-
主键用内容的 MD5,不要用 UUID 或 INT64 自增。同一段文本永远生成同一个 MD5,增量更新时自动覆盖旧 chunk,不用先 delete 再 insert。UUID 每次生成新 ID,同一条 chunk 入库两遍你都发现不了。INT64 自增在迁移和合并时更是噩梦。具体解释见 Embedding 向量化实战 的第九节。
-
标量字段只建你真需要过滤的。我最开始一股脑把所有 metadata(作者、日期、标签、语言、文件路径、页数、字数字数字数……)全部塞进 Milvus。后来发现大部分字段从来没人查,白白浪费了存储和索引开销。现在我只保留
doc_id、chunk_type、created_at这三个真的在过滤条件里出现过的字段。其余全放 Redis,按 id 回查。
从 Redis Stack 迁移数据到 Milvus 我是写了一个很简单脚本:batch read Redis → 构造 Milvus entities → batch insert Milvus。50 万条大概跑了 8 分钟。迁移脚本的核心逻辑:
BATCH = 1000
for offset in range(0, total, BATCH):
# 从 Redis 批量读
keys = [f"chunk:{i}" for i in range(offset, offset + BATCH)]
data = redis_client.json().mget(keys, "$")
# 组织成 Milvus 格式
ids = [d["id"] for d in data]
texts = [d["text"] for d in data]
embeddings = [d["embedding"] for d in data]
doc_ids = [d["doc_id"] for d in data]
types = [d.get("chunk_type", "text") for d in data]
timestamps = [d.get("created_at", 0) for d in data]
collection.insert([ids, texts, embeddings, doc_ids, types, timestamps])
collection.flush()
四、索引选型——花了最多时间的地方
Milvus 的索引类型一大堆,选错了之后要么内存爆炸要么召回掉崖。我把每种都实测了一遍,跟你分享一下数据。
我的测试条件:100 万条 1536 维向量,阿里云 ECS 8 核 32G。
| 索引 | 索引构建时间 | 内存占用 | top-10 召回率 | QPS(ef=64) | 我的结论 |
|---|---|---|---|---|---|
| FLAT | 无(不做索引) | 6GB | 100% | ~200 | 10万以下用这个,精准不废话 |
| IVF_FLAT | 8分钟 | 8GB | 96% | ~800 | 中规中矩,适合入门 |
| IVF_SQ8 | 8分钟 | 4GB | 93% | ~900 | 对内存最友好的选择 |
| HNSW | 25分钟 | 14GB | 99% | ~2500 | 百万级的最优解 |
| SCANN | 18分钟 | 10GB | 98% | ~3000 | 数据再大时的首选 |
我的选择是 HNSW。理由:32G 内存扛得住 14G 的索引,99% 的召回率和 2500 QPS 对于我的场景来说是最优平衡。但如果你的机器只有 16G 内存,那就别纠结——老老实实用 IVF_SQ8,内存省一大半,召回率只掉 6 个百分点,实际体验里感知不出来。
HNSW 的参数调优:
index_params = {
"index_type": "HNSW",
"metric_type": "COSINE",
"params": {
"M": 16, # 别设太高,M=16 到 M=64 提升不到 1% 召回,内存多 4 倍
"efConstruction": 200 # 只影响建索引速度,不影响查询,200 够了
}
}
collection.create_index(field_name="embedding", index_params=index_params)
查询的时候可以动态调 ef:
# 高精度场景(ef=256,稍慢)
search_params = {"metric_type": "COSINE", "params": {"ef": 256}}
# 高吞吐场景(ef=64,快)
search_params = {"metric_type": "COSINE", "params": {"ef": 64}}
五、查询和过滤的实际用法
基础检索没什么说的,就一行:
collection.load() # 必须先 load,把索引加载进内存
results = collection.search(
data=[query_embedding],
anns_field="embedding",
param={"metric_type": "COSINE", "params": {"ef": 128}},
limit=10,
output_fields=["text", "doc_id", "chunk_type"]
)
真正有用的是标量过滤——比如限定在某个文档内搜索:
results = collection.search(
data=[query_embedding],
anns_field="embedding",
param=search_params,
limit=10,
expr='doc_id == "doc_123" and chunk_type == "text"', # 过滤条件
output_fields=["text", "doc_id"]
)
expr 的语法和 SQL WHERE 一样:==、!=、>、<、in、and、or、not,还支持 like。注意字符串值要加引号。
六、迁移过程中碰到的实际问题
问题一:插入时内存爆了。一开始我设了 BATCH=10000,跑了几个 batch 之后 Milvus 直接 OOM。后来降到 BATCH=1000,并且在每批之间加 collection.flush() 手动刷盘,就不爆了。
问题二:先插数据再建索引,比边插边建快一倍。Milvus 默认在插入时会自动建索引,这个行为在大量数据写入时极慢。我改成先关 compaction → 全量插入 → 手动建索引 → 开启 compaction,同批 50 万数据写入时间从 35 分钟降到了 8 分钟。
问题三:HNSW 的冷启动问题。刚建完 HNSW 索引的前几次查询延迟会比较高(50ms+),是因为图的节点还没全加载进内存。跑几分钟后就稳定在 10ms 以内了。这个是正常行为,不是什么 bug。
问题四:MinIO 挂了导致整个 Milvus 不可用。有一次 MinIO 因为磁盘满了宕掉,Milvus 直接报连接错误。虽然 Milvus 的内存里缓存了热数据,但写入链路完全断了。后来给 MinIO 加了 healthcheck + 自动重启,加了个 20G 的磁盘监控告警,就没再出过类似问题。
七、监控和运维
Milvus 在 9091 端口暴露了 Prometheus metrics,接入 Grafana 之后能看到 QPS、延迟分位数、内存占用、segment 数量等。
日常运维其实就是盯着两个指标:
内存使用率 < 80% → 超过就扩容或换 IVF_SQ8
查询 P95 延迟 < 20ms → 持续超标就调低 ef 或加 partition
还有一个小技巧:按时间建 Partition。比如按月分——2026_05、2026_06——查询的时候如果用户只需要最近三个月的数据,只搜三个 partition 而不是全量扫描,延迟少一半。
# 建 Partition
from pymilvus import Partition
partition = Partition(collection, "2026_06")
# 只搜指定分区
results = collection.search(
data=[query_embedding],
anns_field="embedding",
param=search_params,
limit=10,
partition_names=["2026_05", "2026_06"],
output_fields=["text"]
)
八、结论
Redis Stack → Milvus 的迁移不值得在项目早期就做。如果你的向量量还不到 30 万,别折腾,Redis Stack 完全够用,省心省力。
但当数据开始往 50 万、100 万走的时候,Milvus 的优势就非常明显了:内存省、延迟低、能横向扩展。迁移成本其实不高——主要是换一套 pymilvus 的 API,写个 batch 迁移脚本,半天就能搞定。真正花时间的是调索引参数和对标测试,但这是必须做的一次性投入。
我现在线上跑了一年多,100 万+ 向量,HNSW 索引,日常 P95 延迟稳定在 8-12ms,内存占用 14G。比 Redis Stack 时代省了一半内存、快了三倍、睡得安稳。