
📰 科技要闻
• Cathie Wood旗下ARK基金大举买入Coinbase和SpaceX股票,加注科技赛道
• 中国6月CPI增速放缓,但PPI通胀升至近4年新高,制造业成本压力上升
• 播客热议「Agent元年第500天」:GUI思维的软件正在被淘汰,Agent-native架构成新共识
起因:一次惨痛的全文检索翻车
上一篇我们用 Vercel AI SDK 搭好了前端对话界面,但知识库系统的核心瓶颈不在 UI——而在检索。
说个真实的坑。去年年底我接了个内部项目,需求是「把公司几万篇技术文档做成可问答的知识库」。我一开始偷懒,直接用 ElasticSearch 做关键词检索,结果用户问「如何优化启动速度」,ES 给我返回的全是标题带「优化」的运维手册——因为 BM25 只认字面匹配,根本理解不了语义。
那次之后我开始正经研究向量数据库。折腾了 Pinecone、Qdrant、Weaviate、Milvus 好几个,最后在生产环境定了 Milvus。这篇就讲讲为什么选它,以及从安装到百万级向量检索踩过的那些坑。
向量数据库赛道全景
先说结论,再展开。这四个数据库就像四种交通工具:Pinecone 是打车(贵但无脑),Qdrant 是赛车(快但生态年轻),Weaviate 是房车(什么都包但不够极致),Milvus 是地铁(搭建慢但运力大,越多人用越值)。如果你的场景是「企业内部、数据量百万到千万级、需要私有化部署、团队有运维能力」,Milvus 是当前最稳的选择。
数据库 | 部署模式 | 适合场景 | 短板 |
|---|---|---|---|
Pinecone | 纯SaaS | 快速原型 | 不能私有化,成本随量暴涨 |
Qdrant | 自部署/云 | Rust极致性能 | 生态年轻,大规模案例少 |
Weaviate | 自部署/云 | 内置向量化模块 | Go写的,性能中等 |
Milvus | 自部署/Zilliz云 | 百万~十亿级生产 | 部署复杂度较高 |
Pinecone 完全托管,5分钟跑起来,但数据出不了别人的机房,而且百万向量以上的月账单看了心疼。Qdrant 用 Rust 写的,单机性能确实猛,但 2024 年底才出 sharding,大集群经验在社区还不多。Weaviate 的亮点是可以把 embedding 模型直接挂进数据库,省掉一层调用,但这种「全包」架构反而让你不好换模型。
Milvus 胜在三点:分布式原生(从 2.0 架构就是存算分离的),索引类型全(IVF/HNSW/DiskANN 全覆盖),社区活跃(Zilliz 在背后投入大量工程资源,GitHub 3万+ star 不是刷的)。
Milvus 架构拆解
要用好 Milvus,必须理解它的四层架构。很多人把它当 Redis 用,结果遇到性能问题两眼一黑,根本不知道该调哪一层。
SDK Client
↓ gRPC
Proxy(接入层)
↓ 路由分发
Coord 协调层
RootCoord / QueryCoord / DataCoord / IndexCoord
↓ 调度任务
QueryNode → 负责向量检索(内存加载 segment)
DataNode → 负责数据写入(WAL + flush 到 MinIO)
IndexNode → 负责后台建索引(CPU密集型)
↓ 持久化
MinIO/S3 + etcd + Pulsar/Kafka
为什么要存算分离?
翻译成人话:想象一个图书馆。所有的书(数据)存在地下仓库(MinIO/S3)里,永远不丢。阅览室(QueryNode)只把读者当前可能要查的书搬上来。管理员(Coord 层)负责调度哪些书该搬到哪个阅览室。整理员(IndexNode)在后台给书贴标签建索引。
这个设计的好处很直接:
1. 写入和查询可以独立扩缩容——读者太多?多开一间阅览室(加 QueryNode)。入库太慢?多招一个搬运工(加 DataNode) 2. IndexNode 可以用 GPU 机器专门建索引,建完就释放——相当于临时雇了一队人贴完标签就走 3. 数据持久性靠 S3/MinIO 保证——仓库着火了(节点挂了),数据还在异地备份里
说实话,第一次看这个架构我觉得太重了——搭一套 Milvus 还得带上 etcd、MinIO、Pulsar?这不是搬个家吗,这是搬一个图书馆。但后来数据量到 500 万条的时候,我开始感谢这个设计。QueryNode 内存不够?加一台机器,自动 rebalance。IndexNode 建索引太慢?再开两个 pod。这种灵活性是单体架构给不了的。
生产级安装:Docker Compose vs K8s
Milvus 官方给了两种部署方式。我建议的路径:开发/POC 用 Docker Compose,生产用 Helm Chart 上 K8s。别直接 standalone 模式上生产,后面迁移会很痛苦。
Docker Compose 快速启动
# 拉取官方 compose 文件
wget https://github.com/milvus-io/
milvus/releases/download/
v2.4.17/
milvus-standalone-docker-
compose.yml \
-O docker-compose.yml# 启动(后台运行)
docker compose up -d# 验证服务状态
docker compose ps
# 确认 milvus-standalone
# 状态为 running⚠️ Standalone 模式默认内存限制 8GB。如果你的向量维度是 1536(OpenAI embedding),100万条大约需要 ~6GB 纯向量内存,加上索引和标量字段会更多。开发环境建议预留 16GB 以上。
K8s Helm 部署(生产推荐)
# 添加 Milvus Helm repo
helm repo add milvus \
https://zilliztech.github.io/
milvus-helm/
helm repo update# 安装集群模式
helm install my-milvus \
milvus/milvus \
--set cluster.enabled=true \
--set queryNode.replicas=2 \
--set dataNode.replicas=2 \
--set indexNode.replicas=1 \
-n milvus \
--create-namespace生产环境的关键配置项(写在 values.yaml 里):
# values-prod.yaml
queryNode:
replicas: 3
resources:
requests:
memory: "16Gi"
cpu: "4"
limits:
memory: "32Gi"dataNode:
replicas: 2indexNode:
replicas: 2
resources:
requests:
cpu: "8"
memory: "16Gi"# 对象存储用 S3 而非内置 MinIO
externalS3:
enabled: true
host: "s3.amazonaws.com"
bucketName: "milvus-prod"索引类型选择:这是最容易翻车的地方
Milvus 支持多种索引,但选错索引比不建索引更可怕——因为你会得到一个「看起来能用,但召回率惨不忍睹」的系统。这就像你用百度搜一个问题,第一页全是广告——搜索引擎确实“工作”了,但结果对你毫无价值。
三种主流索引的本质区别
用一个类比来解释:假设你要在 100 万人的公司里找到“和你兴趣最相近的 10 个人”:
• IVF_FLAT:先把人分成 1000 个部门(聚类),然后只在你可能属于的几个部门里一个一个比对(暴力搜) • IVF_PQ:同样分部门,但比对时只看每个人的“标签摘要”而不是完整简历(量化压缩)——快很多,但会漏人 • HNSW:建一个“六度分隔”社交网络,每个人和最相似的几个人连线。找人的时候沿着关系链跳转,几跳就能找到目标
索引 | 原理 | 内存占用 | 适合场景 |
|---|---|---|---|
IVF_FLAT | 聚类后暴力搜 | 高(原始向量) | 百万级、高召回 |
IVF_PQ | 聚类+量化压缩 | 低(4~8x压缩) | 千万级、省内存 |
HNSW | 分层图结构 | 高(向量+图) | 低延迟、高精度 |
我的选型经验:知识库 RAG 场景,首选 HNSW。原因很简单:知识库最怕漏召回——用户问了一个问题,明明有相关文档,但检索没找到,这是灾难性体验。HNSW 的 recall 可以做到 98%+ 而延迟依然在 10ms 以内。
IVF_FLAT 适合「数据量大但内存够」的场景,比如你有 3000 万条向量且每条都需要高召回。IVF_PQ 适合「内存紧张、可以接受 5~10% 精度损失」的场景,比如推荐系统的粗排。
💡 一个容易被忽略的点:HNSW 的内存占用 = 原始向量 + 图结构。图结构大小约等于 向量数 × M × 2 × 8字节(M 是建图时每个节点的连接数,默认 16)。所以 100 万条 1536 维向量,HNSW 大约需要 6GB(向量) + 0.25GB(图) ≈ 6.25GB。
Collection 设计:Schema 不是随便写的
Milvus 的 Collection 类似关系数据库的表,但有向量数据库特有的设计陷阱。直接上我在生产中用的 Schema:
from pymilvus import (
Collection, CollectionSchema,
FieldSchema, DataType,
utility
)# 定义字段
fields = [
FieldSchema(
name="id",
dtype=DataType.VARCHAR,
max_length=128,
is_primary=True
),
FieldSchema(
name="doc_id",
dtype=DataType.VARCHAR,
max_length=64
),
FieldSchema(
name="chunk_index",
dtype=DataType.INT32
),
FieldSchema(
name="text",
dtype=DataType.VARCHAR,
max_length=8192
),
FieldSchema(
name="embedding",
dtype=DataType.FLOAT_VECTOR,
dim=1536
),
FieldSchema(
name="source",
dtype=DataType.VARCHAR,
max_length=256
),
FieldSchema(
name="created_at",
dtype=DataType.INT64
),
]schema = CollectionSchema(
fields=fields,
enable_dynamic_field=True
)# 创建 Collection
collection = Collection(
name="knowledge_chunks",
schema=schema
)几个关键设计决策
1. 主键用 VARCHAR 而非自增 INT64
知识库的 chunk 有天然业务 ID(doc_id + chunk_index),用自增 ID 你后续做增量更新会很痛苦——你得先查旧 ID 再删再插。用业务 ID 做主键,upsert 的时候直接覆盖。
2. enable_dynamic_field=True
这是 Milvus 2.4 加的功能,允许你往文档里塞 schema 以外的字段(类似 MongoDB 的灵活字段)。比如后面你想加个 department 字段做权限过滤,不用 alter schema,直接 insert 带上就行。
3. text 字段存原文
有人说「向量数据库只存向量和 ID,原文放关系库」。理论上没错,但实际上多一次数据库查询就多一层出错可能。RAG 场景下,检索完就要把 text 返回给 LLM,存在一起省一次网络往返。8192 字符的 VARCHAR 够用——chunk 一般不超过 2000 字。
Partition 策略:别滥用,也别不用
Milvus 的 Partition 本质是把 Collection 按某个维度物理拆分。类比图书馆按楼层分区:你知道要找的是计算机书,直接上 3 楼,不用把整个馆翻一遍。查询时可以指定只搜某个 partition,减少扫描范围。
# 按租户分 partition
collection.create_partition(
"tenant_abc"
)
collection.create_partition(
"tenant_xyz"
)# 查询时指定 partition
results = collection.search(
data=[query_vector],
anns_field="embedding",
param={"metric_type": "COSINE",
"params": {"ef": 64}},
limit=10,
partition_names=["tenant_abc"]
)我的建议:多租户用 Partition,同一个租户内不要再分。Partition 数量上限是 4096,而且每个 Partition 都会独立维护 segment,太碎了会增加 flush 和 compaction 的负担。
百万级数据的批量导入
往 Milvus 塞 100 万条向量,一条一条 insert 大约需要... 别这么干,我试过,8 小时还没跑完。这就像用勺子往池子里舰水——技术上可行,实践上大可不必。正确姿势是批量 insert + 控制 batch size,相当于换一根水管。
import numpy as np
from pymilvus import CollectionBATCH_SIZE = 5000
TOTAL = 1_000_000col = Collection("knowledge_chunks")for i in range(
0, TOTAL, BATCH_SIZE
):
batch = load_batch(i, BATCH_SIZE)
col.insert(batch)# 每 10 万条手动 flush 一次
if (i + BATCH_SIZE) % 100_000 == 0:
col.flush()
print(
f"Flushed at "
f"{i+BATCH_SIZE}"
)# 全部导入后建索引
index_params = {
"metric_type": "COSINE",
"index_type": "HNSW",
"params": {
"M": 16,
"efConstruction": 256
}
}
col.create_index(
"embedding", index_params
)
col.load()踩过的坑
坑1:batch_size 不是越大越好。单次 insert 的数据量受 gRPC message size 限制(默认 64MB)。1536 维 float32 × 5000 条 ≈ 30MB,安全。如果 batch_size 开到 2 万,很可能超限报错。
坑2:先导完数据再建索引。如果你先 create_index 再 insert,每次 flush 都会触发一次索引构建,导入速度直接慢 10 倍。正确做法:导入完 → flush → create_index → load。
坑3:flush 不要太频繁。flush 会把内存中的 growing segment 转成 sealed segment 并持久化。太频繁会产生大量小 segment,后续 compaction 压力大。我的经验是每 10~20 万条 flush 一次。
性能调优:nprobe 和 ef 的玄学
索引建好了不代表性能就好。查询时的搜索参数直接决定了「召回率 vs 延迟」的权衡。翻译成人话:建索引相当于给图书馆装好了书架,但你找书时愿意多翻几个书架(更准但更慢)还是只看最近的一个(快但可能漏),这就是 ef 参数在做的事。
HNSW 的 ef 参数
ef(search parameter)控制搜索时遍历的候选节点数。回到「六度分隔」的类比:ef 就是你愿意问多少个朋友的朋友。问的人越多,找到目标人的概率越大,但花的时间也越多。
我做过的实测数据(100 万条 1536 维向量,top-10):
ef 值 | Recall@10 | P99 延迟 |
|---|---|---|
16 | 87.2% | 3ms |
64 | 96.8% | 8ms |
128 | 98.9% | 15ms |
256 | 99.5% | 28ms |
知识库场景我用 ef=128,98.9% 的召回率对 RAG 足够用了,15ms 的延迟用户完全无感。如果你的场景是实时推荐需要 5ms 以内,可以降到 32~64。
HNSW 建图参数 M 和 efConstruction
这两个是建索引时设置的,建完就不能改(除非 drop 重建):
• M(默认 16):每个节点的最大连接数。越大图越稠密,recall 越好,但内存和建索引时间都增加。 • efConstruction(默认 256):建图时的搜索宽度。越大建出来的图质量越好,但建索引越慢。
我的推荐:
你的数据量级?
↓
< 100万 → M=16, efConstruction=256(默认即可)
100万~500万 → M=32, efConstruction=512
> 500万 → 考虑 IVF_FLAT 或 DiskANN,HNSW 内存顶不住
增量更新:知识库不是一次性的
知识库最头疼的不是第一次导入,而是后续更新。文档改了一段话,你是全量重建还是增量更新?
Milvus 2.4+ 支持 upsert,这是增量更新的基础:
def update_document(
doc_id: str,
new_chunks: list
):
col = Collection(
"knowledge_chunks"
)# 1. 删除旧 chunks
col.delete(
expr=f'doc_id == "{doc_id}"'
)# 2. 插入新 chunks
entities = []
for i, chunk in enumerate(
new_chunks
):
entities.append({
"id": f"{doc_id}_{i}",
"doc_id": doc_id,
"chunk_index": i,
"text": chunk["text"],
"embedding": chunk["vec"],
"source": chunk["src"],
"created_at": int(
time.time()
),
})col.insert(entities)
col.flush()注意这里我用的是 delete + insert 而不是 upsert,原因是文档更新后 chunk 数量可能变化(比如原来切了 5 段,改完变成 7 段),upsert 只能覆盖同 ID 的,多出来的旧数据它删不掉。
生产环境的监控与运维
最后说几个生产环境必须关注的指标:
1. Growing Segment 大小:如果持续增长说明 flush 没有正常工作,查 DataNode 日志。
2. QueryNode 内存使用率:超过 85% 就该扩容了。向量一旦加载到内存就不会自动释放(除非你 release collection)。
3. 搜索延迟 P99:HNSW 正常应该在 20ms 以内。如果突然飙升,大概率是 segment 太碎没 compact,或者新插入的数据还在 growing segment(未建索引)里。
4. Compaction 频率:Milvus 会自动合并小 segment,但如果 insert 极其频繁,compaction 可能跟不上。可以通过调 dataCoord.compaction.interval 加速。
# 查看 collection 统计信息
from pymilvus import utilitystats = utility.get_query_segment_info(
"knowledge_chunks"
)
for seg in stats:
print(
f"Segment {seg.segmentID}: "
f"rows={seg.num_rows}, "
f"state={seg.state}"
)💭 下一篇预告:有了向量数据库,下一个问题就是「向量从哪来」——文档怎么切 chunk、切多大、重叠多少?Chunking 策略的好坏直接决定检索质量。我们第5篇见。
— Agent全栈工程师 · 第4篇 / 共20篇 —