VectorStore 统一抽象
本教程共 45 篇 · 第 34 篇 · 更新于 2026-08-16 · 约 8 分钟阅读
本节目标:掌握 VectorStore 接口的增删查方法、SearchRequest 的检索参数、与 EmbeddingModel 的配合方式,理解 schema 初始化、批量嵌入和读写分离这些工程细节。
34.1 为什么需要统一抽象
第 35、36 章会看到,Spring AI 支持 20 余种向量库实现。如果没有抽象层,每种库一套 API,业务代码会被供应商锁死。
VectorStore 接口解决的就是这个问题。它把”存向量、查向量”这两个动作抽象成统一方法,底层换成哪个向量库,业务代码一行不用改。
这套抽象在 2.0 里是核心设计。S3 Vector Store、统一命名的 starter、VectorStoreRetriever 只读接口,都围绕它展开。
34.2 两个接口:读写分离
Spring AI 提供两个接口,按职责拆开。
VectorStoreRetriever 是只读接口,只有一个相似度搜索方法。它遵循最小权限原则:只需要查询的组件,不暴露任何写方法。
@FunctionalInterface
public interface VectorStoreRetriever {
List<Document> similaritySearch(SearchRequest request);
default List<Document> similaritySearch(String query) {
return this.similaritySearch(SearchRequest.builder().query(query).build());
}
}
VectorStore 继承 VectorStoreRetriever,再加上写操作:
public interface VectorStore extends DocumentWriter, VectorStoreRetriever {
void add(List<Document> documents);
void delete(List<String> idList);
void delete(Filter.Expression filterExpression);
default void delete(String filterExpression) { ... }
default <T> Optional<T> getNativeClient() { return Optional.empty(); }
}
注意 VectorStore 同时继承 DocumentWriter。上一章的 vectorStore.write(...) 能成立,靠的就是这层关系。getNativeClient() 返回底层原生客户端,需要用到各家特有功能时再取。
Tip查询服务注入
VectorStoreRetriever,写入服务注入VectorStore。接口一拆,代码里谁有权写数据一目了然,也方便测试时替换成假实现。
34.3 相似度是怎么算的
向量检索的底层是距离计算。两个向量离得近,语义就相近。
最常见的度量是余弦相似度,算两个向量的夹角,取值在 -1 到 1 之间,越接近 1 越相似。还有欧氏距离(L2)和内积(IP)两种,一般要求向量先归一化。Spring AI 的 similarityThreshold 统一按 0 到 1 的相似度语义来,越接近 1 越相似。各实现内部会把自己的度量归一化到这一语义,所以业务代码不用关心底层用的是余弦还是欧氏。
向量库只负责存和算,不负责生成向量。入库时用哪个嵌入模型,检索时就用哪个。 模型不一致,向量空间都不一样,检索结果没有意义。这一点在换模型、换维度时要特别小心。
34.4 SearchRequest:检索参数
相似度搜索的参数封装在 SearchRequest 里,用 Builder 构造:
SearchRequest request = SearchRequest.builder()
.query("退款政策是什么")
.topK(5) // 返回最相似的 5 条
.similarityThreshold(0.7) // 相似度低于 0.7 的不要
.build();
List<Document> docs = vectorStore.similaritySearch(request);
四个参数各有讲究。
query 是要检索的文本,接口内部会用 EmbeddingModel 把它转成向量。
topK 是返回条数,默认 4。K 越大上下文越全,但噪声和 token 成本也越高。
similarityThreshold 是相似度下限,取值 0 到 1,默认 0.0(不设限)。设了阈值,检索结果不够像的直接丢弃,宁可少给也不给错的。
filterExpression 是元数据过滤,类似 SQL 的 WHERE。可以直接传字符串表达式:
SearchRequest request = SearchRequest.builder()
.query("The World")
.topK(5)
.filterExpression("country == 'UK' && year >= 2020")
.build();
也可以用 FilterExpressionBuilder 写类型安全的 DSL:
FilterExpressionBuilder b = new FilterExpressionBuilder();
SearchRequest request = SearchRequest.builder()
.query("The World")
.filterExpression(b.and(
b.in("country", "UK", "NL"),
b.gte("year", 2020)).build())
.build();
过滤表达式支持 ==、!=、>、>=、<、<=、in、not、is null 等操作,&& 和 || 组合条件。它是跨向量库可移植的,各实现内部会翻译成自己的查询语法。
过滤条件写得太宽会拖慢检索。能用等值匹配的别用范围匹配,能在入库前筛掉的别留到检索时筛。索引设计各库不同,复杂查询先看目标库的文档。
34.5 与 EmbeddingModel 的配合
向量库只负责存储和检索,不负责生成向量。add 时,VectorStore 内部会调用配置好的 EmbeddingModel 给每个文档算向量,再连同正文、元数据一起入库。所以装配 VectorStore 时必须同时提供 EmbeddingModel。
@Configuration
public class VectorStoreConfig {
@Bean
public VectorStore vectorStore(JdbcTemplate jdbcTemplate, EmbeddingModel embeddingModel) {
return PgVectorStore.builder(jdbcTemplate, embeddingModel)
.dimensions(1536)
.initializeSchema(true)
.build();
}
}
用 Spring Boot starter 时连这个 Bean 都不用写,加依赖加配置,VectorStore 自动装配好,直接注入即可。
自动装配的关键是配置前缀。每种向量库一套前缀,比如 spring.ai.vectorstore.pgvector.*、spring.ai.vectorstore.redis.*、spring.ai.vectorstore.milvus.*。加了对的 starter、写了对应的配置、提供了 EmbeddingModel,容器里就会出现 VectorStore Bean。第 35 章会逐个演示。
没有 starter 的场景,手工装配也简单:找到对应实现的 Builder,传入客户端和嵌入模型即可。两种方式产物相同,业务代码无差别。
大批量入库有个坑:嵌入模型有 token 上限,一次塞几万条文本会报错。Spring AI 用 BatchingStrategy 解决,默认实现是 TokenCountBatchingStrategy,按 token 数把文档分批,每批不超过上限,默认按 8191 token 预留 10% 缓冲计算。
需要自定义时,注册一个 BatchingStrategy Bean 即可全局替换:
@Bean
public BatchingStrategy batchingStrategy() {
return new TokenCountBatchingStrategy(
EncodingType.CL100K_BASE, // tokenizer 编码
8000, // 每批最大 token 数
0.1 // 预留比例
);
}
Note2.0 起 schema 初始化默认关闭。PGvector 建表、Redis 建索引这些动作都需要显式开启,比如
spring.ai.vectorstore.pgvector.initialize-schema=true。这是 1.x 时代默认行为变更过来的,升级项目时最容易踩。
34.6 删除与版本管理
删除有两个入口:按 ID 删,或按过滤表达式删。
// 按 ID 删
vectorStore.delete(List.of(document.getId()));
// 按过滤条件删(字符串或 DSL 表达式)
vectorStore.delete("country == 'Bulgaria'");
按过滤条件删很有用,最常见的场景是文档版本管理:新版本入库前,先把旧版本删掉。
// 写入时给元数据打上文档 ID 和版本号
Document docV1 = new Document("旧版内容",
Map.of("docId", "AIML-001", "version", "1.0"));
// 更新时先删旧版,再写新版
vectorStore.delete("docId == 'AIML-001' && version == '1.0'");
vectorStore.add(List.of(docV2));
删除操作可能抛异常,比如过滤表达式非法,记得用 try-catch 包住。
删除的性能因库而异:按 ID 删一般最快,按过滤表达式删可能要扫描索引。大批量删除分批进行,避免压垮数据库。
删除不可逆,生产环境先确认过滤条件再执行。误删后重新入库要重新生成向量,时间和成本都是双份的。
34.7 完整增删查示例
把接口方法串起来看一遍:
@Service
public class KnowledgeService {
private final VectorStore vectorStore;
public KnowledgeService(VectorStore vectorStore) {
this.vectorStore = vectorStore;
}
public void addDocument(String content, String category) {
Document doc = new Document(content, Map.of("category", category));
vectorStore.add(List.of(doc));
}
public List<Document> search(String query, String category) {
SearchRequest request = SearchRequest.builder()
.query(query)
.topK(5)
.similarityThreshold(0.6)
.filterExpression("category == '" + category + "'")
.build();
return vectorStore.similaritySearch(request);
}
public void removeByCategory(String category) {
vectorStore.delete("category == '" + category + "'");
}
}
增、查、删各一个方法,底层是 PGvector 还是 Milvus,调用方完全无感。这就是统一抽象的价值。
如果某个向量库的能力接口覆盖不了,比如要执行原生 SQL 或调专用 API,用 getNativeClient() 取底层客户端,绕过抽象直接操作。取不到会返回空 Optional,用前记得判空。这是抽象层预留的后门,常规场景用不到,但知道它在,心里有底。
34.8 小结
VectorStore 把 20 余种向量库收敛成一套 API:add 写入、similaritySearch 查询、delete 删除。SearchRequest 用 topK、相似度阈值、过滤表达式三件套控制检索质量。读写分离的 VectorStoreRetriever 让权限边界更清晰。下一章挑三个主流向量库,看它们怎么接进来。