家具图搜里,产品照有绿色绒面细节,SKU 文案却没有;VectorStore.add() 总会用店里的 EmbeddingModel 再嵌一次文档文本,图向量、批量预计算向量、空文本加 CONTENT_REF 都会被写歪。Spring AI 2.1 的 upsert(EmbeddedDocument) 写入应用侧 float[],配套 similaritySearch(float[], SearchRequest) 用查询向量检索,整条链路都不再碰店内嵌入模型。官方博客 Using Your Own Embeddings in Spring AI 2.1 与参考文档 Writing Precomputed Embeddings 把这条路径定成一等公民。
环境:JDK 21.0.12.1、Spring Boot 4.1.1、spring-ai-bom 2.1.0-RC1(spring-ai-redis-store 解析为 2.1.0-RC1)。向量库用 Redis Stack;本地计数型 EmbeddingModel 替掉真实厂商,固定 4 维 float[],不接 Vertex 多模态。RC1 里同时实现 upsert 与查询向量检索的还有 PGvector、Elasticsearch、Qdrant、OpenSearch。
add 会调 EmbeddingModel,upsert 不会
挂上会递增计数的 CountingEmbeddingModel,先 add 一条有文本的 SKU,再 upsert 一条空文本、带 DocumentMetadata.CONTENT_REF 的商品照行:
Document textDoc = Document.builder()
.id("sku-text-1")
.text("Armchair, model A42")
.metadata("sku", "A42")
.build();
model.reset();
store.add(List.of(textDoc));
// embedCalls.afterAdd == 1
float[] imageVector = new float[] { 0.9f, 0.1f, 0.2f, 0.3f };
Document photoRow = Document.builder()
.id("sku-photo-1")
.text("")
.metadata(DocumentMetadata.CONTENT_REF.value(),
"https://cdn.example/products/a42.jpg")
.metadata("sku", "A42")
.build();
model.reset();
store.upsert(List.of(new EmbeddedDocument(photoRow, imageVector)));
// embedCalls.afterUpsert == 0
跑出来的断言:
embedCalls.afterAdd=1
ASSERT addCallsEmbeddingModel=true
embedCalls.afterUpsert=0
ASSERT upsertDoesNotCallEmbeddingModel=true
EmbeddedDocument 把 Document 和应用侧向量绑在一起;空 .text("") 合法,向量照写,不会把空串再嵌一遍。官方家具图搜示例用 VertexAiMultimodalEmbeddingModel 产出图向量(来源:Using Your Own Embeddings in Spring AI 2.1),本文用固定 float[] 替代那一步,只验证写入路径。
Redis:维度读不出来就先抛,配上就能搜到
官方专门点名 Redis:索引维度既读不到、builder 也没配 dimensions 时,错尺寸向量可能静默写入却永远搜不出来,所以 RedisVectorStore 在写入前抛 IllegalStateException。本地构造未设 dimensions、且 initializeSchema(false)、EmbeddingModel.dimensions() 也失败的 store,对同一条 EmbeddedDocument 调用 upsert:
dimensionGuard.threw=true
dimensionGuard.message=Could not determine the vector size of index idx-missing-dims. Configure dimensions on the store, or make sure the index exists and can be read.
ASSERT redisUpsertThrowsWithoutDimensions=true
再开一个 dimensions(4) 且 initializeSchema(true) 的索引,用空文本 + CONTENT_REF 写入绿色绒面近似向量,再以查询 float[] 检索:
float[] greenVelvet = new float[] { 0.91f, 0.12f, 0.08f, 0.05f };
store.upsert(List.of(new EmbeddedDocument(row, greenVelvet)));
float[] query = new float[] { 0.90f, 0.11f, 0.09f, 0.06f };
List<Document> hits = store.similaritySearch(query,
SearchRequest.builder().topK(1).similarityThresholdAll().build());
embedCalls.afterUpsertBeforeSearch=0
ASSERT upsertStillNoEmbedCall=true
embedCalls.afterQueryEmbeddingSearch=0
ASSERT queryEmbeddingSearchNoEmbedCall=true
search.hits=1
search.hit.id=sku-a42
search.hit.sku=A42
search.hit.contentRef=https://cdn.example/products/a42.jpg
search.hit.textEmpty=true
ASSERT similaritySearchFindsUpsertedRow=true
查询重载同样不调店内 EmbeddingModel;SearchRequest 里的 query 文本被忽略,过滤与 topK 仍生效。CONTENT_REF 只是元数据键(值为 content_ref),Spring AI 不会去拉照片 URL。
合同边界
upsert / similaritySearch(float[], ...) 是 opt-in 默认方法,未实现的 store 抛 UnsupportedOperationException。EmbeddedDocument 构造期拒绝空向量与非有限值;store 已知维度时,列表写入前整批校验,尺寸不匹配不会半写。同一 ID 再 upsert 是替换;单次调用内不保证事务。稳定 ID(例如从 SKU 派生)让图片索引任务可幂等更新。
图搜场景里,索引与查询应落在同一多模态空间;本文未跑 Vertex,只证明 Redis 上「自带向量写入不触发 EmbeddingModel、缺维度先失败、配维度后能用查询向量命中空文本行」。
一起交流
分享你的思考,让讨论更进一步。