3007 字
约 10 分钟
15
S22-A05 · Spring AI 与向量库集成:抽象边界与替换成本

选型会上定了 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. 自测

  1. VectorStore 只有四个方法,这个事实本身提示了什么?
  2. 同一句 filterExpression 在 Milvus 与 pgvector 上落地差别在哪一步?
  3. 替换向量库的三笔成本里,哪一笔最容易被低估,为什么?
  4. 嵌入维度一致的两个模型,为什么不能混在同一个集合里?
  5. "先 topK 再按租户过滤"会导致什么现象,正确位置在哪?
  6. 双写过渡里,为什么必须先切读再停写?
S22-A05 · Spring AI 与向量库集成:抽象边界与替换成本
https://www.clxhxhhr.top/posts/6116/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。