Spring AI 2.0.1 深度解析-第 16 章 向量存储(上):`VectorStore` 接口体系、`BatchingStrategy`

Spring AI 2.0.1 深度解析-第 16 章 向量存储(上):VectorStore 接口体系、BatchingStrategy 与 SearchRequest

前八章讲的是"检索增强怎么编排"------RetrievalAugmentationAdvisor 的七步流水线、Query 的四种变体、DocumentPostProcessor 怎么压缩。但那些章节里有一个始终没被追问的前提:

文档到底存在哪?相似度到底是怎么算出来的?topK=4 这个 4 是谁定的?

这一章回到地基。你要看到的不是一个"存文档的接口",而是三件事:

  1. 一个 144 行 的接口,怎么用双继承把"写入 / 删除 / 检索"三组契约压进最小化的方法集,以及为什么 delete 有三个重载而 add 只有一个;
  2. 一个只有 四个字段 的 SearchRequest,其中 topK 的默认值 4 和 similarityThreshold 的默认值 0.0 会在你完全不知情的情况下决定 RAG 的召回质量;
  3. 一套从 Filter.Expression AST 到十几种数据库方言 的翻译链路------它有 17 个实现、一个共享基类和三个"不守规矩"的异类,而其中两个最基础的操作(IS NULL 和 NOT)在多数方言里会直接抛异常。

16.0 本章的五句总纲

  1. VectorStore extends DocumentWriter, VectorStoreRetriever(VectorStore:40)。 这个双继承是理解整个体系的钥匙:写入侧来自 DocumentWriter(Consumer<List<Document>> 语义),检索侧被抽成独立的 @FunctionalInterface VectorStoreRetriever(:35-36)。"只读检索"是一等公民,而不是"完整 store 的权限裁剪版"。

  2. add 只有 1 个方法,delete 有 3 个重载------不对称是有意为之的。 写入方向的变体(按 ID / 按表达式 / 按字符串表达式)全部发生在删除侧,因为"加"只需要一种语义,而"删"必须支持"我不知道 ID,只知道条件"。

  3. similarityThreshold 在哪一侧过滤,取决于实现------而且分成三类,语义不等价。 PgVector 把它下推成 distance = 1 - threshold 并作为 SQL 谓词(数据库先过滤再取 topK);Azure / Milvus / Pinecone 是把 topK 下推、在 Java 侧 过滤(先取 topK 再过滤,返回条数可能远小于 topK 甚至为 0);SimpleVectorStore 则是全量过滤后再截断。javadoc 说"这是客户端后处理"(SearchRequest:167-168),这句话只对第二类成立。

  4. BatchingStrategy 全仓库只有一个实现,且它的 javadoc 算错了自己的默认值。 TokenCountBatchingStrategy 的 javadoc 写"实际最大输入 token 数是 7371"(:41-42),但 Math.round(8191 * 0.9) 是 7372 (:108)。同时,22 个实现里有 21 个把它传给 embeddingModel.embed(documents, options, batchingStrategy),只有 SimpleVectorStore 逐条 embed(document)------它的 batchingStrategy 字段是个死字段。

  5. 过滤表达式的能力边界是"最弱方言"决定的。 Filter.ExpressionType 有 13 个值(含 NOT、ISNULL、ISNOTNULL),语法解析器全部支持,FilterExpressionBuilder 全部支持,内存求值器也全部支持,但十几个方言 converter 里只有 Elasticsearch 实现了 ISNULL/ISNOTNULL ------它们要么在 Assert.state(expression.right() != null) 处抛 IllegalStateException(PgVector :58、Pinecone :36),要么在 default -> throw 分支抛 RuntimeException。而 NOT 之所以"能用",是因为基类把它消解掉 了(AbstractFilterExpressionConverter:126-132)。

本章的行文范围:模块拓扑(16.1)、VectorStore 接口(16.2)、VectorStoreRetriever 与 DocumentWriter(16.3)、AbstractObservationVectorStore(16.4)、SearchRequest(16.5)、阈值在哪一侧(16.6)、BatchingStrategy(16.7)、Filter 模型(16.8)、FilterExpressionBuilder(16.9)、文本解析器与 ANTLR 语法(16.10)、AbstractFilterExpressionConverter(16.11)、自动配置(16.12)。

与第 17 章的分工:本章讲抽象层 ,第 17 章讲实现层。有两个专题因为体量原因后移到了第 17 章,本章只做横向引用:

  • 二十余个 FilterExpressionConverter 方言的逐个对照 (谁支持 ISNULL、谁把 NOT 写成了不可达的死分支、doKey 的注入风险分档、换库时的可移植性速查)------它与各个 VectorStore 实现天然同构;
  • 向量存储的 Micrometer 观测体系 (span 名 db.vector.client.operation、3 个低基数标签与 9 个高基数标签的划分、log-query-response 会把检索正文打进 INFO 日志)------它需要结合各实现传入的 databaseSystem 取值来讲。本章 16.4 节已覆盖 AbstractObservationVectorStore 的模板方法骨架。

16.1 模块拓扑:22 个实现,一个内核

16.1.1 内核在 spring-ai-vector-store,实现全在 vector-stores/

arduino 复制代码
$ ls vector-stores/
spring-ai-azure-store                 spring-ai-opensearch-store
spring-ai-bedrock-knowledgebase-store spring-ai-oracle-store
spring-ai-cassandra-store             spring-ai-pgvector-store
spring-ai-chroma-store                spring-ai-pinecone-store
spring-ai-coherence-store             spring-ai-qdrant-store
spring-ai-couchbase-store             spring-ai-redis-semantic-cache
spring-ai-elasticsearch-store         spring-ai-redis-store
spring-ai-gemfire-store               spring-ai-s3-vector-store
spring-ai-mariadb-store               spring-ai-typesense-store
spring-ai-milvus-store                spring-ai-weaviate-store
spring-ai-mongodb-atlas-store
spring-ai-neo4j-store

22 个目录,但只有 21 个是 VectorStore 实现 ------spring-ai-redis-semantic-cache 是语义缓存(第 12 章讲过的东西换了个存储后端),不实现 VectorStore。加上内核模块里的 SimpleVectorStore,本仓库一共有 22 个 VectorStore 实现类。

arduino 复制代码
$ grep -rn "class .*extends AbstractObservationVectorStore\|class .*implements VectorStore" \
    --include=*.java vector-stores/ spring-ai-vector-store/src/main
# 实现类 继承关系 额外接口
1 AzureVectorStore extends AbstractObservationVectorStore InitializingBean
2 BedrockKnowledgeBaseVectorStore implements VectorStore (final) ---
3 CassandraVectorStore extends AbstractObservationVectorStore AutoCloseable
4 ChromaVectorStore extends AbstractObservationVectorStore InitializingBean
5 CoherenceVectorStore extends AbstractObservationVectorStore InitializingBean
6 CouchbaseSearchVectorStore extends AbstractObservationVectorStore ---
7 ElasticsearchVectorStore extends AbstractObservationVectorStore InitializingBean
8 GemFireVectorStore extends AbstractObservationVectorStore InitializingBean
9 MariaDBVectorStore extends AbstractObservationVectorStore InitializingBean
10 MilvusVectorStore extends AbstractObservationVectorStore InitializingBean
11 MongoDBAtlasVectorStore extends AbstractObservationVectorStore InitializingBean
12 Neo4jVectorStore extends AbstractObservationVectorStore InitializingBean
13 OpenSearchVectorStore extends AbstractObservationVectorStore InitializingBean
14 OracleVectorStore extends AbstractObservationVectorStore InitializingBean
15 PgVectorStore extends AbstractObservationVectorStore InitializingBean
16 PineconeVectorStore extends AbstractObservationVectorStore ---
17 QdrantVectorStore extends AbstractObservationVectorStore InitializingBean
18 RedisVectorStore extends AbstractObservationVectorStore InitializingBean
19 S3VectorStore extends AbstractObservationVectorStore InitializingBean
20 TypesenseVectorStore extends AbstractObservationVectorStore InitializingBean
21 WeaviateVectorStore extends AbstractObservationVectorStore ---
22 SimpleVectorStore extends AbstractObservationVectorStore ---

第一条值得记住的结论 :22 个实现里有 21 个 继承 AbstractObservationVectorStore,唯一例外是 BedrockKnowledgeBaseVectorStore------它直接 implements VectorStore 且被声明为 final(:60)。

这带来一个直接后果:

BedrockKnowledgeBaseVectorStore 没有任何观测。 它不是"观测配置没生效",而是根本没走那条代码路径 ------AbstractObservationVectorStore.add/delete/similaritySearch 三个方法里的 Observation 包装它一个都没继承到。如果你在生产用 Bedrock Knowledge Base 并且指望 db.vector.client.operation 这个 span 出现,它会永远缺席。

16.1.2 内核模块的 3,124 行

spring-ai-vector-store 主包一共 3,124 行 Java(不含测试):

文件 行数 职责
observation/AbstractObservationVectorStore.java 183 模板方法基类 + 观测包装
SearchRequest.java 295 检索请求(四个字段)
SimpleVectorStore.java 321 内存实现
SimpleVectorStoreContent.java 167 内存实现的存储单元
SimpleVectorStoreFilterExpressionEvaluator.java 221 Java 侧表达式求值
VectorStore.java 144 接口
VectorStoreRetriever.java 58 只读检索接口
AbstractVectorStoreBuilder.java 101 Builder 基类
SpringAIVectorStoreTypes.java 67 spring.ai.vectorstore.type 的取值常量
filter/Filter.java 145 表达式模型(13 种类型)
filter/FilterExpressionTextParser.java 350 ANTLR 文本解析器
filter/FilterHelper.java 206 取反 / IN 展开
filter/FilterExpressionBuilder.java 139 编程式 DSL
filter/converter/AbstractFilterExpressionConverter.java 358 方言转换基类
filter/converter/PineconeFilterExpressionConverter.java 76 Pinecone 方言
filter/converter/PrintFilterExpressionConverter.java 61 调试用方言
observation/VectorStoreObservationContext.java 215 观测上下文
observation/VectorStoreObservationDocumentation.java 198 Micrometer 文档定义
observation/DefaultVectorStoreObservationConvention.java 159 默认命名/标签约定
properties/CommonVectorStoreProperties.java 43 initialize-schema

整个向量存储抽象层只有 3,124 行。 这个数字本身就是结论:Spring AI 对向量存储的抽象是刻意做薄的 ------它不试图统一各家的数据模型、不试图统一索引策略、不试图统一事务语义,只统一三件事:写入、删除、检索 ,外加一套可移植的过滤表达式。

16.1.3 自动配置模块:21 个

arduino 复制代码
$ ls auto-configurations/vector-stores/
spring-ai-autoconfigure-vector-store-azure
spring-ai-autoconfigure-vector-store-bedrock-knowledgebase
spring-ai-autoconfigure-vector-store-cassandra
spring-ai-autoconfigure-vector-store-chroma
spring-ai-autoconfigure-vector-store-couchbase
spring-ai-autoconfigure-vector-store-elasticsearch
spring-ai-autoconfigure-vector-store-gemfire
spring-ai-autoconfigure-vector-store-mariadb
spring-ai-autoconfigure-vector-store-milvus
spring-ai-autoconfigure-vector-store-mongodb-atlas
spring-ai-autoconfigure-vector-store-neo4j
spring-ai-autoconfigure-vector-store-observation
spring-ai-autoconfigure-vector-store-opensearch
spring-ai-autoconfigure-vector-store-oracle
spring-ai-autoconfigure-vector-store-pgvector
spring-ai-autoconfigure-vector-store-pinecone
spring-ai-autoconfigure-vector-store-qdrant
spring-ai-autoconfigure-vector-store-redis
spring-ai-autoconfigure-vector-store-redis-semantic-cache
spring-ai-autoconfigure-vector-store-s3
spring-ai-autoconfigure-vector-store-typesense
spring-ai-autoconfigure-vector-store-weaviate

21 个模块,其中 20 个对应具体 store、-observation 一个是横切。注意 coherence 有实现模块(vector-stores/spring-ai-coherence-store)但没有自动配置模块 ------Coherence 只能手动装配。这是一个容易踩空的不对称:你引了 spring-ai-coherence-store,然后发现没有 starter 也没有自动配置。


16.2 VectorStore 接口:144 行的三组契约

java 复制代码
public interface VectorStore extends DocumentWriter, VectorStoreRetriever {

	default String getName() {
		return this.getClass().getSimpleName();
	}

	void add(List<Document> documents);

	@Override
	default void accept(List<Document> documents) {
		add(documents);
	}

	void delete(List<String> idList);

	void delete(Filter.Expression filterExpression);

	default void delete(String filterExpression) {
		SearchRequest searchRequest = SearchRequest.builder().filterExpression(filterExpression).build();
		Filter.Expression textExpression = searchRequest.getFilterExpression();
		Assert.notNull(textExpression, "Filter expression must not be null");
		this.delete(textExpression);
	}

	default <T> Optional<T> getNativeClient() {
		return Optional.empty();
	}

	interface Builder<T extends Builder<T>> {
		T observationRegistry(ObservationRegistry observationRegistry);
		T customObservationConvention(VectorStoreObservationConvention convention);
		T batchingStrategy(BatchingStrategy batchingStrategy);
		VectorStore build();
	}
}

(VectorStore:40-144)

16.2.1 写入侧:只有一个 add,但有三条路径汇进来

add(List<Document>) 是本接口唯一的抽象方法式写入契约。但调用方有三条路可以走到它:

scss 复制代码
vectorStore.add(documents)                    // 直接调
        ↓
DocumentWriter.write(documents)               // :default void write -> accept(documents)
        ↓
Consumer<List<Document>>.accept(documents)    // VectorStore 覆写 accept -> add(documents)
        ↓
VectorStore.add(documents)

DocumentWriter 的定义(DocumentWriter:26-33):

java 复制代码
public interface DocumentWriter extends Consumer<List<Document>> {

	default void write(List<Document> documents) {
		accept(documents);
	}

}

所以 write() 和 accept() 完全等价 ------两个名字,一条实现。而 VectorStore 覆写了 accept 指向 add(:53-56)。

这个设计的好处 :ETL 流水线(DocumentWriter 是 spring-ai-rag / ETL 侧的写入目标抽象)可以接受任何 VectorStore 而无需知道它是向量库。第 18 章讲 ETL 时会回到这里。

这个设计的代价 :add 的语义变得模糊。javadoc(:46-50)只说了一句 "Throws an exception if the underlying provider checks for duplicate IDs"------"重复 ID 会怎样"被交给了实现:

  • SimpleVectorStore.doAdd 用 this.store.put(document.getId(), storeContent)(:128)→ 静默覆盖;
  • 其它实现(PgVector 等)走 SQL INSERT,主键冲突 → 抛异常。

同一份 documents 列表,SimpleVectorStore 覆盖、PgVector 抛异常。 如果你的集成测试跑在 SimpleVectorStore 上、生产跑在 PgVector 上,"重复 ID"这个场景的行为是完全相反的。这是"用内存实现做单测"这类做法最典型的失真点之一。

16.2.2 删除侧:三个重载,两个默认

方法 类型 语义
delete(List<String> idList) 抽象 (:62) 按 ID 批量删
delete(Filter.Expression) 抽象 (:69) 按表达式删
delete(String) default(:79-84) 按文本表达式删 → 转 Expression → 委托

第三个重载的实现值得逐行看:

java 复制代码
default void delete(String filterExpression) {
	SearchRequest searchRequest = SearchRequest.builder().filterExpression(filterExpression).build();
	Filter.Expression textExpression = searchRequest.getFilterExpression();
	Assert.notNull(textExpression, "Filter expression must not be null");
	this.delete(textExpression);
}

它借用 SearchRequest.Builder 做了一次文本解析 ------因为 SearchRequest.Builder.filterExpression(String) 内部就是 new FilterExpressionTextParser().parse(textExpression)(SearchRequest:283-287)。

两个细节:

(1)这个"借道"意味着删除侧的表达式语法和检索侧完全同源。 你不用学两套语法。

(2)Assert.notNull 永远不会被触发。 因为 Builder.filterExpression(String) 的实现是:

java 复制代码
this.searchRequest.filterExpression = (textExpression != null)
		? new FilterExpressionTextParser().parse(textExpression) : null;

传入 null 时结果是 null,于是 Assert.notNull 抛 IllegalArgumentException("Filter expression must not be null")------这个断言是能触发的 (当你显式传 null:vectorStore.delete((String) null))。但如果你传的是"能解析成功"的表达式,它永远不为 null,断言就是死代码。真正会抛异常的是解析器本身的 Assert.hasText(FilterExpressionTextParser:129)------传空串 "" 会在解析器里抛 IllegalArgumentException("Expression should not be empty!") ,而不是走到这个 Assert.notNull。

16.2.3 检索侧:不在本接口里

VectorStore 自己没有定义任何检索方法 。检索全部来自 VectorStoreRetriever(见 16.3)。这是"接口隔离"在 Spring AI 里做得最干净的一处。

16.2.4 getNativeClient():一个泛型逃逸口

java 复制代码
default <T> Optional<T> getNativeClient() {
	return Optional.empty();
}

默认返回 Optional.empty()(:100-102)。javadoc(:86-99)里有一段很有意思的自白:

"Due to Java type erasure, runtime type checking is not possible"

"Note: Using Optional<?> will return the native client if one exists, rather than an empty Optional. For type safety, prefer using the specific client type."

翻译成人话:

Optional<JdbcTemplate> c = vectorStore.getNativeClient() 在没有 native client 的实现上会正常返回 Optional.empty();但如果你写 Optional<?> c = vectorStore.getNativeClient(),Java 会推断 T 为 Object,而实现返回的 Optional.empty() 恰好可以赋给它------所以你拿到的是"空的 Optional",这在行为上没差别。

真正的坑在另一边 :如果你写 Optional<MilvusClient> c = pgVectorStore.getNativeClient(),编译通过、运行也通过、但拿到的是 Optional.empty() ------因为 PgVectorStore 没覆写这个方法,返回的就是 Optional.empty(),而你声明的类型 MilvusClient 只是一次未检查的泛型转换。没有任何异常会告诉你"这个实现没有 native client"。

可复核的事实 :全仓库有多少个实现覆写了 getNativeClient?

perl 复制代码
$ grep -rn "getNativeClient" --include=*.java vector-stores/ | grep -v "/test/"

(留作 16.17 的复核命令之一。)

16.2.5 内嵌 Builder 接口:只有四个方法

VectorStore.Builder<T>(:111-142)只有四个方法,其中三个是观测与批处理:

  • observationRegistry(ObservationRegistry) ------ 默认 ObservationRegistry.NOOP
  • customObservationConvention(VectorStoreObservationConvention) ------ 默认 DefaultVectorStoreObservationConvention
  • batchingStrategy(BatchingStrategy) ------ 默认 new TokenCountBatchingStrategy()

注意这里没有 dimensions()、没有 initializeSchema()、没有 collectionName()。 这些"每个实现都需要的参数"被刻意排除在接口之外------它们由各自的具体 Builder 定义。

这是接口设计上很清醒的一处取舍:不设计一个"通用配置"的假象 。因为 dimensions 对 PgVector 是 vector(N) 的列宽、对 Pinecone 是索引维度、对 Milvus 是 collection schema 字段------语义不同,放进统一接口只会误导。


16.3 VectorStoreRetriever 与 DocumentWriter:为什么拆成两个

16.3.1 VectorStoreRetriever:一个 @FunctionalInterface

java 复制代码
@FunctionalInterface
public interface VectorStoreRetriever {

	List<Document> similaritySearch(SearchRequest request);

	default List<Document> similaritySearch(String query) {
		return this.similaritySearch(SearchRequest.builder().query(query).build());
	}

}

(VectorStoreRetriever:35-58)

@FunctionalInterface + 只有一个抽象方法 → 它可以用 lambda 表示:

java 复制代码
VectorStoreRetriever retriever = request -> List.of();   // 合法

这在测试和桩实现里非常方便。更重要的是它的 javadoc 明确写了设计意图(:23-33):

"This is useful when you want to provide retrieval-only access to a vector store, following the principle of least privilege by not exposing write operations."

"最小权限原则" ------这是 1.0.0 就有的设计(@since 1.0.0),到 2.0.1 依然如此。

实际用法:你的 @Bean 方法可以声明返回类型 VectorStoreRetriever 而不是 VectorStore,下游注入方就物理上无法调用 add/delete。

16.3.2 similaritySearch(String) 的默认实现:一条隐蔽的默认值链

java 复制代码
default List<Document> similaritySearch(String query) {
	return this.similaritySearch(SearchRequest.builder().query(query).build());
}

这一行代码默默地 决定了三件事(因为它们都取 SearchRequest 的字段默认值):

参数 值 来源
topK 4 SearchRequest.DEFAULT_TOP_K(:49)
similarityThreshold 0.0 SearchRequest.SIMILARITY_THRESHOLD_ACCEPT_ALL(:44)
filterExpression null 字段初值(:60)

绝大多数 RAG 应用第一次"能跑通"时,用的就是 topK=4。 不是因为 4 合理,而是因为 similaritySearch(String) 这个便利方法把 4 藏起来了。第 7 章讲 RetrievalAugmentationAdvisor 时提到的"召回不足",头号原因往往就是这个 4。

16.3.3 DocumentWriter:为什么它是 Consumer

java 复制代码
public interface DocumentWriter extends Consumer<List<Document>> {
	default void write(List<Document> documents) {
		accept(documents);
	}
}

继承 Consumer<List<Document>> 意味着 DocumentWriter 可以直接用在任何接受 Consumer 的地方 ------包括 Flux.doOnNext()、.subscribe()、List.forEach() 之外的 Stream 管道。

而 VectorStore 继承了它,所以:

java 复制代码
Flux.fromIterable(documents)
	.buffer(100)
	.doOnNext(vectorStore)      // ← VectorStore 本身就是一个 Consumer
	.blockLast();

这是合法且能跑的 (doOnNext 接受 Consumer<? super T>)。代价是异常语义:Consumer.accept 的签名不抛受检异常,所以所有失败都以运行时异常冒泡,丢进 Reactor 的 error channel。


16.4 AbstractObservationVectorStore:模板方法与那个 UnsupportedOperationException

这是 21 个实现共同的基类,183 行,做的是观测包装 + 模板方法分发。

16.4.1 四个抽象方法 / 具体方法

java 复制代码
public abstract class AbstractObservationVectorStore implements VectorStore {

	private static final VectorStoreObservationConvention DEFAULT_OBSERVATION_CONVENTION =
			new DefaultVectorStoreObservationConvention();

	private final ObservationRegistry observationRegistry;
	private final @Nullable VectorStoreObservationConvention customObservationConvention;
	protected final EmbeddingModel embeddingModel;
	protected final BatchingStrategy batchingStrategy;
	...
}

(AbstractObservationVectorStore:40-58)

四个字段里,embeddingModel 和 batchingStrategy 是 protected ------留给子类直接用;observationRegistry 和 customObservationConvention 是 private------子类碰不到,只能通过抽象方法来提供 context builder。

三个模板方法(doAdd / doDelete / doSimilaritySearch)+ 一个上下文工厂(createObservationContextBuilder):

方法 位置 性质
doAdd(List<Document>) :149 public abstract
doDelete(List<String>) :155 public abstract
doDelete(Filter.Expression) :162-167 protected,默认抛 UnsupportedOperationException
doSimilaritySearch(SearchRequest) :174 public abstract
createObservationContextBuilder(String) :181 public abstract

16.4.2 doDelete(Filter.Expression) 的默认实现:源码自己承认是临时的

java 复制代码
protected void doDelete(Filter.Expression filterExpression) {
	// this is temporary until we implement this method in all concrete vector stores,
	// at which point
	// this method will become an abstract method.
	throw new UnsupportedOperationException();
}

(:162-167)

注释写得非常直白:这是临时状态,等所有实现都补上之后会变成抽象方法。

在 2.0.1,它还没变成抽象方法。 于是:

vectorStore.delete(someFilterExpression) 在没有覆写 doDelete(Filter.Expression) 的实现上会抛 UnsupportedOperationException ,而这个异常不带任何消息(无参构造)。

哪些实现覆写了它?从 16.1.1 的清单里可以看到 QdrantVectorStore(:222)和 ChromaVectorStore(:181)都有 protected void doDelete(Filter.Expression ...)。也就是说,覆写是逐个实现分别做的 ,而且没有编译期强制------漏了不报错,运行时才炸。

16.4.3 add 的文本校验:三个"静默通过"的分支

java 复制代码
@Override
public void add(List<Document> documents) {
	validateNonTextDocuments(documents);
	...
}

private void validateNonTextDocuments(List<Document> documents) {
	if (documents == null) {
		return;                                    // ← ①
	}
	for (Document document : documents) {
		if (document != null && !document.isText()) {   // ← ②
			throw new IllegalArgumentException(
					"Only text documents are supported for now. One of the documents contains non-text content.");
		}
	}
}

(:74-97)

两个静默分支:

① documents == null 直接 return。 不是抛异常,是什么都不做 ------然后继续执行 doAdd(null)。所以真正的失败点被推迟到了各实现的 doAdd 里:SimpleVectorStore.doAdd 第一行是 Objects.requireNonNull(documents, "Documents list cannot be null")(:116),而 PgVector 之类的实现可能在更深处才 NPE。不同实现对 add(null) 的报错位置和文案都不一样。

② document != null 被跳过。 列表里含 null 元素时不报错,null 被静默放过,然后在 doAdd 的循环里 NPE。

③ 真正的校验是 !document.isText()。 Document.isText()(:265)判断的是"有没有 text 内容"------含 Media 内容(图片、PDF 的原始字节)的 Document 会被一律拒绝。

这是一个硬边界:Spring AI 2.0.1 的 VectorStore 不支持多模态向量。 任何想"把图片 embedding 存进向量库"的路径都会在这行被拦下。多模态检索必须自己实现 VectorStore 或绕过 AbstractObservationVectorStore(而绕过后也就失去了观测)。

16.4.4 观测包装:三个操作共用一个 documentation 常量

java 复制代码
@Override
public void add(List<Document> documents) {
	validateNonTextDocuments(documents);
	VectorStoreObservationContext observationContext = this
		.createObservationContextBuilder(VectorStoreObservationContext.Operation.ADD.value())
		.build();

	VectorStoreObservationDocumentation.AI_VECTOR_STORE
		.observation(this.customObservationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext,
				this.observationRegistry)
		.observe(() -> this.doAdd(documents));
}

(:74-85)

三个操作(ADD / DELETE / QUERY)走同一个 VectorStoreObservationDocumentation.AI_VECTOR_STORE,靠 Operation 枚举的值("add" / "delete" / "query",VectorStoreObservationContext:144-152)区分。所以:

  • span 名是同一个 (db.vector.client.operation),
  • contextual name 不同 (<dbSystem> <operation>,观测部分见第 17 章),
  • db.operation.name 这个低基数标签是唯一能区分三者的指标维度。

而 similaritySearch 多了一步------把响应写回 context:

java 复制代码
.observe(() -> {
	var documents = this.doSimilaritySearch(request);
	searchObservationContext.setQueryResponse(documents);
	return documents;
});

(:138-142)

这一步是给 VectorStoreQueryResponseObservationHandler 用的(见第 17 章),也是唯一会"记住检索结果"的地方 ------意味着如果你开启了 log-query-response,检索到的全文会被打进日志。

16.4.5 一个被 @SuppressWarnings("DataFlowIssue") 压掉的空安全警告

java 复制代码
@Override
// Micrometer Observation#observe returns the value of the Supplier, which is never
// null
@SuppressWarnings("DataFlowIssue")
public List<Document> similaritySearch(SearchRequest request) {

(:124-128)

源码作者自己解释了:Micrometer 的 Observation.observe(Supplier) 的签名在空性标注上是"可能返回 null",但实际不会,所以压掉警告。这是一处为了静态分析工具而写的注释------说明这个项目跑着 NullAway 之类的检查(第 1 章讲过的工程化约束在这里有具体体现)。


16.5 SearchRequest:四个字段,和那个会"串味"的 Builder

SearchRequest 295 行,但只有四个字段。这是本仓库里"javadoc 体量/代码体量"比值最高的类之一------大量篇幅在教你怎么用,而状态本身极简。

16.5.1 字段与默认值

java 复制代码
public static final double SIMILARITY_THRESHOLD_ACCEPT_ALL = 0.0;   // :44

public static final int DEFAULT_TOP_K = 4;                          // :49

private String query = "";                                          // :54

private int topK = DEFAULT_TOP_K;                                   // :56

private double similarityThreshold = SIMILARITY_THRESHOLD_ACCEPT_ALL;  // :58

private Filter.@Nullable Expression filterExpression;               // :60
字段 默认值 断言 备注
query ""(空串) Assert.notNull(:149) 空串是合法的------会真的去检索一个空查询
topK 4 Assert.isTrue(topK >= 0, "TopK should be positive.")(:159) 0 是允许的,尽管消息说"should be positive"
similarityThreshold 0.0 Assert.isTrue(threshold >= 0 && threshold <= 1, ...)(:175) 闭区间 [0,1]
filterExpression null 无 @Nullable,hasFilterExpression() 判空(:100-102)

三处值得单独说:

(1)topK 的断言消息与断言条件不一致。 消息说 "TopK should be positive.",但条件是 topK >= 0------topK = 0 会通过 。而 topK = 0 的语义是什么?看实现:

  • PgVector:LIMIT 0 → 返回空列表;
  • Azure:setKNearestNeighborsCount(0) → 取决于 Azure SDK 行为(本仓库无 SDK 源码,不做断言);
  • SimpleVectorStore:.limit(0) → 返回空列表。

所以 topK=0 大概率是"返回空列表"而不是"报错"。 如果你的 topK 是从配置里读的(比如 spring.ai.vectorstore.xxx.top-k),配成 0 会得到一个"不报错但永远召不回任何东西"的服务。

(2)query 默认是空串而不是 null。 于是 new SearchRequest() 直接拿来检索是合法 的------它会真的去 embeddingModel.embed(""),拿到一个空串的向量,然后做全库相似度排序。

这是一个安静的性能陷阱 :embeddingModel.embed("") 会消耗一次真实的 embedding API 调用(对 OpenAI 等按量计费的模型就是真金白银),而结果毫无意义。

(3)similarityThreshold 的 [0,1] 闭区间意味着"精确匹配"是 1.0。 而 SearchRequest 里没有 为 1.0 定义常量------只有 SIMILARITY_THRESHOLD_ACCEPT_ALL = 0.0。源码里 OracleVectorStore:394 出现了一个 SIMILARITY_THRESHOLD_EXACT_MATCH:

ruby 复制代码
$ grep -rn "SIMILARITY_THRESHOLD_EXACT_MATCH" --include=*.java .

这个常量定义在 Oracle 模块内 (本仓库内可复核),核心 SearchRequest 里没有对应常量。也就是说"精确匹配阈值"这个语义只在 Oracle 实现里被显式命名。

16.5.2 ★ Builder.build() 返回的是同一个对象实例

这是本章最容易被忽略、后果最实际的一个坑。

java 复制代码
public static final class Builder {

	private final SearchRequest searchRequest = new SearchRequest();   // :142
	...
	public SearchRequest build() {
		return this.searchRequest;                                      // :289-291
	}

}

build() 直接返回 Builder 内部持有的那个 SearchRequest 实例,没有拷贝、没有"冻结"。

后果:

java 复制代码
var builder = SearchRequest.builder().query("hello").topK(10);
var req1 = builder.build();      // query=hello, topK=10
builder.query("world").topK(2);  // 改 builder
var req2 = builder.build();      // query=world, topK=2

// 此时 req1 是什么?
// req1 == req2(同一个对象)→ req1.getQuery() == "world"

req1 被 req2 的构建过程改掉了。 这不是"可能",是"必然"------两个变量指向同一个 SearchRequest。

什么时候会踩到:

  • 把 SearchRequest.Builder 存成字段/静态常量,反复 build();
  • 在一个循环里复用 builder;
  • 把 builder 传给某个方法,方法内部 build() 后又改了 builder。

正确写法 :每次都从 SearchRequest.builder() 新建,或者用 SearchRequest.from(original)(:67-72)------它内部 new SearchRequest() 是全新的实例:

java 复制代码
public static Builder from(SearchRequest originalSearchRequest) {
	return builder().query(originalSearchRequest.getQuery())
		.topK(originalSearchRequest.getTopK())
		.similarityThreshold(originalSearchRequest.getSimilarityThreshold())
		.filterExpression(originalSearchRequest.getFilterExpression());
}

注意 from() 仍然返回 Builder(而不是 SearchRequest),且这个 Builder 同样有上面的共享实例问题------from() 解决的是"初始值",不是"可变性"。

另一个连带后果 :SearchRequest 有 public SearchRequest() 无参构造(:74-75)和一个 protected SearchRequest(SearchRequest original) 拷贝构造(:77-82)------但 Builder.build() 没有用拷贝构造。所以"可变 Builder → 不可变 Request"这个常见模式在这里不成立。

16.5.3 equals / hashCode 是完整的,但 toString 会打印 filter

java 复制代码
@Override
public String toString() {
	return "SearchRequest{" + "query='" + this.query + '\'' + ", topK=" + this.topK + ", similarityThreshold="
			+ this.similarityThreshold + ", filterExpression=" + this.filterExpression + '}';
}

(:104-108)

Filter.Expression 是 record(Filter:127),它的 toString() 是自动生成的,会打印完整 AST。而 DefaultVectorStoreObservationConvention.metadataFilter(:112-118)正是用 getFilterExpression().toString() 作为高基数标签的值。

所以:过滤表达式会出现在两处"非业务"输出里 ------日志与 trace。如果表达式里含敏感值(例如 tenantId == 'secret-corp'),它会进 trace。

16.5.4 filterExpression(String) 的解析时机

java 复制代码
public Builder filterExpression(@Nullable String textExpression) {
	this.searchRequest.filterExpression = (textExpression != null)
			? new FilterExpressionTextParser().parse(textExpression) : null;
	return this;
}

(:283-287)

每次调用都 new 一个 FilterExpressionTextParser() (:285)。而 FilterExpressionTextParser 内部有一个 ConcurrentHashMap 缓存(:116)------新建实例 = 缓存失效。

也就是说:

java 复制代码
SearchRequest.builder().filterExpression("country == 'BG'").build();   // 解析一次,缓存在这个 parser 实例里
SearchRequest.builder().filterExpression("country == 'BG'").build();   // 换了个 parser,又解析一次

同一条表达式,每次都重新走一遍 ANTLR 词法+语法分析。 只有在你复用同一个 FilterExpressionTextParser 实例时缓存才有效:

java 复制代码
var parser = new FilterExpressionTextParser();
parser.parse("country == 'BG'");   // 解析
parser.parse("country == 'BG'");   // 命中缓存

VectorStore.delete(String)(:79-84)走的是 SearchRequest.builder(),所以删除侧的表达式永远不命中缓存。


16.6 ★ similarityThreshold 到底在哪一侧过滤:三类实现,语义不等价

这是本章最实用的一节。SearchRequest.Builder.similarityThreshold 的 javadoc 写着:

"Note that this is a post-processing step performed on the client not the server side."

(SearchRequest:167-168)

这句话在 2.0.1 里只对一部分实现成立。 把 22 个实现按"阈值在谁那里生效"分类,得到三类:

16.6.1 A 类:阈值下推到数据库,先过滤再取 topK

实现 位置 做法
PgVectorStore :368 double distance = 1 - request.getSimilarityThreshold(); 作为 SQL 参数
MariaDBVectorStore :361 同上
TypesenseVectorStore :247 "distance_threshold: " + (1 - threshold)
OracleVectorStore :423 (1d - threshold) * 2d - 1d(按 distance type 分支)
QdrantVectorStore :267 setScoreThreshold((float) threshold)
WeaviateVectorStore :338 certainty((float) threshold)
Neo4jVectorStore :298 Cypher 参数 threshold
RedisVectorStore :461-471 断言 [0,1] 后下推
MongoDBAtlasVectorStore :317 Criteria(SCORE_FIELD_NAME).gte(threshold)
ElasticsearchVectorStore :251 float threshold = (float) searchRequest.getSimilarityThreshold()

PgVector 的完整调用(PgVectorStore:357-376):

java 复制代码
double distance = 1 - request.getSimilarityThreshold();

PGvector queryEmbedding = getQueryEmbedding(request.getQuery());

return this.jdbcTemplate.query(
		String.format(this.getDistanceType().similaritySearchSqlTemplate, getFullyQualifiedTableName(),
				jsonPathFilter),
		this.documentRowMapper, queryEmbedding, queryEmbedding, distance, request.getTopK());

参数顺序是 queryEmbedding, queryEmbedding, distance, topK → 即 WHERE distance <= ? ORDER BY distance ... LIMIT ?。

语义:数据库先把"不满足阈值"的行全部排除,再在剩余的行里取前 topK。 只要库里有足够多的合格候选,返回条数就是 topK。

16.6.2 B 类:topK 下推,阈值在 Java 侧过滤

实现 位置 做法
AzureVectorStore :253 searchResults.stream().filter(result -> result.getScore() >= threshold)
MilvusVectorStore :412 wrapperSearch.getRowRecords(0).stream().filter(... >= threshold)
PineconeVectorStore :230 .filter(scoredVector -> scoredVector.getScore() >= threshold)
GemFireVectorStore :282 .filter(r -> r.score >= threshold)
CassandraVectorStore :365 循环内 if (score < threshold) continue
ChromaVectorStore :226 if ((1 - distance) >= threshold)
CoherenceVectorStore :210 `if (distanceType != COSINE

Azure 的完整形态(AzureVectorStore:250-269):

java 复制代码
final var searchResults = this.searchClient.search(null, searchOptions, Context.NONE);

return searchResults.stream()
	.filter(result -> result.getScore() >= request.getSimilarityThreshold())
	.map(result -> { ... })
	.toList();

注意:topK 根本没出现在这段代码里 ------它在此之前被设置进了 vectorQuery.setKNearestNeighborsCount(request.getTopK())。

语义:先让引擎返回"最近的 topK 条",然后 Java 侧把这 topK 条里不满足阈值的丢掉。 返回条数 ≤ topK,且可能远小于。

16.6.3 C 类:全量取出,Java 侧过滤,最后截断

只有 SimpleVectorStore(SimpleVectorStore:150-161):

java 复制代码
public List<Document> doSimilaritySearch(SearchRequest request) {
	float[] userQueryEmbedding = getUserQueryEmbedding(request.getQuery());
	return this.store.values()
		.stream()
		.filter(document -> doFilterPredicate(request.getFilterExpression()).test(document))
		.map(content -> content
			.toDocument(EmbeddingMath.cosineSimilarity(userQueryEmbedding, content.getEmbedding())))
		.filter(document -> document.getScore() != null && document.getScore() >= request.getSimilarityThreshold())
		.sorted(Comparator.comparing(Document::getScore).reversed())
		.limit(request.getTopK())
		.toList();
}

顺序是 filter(表达式) → map(算分) → filter(阈值) → sorted → limit(topK)。

语义:与 A 类等价(先过滤后截断),但代价是全量扫描 + 全量算分。 内存实现无所谓,生产实现不可能这么干。

16.6.4 三类语义的实测差异

设 topK = 10、similarityThreshold = 0.8,库里 1000 条数据,其中 50 条相似度 ≥ 0.8:

类别 返回条数 说明
A 类(PgVector) 10 数据库过滤后有 50 条候选,取前 10
B 类(Azure/Milvus/Pinecone) ≤ 10,可能 0 引擎先返回最近 10 条,若这 10 条里只有 3 条 ≥ 0.8,就返回 3 条;若最近的 10 条全部 < 0.8(例如库里那 50 条高分文档都不是"最近邻"------在某些距离度量下可能)→ 返回 0 条
C 类(SimpleVectorStore) 10 全量过滤后取前 10

这是"换了个向量库,召回条数从 10 变成 2"的最常见原因,而且它不报任何错。

16.6.5 一个更隐蔽的分支:余弦相似度可能为负

SimpleVectorStore.EmbeddingMath.cosineSimilarity(:270-287)返回的是余弦相似度 ,值域 [-1, 1]。

而 similarityThreshold 的默认值是 0.0。在 SimpleVectorStore 里:

java 复制代码
.filter(document -> document.getScore() != null && document.getScore() >= request.getSimilarityThreshold())

score >= 0.0 → 所有余弦相似度为负的文档全部被过滤掉。

这不是 bug,但它意味着"默认阈值 0.0"并不等价于"不过滤"。 SIMILARITY_THRESHOLD_ACCEPT_ALL 这个名字(SearchRequest:44)在余弦度量下是名不副实的------它只接受非负相似度。

而在某些实现里(Chroma :226 的 (1 - distance)、Oracle :423 的 (1-t)*2-1),实际参与比较的数值经过了换算,负值的含义又不一样。"0.0 表示全接受"这个承诺,只在换算后的量与阈值同量纲时才成立。

16.6.6 Document.getScore() 的可空性

java 复制代码
/**
 * A numeric score associated with this document that can represent various types of
 * relevance ...
 * and a query vector, where higher scores indicate greater similarity (opposite of
 * distance)
 */
private final @Nullable Double score;

(Document:146-160,spring-ai-commons)

score 是 @Nullable Double,且 javadoc 明说"higher scores indicate greater similarity (opposite of distance)"。

但看 AzureVectorStore:266:

java 复制代码
metadata.put(DocumentMetadata.DISTANCE.value(), 1.0 - result.getScore());
...
return Document.builder().id(id).text(content).metadata(metadata).score(result.getScore()).build();

它把 1.0 - score 作为 distance 塞进 metadata(DocumentMetadata.DISTANCE("distance"),DocumentMetadata:35),而 score 字段保持原值。

所以"距离"只能通过 metadata 的 distance key 拿到,而 score 是相似度。 这两个量的语义相反,且不同实现填的可能是不同的东西------第 17 章会逐家对账。

SimpleVectorStore 里 Comparator.comparing(Document::getScore)(:158)能工作,是因为上一行已经 .filter(score != null)。如果某个实现返回的 Document 里混有 score == null,这个比较器会 NPE ------Comparator.comparing 用 Double.compareTo,null 会抛。


16.7 BatchingStrategy:全仓库只有一个实现,且它的 javadoc 算错了

16.7.1 接口:一个方法,一条约束

java 复制代码
public interface BatchingStrategy {

	/**
	 * EmbeddingModel implementations can call this method to optimize embedding tokens.
	 * The incoming collection of {@link Document}s are split into sub-batches. It is
	 * important to preserve the order of the list of {@link Document}s when batching as
	 * they are mapped to their corresponding embeddings by their order.
	 * @param documents to batch
	 * @return a list of sub-batches that contain {@link Document}s.
	 */
	List<List<Document>> batch(List<Document> documents);

}

(BatchingStrategy:30-42,位于 spring-ai-model 的 org.springframework.ai.embedding 包)

唯一的契约强调是"必须保持顺序" (:34-36)。原因写在注释里:文档列表与其 embedding 列表是按索引一一对应的,顺序乱了就全错位了。

css 复制代码
$ grep -rln "implements BatchingStrategy" --include=*.java .
./spring-ai-model/src/main/java/org/springframework/ai/embedding/TokenCountBatchingStrategy.java

全仓库只有一个实现。 而且这个接口在 spring-ai-model 而不是 spring-ai-vector-store------说明它是 embedding 层的概念 ,被 VectorStore 借用了。

16.7.2 TokenCountBatchingStrategy:三个常量与一处 javadoc 矛盾

java 复制代码
private static final int MAX_INPUT_TOKEN_COUNT = 8191;                       // :59

private static final double DEFAULT_TOKEN_COUNT_RESERVE_PERCENTAGE = 0.1;    // :65

public TokenCountBatchingStrategy() {
	this(EncodingType.CL100K_BASE, MAX_INPUT_TOKEN_COUNT, DEFAULT_TOKEN_COUNT_RESERVE_PERCENTAGE);
}

...

this.maxInputTokenCount = (int) Math.round(maxInputTokenCount * (1 - reservePercentage));   // :108

(:59-108)

默认构造:编码 CL100K_BASE(OpenAI 的 cl100k)、上限 8191、预留 10%。

实际生效值:Math.round(8191 * (1 - 0.1)) = Math.round(7371.9) = 7372。

而 javadoc 写的是:

"For example, with the default reserve percentage of 10% (0.1) and the default max input token count of 8191, the actual max input token count used will be 7371."

(:41-42)

★ javadoc 说 7371,代码算出 7372。 这是 Math.round 对 7371.9 向上取整造成的,差 1 个 token。

差 1 个 token 在实践上无所谓------但它是一个可以被一句话复核的事实 (jshell> Math.round(8191 * 0.9)),也说明这个类在改过 reserve 逻辑后 javadoc 没同步。

更值得注意的是:"8191"这个数字是 OpenAI 的上限 (:56-57 的注释 "Using openai upper limit of input token count as the default"),而它是所有非 OpenAI embedding 模型的默认批处理上限。用 Ollama、用本地 BGE、用 Cohere------默认批处理窗口都是按 OpenAI 的口径算的。

16.7.3 batch() 的实现与那个"不要收集成 Map"的注释

java 复制代码
@Override
public List<List<Document>> batch(List<Document> documents) {
	List<List<Document>> batches = new ArrayList<>();
	int currentSize = 0;
	List<Document> currentBatch = new ArrayList<>();

	// Do not collect the documents into a Map keyed by Document: equal documents
	// would collapse to a single entry and be silently dropped from the batches.
	for (Document document : documents) {
		int tokenCount = this.tokenCountEstimator
			.estimate(document.getFormattedContent(this.contentFormatter, this.metadataMode));
		if (tokenCount > this.maxInputTokenCount) {
			throw new IllegalArgumentException(
					"Tokens in a single document exceeds the maximum number of allowed input tokens");
		}
		currentSize += tokenCount;
		if (currentSize > this.maxInputTokenCount) {
			batches.add(currentBatch);
			currentBatch = new ArrayList<>();
			currentSize = tokenCount;
		}
		currentBatch.add(document);
	}
	if (!currentBatch.isEmpty()) {
		batches.add(currentBatch);
	}
	return batches;
}

(:136-163)

四个要点:

(1)那段注释是一个修复过的 bug 的墓志铭。 "不要用 Map 按 Document 分组,相等的文档会合并成一条并被静默丢弃"------Document 有完整的 equals/hashCode(Document:371-376,含 id/text/media/metadata/score 五个字段),所以两篇内容完全相同的文档会 equals 返回 true。曾经的实现用 Map 收集过,重复文档就丢了。

这个坑的可怕之处在于:丢文档是静默的。 你的 ETL 说"处理了 100 篇",实际进了向量库 97 篇,日志里没有任何异常。

(2)单文档超限直接抛异常,而不是切分。

java 复制代码
if (tokenCount > this.maxInputTokenCount) {
	throw new IllegalArgumentException(
			"Tokens in a single document exceeds the maximum number of allowed input tokens");
}

(:147-150)

一个 8000 token 的文档 → 整个 add() 失败,同一批里其它正常的文档也一起失败 (因为 batch() 在 embed() 之前被调用,抛异常时整批回滚)。

这是 ETL 里最常见的"跑了一半炸了"的根因之一 ,而且异常消息没有告诉你是哪篇文档 超限------只有一句通用的 "Tokens in a single document exceeds..."。定位手段只能自己加:在调用 add 之前先用同一个 BatchingStrategy 预检,或者捕获异常后二分定位。

(3)MetadataMode 默认是 NONE。

java 复制代码
this(encodingType, maxInputTokenCount, reservePercentage, Document.DEFAULT_CONTENT_FORMATTER,
		MetadataMode.NONE);

(:86-87)

估算 token 数时完全不计入 metadata。 而实际 embedding 时(取决于 EmbeddingModel 实现)可能会带上 metadata。这意味着估算偏小------批次可能略超上限。10% 的 reserve 就是为这类偏差留的缓冲。

(4)切分逻辑是"先加后判"。

java 复制代码
currentSize += tokenCount;
if (currentSize > this.maxInputTokenCount) {
	batches.add(currentBatch);
	currentBatch = new ArrayList<>();
	currentSize = tokenCount;     // ← 注意:重置为当前文档的 token 数,不是 0
}
currentBatch.add(document);

每批的累计 token ≤ maxInputTokenCount,且当前文档被放进新批次。所以批次划分是贪心的、顺序的、不回溯的。

16.7.4 ★ 21 个实现用它,只有 SimpleVectorStore 不用

ruby 复制代码
$ grep -rn "this.batchingStrategy);" --include=*.java vector-stores/ | wc -l
19

19 个实现里有这样一行:

java 复制代码
List<float[]> embeddings = this.embeddingModel.embed(documents, EmbeddingOptions.builder().build(),
		this.batchingStrategy);

(例:AzureVectorStore:175-176)

而 SimpleVectorStore.doAdd(:121-129)是:

java 复制代码
for (Document document : documents) {
	if (logger.isInfoEnabled()) {
		logger.info("Calling EmbeddingModel for document id = " + document.getId());
	}
	float[] embedding = this.embeddingModel.embed(document);
	...
}

逐条 embed(document),完全没用 batchingStrategy。

后果:

SimpleVectorStore 从 AbstractObservationVectorStore 继承了 protected final BatchingStrategy batchingStrategy 字段,但这个字段在 SimpleVectorStore 里从未被读取------它是个死字段。

你给 SimpleVectorStore.builder(embeddingModel).batchingStrategy(myStrategy) 设的任何策略都不生效。同时,100 篇文档会触发 100 次独立的 embedding 调用(而不是按 token 预算合并成几批)。

因为 SimpleVectorStore 的定位是"测试与演示"(:76-77 javadoc 明确写了 "not designed for production use"),这在语义上不算 bug,但如果你用它做性能基准测试,测出来的 embedding 调用次数会比生产实现高一个数量级。

另一处相关细节:doAdd 里那行 logger.info("Calling EmbeddingModel for document id = " + document.getId())(:122-124)------每篇文档一条 INFO 日志 。1000 篇文档 = 1000 行日志。这在生产日志里是噪音,在 SimpleVectorStore 上做批量测试时会很明显。

16.7.5 doAdd 的空/空列表语义:与 AbstractObservationVectorStore 的对比

场景 AbstractObservationVectorStore.validateNonTextDocuments SimpleVectorStore.doAdd
documents == null 静默通过 (:88-90) Objects.requireNonNull → NullPointerException(:116)
documents 为空列表 循环不执行 → 通过 **throw new IllegalArgumentException("Documents list cannot be empty")(:117-119)
列表含 null 元素 document != null 判断跳过 (:92) document.getId() → NPE
列表含非文本 Document **IllegalArgumentException(:93-95) 同上(先被基类拦下)

add(Collections.emptyList()) 在 SimpleVectorStore 上抛 IllegalArgumentException。 而在 PgVector 等实现上(走 SQL 批量插入)通常是"什么都不做"。又一次"内存实现与生产实现行为相反"。


16.8 Filter:13 种表达式类型,四种 Operand

16.8.1 ExpressionType:13 个值,分四组

java 复制代码
public enum ExpressionType {
	AND, OR, EQ, NE, GT, GTE, LT, LTE, IN, NIN, NOT, ISNULL, ISNOTNULL
}

(Filter:81-85)

组 类型 元数 right 操作数要求
逻辑 AND、OR 二元 必须是 Expression(或 Group)
一元逻辑 NOT 一元 null
比较 EQ、NE、GT、GTE、LT、LTE 二元 必须是 Value
集合 IN、NIN 二元 必须是 Value 且包 List
空值 ISNULL、ISNOTNULL 一元 null

javadoc(Filter:70-80)只描述了前三组和 IN/NIN:

  • EQ, NE, GT, GTE, LT, LTE operations supports "Key ExprType Value" expressions.
  • AND, OR are binary operations that support "(Expression|Group) ExprType (Expression|Group)" expressions.
  • IN, NIN support "Key (IN|NIN) ArrayValue" expression.

NOT、ISNULL、ISNOTNULL 在 ExpressionType 的 javadoc 里完全没被提到。 这三个是后加的,文档没跟上------而正是这三个,在方言转换层大面积不支持(见第 17 章的方言对照表)。

16.8.2 四种 Operand:Key / Value / Expression / Group

java 复制代码
public interface Operand {
}

public record Key(String key) implements Operand {
}

public record Value(Object value) implements Operand {
}

public record Expression(ExpressionType type, Operand left, @Nullable Operand right) implements Operand {

	public Expression(ExpressionType type, Operand operand) {
		this(type, operand, null);
	}

}

public record Group(Expression content) implements Operand {
}

(Filter:91-143)

全是 record → 不可变、值语义、toString 自动生成。

三个设计点:

(1)Expression 自己也是 Operand → 表达式树可以任意嵌套,递归天然成立。

(2)单参构造 Expression(type, operand) 把 right 设为 null (:129-131)------这是 NOT / ISNULL / ISNOTNULL 的构造方式。FilterExpressionBuilder.not()(:120-122)和 isNull()/isNotNull()(:108-114)都用它。

(3)Group 只是个"括号"包装器。 它唯一的作用是表达优先级。而在求值/转换时,顶层 Group 会被剥掉:

  • FilterExpressionTextParser.castToExpression(:317-326):if (expression instanceof Filter.Group group) return group.content();
  • FilterExpressionBuilder.Op.build()(:126-135):同样的逻辑。

所以 parser.parse("(a == 1)") 与 parser.parse("a == 1") 得到完全相同的 Expression。 你在最外层加不加括号,结果没有区别。但内层 的括号会被保留为 Group,因为 GroupExpression 的 visitor(:300-302)返回 new Filter.Group(...),而只有最外层在 castToExpression 里被剥掉。

16.8.3 Value 的类型集合:由解析器决定,不是由模型决定

Filter.Value(Object value) 的 value 是 Object------模型层不限制类型。实际能出现什么类型,取决于三个入口:

入口 产生的类型
FilterExpressionBuilder.in/eq/... 你传什么就是什么(任意 Object)
FilterExpressionTextParser Integer(:234)、Double(:239)、Boolean(:244)、String(:213)、Long(:314)、List<Object>(:250)
AbstractFilterExpressionConverter.normalizeDateString 额外可能产生 java.util.Date(:182)

没有 Float。 数字字面量解析出来是 Integer / Long / Double 三种之一。而 SimpleVectorStoreFilterExpressionEvaluator.compare(:194-196)在比较时把所有 Number 提升为 double:

java 复制代码
if (metaVal instanceof Number n1 && filterVal instanceof Number n2) {
	return Double.compare(n1.doubleValue(), n2.doubleValue());
}

这解决了 Integer vs Double 的跨类型比较,但也意味着超大 Long(超过 2^53)会丢精度。文档 ID 用雪花 ID 之类的长整型做过滤条件时要注意。


16.9 FilterExpressionBuilder:编程式 DSL 与它的两个"剥壳"点

139 行,13 个工厂方法 + 一个 Op record。

16.9.1 方法全集

方法 位置 产物
eq / ne / gt / gte / lt / lte :60-82 Expression(<比较符>, Key, Value)
and(Op, Op) / or(Op, Op) :84-90 `Expression(AND
in(String, Object...) / in(String, List) :92-98 Expression(IN, Key, Value(List))
nin(String, Object...) / nin(String, List) :100-106 Expression(NIN, Key, Value(List))
isNull(String) / isNotNull(String) :108-114 `Expression(ISNULL
group(Op) :116-118 Group(content.build())
not(Op) :120-122 Expression(NOT, content.expression, null)

16.9.2 Op.build() 的"剥壳"

java 复制代码
public record Op(Filter.Operand expression) {

	public Filter.Expression build() {
		if (this.expression instanceof Filter.Group group) {
			// Remove the top-level grouping.
			return group.content();
		}
		else if (this.expression instanceof Filter.Expression exp) {
			return exp;
		}
		throw new RuntimeException("Invalid expression: " + this.expression);
	}

}

(:124-137)

Op 持有的是 Operand(四种形态之一),而 build() 只能返回 Expression。所以:

  • Op(Group) → 剥掉 Group,返回内层 Expression;
  • Op(Expression) → 直接返回;
  • Op(Key) / Op(Value) → 抛 RuntimeException。

第三个分支实际上是不可达的 ------因为所有工厂方法都只包装 Expression 或 Group。这是一段防御性的死代码。

16.9.3 ★ not() 与 group() 的组合有个语义死角

java 复制代码
public Op not(Op content) {
	return new Op(new Filter.Expression(Filter.ExpressionType.NOT, content.expression, null));
}

注意它取的是 content.expression------即 Op 内部那个 Operand,而不是 content.build() 的结果。

所以:

java 复制代码
var b = new FilterExpressionBuilder();

b.not(b.eq("a", 1)).build();
// → Expression(NOT, Expression(EQ, Key(a), Value(1)), null)     ✓ 正常

b.not(b.group(b.or(...))).build();
// → Expression(NOT, Group(Expression(OR, ...)), null)           ← right 是 Group,不是 Expression

第二种情况下,NOT 的 left 是一个 Group。这在求值侧没问题(SimpleVectorStoreFilterExpressionEvaluator.evaluateOperand 支持 Group,:81-83),但在转换侧 会走 AbstractFilterExpressionConverter.convertOperand 的 Filter.Group 分支(:97-99)→ doGroup → 输出括号。

真正的问题在 negate() 那边 (16.11.2),因为 FilterHelper.negate 的 Group 分支(:72-78)会递归 negate(group.content()) 再把结果重新包成 Group:

java 复制代码
if (operand instanceof Filter.Group group) {
	Operand inEx = negate(group.content());
	if (inEx instanceof Filter.Group inEx2) {
		inEx = inEx2.content();
	}
	return new Filter.Group((Expression) inEx);
}

最后那行 (Expression) inEx 是未检查的强制转换 ------如果 negate 返回的不是 Expression(理论上不可能,因为 negate 只返回 Group 或 Expression,而上面已经剥了 Group),会 ClassCastException。逻辑上是安全的,但 @SuppressWarnings("NullAway") 标注(:69)只压了空检查,没压类型转换。

16.9.4 一个实用的对照:DSL 与文本表达式的产物是否一致?

表达式 DSL 写法 文本写法 产物是否相同
a == 1 b.eq("a", 1).build() parse("a == 1") ✅ 相同
a == 1 AND b == 2 b.and(b.eq("a",1), b.eq("b",2)).build() parse("a == 1 && b == 2") ✅ 相同
(a == 1 OR b == 2) b.group(b.or(...)).build() `parse("(a == 1
a IN [1,2] b.in("a", 1, 2).build() parse("a in [1, 2]") ⚠️ 值类型可能不同 (DSL 传 Integer,文本解析也是 Integer)→ 相同
a IN [1, 2.5] b.in("a", 1, 2.5).build() parse("a in [1, 2.5]") ❌ DSL 传 List.of(1, 2.5)(Integer+Double 混装);文本解析出 List.of(Integer(1), Double(2.5)) → 实际相同
a == 1L b.eq("a", 1L).build() parse("a == 1L") ✅ 都是 Long

结论:两条路径的产物在绝大多数情况下是一致的 ,FilterExpressionTextParser 的 javadoc(:56-103)里那七个示例就是这么承诺的。差异只出现在:

  1. key 的大小写与引号 :文本解析的 QuotedIdentifier(:205-208)会做反转义,DSL 的 new Key(key) 直接原样;
  2. 转义处理 :文本串 'a\'b' 会被解析成 a'b(unescapeStringValue,:222-230),DSL 里你得自己写 b.eq("a", "a'b")------结果相同,但输入不同;
  3. 日期 :文本里的 ISO 日期串在转换时 (而非解析时)被 normalizeDateString 转成 Date(AbstractFilterExpressionConverter:177-187),DSL 里你直接传 Date 对象------两条路在转换前是不等价的(一个是 String、一个是 Date),转换后才等价。

16.10 FilterExpressionTextParser:ANTLR 语法、强制 WHERE、和一个共享错误列表

16.10.1 语法本体(Filters.g4,126 行)

ini 复制代码
where
    : WHERE booleanExpression EOF
    ;

booleanExpression
    : identifier compare constant                                 # CompareExpression
    | identifier IN constantArray                                 # InExpression
    | identifier (NOT IN | NIN) constantArray                     # NinExpression
    | identifier IS NULL                                          # IsNullExpression
    | identifier IS NOT NULL                                      # IsNotNullExpression
    | left=booleanExpression operator=AND right=booleanExpression # AndExpression
    | left=booleanExpression operator=OR right=booleanExpression  # OrExpression
    | LEFT_PARENTHESIS booleanExpression RIGHT_PARENTHESIS        # GroupExpression
    | NOT booleanExpression                                       # NotExpression
    ;

(Filters.g4:25-39)

关键语法事实(全部可复核):

(1)WHERE 是强制的,但解析器会替你补。

java 复制代码
private static final String WHERE_PREFIX = "WHERE";
...
// Prefix the expression with the compulsory WHERE keyword.
if (!textFilterExpression.toUpperCase(Locale.ROOT).startsWith(WHERE_PREFIX)) {
	textFilterExpression = String.format("%s %s", WHERE_PREFIX, textFilterExpression);
}

(FilterExpressionTextParser:110、:131-134)

toUpperCase(Locale.ROOT) 后判断 startsWith("WHERE") → 大写化后比较,所以 where/WHERE/Where 都被识别为已有前缀,不会重复添加。

注意 startsWith 而不是 startsWith("WHERE ")。 理论上 "wherever == 1" 会被判定为"已有前缀"而不再补。但 ANTLR lexer 采用最长匹配 ,wherever 会整体匹配 IDENTIFIER(WHERE token 只有 5 个字符,更短),所以 "WHERE wherever == 1" 这种情况不会发生;而 "wherever == 1" 不补前缀时会被 lexer 拆成 IDENTIFIER(wherever) == 1,缺少 WHERE token → 语法错误。

实际后果:"wherever == 1" 会因为 startsWith("WHERE") 为真而不补前缀 ,然后 lexer 认不出 WHERE token,where 规则匹配失败 → 抛 FilterExpressionParseException。这是 startsWith 少写一个空格造成的边界情况。

(2)标识符只允许 ASCII 字母、数字、下划线,且不能以数字开头。

css 复制代码
IDENTIFIER
    : (LETTER | '_') (LETTER | DIGIT | '_')*
    ;

fragment LETTER
    : [a-zA-Z]
    ;

(Filters.g4:106-121)

★ 中文元数据 key 必须加引号。 "国家" 这样的 key 在裸写时会被 lexer 拒绝;必须写成 '"国家" == "中国"'(用 QuotedIdentifier,:51),它会走 visitQuotedIdentifier(:205-208)做反转义。

同理,含连字符的 key(content-type)、含空格的 key、以数字开头的 key(2fa)都必须加引号。

这一条在中文业务场景里是高频踩坑点 :你的 metadata 里写了 {"标题": "..."},然后 filterExpression("标题 == 'x'") 直接抛解析异常。

(3)AND 与 OR 的优先级由 alternative 顺序决定。

ini 复制代码
    | left=booleanExpression operator=AND right=booleanExpression # AndExpression
    | left=booleanExpression operator=OR right=booleanExpression  # OrExpression

(Filters.g4:35-36)

这是 ANTLR4 的直接左递归 写法。按 ANTLR4 的规则,同一规则内的多个左递归 alternative 按声明顺序赋予优先级,先声明的优先级高 。因此 AND(第 35 行)优先于 OR(第 36 行)------与 SQL 惯例一致:a == 1 || b == 2 && c == 3 等价于 a == 1 || (b == 2 && c == 3)。

FilterExpressionTextParser 的 javadoc 示例 4 正好印证了这一点(:74-80):

java 复制代码
exp4 = parser.parse("year >= 2020 || country == 'BG' && city != 'Sofia'");
  +-> new Expression(OR,
          new Expression(GTE, new Key("year"), new Value(2020)),
          new Expression(AND, new Expression(EQ, new Key("country"), new Value("BG")),
                              new Expression(NE, new Key("city"), new Value("Sofia"))));

外层是 OR,右侧是 AND → AND 绑得更紧。

不要依赖这个优先级。 写复杂表达式时显式加括号------Group 会被保留为 Filter.Group,转换时会输出真实的括号(AbstractFilterExpressionConverter.doGroup,:289-293)。

(4)常量类型只支持五种。

ini 复制代码
constant
    : (MINUS | PLUS)? INTEGER_VALUE LONG_SUFFIX # LongConstant
    | (MINUS | PLUS)? INTEGER_VALUE # IntegerConstant
    | (MINUS | PLUS)? DECIMAL_VALUE # DecimalConstant
    | QUOTED_STRING                 # TextConstant
    | BOOLEAN_VALUE                 # BooleanConstant
    ;

(Filters.g4:54-60)

LONG_SUFFIX : [lL];(:62)→ 123L 是 Long。

没有 null 字面量 (NULL 只出现在 IS NULL / IS NOT NULL 里,:87)。所以你不能写 a == null ------必须写 a IS NULL。

(5)字符串支持单引号和双引号,且支持反斜杠转义。

matlab 复制代码
QUOTED_STRING
    : '\'' ( ~('\''|'\\') | ('\\' .) )* '\''
    | '"' ( ~('"'|'\\') | ('\\' .) )* '"'
    ;

(Filters.g4:93-96)

反转义(:222-230):

java 复制代码
private String unescapeStringValue(String in) {
	char quoteStyle = in.charAt(0);
	in = in.substring(1, in.length() - 1);
	return switch (quoteStyle) {
		case '"' -> in.replace("\\\"", "\"").replace("\\\\", "\\");
		case '\'' -> in.replace("\\'", "'").replace("\\\\", "\\");
		default -> throw new IllegalStateException();
	};
}

default -> throw new IllegalStateException() 是不可达分支 (lexer 保证首字符只能是 ' 或 ")。

(6)空白字符只认空格、CR、LF、Tab。

css 复制代码
WS
    : [ \r\n\t]+ -> channel(HIDDEN)
    ;

(Filters.g4:123-125)

全角空格(U+3000)、不间断空格(U+00A0)、垂直 Tab 都不在列------它们会导致词法错误。中文输入法下敲出的全角空格是真实风险。

有意思的是,AbstractFilterExpressionConverter.emitLuceneString(:248)的转义表里恰恰包含了 '\u3000' 。也就是说:Lucene 系(Elasticsearch/OpenSearch/GemFire)的值转义考虑了全角空格,但语法层面的词法分析没有。 两处对"全角空格"的态度不一致。

16.10.2 缓存与错误监听:一处真实的线程安全缺陷

java 复制代码
public class FilterExpressionTextParser {

	private static final String WHERE_PREFIX = "WHERE";

	private final DescriptiveErrorListener errorListener;

	private final ANTLRErrorStrategy errorHandler;

	private final Map<String, Filter.Expression> cache = new ConcurrentHashMap<>();

	public FilterExpressionTextParser() {
		this(new BailErrorStrategy());
	}

	public FilterExpressionTextParser(ANTLRErrorStrategy handler) {
		this.errorListener = DescriptiveErrorListener.INSTANCE;   // ← 单例
		this.errorHandler = handler;
	}

(:108-125)

而 DescriptiveErrorListener(:330-348):

java 复制代码
public static class DescriptiveErrorListener extends BaseErrorListener {

	public static final DescriptiveErrorListener INSTANCE = new DescriptiveErrorListener();

	public final List<String> errorMessages = new CopyOnWriteArrayList<>();

	@Override
	public void syntaxError(...) {
		...
		this.errorMessages.add(errorMessage);
	}

}

INSTANCE 是 static final 单例,它的 errorMessages 是实例级的可变 List。

而 parse() 里的用法(:144-163):

java 复制代码
parser.removeErrorListeners();
this.errorListener.errorMessages.clear();     // ← 清空共享单例的列表
parser.addErrorListener(this.errorListener);
...
catch (ParseCancellationException e) {
	var msg = String.join("", this.errorListener.errorMessages);   // ← 读共享单例的列表
	var rootCause = NestedExceptionUtils.getRootCause(e);
	throw new FilterExpressionParseException(msg, rootCause);
}

★ 这是一个竞态:errorMessages 被所有 FilterExpressionTextParser 实例共享,且在每次 parse() 时被清空。

并发解析两条都失败的表达式时(比如 Web 应用里两个请求同时带错误的 filter 进来),可能发生的交错是:

less 复制代码
线程A: clear()
线程A: parse → 失败 → errorMessages.add(A的错误)
线程B: clear()                       ← A 的错误被清掉
线程B: parse → 失败 → errorMessages.add(B的错误)
线程A: String.join(errorMessages)     ← 读到的是 B 的错误(或两者的混合)
线程B: String.join(errorMessages)

后果:错误信息串台,甚至为空 (String.join 空列表 → msg = "",于是抛出一个没有消息 的 FilterExpressionParseException)。

注意 cache 用的是 ConcurrentHashMap(线程安全),errorMessages 用的是 CopyOnWriteArrayList(单次 add 线程安全)------但 clear() 与 add() 之间没有任何同步,所以整体不是原子的。

这是一处典型的"每个成员都是线程安全的,组合起来不安全"的缺陷。

规避手段 :不要让共享的 FilterExpressionTextParser 处理用户输入;或者捕获 FilterExpressionParseException 时不要依赖它的 getMessage()(自己用 getCause() 或重新校验)。

16.10.3 其它解析细节

(1)BailErrorStrategy 是默认错误策略 (:119)。它遇到第一个语法错误就抛 ParseCancellationException,不做错误恢复。所以你拿到的错误信息只报第一个错误------一条表达式里有两个错,修完第一个才能看到第二个。

(2)Assert.hasText 是第一个门槛 (:129)。空串 / 全空白 / null → IllegalArgumentException("Expression should not be empty!")。注意这不是 FilterExpressionParseException------两种异常的调用方处理方式不同。

(3)visitCompoundIdentifier 支持点号路径 (:200-203):

bash 复制代码
identifier
    : IDENTIFIER DOT IDENTIFIER		# CompoundIdentifier

(Filters.g4:49)

a.b 会生成一个 Key("a.b")------注意是整个字符串 "a.b",没有被拆成路径 。所以"嵌套字段"的具体含义由各 converter 决定:PgVector 的 doKey 输出 $. + JSON 转义的 key(:143)→ $.a.b 在 JSONPath 里会被解释为嵌套访问;而 Pinecone 的 doKey 输出 "a.b":(:67-68)→ 是一个扁平的字面量 key。

同一个 Key("a.b") 在 PgVector 上是嵌套字段,在 Pinecone 上是带点的字段名。 "可移植"在这里打了折扣。

(4)visitNotExpression 把 right 设为 null (:305-307):

java 复制代码
return new Filter.Expression(Filter.ExpressionType.NOT, this.visit(ctx.booleanExpression()), null);

(5)convertCompare 的白名单 (:185-187、:282-287):

java 复制代码
private static final Map<String, Filter.ExpressionType> COMP_EXPRESSION_TYPE_MAP = Map.of("==",
		Filter.ExpressionType.EQ, "!=", Filter.ExpressionType.NE, ">", Filter.ExpressionType.GT, ">=",
		Filter.ExpressionType.GTE, "<", Filter.ExpressionType.LT, "<=", Filter.ExpressionType.LTE);

只有 6 个比较符。convertCompare 对未知符抛 RuntimeException("Unknown compare operator: ")------但由于语法层已经把这 6 个之外的一切排除了,这个分支不可达。

(6)getCache() 是 /** For testing only */ (:170-173),clearCache() 是 public(:166-168)。缓存无容量上限 ------如果表达式来自用户输入且每次都不同(比如按用户 ID 过滤),这个 ConcurrentHashMap 会无限增长。

filterExpression("userId == '" + userId + "'") 这种拼接写法,会让每个不同 userId 都在缓存里留一个 Filter.Expression。 这是真实的内存泄漏风险。正确做法是用 FilterExpressionBuilder 构造参数化表达式,或者复用同一个表达式对象。


16.11 AbstractFilterExpressionConverter:二十个方言共用的那台"翻译机"

前面三节(16.8 / 16.9 / 16.10)解决的是"怎么把用户的意图表达成一个 Filter.Expression AST "。但 AST 本身不能被任何数据库执行------PgVector 要的是 JSONPath,Pinecone 要的是 $eq/$in 的 JSON 文档,Elasticsearch 要的是 Lucene 查询串,Redis 要的是 RediSearch 的 @field:[...] 语法。

这一层转换由 FilterExpressionConverter 接口和它的抽象基类 AbstractFilterExpressionConverter 承担。

16.11.1 接口只有两个方法,而且第二个是 default

先把接口本体看清楚(spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/filter/FilterExpressionConverter.java):

java 复制代码
public interface FilterExpressionConverter {
	String convertExpression(Filter.Expression expression);
}

是的------只有一个抽象方法 。你如果在别的地方看到过 convertExpression(Filter.Operand) 这种重载,那是具体实现类自己加的(比如 CoherenceFilterExpressionConverter 就提供了 public Filter<?> convert(Operand expression)),不是接口契约。

这个"只有一个方法"的设计非常关键:它意味着整个转换体系是单向的 。Filter.Expression → String,没有反向的 String → Filter.Expression。也就是说:

你无法把一个已有的原生过滤条件(比如一段手写 SQL WHERE)导入回 Spring AI 的过滤模型。

这在集成遗留系统时是个真实的痛点:如果你的业务代码里已经有一段复杂的过滤逻辑以原生形式存在,你只能把它重写 成 FilterExpressionBuilder DSL 或文本表达式,没有迁移路径。

16.11.2 convertOperand:一个五分支的调度器,外加一条防御性断言

AbstractFilterExpressionConverter.convertExpression() 只是把活儿转包给 convertOperand:

java 复制代码
// AbstractFilterExpressionConverter.java:69-72
@Override
public String convertExpression(Expression expression) {
	return this.convertOperand(expression);
}

真正的核心是这个 5 分支调度(:89-112,为紧凑起见省略空行):

java 复制代码
protected void convertOperand(Operand operand, StringBuilder context) {

	if (operand instanceof Filter.Group group) {
		this.doGroup(group, context);
	}
	else if (operand instanceof Filter.Key key) {
		this.doKey(key, context);
	}
	else if (operand instanceof Filter.Value value) {
		this.doValue(value, context);
	}
	else if (operand instanceof Filter.Expression expression) {
		if ((expression.type() != ExpressionType.NOT && expression.type() != ExpressionType.AND
				&& expression.type() != ExpressionType.OR) && !(expression.right() instanceof Filter.Value)
				&& !(expression.type() == ExpressionType.ISNULL || expression.type() == ExpressionType.ISNOTNULL)) {
			throw new RuntimeException("Non AND/OR/ISNULL/ISNOTNULL expression must have Value right argument!");
		}
		if (expression.type() == ExpressionType.NOT) {
			this.doNot(expression, context);
		}
		else {
			this.doExpression(expression, context);
		}
	}
}

这段代码有三个值得停下来看的点。

第一点:第四个分支没有 else。 如果 operand 既不是 Group、Key、Value,也不是 Expression------那么这个方法静默什么都不做 ,返回一个空字符串。Filter.Operand 是一个 sealed 接口(16.8 节讲过四种 Operand),所以理论上不可能出现第五种;但如果未来加了新类型而这里忘了加分支,症状不会是 UnsupportedOperationException,而是过滤条件凭空消失、查询变成全表扫描。这是一个"静默降级为不安全"的写法。

第二点:那条防御性断言的布尔逻辑值得逐字读。 它说的是:

如果一个表达式的类型不是 NOT/AND/OR(即它是一个比较类或集合类操作),并且它的右操作数不是 Value,并且它的类型不是 ISNULL/ISNOTNULL ------ 那就抛异常。

换句话说,唯一允许"右操作数不是 Value"的情形只有四类 :NOT(一元)、AND(右操作数是子表达式)、OR(同上)、ISNULL/ISNOTNULL(一元)。

这条断言保护的是什么?是手写 DSL 时把 Filter.Expression 直接塞到右边这种错误。比如你写:

java 复制代码
// 错误示范:右边塞了一个 Expression 而不是 Value
new Filter.Expression(Filter.ExpressionType.EQ,
    new Filter.Key("country"),
    new Filter.Expression(Filter.ExpressionType.EQ, new Filter.Key("x"), new Filter.Value("y")));

编译能过(因为 right() 的类型是 Operand),运行时在这里被拦下,抛 RuntimeException("Non AND/OR/ISNULL/ISNOTNULL expression must have Value right argument!")。

注意异常类型是 RuntimeException ,不是 IllegalArgumentException。这意味着你没法在业务代码里针对性地 catch 它而不误伤其它运行时异常。

第三点:NOT 被单独拎出来交给 doNot。 这是整个体系里最优雅的一处设计,下一小节专门讲。

16.11.3 ★ doNot 的默认实现:用德摩根律把 NOT 从树里"消掉"

这是本节最值得记的一条结论。

java 复制代码
// AbstractFilterExpressionConverter.java:121-127
protected void doNot(Filter.Expression expression, StringBuilder context) {
	// Default behavior is to convert the NOT expression into its semantically
	// equivalent negation expression.
	// Effectively removing the NOT types form the boolean expression tree before
	// passing it to the doExpression.
	this.convertOperand(FilterHelper.negate(expression), context);
}

基类不直接翻译 NOT ,而是先把 NOT(a) 改写成语义等价、但不含 NOT 节点的形式,再递归地翻译改写后的树。

这意味着:任何一个继承 AbstractFilterExpressionConverter 的方言转换器,只要它不覆写 doNot,它就自动获得了 NOT 支持------哪怕目标数据库根本没有 NOT 这个概念。

改写规则全部集中在 FilterHelper.negate 里(spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/filter/FilterHelper.java:41-46):

java 复制代码
private static final Map<ExpressionType, ExpressionType> TYPE_NEGATION_MAP = Map.of(ExpressionType.AND,
		ExpressionType.OR, ExpressionType.OR, ExpressionType.AND, ExpressionType.EQ, ExpressionType.NE,
		ExpressionType.NE, ExpressionType.EQ, ExpressionType.GT, ExpressionType.LTE, ExpressionType.GTE,
		ExpressionType.LT, ExpressionType.LT, ExpressionType.GTE, ExpressionType.LTE, ExpressionType.GT,
		ExpressionType.IN, ExpressionType.NIN, ExpressionType.NIN, ExpressionType.IN);

这是一张纯符号表 ------把 8 对操作符两两互换。配合 negate 方法体(:59-97):

java 复制代码
public static Filter.Operand negate(Filter.Operand operand) {

	if (operand instanceof Filter.Group group) {
		Operand inEx = negate(group.content());
		if (inEx instanceof Filter.Group inEx2) {
			inEx = inEx2.content();
		}
		return new Filter.Group((Expression) inEx);
	}
	else if (operand instanceof Filter.Expression exp) {
		switch (exp.type()) {
			case NOT: // NOT(NOT(a)) = a
				return negate(exp.left());
			case AND: // NOT(a AND b) = NOT(a) OR NOT(b)
			case OR: // NOT(a OR b) = NOT(a) AND NOT(b)
				return new Filter.Expression(TYPE_NEGATION_MAP.get(exp.type()), negate(exp.left()),
						negate(exp.right()));
			case EQ: case NE: case GT: case GTE: case LT: case LTE:
				return new Filter.Expression(TYPE_NEGATION_MAP.get(exp.type()), exp.left(), exp.right());
			case IN: case NIN:
				return new Filter.Expression(TYPE_NEGATION_MAP.get(exp.type()), exp.left(), exp.right());
			default:
				throw new IllegalArgumentException("Unknown expression type: " + exp.type());
		}
	}
	else {
		throw new IllegalArgumentException("Can not negate operand of type: " + operand.getClass());
	}
}

这里有五个必须讲透的细节。

(1) NOT(NOT(a)) = a 走的是"双重否定律"而非符号映射。 case NOT: return negate(exp.left()); ------ 注意它取的是 exp.left()(NOT 的被否定对象),然后再递归 negate 一次 。所以 NOT(NOT(country == "BG")) 会先剥掉外层 NOT 得到 NOT(country == "BG"),再剥一次得到 country == "BG"。递归深度 = NOT 嵌套层数,不是常数。

(2) AND/OR 的改写是递归下推的(德摩根律)。 NOT(a AND b) → OR(negate(a), negate(b)),而 negate(a) 又是一个递归调用。这意味着 NOT(a AND (b OR NOT(c))) 这种三层嵌套也会被完整展开成一棵只含 OR/AND/EQ/NE/... 的树。

(3) 比较类操作只换符号、不动操作数。 case EQ: return new Expression(NE, exp.left(), exp.right()); ------ left() 和 right() 原样透传 ,不做递归。这是正确的(因为比较的两侧是 Key 和 Value 叶子节点,不可再分),但也带来一个后果:negate 不会去规范化 Value ,所以 Date 归一化(见 16.11.5)只会在 doValue 阶段发生一次。

(4) ★ default 分支抛 IllegalArgumentException("Unknown expression type: ")。 而 TYPE_NEGATION_MAP 里恰好没有 ISNULL、ISNOTNULL、NOT 这三个键(NOT 由 case 单独处理)。所以:

NOT(ISNULL(x)) 会抛 IllegalArgumentException: Unknown expression type: ISNULL。

这是一个真实的能力缺口。Filter.ExpressionType 有 13 个值,而 TYPE_NEGATION_MAP 只覆盖了 10 个(AND/OR/EQ/NE/GT/GTE/LT/LTE/IN/NIN)。剩下的 ISNULL、ISNOTNULL 无法取反。

后果是什么?考虑这个完全合理的业务表达式:

vbscript 复制代码
NOT (author IS NULL)

直觉上它应该等于 author IS NOT NULL。但实际上:

  • FilterExpressionBuilder.not(...) 会产出一个 Expression(NOT, ...);
  • 转换时 convertOperand 把它交给 doNot;
  • doNot 调 FilterHelper.negate;
  • negate 一路下推到 ISNULL,落到 default,抛 IllegalArgumentException。

十五个方言转换器里没有一个例外 ------因为 doNot 的默认实现是统一的。唯一的绕法是:不要写 NOT(ISNULL(...)),直接写 ISNOTNULL(...)。

顺带一提:Filter.ExpressionType 里还有第 13 个类型。我们数一下被 negate 覆盖的:AND、OR、EQ、NE、GT、GTE、LT、LTE、IN、NIN = 10 个;加 NOT、ISNULL、ISNOTNULL = 13 个。所以 13 个类型全部被枚举到了,只是 3 个不可取反。

(5) Group 的处理有一个"拆壳再装壳"的动作。

java 复制代码
if (operand instanceof Filter.Group group) {
	Operand inEx = negate(group.content());
	if (inEx instanceof Filter.Group inEx2) {
		inEx = inEx2.content();
	}
	return new Filter.Group((Expression) inEx);
}

negate(group.content()) 的返回值如果又是一个 Group(比如内层内容取反后仍然被包了壳),就剥掉一层 (inEx = inEx2.content()),然后再装回 new Filter.Group(...)。这是为了避免 Group(Group(Group(...))) 无限加壳。代价是最后那个 (Expression) inEx 是一个无检查的向下转型 ------如果 group.content() 是 Key 或 Value(理论上不该发生,但 sealed 接口挡不住构造时的手误),这里会抛 ClassCastException。

16.11.4 doGroup 与括号:默认不加括号

java 复制代码
// AbstractFilterExpressionConverter.java:245-249
protected void doGroup(Group group, StringBuilder context) {
	this.doStartGroup(group, context);
	this.convertOperand(group.content(), context);
	this.doEndGroup(group, context);
}

而基类的 doStartGroup / doEndGroup 是空的 (:256-266):

java 复制代码
protected void doStartGroup(Group group, StringBuilder context) {
}

protected void doEndGroup(Group group, StringBuilder context) {
}

也就是说,如果一个方言不覆写这两个方法,那么 Group 在输出里不会产生任何括号。

谁覆写了?我们逐个核对:

转换器 doStartGroup doEndGroup 产出
PgVectorFilterExpressionConverter ✅ :147-149 ✅ :151-154 ( / )
PrintFilterExpressionConverter ✅ :47-49 ✅ :51-53 ( / )
MilvusFilterExpressionConverter ✅ :72-74 ✅ :76-79 ( / )
RedisFilterExpressionConverter ✅ :52-54 ✅ :56-59 ( / )
PineconeFilterExpressionConverter ❌ 未覆写 ❌ 未覆写 无括号(用 JSON 嵌套表达)
ChromaFilterExpressionConverter ❌ 未覆写 ❌ 未覆写 继承 Pinecone
CouchbaseAiSearchFilterExpressionConverter ❌ 未覆写 ❌ 未覆写 无括号
MariaDBFilterExpressionConverter ❌ 未覆写 ❌ 未覆写 无括号
TypesenseFilterExpressionConverter ❌ 未覆写 ❌ 未覆写 无括号 (且覆写了 doGroup,见下)
其余 ❌ ❌ 无括号

★ Couchbase 和 MariaDB 生成的 SQL 里没有括号------这是一个真实的优先级陷阱。

这两家生成的都是类 SQL 文本(AND / OR / IN 关键字),而 SQL 里 AND 的优先级高于 OR。假设你写:

java 复制代码
new FilterExpressionBuilder()
    .or(b.eq("country", "BG"), b.and(b.eq("year", 2020), b.eq("active", true)))
    .build();

语义是 country == "BG" OR (year == 2020 AND active == true)。

  • 在 PgVector 上:外层 OR 的两边分别是 Key/Value 比较和一个 Group,Group 会被包成 (...),输出 $.country == "BG" || ($.year == 2020 && $.active == true) ------ 正确。
  • 在 MariaDB 上:Group 不产生括号,输出 country = 'BG' OR year = 2020 AND active = true。MySQL/MariaDB 里 AND 优先于 OR,所以它会被解析成 country = 'BG' OR (year = 2020 AND active = true) ------ 巧合地正确。
  • 但如果反过来写 .and(b.eq("country","BG"), b.or(b.eq("year",2020), b.eq("year",2021))): 期望 country = 'BG' AND (year = 2020 OR year = 2021), MariaDB 实际输出 country = 'BG' AND year = 2020 OR year = 2021 → 被解析成 (country = 'BG' AND year = 2020) OR year = 2021 ------ 语义被改变,查询范围被放大。

这是一个静默的正确性问题 :不报错、不告警,只是查出不该查的东西。规避办法:在 MariaDB / Couchbase 上避免混用 AND 与 OR,或者手动把 OR 分支写成两个独立查询后在 Java 侧合并。

顺带说明 TypesenseFilterExpressionConverter 的 doGroup 覆写(:58 附近):

java 复制代码
this.convertOperand(new Filter.Expression(Filter.ExpressionType.AND, group.content(), group.content()),
        context);

它把 Group 转成一个 AND(x, x) ------ 左右操作数都是同一个 group.content()。这在 AND 是幂等的前提下等价于 x,是个取巧的"恒等式"写法;但这意味着内容会被求值两次 (对字符串拼接而言就是内容被输出两遍)。我们核对一下 Typesense 的 AND 符号是 " && ",所以一个 Group 会输出成 x && x。这是语义等价但文本冗余的实现,在日志和观测里你会看到重复条件------不要以为是 bug,是刻意这么写的。

16.11.5 ★ normalizeDateString:ISO 字符串到 Date 的"隐式升格"

这是整个过滤体系里最容易踩、也最少被文档提及的一个行为。

java 复制代码
// AbstractFilterExpressionConverter.java:186-196
protected static Object normalizeDateString(Object value) {
	if (!(value instanceof String text) || !ISO_DATE_PATTERN.matcher(text).matches()) {
		return value;
	}
	try {
		return Date.from(Instant.from(ISO_DATE_FORMATTER.parse(text)));
	}
	catch (DateTimeParseException e) {
		throw new IllegalArgumentException("Invalid date type: " + text, e);
	}
}

配合两个常量(:56-68):

java 复制代码
protected static final Pattern ISO_DATE_PATTERN = Pattern
	.compile("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,9})?Z");

protected static final DateTimeFormatter ISO_DATE_FORMATTER = DateTimeFormatter
	.ofPattern("yyyy-MM-dd'T'HH:mm:ss[.SSS]'Z'")
	.withZone(ZoneOffset.UTC);

它做什么?在 doValue 阶段,任何符合 ISO-8601 UTC 格式的字符串都会被自动转成 java.util.Date,然后再交给方言的 doSingleValue。

调用点在 doValue(:158-172):

java 复制代码
protected void doValue(Filter.Value filterValue, StringBuilder context) {
	if (filterValue.value() instanceof List list) {
		doStartValueRange(filterValue, context);
		int c = 0;
		for (Object v : list) {
			this.doSingleValue(normalizeDateString(v), context);
			if (c++ < list.size() - 1) {
				this.doAddValueRangeSpitter(filterValue, context);
			}
		}
		this.doEndValueRange(filterValue, context);
	}
	else {
		this.doSingleValue(normalizeDateString(filterValue.value()), context);
	}
}

注意两个调用点都过 normalizeDateString------单个值和列表元素一视同仁。

★ 这个"隐式升格"有三层后果。

后果一:方言的 doSingleValue 必须处理 Date,否则你拿到的类型会出乎意料。

绝大多数方言的 doSingleValue 都是直接 emitJsonValue(value, context)(PgVector :164-174、Milvus :97-99、Pinecone :72-74、Redis :217-219 等)。Jackson 序列化 java.util.Date 的默认产出是毫秒时间戳(一个数字),不是 ISO 字符串。

所以这个表达式:

java 复制代码
b.gte("createdAt", "2024-01-01T00:00:00Z")

在大多数方言上会被转成:

makefile 复制代码
createdAt: 1704067200000        ← 时间戳数字

而不是你可能预期的:

vbnet 复制代码
createdAt: "2024-01-01T00:00:00Z"   ← ISO 字符串

如果你的元数据里存的是 ISO 字符串 (这是 Spring AI 的 Document metadata 存 Date 时的默认形式------见 SimpleVectorStoreFilterExpressionEvaluator 里那段注释,"matching the format used to store dates in metadata"),那么这个比较就永远不匹配。

PgVector 是唯一显式处理了这个的:

java 复制代码
// PgVectorFilterExpressionConverter.java:164-174
protected void doSingleValue(Object value, StringBuilder context) {
	StringBuilder jsonBuffer = new StringBuilder();
	if (value instanceof Date date) {
		emitJsonValue(ISO_DATE_FORMATTER.format(date.toInstant()), jsonBuffer);
	}
	else {
		emitJsonValue(value, jsonBuffer);
	}
	// Escape single quotes for SQL string literal embedding
	context.append(jsonBuffer.toString().replace("'", "''"));
}

它把 Date 重新格式化回 ISO 字符串 再 JSON 化,绕开了 Jackson 的时间戳行为。这是 PgVector 在日期过滤上比其它方言可靠的原因。

后果二:正则与 formatter 的能力不对等,导致"半合法"输入走异常路径。

ISO_DATE_PATTERN 允许 1 到 9 位小数秒 :(?:\.\d{1,9})?。 ISO_DATE_FORMATTER 的模式是 "yyyy-MM-dd'T'HH:mm:ss[.SSS]'Z'" ------ [.SSS] 只接受恰好 3 位。

于是:

输入 正则匹配? formatter 解析? 结果
2024-01-01T00:00:00Z ✅ ✅ 正常转 Date
2024-01-01T00:00:00.123Z ✅ ✅ 正常转 Date
2024-01-01T00:00:00.1Z ✅ ❌ 抛 IllegalArgumentException("Invalid date type: ...")
2024-01-01T00:00:00.123456Z ✅ ❌ 抛 IllegalArgumentException
2024-01-01T00:00:00.123456789Z ✅ ❌ 抛 IllegalArgumentException
2024-01-01T00:00:00+08:00 ❌ --- 原样当字符串处理
2024-01-01 00:00:00 ❌ --- 原样当字符串处理

所以:

★ 一个"看起来合法的 ISO 时间戳"(非零非三位的小数秒)会让整个过滤转换抛出 IllegalArgumentException,而不是安静地当字符串处理。

而 microseconds 精度的时间戳在 PostgreSQL、Go 服务、各类 API 里极其常见。你在 metadata 里存 "2024-01-01T00:00:00.123456Z",然后用它做过滤值(不是被过滤字段,而是过滤的右值),就炸了。

后果三:只认 Z,不认偏移。 正则末尾是硬编码的 Z,formatter 也是 'Z'(字面量)配 withZone(ZoneOffset.UTC)。所以 2024-01-01T00:00:00+08:00 完全不参与归一化 ,被当普通字符串。这倒是一致的------但如果你从前端收到带偏移的时间字符串,它和数据库里 UTC 存的 Z 结尾字符串不会相等,比较静默失败。

16.11.6 emitJsonValue 与 emitLuceneString:两套转义哲学

基类提供了两个静态转义工具,方言按需取用。

emitJsonValue(:316-322):

java 复制代码
protected static void emitJsonValue(Object value, StringBuilder context) {
	try {
		context.append(OBJECT_MAPPER.writeValueAsString(value));
	}
	catch (JacksonException e) {
		throw new RuntimeException("Error serializing value to JSON.", e);
	}
}

用的是一个 private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();(:51),Jackson 3.x(tools.jackson.databind.ObjectMapper)。它保证:

  • 字符串 → 带双引号、内部 " 和 \ 被转义;
  • 数字 → 无引号;
  • 布尔 → true / false;
  • null → null;
  • Date(默认配置)→ 毫秒时间戳数字。

用者:PgVector(doKey + doSingleValue)、Pinecone、Milvus、Redis、MongoDB Atlas、Neo4j、Weaviate、Couchbase、Oracle、Coherence 相关的若干处。

emitLuceneString(:281-312):

java 复制代码
protected static void emitLuceneString(String value, StringBuilder context) {
	for (int i = 0; i < value.length(); i++) {
		char c = value.charAt(i);
		switch (c) {
			case '+': case '-': case '=': case '!': case '(': case ')':
			case '{': case '}': case '[': case ']': case '^': case '"':
			case '~': case '*': case '?': case ':': case '\\': case '/':
			case '&': case '|': case '<': case '>': case ' ':
			case '\t': case '\n': case '\r': case '\u3000':
				context.append('\\').append(c);
				break;
			default:
				context.append(c);
				break;
		}
	}
}

转义清单共 29 个字符 :+ - = ! ( ) { } [ ] ^ " ~ * ? : \ / & | < > (22 个 Lucene 保留字)加上空格、\t、\n、\r、以及......\u3000(全角空格 / IDEOGRAPHIC SPACE)。

★ 这个 \u3000 是全仓库里唯一一处对中文排版字符的显式处理,而且它和语法文件是矛盾的。

回忆 16.10 节讲的 ANTLR 语法:Filters.g4 里的 WS : [ \t\r\n]+ -> skip; 只认半角空格、Tab、CR、LF。也就是说:

  • 如果你在文本表达式 里写 country == "中 国"(中间是全角空格),lexer 直接拒绝,解析失败;
  • 但如果你用 DSL 构造 b.eq("country", "中\u3000国"),绕过 lexer 直达转换器,emitLuceneString 会把它转义成 中\ 国。

所以这条转义在"文本表达式"路径上是死代码,只在 DSL 路径上生效。而它出现在 Elasticsearch / OpenSearch / GemFire 这三家的 Lucene 串里,属于防御性编程(有人手写过带全角空格的 metadata),不是主流用法。

有意思的是,这 29 个字符里没有 '(单引号)。对 Lucene 来说单引号不是保留字,没问题;但如果某个方言把 emitLuceneString 的输出再嵌进单引号包围的 SQL 字面量里,就会出问题------好在三家 Lucene 用户(ES / OpenSearch / GemFire)都不这么做。

16.11.7 值域(IN / NIN)的默认形态:方括号 + 逗号

doValue 里对 List 的处理会调用三个可覆写钩子,基类默认值是:

java 复制代码
protected void doStartValueRange(Filter.Value listValue, StringBuilder context) {
	context.append("[");
}

protected void doEndValueRange(Filter.Value listValue, StringBuilder context) {
	context.append("]");
}

protected void doAddValueRangeSpitter(Filter.Value listValue, StringBuilder context) {
	context.append(",");
}

(:268-292)

也就是默认产出 ["a","b","c"] 这种 JSON 数组形态------正好匹配 Pinecone、MongoDB Atlas、Milvus 的原生语法,所以它们不需要覆写。

谁覆写了?

转换器 doStartValueRange 分隔符 doEndValueRange 产出
基类默认 [ , ] ["a","b","c"]
Elasticsearch 空 (:101-103) OR (:110-112) 空 "a" OR "b" OR "c"
OpenSearch 空 OR 空 同上

注意方法名 doAddValueRangeSpitter ------ Spitter 是 Splitter 的拼写错误 (少了一个 l)。它从 1.0 就存在,因为改名字会破坏所有覆写它的子类(二进制不兼容),所以一直留着。你在自己写转换器覆写它的时候,名字必须跟着错。

16.11.8 三个抽象方法:实现一个新方言的最小契约

如果你要为一个新的向量数据库写转换器,继承 AbstractFilterExpressionConverter 后必须实现三个方法:

java 复制代码
protected abstract void doExpression(Filter.Expression expression, StringBuilder context);
protected abstract void doKey(Filter.Key filterKey, StringBuilder context);
protected abstract void doSingleValue(Object value, StringBuilder context);

(:132、:140、:181)

外加可选覆写的九个钩子:doNot / doGroup / doStartGroup / doEndGroup / doValue / doStartValueRange / doEndValueRange / doAddValueRangeSpitter / convertExpression。

以及两个字符串工具:hasOuterQuotes(String)(:298)和 removeOuterQuotes(String)(:307)。注意后者:

java 复制代码
protected String removeOuterQuotes(String in) {
	return in.substring(1, in.length() - 1);
}

它不校验 首尾是否真的是引号,也不校验长度 ≥ 2。如果你传一个空字符串进去,substring(1, -1) 直接 StringIndexOutOfBoundsException。必须先调 hasOuterQuotes 判断------但基类没有把这两个方法绑在一起,是调用方的责任。

关于后移内容的说明 :有三块内容因为体量原因整体后移到了第 17 章,本章只保留它们共用的抽象机制:

  1. 二十余个 FilterExpressionConverter 方言的逐个对照 ------谁支持 ISNULL、谁把 NOT 写成了不可达的死分支、doKey 的注入风险分档、换库时的可移植性速查表;
  2. SimpleVectorStoreFilterExpressionEvaluator ------SimpleVectorStore 不走字符串、直接在 Java 侧对 metadata map 求值的那条路(它是全仓库唯一覆盖全部 13 种 ExpressionType 的求值器);
  3. 向量存储的 Micrometer 观测体系 ------span 名、低/高基数标签划分、log-query-response。

16.12 自动配置:21 个模块如何各自装配

16.12.1 通用骨架:@ConditionalOnClass + @ConditionalOnProperty(TYPE) + ObjectProvider

以 PgVector 为例(auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-pgvector/.../PgVectorStoreAutoConfiguration.java):

java 复制代码
@AutoConfiguration
@ConditionalOnClass({ PgVectorStore.class, DataSource.class, JdbcTemplate.class })
@EnableConfigurationProperties(PgVectorStoreProperties.class)
@ConditionalOnProperty(name = SpringAIVectorStoreTypes.TYPE, havingValue = SpringAIVectorStoreTypes.PGVECTOR,
		matchIfMissing = true)
public class PgVectorStoreAutoConfiguration {

三个条件各有讲究:

(1) @ConditionalOnClass({ PgVectorStore.class, DataSource.class, JdbcTemplate.class }) ------ 三个类都要在 classpath。注意它把 DataSource 和 JdbcTemplate 也列进去了,这意味着:只引入 spring-ai-pgvector-store 但没引入 spring-jdbc,自动配置不会生效。这是一个容易被忽略的传递依赖要求。

(2) @ConditionalOnProperty(name = TYPE, havingValue = PGVECTOR, matchIfMissing = true) ------ SpringAIVectorStoreTypes.TYPE 是那个全局开关属性。

★ 注意 matchIfMissing = true:如果你不配置 spring.ai.vectorstore.type,PgVector 的自动配置默认是开启的。

而 havingValue = PGVECTOR 意味着:一旦你显式配了 spring.ai.vectorstore.type=redis,PgVector 就关闭。这是 Spring AI 的"一次只启用一个向量存储"机制。

但这里有个陷阱:★ 如果 classpath 上同时有多个 vector-store 模块,且你没配 type,那么多个自动配置都会尝试装配 (因为各自的 matchIfMissing = true),然后因为 @ConditionalOnMissingBean 只有一个 VectorStore bean 能成功,其它静默退让------谁赢取决于自动配置类的排序 ,而不是你的意图。所以显式配 spring.ai.vectorstore.type 是强制性的好习惯。

(3) ObjectProvider.getIfUnique(...) / getIfAvailable() ------ 观测相关的两个可选依赖:

java 复制代码
.observationRegistry(observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP))
.customObservationConvention(customObservationConvention.getIfAvailable())

★ 这一行是"观测默认关闭"的根源:

  • getIfUnique(() -> ObservationRegistry.NOOP) ------ 如果容器里没有 ObservationRegistry bean(即你没引 micrometer-observation 或没开 actuator),就用 ObservationRegistry.NOOP(空实现,什么都不记录);
  • 如果容器里有多个 ObservationRegistry bean(getIfUnique 的语义:不唯一时返回默认值),同样降级到 NOOP ------ ★ 这是最坑的一点:多 registry 不会报错,而是静默关闭全部观测;
  • getIfAvailable() 无默认值 → 没有自定义 convention 时返回 null,进而用 DefaultVectorStoreObservationConvention。

16.12.2 BatchingStrategy 也是一个 @Bean

java 复制代码
@Bean
@ConditionalOnMissingBean
BatchingStrategy pgVectorStoreBatchingStrategy() {
	return new TokenCountBatchingStrategy();
}

★ 注意:这个 bean 的方法名是 pgVectorStoreBatchingStrategy,但每个 vector-store 自动配置模块都定义了一个 BatchingStrategy bean 。由于 @ConditionalOnMissingBean,多个模块同时存在时只有一个胜出。

而 vectorStore(...) 方法通过参数注入 BatchingStrategy batchingStrategy(:64)------它拿到的是容器里那个唯一胜出的 bean ,未必是 PgVector 自己定义的那个。因为所有实现都是 new TokenCountBatchingStrategy(),行为一致,所以实际上无所谓。但如果你自定义了一个全局 BatchingStrategy bean,它会覆盖所有 vector-store 模块的内置策略------这是定制批处理行为的推荐入口。

16.12.3 initializeSchema 默认 false,且是每个模块各有一份

java 复制代码
// CommonVectorStoreProperties.java:33
private boolean initializeSchema = false;

javadoc 解释了为什么(:27-32):

Vector stores do not initialize schema by default on application startup. The applications explicitly need to opt-in for initializing the schema on startup.

★ 这是生产环境的重要默认 :Spring AI 不会 在启动时自动建表/建索引。你必须显式设 spring.ai.vectorstore.pgvector.initialize-schema=true。

后果:第一次跑应用时如果没开这个开关,会报"表不存在"。而开了这个开关但不注意 removeExistingVectorStoreTable ,可能把已有数据删掉------PgVector 的 builder 里有这个选项(:75 .removeExistingVectorStoreTable(properties.isRemoveExistingVectorStoreTable()))。

★ 危险组合警告 :initialize-schema=true + remove-existing-vector-store-table=true = 每次启动都清空向量库 。注意 Spring Boot 配置文件里 boolean 属性名与 builder 方法的映射(isRemoveExistingVectorStoreTable ↔ remove-existing-vector-store-table)。

16.12.4 21 个自动配置模块与 22 个实现不完全一一对应

用 ls auto-configurations/vector-stores/ 可以列出 21 个自动配置模块,而 vector-stores/ 下有 22 个实现目录。差的那一个(前已核实)是 spring-ai-coherence-store ------它有 VectorStore 实现但没有对应的自动配置模块。

★ 也就是说:Coherence 无法通过 Spring Boot 自动配置装配,你必须手工 @Bean 声明。 这在选型时是个实际成本。


16.13 陷阱总表(30 条)

按"踩中概率 × 排查难度"排序。★ 标记的是静默失败(不报错、不告警,只是结果不对)。

A. 接口与继承(16.2 / 16.3)

# 陷阱 症状 出处
1 ★ VectorStore.similaritySearch(String) 便捷方法静默带 topK=4 只返回 4 条,还以为是召回率低 VectorStoreRetriever 默认实现
2 ★ getNativeClient() 默认返回 Optional.empty() getNativeClient().get() 抛 NoSuchElementException VectorStore default 方法
3 VectorStore.Builder 只有四个方法,不含 dimensions / initializeSchema 想通过统一 Builder 设维度,编译不过 VectorStore.Builder
4 DocumentWriter.write() 与 accept() 是三条不同路径,异常语义不一致 同一批数据两种写法报错时机不同 DocumentWriter / VectorStore
5 内嵌 Builder 的泛型自类型写法导致无法用父类引用持有 builder 想写 VectorStore.Builder b = ... 会丢失具体类型 VectorStore.Builder

B. AbstractObservationVectorStore(16.4)

# 陷阱 症状 出处
6 doDelete(Filter.Expression) 默认抛 UnsupportedOperationException 按过滤删除在部分实现上不可用 AbstractObservationVectorStore
7 ★ validateNonTextDocuments 对 !isText() 一律拒绝,不支持多模态文档 带图片/音频的 Document 被拒 AbstractObservationVectorStore
8 ★ 同方法对 null 列表直接放行、对 null 元素跳过 脏数据静默混入 AbstractObservationVectorStore
9 三个操作共用同一个 AI_VECTOR_STORE 观测文档 无法按操作类型区分 key VectorStoreObservationDocumentation

C. SearchRequest(16.5 / 16.6)

# 陷阱 症状 出处
10 ★ Builder.build() 返回共享实例 ,req1 会被 req2 改写 复用 builder 时前一个请求被污染 SearchRequest.Builder
11 ★ similarityThreshold 有三类互不等价的语义 同一阈值在不同库返回条数不同(0 / ≤topK / topK) 见 16.6 对照表
12 ★ B 类实现(topK 下推 + Java 侧过滤)可能返回 0 条 明明有相似文档却召回为空 见 16.6
13 余弦距离下 score 可以为负 用 > 0 过滤会全部丢掉 见 16.6
14 Document.getScore() 可能为空,各库把分数塞在不同的 metadata key 取分数的代码换库就失效 Document
15 filterExpression(String) 每次 new 一个 parser,导致解析缓存失效 高频调用时重复解析,CPU 空转 SearchRequest.Builder
16 toString() 会打印 filterExpression 全文 日志/观测里泄漏 PII SearchRequest

D. BatchingStrategy(16.7)

# 陷阱 症状 出处
17 全仓库只有一个实现 TokenCountBatchingStrategy,想换没得换 --- spring-ai-model/.../embedding/
18 javadoc 说 7371,实际常量是 7372 (Math.round(8191*0.9)) 按文档设阈值差 1 TokenCountBatchingStrategy
19 ★ 单文档超限 → 整批失败,不是跳过该条 一篇长文毁掉一批写入 TokenCountBatchingStrategy
20 ★ SimpleVectorStore 逐条 embed(document),batchingStrategy 是死字段 配了策略也不生效 SimpleVectorStore
21 add(emptyList()) 在 SimpleVectorStore 抛 IllegalArgumentException 空列表要自己判空 SimpleVectorStore
22 计数用 MetadataMode.NONE 只算正文,metadata 不入 token 预算 TokenCountBatchingStrategy

E. 过滤表达式(16.8 -- 16.11)

# 陷阱 症状 出处
23 ★ 中文 metadata key 必须加引号 写成 '"国家" == "中国"' 裸写中文 key 被 lexer 拒绝 Filters.g4 的 IDENTIFIER : [a-zA-Z]
24 ★ DescriptiveErrorListener.INSTANCE 是静态单例,errorMessages 被并发 clear()/add() 并发解析失败表达式时错误信息串台或为空 FilterExpressionTextParser
25 解析结果缓存是无界 ConcurrentHashMap 表达式来自用户输入时内存无上限增长 FilterExpressionTextParser
26 ★ NOT(ISNULL(x)) 抛 IllegalArgumentException: Unknown expression type: ISNULL 想写"非空"得直接用 ISNOTNULL FilterHelper.negate 的 default
27 ★ ISNULL / ISNOTNULL 在方言层几乎全不支持(仅 Elasticsearch) 本地 SimpleVectorStore 跑通,上线 PgVector 抛异常 见第 17 章对照表
28 ★ normalizeDateString 只接受 0 或 3 位 小数秒,1/6/9 位直接抛 IllegalArgumentException 微秒精度时间戳让整个转换炸掉 AbstractFilterExpressionConverter:186-196
29 ★ Date 经 emitJsonValue 变成毫秒时间戳数字,与 metadata 里的 ISO 字符串永不匹配 日期过滤静默无结果 各 doSingleValue
30 ★ convertOperand 的第四个分支没有 else ,未知 Operand 静默产出空串 过滤条件凭空消失 → 全表扫描 AbstractFilterExpressionConverter:89-112

补充三条"值得知道但不算坑":

  • doAddValueRangeSpitter 是 Splitter 的拼写错误 (少一个 l),为二进制兼容一直没改;
  • removeOuterQuotes("") 会 StringIndexOutOfBoundsException,必须先调 hasOuterQuotes;
  • FilterExpressionConverter 是单向 的(Expression → String),没有反向解析,无法把已有原生 SQL WHERE 导入回 Spring AI 过滤模型。

16.14 小结

  1. VectorStore extends DocumentWriter, VectorStoreRetriever 这个双继承是整个体系的钥匙。 写入侧只有一个 add(List<Document>) 是真方法,write/accept 都是借道;读取侧 VectorStoreRetriever 是 @FunctionalInterface,similaritySearch(String) 的默认实现静默带上 topK=4。

  2. 22 个实现里 21 个继承 AbstractObservationVectorStore,唯一例外是 BedrockKnowledgeBaseVectorStore (final 且直接 implements VectorStore)------它因此完全没有观测 。内核 3,124 行,21 个自动配置模块,coherence 有实现无自动配置。

  3. SearchRequest 只有四个字段 :query="" / topK=4 / similarityThreshold=0.0 / filterExpression=null。它的 Builder.build() 返回共享实例,重复 build 会互相污染。

  4. similarityThreshold 的语义有三类,且不等价 :A 类下推数据库(先过滤后 topK,返回 topK 条)、B 类 topK 下推 + Java 侧过滤(返回 ≤ topK,可能为 0)、C 类全量过滤后截断(仅 SimpleVectorStore)。"阈值在哪一侧"没有统一答案,只能查具体实现。

  5. BatchingStrategy 全仓库只有一个实现 ,javadoc 里的 7371 与实际常量 7372 差 1;单文档超限会让整批 失败;而 SimpleVectorStore 根本不用它(逐条 embed),这个字段在它身上是死的。

  6. Filter 模型是 13 种 ExpressionType + 四种 Operand(全是 record) ,ANTLR 语法 Filters.g4 强制 WHERE 前缀、IDENTIFIER 只认 ASCII(所以中文 key 必须加引号)、AND 优先于 OR、没有 null 字面量。

  7. 文本解析器有一个并发缺陷 :DescriptiveErrorListener.INSTANCE 是静态单例,共享的 errorMessages 列表在 parse() 里被 clear() 后又 add(),多线程同时解析失败表达式时会串台。

  8. 过滤能力的上限由"最弱的一环"决定 :解析器支持全部 13 种类型,FilterExpressionBuilder 支持全部,SimpleVectorStoreFilterExpressionEvaluator 也支持全部(穷举 switch、无 default,见第 17 章),但方言转换器层只有 Elasticsearch 支持 ISNULL/ISNOTNULL。

  9. NOT 之所以在所有方言上都"能用",是因为基类把它消解掉了 (doNot → FilterHelper.negate 的德摩根展开),不是因为各家实现了它。这带来一个副作用:只要不覆写 doNot,方言里写的 case NOT 就是不可达的死代码 ;而 negate 无法处理 ISNULL/ISNOTNULL。

  10. 日期是跨实现一致性的重灾区 :normalizeDateString 把 ISO 字符串升格为 Date(但只认 0/3 位小数秒),而 metadata 里 Date 存的是 ISO 字符串,多数方言却又把 Date 序列化成毫秒时间戳------只有 PgVector 和 SimpleVectorStore 把它格式回 ISO。


16.15 思考题

  1. VectorStore 为什么要同时继承 DocumentWriter 和 VectorStoreRetriever,而不是直接把方法写在一起?如果合并,ETL 流水线(DocumentReader → DocumentTransformer → DocumentWriter)会失去什么?

  2. SearchRequest.Builder.build() 返回共享实例。请设计一种修改,既保留"builder 复用"的便利,又不让已 build 的请求被后续修改污染------并说明为什么 Spring AI 没这么做(提示:二进制兼容)。

  3. 假设库里有 1000 条文档、其中 50 条满足 similarityThreshold。在 A 类实现(下推)和 B 类实现(Java 侧过滤)上,topK=10, threshold=0.8 分别返回几条?如果 50 条里分数最高的只有 3 条,结果又如何?

  4. 为什么 TokenCountBatchingStrategy 的单文档超限要让整批失败,而不是跳过该条?两种策略各自的失败模式是什么?如果你要改成分批降级(超限文档单独一批),会引入什么新问题?

  5. FilterHelper.negate 的 TYPE_NEGATION_MAP 覆盖了 10 个类型,漏了 ISNULL / ISNOTNULL / NOT。请给出让 NOT(ISNULL(x)) 正确工作的两种改法(改 negate 还是改 doNot),并比较它们对现有 17 个方言的兼容性影响。

  6. DescriptiveErrorListener.INSTANCE 的静态单例 + 共享 errorMessages 会串台。请给出最小侵入的修复方案(要求不破坏 INSTANCE 这个公开字段)。

  7. 为什么 emitLuceneString 转义了 \u3000(全角空格),而 Filters.g4 的 WS 规则又不认全角空格?这两处不一致说明什么?

  8. 如果你要为一个新的向量数据库实现 VectorStore,从 AbstractObservationVectorStore 继承后必须实现哪些方法?为了让 similarityThreshold 与 PgVector 语义一致,你应该把它下推还是 Java 侧过滤?各自的性能与正确性代价是什么?


16.16 文件索引

内核(3,124 行)

文件 路径 关键行
VectorStore spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/VectorStore.java :40 双继承;getNativeClient();内嵌 Builder
VectorStoreRetriever 同目录 VectorStoreRetriever.java @FunctionalInterface;similaritySearch(String) 默认实现
DocumentWriter spring-ai-commons/src/main/java/org/springframework/ai/document/DocumentWriter.java extends Consumer<List<Document>>
SearchRequest 同目录 SearchRequest.java 四字段;Builder.build() 共享实例
AbstractObservationVectorStore .../vectorstore/observation/AbstractObservationVectorStore.java doDelete(Filter) 默认抛异常;validateNonTextDocuments
AbstractVectorStoreBuilder 同目录 AbstractVectorStoreBuilder.java 自类型泛型
SimpleVectorStore 同目录 SimpleVectorStore.java 逐条 embed;add(emptyList())
SimpleVectorStoreFilterExpressionEvaluator 同目录(详解见第 17 章) :94-121 13 个 case;:183-205 compare
SpringAIVectorStoreTypes 同目录 TYPE 常量与各 havingValue
CommonVectorStoreProperties .../vectorstore/properties/CommonVectorStoreProperties.java :33 initializeSchema = false

过滤子系统

文件 路径
Filter .../vectorstore/filter/Filter.java(13 种 ExpressionType、4 种 Operand)
FilterExpressionBuilder 同目录(13 个方法、Op.build() 剥壳)
FilterExpressionTextParser 同目录(:145 clear()、:160 join、无界缓存)
FilterHelper 同目录(:41-46 TYPE_NEGATION_MAP、:59-97 negate)
FilterExpressionConverter 同目录(只有一个抽象方法)
AbstractFilterExpressionConverter .../filter/converter/AbstractFilterExpressionConverter.java(:89-112 调度、:121-127 doNot、:186-196 日期归一化、:281-312 Lucene 转义)
PineconeFilterExpressionConverter .../filter/converter/(:57-59 泛型符号)
PrintFilterExpressionConverter 同目录(测试用)
Filters.g4 spring-ai-vector-store/src/main/antlr4/org/springframework/ai/vectorstore/filter/Filters.g4

批处理与自动配置

文件 路径
BatchingStrategy spring-ai-model/src/main/java/org/springframework/ai/embedding/BatchingStrategy.java
TokenCountBatchingStrategy 同目录(唯一实现)
PgVectorStoreAutoConfiguration auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-pgvector/src/main/java/org/springframework/ai/vectorstore/pgvector/autoconfigure/
VectorStoreObservationAutoConfiguration auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-observation/.../observation/autoconfigure/

16.17 复核命令

在仓库根目录 D:/CODE/spring-ai-2.0.1 下执行:

bash 复制代码
# 1. 统计实现数与继承数
ls vector-stores/ | wc -l
grep -rl "extends AbstractObservationVectorStore" vector-stores/*/src/main/java --include=*.java | wc -l
grep -rn "implements VectorStore" vector-stores/*/src/main/java --include=*.java

# 2. VectorStore 接口本体
sed -n '30,60p' spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/VectorStore.java
grep -n "getNativeClient\|interface Builder\|default void delete" spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/VectorStore.java

# 3. SearchRequest 四字段与 Builder 共享实例
grep -n "query\|topK\|similarityThreshold\|filterExpression" spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/SearchRequest.java | head -30

# 4. 阈值的三类实现:看各实现的 doSimilaritySearch
grep -rn "similarityThreshold" vector-stores/*/src/main/java --include=*.java | grep -v test

# 5. BatchingStrategy 的实现数(应为 1)
grep -rl "implements BatchingStrategy" . --include=*.java
grep -n "MAX_INPUT_TOKEN_COUNT\|8191\|0.9" spring-ai-model/src/main/java/org/springframework/ai/embedding/TokenCountBatchingStrategy.java

# 6. Filter 的 13 种类型与 4 种 Operand
grep -n "ExpressionType\|record Key\|record Value\|record Expression\|record Group" spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/filter/Filter.java

# 7. ANTLR 语法:IDENTIFIER 只认 ASCII、WS 不含全角空格
grep -n "IDENTIFIER\|LETTER\|WS\|QuotedIdentifier" spring-ai-vector-store/src/main/antlr4/org/springframework/ai/vectorstore/filter/Filters.g4

# 8. 解析器的共享错误列表
grep -n "INSTANCE\|errorMessages\|clear()\|ConcurrentHashMap" spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/filter/FilterExpressionTextParser.java

# 9. negate 的覆盖缺口(应缺 ISNULL / ISNOTNULL)
grep -n "TYPE_NEGATION_MAP\|Unknown expression type" spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/filter/FilterHelper.java

# 10. 日期归一化与小數秒限制
grep -n "ISO_DATE_PATTERN\|ISO_DATE_FORMATTER\|Invalid date type" spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/filter/converter/AbstractFilterExpressionConverter.java

# 11. 求值器的穷举 switch(应有 13 个 case、无 default)
grep -n "case \|default" spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/SimpleVectorStoreFilterExpressionEvaluator.java

# 12. 全部转换器清单与继承关系
find . -name "*FilterExpressionConverter*.java" -not -path "*/test/*" | sort
grep -rn "extends AbstractFilterExpressionConverter" . --include=*FilterExpressionConverter.java

# 13. 自动配置模块数 vs 实现数
ls auto-configurations/vector-stores/ | wc -l
grep -rn "matchIfMissing = true" auto-configurations/vector-stores/*/src/main/java --include=*AutoConfiguration.java | head -5

# 14. initializeSchema 默认值
grep -rn "initializeSchema = false" spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/properties/

16.18 下一章预告

第 17 章:向量存储(下)------从 PgVector 到 S3 Vectors。

本章把抽象层剖完了,第 17 章下到实现层,处理四件事:

  • 二十余个 FilterExpressionConverter 方言的逐个对照 (本章后移过来的那两块之一)。核心是一张操作符支持矩阵:ISNULL/ISNOTNULL 全仓库只有 Elasticsearch 一家支持;Cassandra 显式拒绝 NIN;Redis 用白名单校验 代替转义(未声明的 key 直接抛 IllegalArgumentException),是全仓库唯一强 schema 的过滤;Qdrant / Coherence / BedrockKnowledgeBase / S3Vector 四个"不守规矩"的异类压根不走字符串,各自产出 gRPC / Coherence / AWS 的原生对象。

  • 向量存储的 Micrometer 观测体系 (本章后移过来的另一块)。span 名固定为 db.vector.client.operation;只有 3 个低基数标签(spring.ai.kind / db.system / db.operation.name),却有 9 个高基数标签,其中 db.vector.query.content 会把用户提问原文写进 span、db.vector.query.filter 会把过滤条件(可能含 PII)写进 span。以及那个默认关闭、一旦开启就把检索正文以 INFO 打进日志的 log-query-response------框架会主动打 WARN 提醒你。

  • PgVector 作为参照实现 :distanceType 与二十余种操作符、indexType(HNSW vs IVFFlat)的取舍、removeExistingVectorStoreTable 这个危险开关、以及为什么它 doSingleValue 里那句 if (value instanceof Date date) 让它在日期过滤上比所有同行可靠。

  • S3 Vectors 这个新物种 :它为什么需要一个接口 而不是类(S3VectorFilterExpressionConverter)来产出 AWS SDK 的 Document 对象树------这代表了"字符串不够用了"的演进方向。

以及那个贯穿两章的问题:当你从 SimpleVectorStore 换到 PgVector 再换到 Redis,哪些过滤条件会静默失效,哪些会直接抛异常?

相关推荐
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 12 章 工具循环的流式形态、Usage 累加与扩展体系
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 23 章 模型供应商实现:从 `OpenAiSetup` 到 Bedrock
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 06 章 内置 Advisor 全景:13 个实现逐个剖到源码级
java·人工智能·开源
鶴哥只手遮天2 小时前
Spring AI 2.0.1 深度解析-第 03 章 消息体系与 Prompt:不可变模型、`ChatOptions` 与模板渲染
java·人工智能·开源
鶴哥只手遮天2 小时前
Spring AI 2.0.1 深度解析-第 24 章 生产实践与 2.x 迁移:陷阱清单、AOT 与调优
java·人工智能·开源
鶴哥只手遮天2 小时前
Spring AI 2.0.1 深度解析-第 10 章 工具调用(中):执行链路、结果转换与工具限制
java·人工智能·开源
鶴哥只手遮天2 小时前
Spring AI 2.0.1 深度解析-第 13 章 MCP 客户端:传输、工具聚合与命名冲突
java·人工智能·开源
鶴哥只手遮天2 小时前
Spring AI 2.0.1 深度解析-第 11 章 工具调用(下):ToolCallingManager、ToolCallingAdvisor与调用预算
java·人工智能·开源
鶴哥只手遮天2 小时前
Spring AI 2.0.1 深度解析-第 17 章 向量存储(下):实现剖析,从 PgVector 到 S3 Vectors
java·人工智能·开源