Spring AI 2.0.1 深度解析-第 16 章 向量存储(上):VectorStore 接口体系、BatchingStrategy 与 SearchRequest
前八章讲的是"检索增强怎么编排"------
RetrievalAugmentationAdvisor的七步流水线、Query的四种变体、DocumentPostProcessor怎么压缩。但那些章节里有一个始终没被追问的前提:文档到底存在哪?相似度到底是怎么算出来的?
topK=4这个 4 是谁定的?这一章回到地基。你要看到的不是一个"存文档的接口",而是三件事:
- 一个 144 行 的接口,怎么用双继承把"写入 / 删除 / 检索"三组契约压进最小化的方法集,以及为什么
delete有三个重载而add只有一个;- 一个只有 四个字段 的
SearchRequest,其中topK的默认值4和similarityThreshold的默认值0.0会在你完全不知情的情况下决定 RAG 的召回质量;- 一套从
Filter.ExpressionAST 到十几种数据库方言 的翻译链路------它有 17 个实现、一个共享基类和三个"不守规矩"的异类,而其中两个最基础的操作(IS NULL和NOT)在多数方言里会直接抛异常。
16.0 本章的五句总纲
-
VectorStore extends DocumentWriter, VectorStoreRetriever(VectorStore:40)。 这个双继承是理解整个体系的钥匙:写入侧来自DocumentWriter(Consumer<List<Document>>语义),检索侧被抽成独立的@FunctionalInterface VectorStoreRetriever(:35-36)。"只读检索"是一等公民,而不是"完整 store 的权限裁剪版"。 -
add只有 1 个方法,delete有 3 个重载------不对称是有意为之的。 写入方向的变体(按 ID / 按表达式 / 按字符串表达式)全部发生在删除侧,因为"加"只需要一种语义,而"删"必须支持"我不知道 ID,只知道条件"。 -
similarityThreshold在哪一侧过滤,取决于实现------而且分成三类,语义不等价。 PgVector 把它下推成distance = 1 - threshold并作为 SQL 谓词(数据库先过滤再取 topK);Azure / Milvus / Pinecone 是把topK下推、在 Java 侧 过滤(先取 topK 再过滤,返回条数可能远小于 topK 甚至为 0);SimpleVectorStore则是全量过滤后再截断。javadoc 说"这是客户端后处理"(SearchRequest:167-168),这句话只对第二类成立。 -
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字段是个死字段。 -
过滤表达式的能力边界是"最弱方言"决定的。
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.NOOPcustomObservationConvention(VectorStoreObservationConvention)------ 默认DefaultVectorStoreObservationConventionbatchingStrategy(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-77javadoc 明确写了 "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)里那七个示例就是这么承诺的。差异只出现在:
- key 的大小写与引号 :文本解析的
QuotedIdentifier(:205-208)会做反转义,DSL 的new Key(key)直接原样; - 转义处理 :文本串
'a\'b'会被解析成a'b(unescapeStringValue,:222-230),DSL 里你得自己写b.eq("a", "a'b")------结果相同,但输入不同; - 日期 :文本里的 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(WHEREtoken 只有 5 个字符,更短),所以"WHERE wherever == 1"这种情况不会发生;而"wherever == 1"不补前缀时会被 lexer 拆成IDENTIFIER(wherever) == 1,缺少WHEREtoken → 语法错误。实际后果:
"wherever == 1"会因为startsWith("WHERE")为真而不补前缀 ,然后 lexer 认不出WHEREtoken,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 章,本章只保留它们共用的抽象机制:
- 二十余个
FilterExpressionConverter方言的逐个对照 ------谁支持ISNULL、谁把NOT写成了不可达的死分支、doKey的注入风险分档、换库时的可移植性速查表;SimpleVectorStoreFilterExpressionEvaluator------SimpleVectorStore不走字符串、直接在 Java 侧对 metadata map 求值的那条路(它是全仓库唯一覆盖全部 13 种ExpressionType的求值器);- 向量存储的 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)------ 如果容器里没有ObservationRegistrybean(即你没引micrometer-observation或没开 actuator),就用ObservationRegistry.NOOP(空实现,什么都不记录);- 如果容器里有多个
ObservationRegistrybean(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 小结
-
VectorStore extends DocumentWriter, VectorStoreRetriever这个双继承是整个体系的钥匙。 写入侧只有一个add(List<Document>)是真方法,write/accept都是借道;读取侧VectorStoreRetriever是@FunctionalInterface,similaritySearch(String)的默认实现静默带上topK=4。 -
22 个实现里 21 个继承
AbstractObservationVectorStore,唯一例外是BedrockKnowledgeBaseVectorStore(final且直接implements VectorStore)------它因此完全没有观测 。内核 3,124 行,21 个自动配置模块,coherence有实现无自动配置。 -
SearchRequest只有四个字段 :query=""/topK=4/similarityThreshold=0.0/filterExpression=null。它的Builder.build()返回共享实例,重复 build 会互相污染。 -
similarityThreshold的语义有三类,且不等价 :A 类下推数据库(先过滤后 topK,返回 topK 条)、B 类 topK 下推 + Java 侧过滤(返回 ≤ topK,可能为 0)、C 类全量过滤后截断(仅SimpleVectorStore)。"阈值在哪一侧"没有统一答案,只能查具体实现。 -
BatchingStrategy全仓库只有一个实现 ,javadoc 里的 7371 与实际常量 7372 差 1;单文档超限会让整批 失败;而SimpleVectorStore根本不用它(逐条 embed),这个字段在它身上是死的。 -
Filter模型是 13 种ExpressionType+ 四种Operand(全是 record) ,ANTLR 语法Filters.g4强制WHERE前缀、IDENTIFIER只认 ASCII(所以中文 key 必须加引号)、AND优先于OR、没有null字面量。 -
文本解析器有一个并发缺陷 :
DescriptiveErrorListener.INSTANCE是静态单例,共享的errorMessages列表在parse()里被clear()后又add(),多线程同时解析失败表达式时会串台。 -
过滤能力的上限由"最弱的一环"决定 :解析器支持全部 13 种类型,
FilterExpressionBuilder支持全部,SimpleVectorStoreFilterExpressionEvaluator也支持全部(穷举 switch、无default,见第 17 章),但方言转换器层只有 Elasticsearch 支持ISNULL/ISNOTNULL。 -
NOT之所以在所有方言上都"能用",是因为基类把它消解掉了 (doNot→FilterHelper.negate的德摩根展开),不是因为各家实现了它。这带来一个副作用:只要不覆写doNot,方言里写的case NOT就是不可达的死代码 ;而negate无法处理ISNULL/ISNOTNULL。 -
日期是跨实现一致性的重灾区 :
normalizeDateString把 ISO 字符串升格为Date(但只认 0/3 位小数秒),而 metadata 里Date存的是 ISO 字符串,多数方言却又把Date序列化成毫秒时间戳------只有 PgVector 和SimpleVectorStore把它格式回 ISO。
16.15 思考题
-
VectorStore为什么要同时继承DocumentWriter和VectorStoreRetriever,而不是直接把方法写在一起?如果合并,ETL 流水线(DocumentReader → DocumentTransformer → DocumentWriter)会失去什么? -
SearchRequest.Builder.build()返回共享实例。请设计一种修改,既保留"builder 复用"的便利,又不让已 build 的请求被后续修改污染------并说明为什么 Spring AI 没这么做(提示:二进制兼容)。 -
假设库里有 1000 条文档、其中 50 条满足
similarityThreshold。在 A 类实现(下推)和 B 类实现(Java 侧过滤)上,topK=10, threshold=0.8分别返回几条?如果 50 条里分数最高的只有 3 条,结果又如何? -
为什么
TokenCountBatchingStrategy的单文档超限要让整批失败,而不是跳过该条?两种策略各自的失败模式是什么?如果你要改成分批降级(超限文档单独一批),会引入什么新问题? -
FilterHelper.negate的TYPE_NEGATION_MAP覆盖了 10 个类型,漏了ISNULL/ISNOTNULL/NOT。请给出让NOT(ISNULL(x))正确工作的两种改法(改negate还是改doNot),并比较它们对现有 17 个方言的兼容性影响。 -
DescriptiveErrorListener.INSTANCE的静态单例 + 共享errorMessages会串台。请给出最小侵入的修复方案(要求不破坏INSTANCE这个公开字段)。 -
为什么
emitLuceneString转义了\u3000(全角空格),而Filters.g4的WS规则又不认全角空格?这两处不一致说明什么? -
如果你要为一个新的向量数据库实现
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,哪些过滤条件会静默失效,哪些会直接抛异常?