选型会上定了 Milvus,半年后运维算账说单实例成本太高,要换 pgvector。评估结论是:检索代码只改了两行,但过滤条件全部重写、元数据字段重新映射、三百万条向量重新嵌入。框架抽象省下的是调用方式,没省下能力差异。
这篇文章谈抽象覆盖到哪里为止,以及替换的钱具体花在哪。
1. 框架抽象实际覆盖了什么
Spring AI 在向量库这一层只抽象了三样东西:
| 抽象对象 | 覆盖内容 | 不覆盖 |
|---|---|---|
VectorStore |
add / delete / similaritySearch |
索引类型、分片、副本、量化方式 |
Document |
id、text、metadata(Map<String,Object>) |
元数据是否可过滤、是否建了标量索引 |
SearchRequest |
query、topK、similarityThreshold、filterExpression | 混合检索权重、rerank、多向量查询 |
EmbeddingModel |
embed(text) → float[] |
维度约定、归一化、批量与限流 |
接口只有四个方法,这件事本身就是信号:能被四个方法概括的,只能是"最小公共能力"。凡是需要第二个方法才能表达的(比如按时间范围做分区检索、按字段排序后取最近 N 条),抽象里就没有位置。
2. "接口统一"与"能力对齐"的区别
同一句过滤条件,落到三个库上是三件不同的事:
| 库 | 同一句 tenantId == "t1" 的落地 |
需要的前置条件 |
|---|---|---|
| Milvus | schema 里必须有 tenantId 标量字段 |
建 collection 时就定好,事后加字段要改 schema |
| pgvector | 退化成 SQL WHERE,走普通 B-tree |
建索引;过滤与向量排序同一执行计划里竞争 |
| Redis | 只能塞进 tag 字段,或做 NUMERIC 过滤 |
tag 基数一大,内存与重建成本陡增 |
| Elasticsearch | filter context,有独立缓存 | mapping 必须 keyword,text 不能过滤 |
框架提供的 FilterExpressionBuilder 是可移植的 DSL,不是各库原生语法的超集:
var b = new FilterExpressionBuilder();
var expr = b.and(
b.eq("tenantId", "t-42"),
b.gte("updatedAt", since)
).build();
var req = SearchRequest.builder()
.query("退款政策")
.topK(8)
.similarityThreshold(0.62)
.filterExpression(expr)
.build();
这段代码在四个库上都能编译通过,但只有两个库会真的"先过滤再检索"。另外两个的行为差异要靠实测确认——编译通过不等于语义一致,这是替换时最容易踩的一层。
3. 各向量库的差异面
选型真正要比的不是"支持不支持过滤",而是下面这几行:
| 差异面 | 表现 | 影响 |
|---|---|---|
| 过滤表达式能力 | 是否支持 in、区间、嵌套、正则;能否与 ANN 同层执行 |
复杂过滤下召回骤降 |
| 更新语义 | 按 id upsert,还是"删了再插" | 更新是否原子、是否短暂不可见 |
| 删除成本 | 逻辑删除 + 后台 compaction,还是立即物理删除 | 删除风暴时的延迟抖动 |
| 批量写入 | 单批上限、请求体大小限制、是否有事务 | 全量重建的时长 |
| 元数据索引 | 需手工建,还是自动;支持哪些类型 | 过滤是否走全表扫 |
| 一致性与可见性 | 写入多久可检索到 | "写完立刻查"的用例是否成立 |
更新与删除要单独看,因为它们决定了数据修正的代价:
| 库 | upsert | 删除 | 删除后可见性 |
|---|---|---|---|
| Milvus | upsert,同主键覆盖 |
逻辑删除,compaction 后回收 | 立即不可查,空间延后释放 |
| pgvector | INSERT ... ON CONFLICT |
DELETE,物理删 |
事务提交即可见 |
| Redis | HSET 覆盖字段 |
DEL / 过期 |
立即 |
| Elasticsearch | index 同 _id 覆盖 |
delete + 段合并 |
refresh 间隔后才可见 |
"删除要多久才真正释放空间"这个问题,只在磁盘开始报警的时候才会被问起。
4. 替换成本的三个来源
| 来源 | 具体动作 | 典型量级 |
|---|---|---|
| 元数据模型迁移 | 字段改名、类型换算(时间戳 ↔ 字符串)、补缺失字段 | 天级,需逐条核对 |
| 重新嵌入 | 全量语料重跑嵌入,token 计费 + 限流排队 | 与语料量成正比,通常是最贵的一项 |
| 业务侧过滤改写 | 每个 filter 逐条翻译并回归,边界条件重测 | 最容易被低估,出错最隐蔽 |
嵌入模型更换等于全量重建,这一条经常被当成"改个配置"。原因不是维度,是语义空间:
java.lang.IllegalArgumentException:
The embedding dimension of the vector store is 1536,
but the embedding model returned 1024.
维度对得上也不能混用。两个模型各自训练的向量空间彼此不可比,混在一个集合里做近邻,得到的是噪声而不是相似度。判断方法:抽 100 条已知相似对,看同一模型内与跨模型的分数分布是否重合——重合就说明没区分度。
迁移脚本的骨架(先小批量验证再放量):
# 1) 抽样 500 条,双写新旧两个集合
# 2) 对新集合做 20 条已知 query 的命中比对
# 3) 通过后按 updatedAt 升序全量回填,幂等键 = 文档 id + 内容 hash
# 4) 回填完抽样 1% 重跑检索,比对 topK 重合率
# 5) 切读流量,保留旧集合 7 天只读
5. 多租户与权限过滤放在哪一层
| 层次 | 做法 | 代价 |
|---|---|---|
| 应用层拼 filter | 每条查询强制注入 tenantId |
漏一处就是越权,必须集中封装 |
| 物理隔离 | 每租户独立 collection / namespace | 租户多时元数据爆炸、建表慢 |
| 库级权限 | pgvector + RLS,连接上设 current_setting |
只有关系型有;向量扩展是否尊重 RLS 需实测 |
| 检索后过滤 | 先 topK 再过滤 | topK 被污染,命中不足就直接空结果 |
最后一行的失效方式值得单独记住:
// 反例:先检索后过滤
var hits = store.similaritySearch(req); // topK=8
var mine = hits.stream()
.filter(d -> "t-42".equals(d.getMetadata().get("tenantId")))
.toList(); // 可能只剩 1 条,甚至 0 条
正确做法是把租户条件压进 filterExpression,让库在 ANN 层就排除掉。空结果率高不一定是召回问题,很可能是过滤位置错了。
6. 写入模式与双写过渡
| 模式 | 一致性 | 复杂度 | 适用 |
|---|---|---|---|
| 同事务同步双写 | 强,但库不支持跨源事务 | 低 | 数据量小、可接受部分失败 |
| 事务提交后发消息 | 最终一致,可能丢消息 | 中 | 主流程不能被检索引擎拖慢 |
| CDC 订阅 binlog | 最终一致,不改业务代码 | 中高 | 已有 CDC 基建 |
| 定时全量重建 | 弱,窗口内是旧数据 | 低 | 语料变化慢、可容忍延迟 |
两种更新模式要分清:
| 模式 | 触发条件 | 成本 | 风险 |
|---|---|---|---|
| 增量 upsert | 单文档变更 | 低,一条写请求 | 删除/改 id 时留下孤儿向量 |
| 全量重建 | 换嵌入模型、改分片、切库 | 高,全量 token | 重建期间双份存储与双份查询 |
双写过渡的顺序不能反:
1. 写:旧库 + 新库同时写(新库失败只告警,不回滚主流程)
2. 读:仍读旧库,离线比对新库 topK 重合率
3. 读:灰度切到新库,比例 5% → 50% → 100%
4. 写:停旧库写,观察一个发布周期
5. 下线旧库(先只读两周,再删)
先切读再停写的顺序一旦颠倒,回退时就没有可回退的数据。
7. 可观测:只看四个指标
| 指标 | 采集点 | 异常信号 |
|---|---|---|
| 检索耗时 P95/P99 | VectorStore 外层埋点 |
过滤条件变复杂后陡增 |
| topK 命中数分布 | 每次检索返回条数 | 命中数长期低于 topK → 过滤过严或 threshold 过高 |
| 空结果率 | 返回空列表的比例 | 突升通常对应写入/刷新问题,不是模型问题 |
| 过滤后有效率 | 过滤前后条数比 | 比值过低说明过滤条件写错了维度 |
嵌入调用量要单独计一次账,因为它按 token 计费且容易被全量重建放大:
# 建议在配置里显式暴露,便于按环境核对
app:
vector:
batch-size: 64 # 按目标库的单批上限设置
max-retries: 3
embed-concurrency: 4 # 与供应商限流对齐
8. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 换库后检索全空 | filter DSL 编译到新库时字段名/类型不匹配 | 用同一 query 在原库对照,先去掉 filter 验证 |
| 启动报维度不匹配 | 嵌入模型与集合维度不一致 | 换模型必须重建集合,不能改配置了事 |
topK=8 只返 2 条 |
检索后过滤,或 threshold 过高 | 把条件压进 filter;单独调 threshold 观察 |
| 写入成功但查不到 | 异步队列未消费,或 ES 未 refresh | 查写入侧指标与 refresh 间隔 |
| 删除后仍能检索到 | 逻辑删除未 compaction / 缓存未失效 | 查库的删除语义;应用层缓存要一起清 |
| 批量写入超时 | 单批过大超过请求体上限 | 降 batch-size,按库上限而非内存估算 |
9. 边界:什么时候不必套框架抽象
| 场景 | 建议 | 理由 |
|---|---|---|
| 向量少于十万条 | 直接 SQL + 暴力距离计算 | 建索引的复杂度超过收益 |
| 检索形态固定 | 直接用官方 SDK | 抽象换来的是灵活性,用不上就是负担 |
| 已有成熟检索栈 | 在 ES/OpenSearch 里加向量字段 | 两套检索系统的一致性成本更高 |
| 需要深度调优 | 用原生客户端 + 自管索引参数 | IVF/HNSW 参数在抽象层外 |
| 只用一家且长期不换 | 薄封装而非框架 | 抽象的价值随替换频率上升 |
| 查询形态特殊 | 多向量、稀疏+稠密混合、按字段排序取最近 | 这些在最小公共接口里表达不了 |
| 强一致读 | 关系型 + 事务 | 向量库多数是最终可见 |
判断标准只有一句:你预计多久换一次向量库? 三年不换,抽象的成本大于收益;半年可能换,那就先接受抽象,但把过滤条件集中在一处,别散落在业务代码里。
10. 自测
VectorStore只有四个方法,这个事实本身提示了什么?- 同一句
filterExpression在 Milvus 与 pgvector 上落地差别在哪一步? - 替换向量库的三笔成本里,哪一笔最容易被低估,为什么?
- 嵌入维度一致的两个模型,为什么不能混在同一个集合里?
- "先 topK 再按租户过滤"会导致什么现象,正确位置在哪?
- 双写过渡里,为什么必须先切读再停写?