Spring AI 2.0.1 深度解析-第 17 章 向量存储(下):实现剖析,从 PgVector 到 S3 Vectors

Spring AI 2.0.1 深度解析-第 17 章 向量存储(下):实现剖析,从 PgVector 到 S3 Vectors

上一章把抽象层剖开了:VectorStore 的双继承、SearchRequest 的四个字段、BatchingStrategy 的唯一实现、Filter.Expression 的 13 种类型与四个 Operand、以及 AbstractFilterExpressionConverter 那台"翻译机"的公共骨架。

本章下到实现层 。核心问题是:同样一份 SearchRequest,落到 22 个不同的 VectorStore 上,会发生什么?

本章的行文范围:阈值处理全景(17.1)、PgVector 作为参照实现(17.2)、各家在阈值上的"方言"(17.3)、S3 Vectors 这个新物种(17.4)、SimpleVectorStoreFilterExpressionEvaluator(17.5)、Micrometer 观测体系(17.6)、二十余个方言 converter 逐个对照(17.7)。

17.0 本章的五句总纲

  1. similarityThreshold 不是一个有统一语义的参数。 22 个实现里有 21 个 读了它,但处理方式分成四类:下推到数据库、Java 侧流过滤、全量过滤后截断、以及------静默丢弃。第 16 章讲的三类之外,本章补上了第四类。

  2. S3VectorStore 完全忽略 similarityThreshold。 它的 doSimilaritySearch 只用了 topK,没有一行代码读阈值。这是全仓库唯一一个把用户配置的阈值无声吃掉的实现------不报错、不告警,只是"设了等于没设"。

  3. PgVector 用的是操作符(<-> / <#> / <=>),不是函数。 README 里的问题有了答案:vector_cosine_distance() 从未出现在源码里,用的是 <=> 配 vector_cosine_ops 索引。阈值换算是 double distance = 1 - request.getSimilarityThreshold(),且 SQL 里是严格小于 (<),不是 <=。

  4. S3VectorStore 有两套互不相干的过滤路径。 检索时走 converter 下推到 AWS,删除时走 S3VectorStoreFilterExpressionEvaluator 做全量分页扫描 + Java 侧求值。同一个过滤条件,在两条路径上语义可能不一致。

  5. S3VectorStoreFilterExpressionEvaluator 是 SimpleVectorStoreFilterExpressionEvaluator 的近乎逐字复制品。 158 行 vs 221 行,同样的 DATE_FORMATTER、compare、剥引号逻辑、13 个 case------两个维护者各写了一份,没抽公共基类。

17.1 ★ 阈值处理全景:22 个实现,四类语义

先把这张表摆出来------它是本章最实用的一张表,也是第 16 章 16.6 节那张"三类实现"的完整版。

用 grep -rn "getSimilarityThreshold()" vector-stores/*/src/main/java --include=*.java 可以列出所有读取阈值的地方。命中 21 个文件,恰好少一个:spring-ai-s3-vector-store。

17.1.1 全景表

# 实现 阈值处理方式 归类 关键行
1 PgVector double distance = 1 - threshold,SQL WHERE ... < ? A 下推 :368
2 MariaDB 同 PgVector 的 1 - threshold A 下推 :361
3 Qdrant setScoreThreshold((float) threshold) A 下推 :267
4 Weaviate .certainty((float) threshold) A 下推 :338
5 MongoDB Atlas Criteria(SCORE_FIELD_NAME).gte(threshold)(聚合管道) A 下推 :317
6 Neo4j 作为参数传给 Cypher(:298),带 Assert.isTrue(0..1) A 下推 :278/:298
7 Redis effectiveThreshold = (float) threshold,带 Assert.isTrue(0..1) A 下推 :461/:471
8 Elasticsearch float threshold = (float) searchRequest.getSimilarityThreshold() A 下推 :251
9 OpenSearch 传入 search request builder A 下推 :308
10 Oracle 三分支特判(见 17.3.1) A 下推 :363/:394/:423
11 Typesense 字符串拼接进查询串(见 17.3.2) A 下推(有风险) :247
12 BedrockKnowledgeBase threshold >= 0 ? threshold : ... 三元兜底 A 下推 :122
13 Azure AI Search .filter(result -> result.getScore() >= threshold) B Java 侧 :253
14 GemFire .filter(r -> r.score >= threshold) B Java 侧 :282
15 Milvus .filter(rowRecord -> getResultSimilarity(rowRecord) >= threshold) B Java 侧 :412
16 Pinecone .filter(scoredVector -> scoredVector.getScore() >= threshold) B Java 侧 :230
17 Cassandra if (score < threshold) { ... } B Java 侧 :365
18 Chroma if ((1 - distance) >= threshold) B Java 侧 :226
19 Coherence `if (distanceType != COSINE (1 - r.getDistance()) >= threshold)`
20 SimpleVectorStore 全量计算后过滤再截断 C 截断 ---
21 S3 Vectors 不读阈值 ★ D 静默丢弃 ---
22 Couchbase double similarityThreshold = springAiRequest.getSimilarityThreshold() A 下推 :179

四类语义的差别,用一个具体场景说清楚。

假设库里 1000 条文档,其中 50 条与查询的相似度 ≥ 0.8,请求是 topK=10, threshold=0.8:

类别 返回结果 说明
A 下推 最多 10 条,全部 ≥ 0.8 数据库先按阈值过滤,再取 top 10。如果 50 条合格,稳定返回 10 条
B Java 侧 0--10 条 数据库先返回 top 10(不管阈值),Java 再过滤。如果 top 10 里只有 3 条 ≥ 0.8,就只返回 3 条
C 截断 最多 10 条 全量算分、过滤、再截断
★ D 丢弃 恰好 10 条,可能全部不相似 阈值被无视,等价于 threshold=0.0

★ B 类和 D 类的差别是"召回不足"与"召回污染"的差别:B 类让你漏掉本该召回的(因为 topK 名额被不合格文档占了),D 类让你召回一堆垃圾(因为根本没有阈值)。

17.1.2 A 类内部的两个子变种

A 类(下推)也不完全一致,看阈值到距离的换算:

子变种 A1:1 - threshold(PgVector、MariaDB、Chroma、Coherence、Typesense、Oracle)

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

这是"相似度 → 距离"的标准换算,隐含假设是余弦距离 (cosine_distance = 1 - cosine_similarity)。如果配的是 EUCLIDEAN_DISTANCE,这个换算就是错的------欧氏距离的取值范围是 [0, +∞),不是 [0, 2]。

★ PgVector 在 EUCLIDEAN_DISTANCE 下,threshold=0.8 会变成 distance < 0.2 的欧氏距离过滤------这是一个几乎不可能满足的条件,结果几乎必然是空集。

PgDistanceType 的 javadoc 确实警告了(:583-586):

arduino 复制代码
// NOTE: works only if vectors are normalized to length 1 (like OpenAI
// embeddings), use inner product for best performance.
// The Sentence transformers are NOT normalized:
// https://github.com/UKPLab/sentence-transformers/issues/233

但这段注释在 EUCLIDEAN_DISTANCE 和 NEGATIVE_INNER_PRODUCT 两个枚举值上都贴了一份 ,而语义上它讲的是"归一化的向量用内积最快"------并不是在警告"1 - threshold 对欧氏距离不成立"。

子变种 A2:直接把 threshold 当"分数阈值"传下去(Qdrant、Weaviate、MongoDB Atlas、Neo4j、Redis、Elasticsearch)

java 复制代码
// QdrantVectorStore.java:267
.setScoreThreshold((float) request.getSimilarityThreshold())

这些数据库原生接受"相似度阈值"语义,所以不做换算。

★ 注意 Weaviate 用的是 .certainty(...):Weaviate 的 certainty 与 distance 是两个不同的概念(certainty = 1 - normalizedDistance),把 Spring AI 的 threshold 直接当 certainty 传是语义对齐的,但仅限于余弦。

17.1.3 只有两家做了取值范围校验

java 复制代码
// RedisVectorStore.java:461
Assert.isTrue(request.getSimilarityThreshold() >= 0 && request.getSimilarityThreshold() <= 1,
		"Similarity threshold must be in [0,1] range.");
java 复制代码
// Neo4jVectorStore.java:278
Assert.isTrue(request.getSimilarityThreshold() >= 0 && request.getSimilarityThreshold() <= 1, ...);

★ 其余 20 家都不校验。 你传 threshold = 1.5 或 threshold = -1:

  • PgVector → distance = 1 - 1.5 = -0.5 → SQL WHERE embedding <=> ? < -0.5 → 余弦距离非负,永远为空;
  • Qdrant → setScoreThreshold(1.5f) → 交给服务端,行为未知;
  • 各家 B 类实现 → score >= 1.5 → 永远为空。

都不会报错。 这是一个"配置错误静默变成空结果"的典型。

17.1.4 SIMILARITY_THRESHOLD_ACCEPT_ALL 与 SIMILARITY_THRESHOLD_EXACT_MATCH

SearchRequest 定义了两个常量(上一章 16.5):SIMILARITY_THRESHOLD_ACCEPT_ALL = 0.0、SIMILARITY_THRESHOLD_EXACT_MATCH = 1.0。

只有 Oracle 显式识别了它们:

java 复制代码
// OracleVectorStore.java:363
if (request.getSimilarityThreshold() == SearchRequest.SIMILARITY_THRESHOLD_ACCEPT_ALL) {
	...
}
// :394
else if (request.getSimilarityThreshold() == SIMILARITY_THRESHOLD_EXACT_MATCH) {
	...
}

其余 21 家都是"当普通数值处理"。这在功能上等价(0.0 和 1.0 本来就是这两个语义),但意味着没有任何实现会为 ACCEPT_ALL 走"跳过过滤"的快路径 ------它们照样会把 distance < 1.0 这样的条件发给数据库。

17.1.5 ★ 为什么 S3VectorStore 不读阈值

看它的 doSimilaritySearch(spring-ai-s3-vector-store/.../S3VectorStore.java:150-174):

java 复制代码
@Override
public List<Document> doSimilaritySearch(SearchRequest searchRequest) {
	Assert.notNull(searchRequest, "The search request must not be null.");

	QueryVectorsRequest.Builder requestBuilder = QueryVectorsRequest.builder()
		.indexName(this.indexName)
		.vectorBucketName(this.vectorBucketName)
		.topK(searchRequest.getTopK())
		.returnMetadata(true)
		.returnDistance(true);

	if (searchRequest.hasFilterExpression()) {
		Filter.Expression filterExpression = Objects.requireNonNull(searchRequest.getFilterExpression());
		software.amazon.awssdk.core.document.Document filter = this.filterExpressionConverter
			.convertExpression(filterExpression);
		requestBuilder.filter(filter);
	}

	float[] embeddings = this.embeddingModel.embed(searchRequest.getQuery());
	VectorData vectorData = constructVectorData(embeddings);
	requestBuilder.queryVector(vectorData);

	QueryVectorsResponse response = this.s3VectorsClient.queryVectors(requestBuilder.build());
	return response.vectors().stream().map(this::toDocument).toList();
}

★ 整个方法体里出现的是 getTopK()、hasFilterExpression()、getFilterExpression()、getQuery()------getSimilarityThreshold() 一次都没出现。

而 toDocument(:176-185)确实把距离取出来了:

java 复制代码
if (vector.distance() != null) {
	metadata.put("SPRING_AI_S3_DISTANCE", vector.distance());
}
return Document.builder().metadata(metadata).text(vector.key()).build();

注意两点:

  1. 距离被塞进 metadata key SPRING_AI_S3_DISTANCE (一个字面量字符串,不是 DocumentMetadata.DISTANCE),而不是 Document.getScore();
  2. ★ Document 没有设 score ------ Document.builder().metadata(metadata).text(vector.key()).build() 里没有 .score(...)。所以 S3 Vectors 的检索结果 getScore() 永远是初始值。

对比 PgVector 的 DocumentRowMapper(:641-646):

java 复制代码
return Document.builder()
	.id(id)
	.text(content)
	.metadata(metadata)
	.score(1.0 - distance)
	.build();

PgVector 既设了 score(1.0 - distance),又把 distance 塞进 DocumentMetadata.DISTANCE.value()。

★ 所以"取分数"这件事在 S3 Vectors 上完全换了一套 :既没有 score,距离也在一个自定义 key 里。这是换库时最容易漏改的一处。

至于为什么不读阈值------源码里没有注释说明。afterPropertiesSet() 里留着一句意味深长的话(:232-236):

java 复制代码
@Override
public void afterPropertiesSet() throws Exception {
	// Index requires distance and other stuff to be created. Not sure if this is
	// place to do.
	// I can provide rather Util Class like builder which creates index.
}

一个空方法体 ,只有两行"我也不确定该不该在这里做"的注释。这印证了 S3 Vectors 模块(@author Matej Nedic、@author Jewoo Shin)是较新、尚未打磨完全的模块。

17.2 PgVector:参照实现,743 行的六个决策点

PgVector 是全仓库里注释最完整、行为最可预测的一个实现。把它的六个关键决策剖开,其它实现都可以拿它当尺子量。

17.2.1 ★ 用的是操作符,不是函数

README 里的那个问题------"PgVector 用的是 <-> 操作符还是 vector_cosine_distance() 函数?"------答案在 PgDistanceType 枚举里(:581-612):

java 复制代码
public enum PgDistanceType {

	// NOTE: works only if vectors are normalized to length 1 (like OpenAI
	// embeddings), use inner product for best performance.
	// The Sentence transformers are NOT normalized:
	// https://github.com/UKPLab/sentence-transformers/issues/233
	EUCLIDEAN_DISTANCE("<->", "vector_l2_ops",
			"SELECT *, embedding <-> ? AS distance FROM %s WHERE embedding <-> ? < ? %s ORDER BY distance LIMIT ? "),

	// NOTE: works only if vectors are normalized to length 1 (like OpenAI
	// embeddings), use inner product for best performance.
	// The Sentence transformers are NOT normalized:
	// https://github.com/UKPLab/sentence-transformers/issues/233
	NEGATIVE_INNER_PRODUCT("<#>", "vector_ip_ops",
			"SELECT *, (1 + (embedding <#> ?)) AS distance FROM %s WHERE (1 + (embedding <#> ?)) < ? %s ORDER BY distance LIMIT ? "),

	COSINE_DISTANCE("<=>", "vector_cosine_ops",
			"SELECT *, embedding <=> ? AS distance FROM %s WHERE embedding <=> ? < ? %s ORDER BY distance LIMIT ? ");

	public final String operator;

	public final String index;

	public final String similaritySearchSqlTemplate;

	PgDistanceType(String operator, String index, String sqlTemplate) {
		this.operator = operator;
		this.index = index;
		this.similaritySearchSqlTemplate = sqlTemplate;
	}

}

每个枚举值携带三样东西 :操作符、索引 opclass、以及一整条 SQL 模板。

枚举 操作符 索引 opclass SQL 里的距离表达式 pgvector 语义
EUCLIDEAN_DISTANCE <-> vector_l2_ops embedding <-> ? L2 距离
NEGATIVE_INNER_PRODUCT <#> vector_ip_ops (1 + (embedding <#> ?)) 负内积,加 1 归一
COSINE_DISTANCE <=> vector_cosine_ops embedding <=> ? 余弦距离

★ 三个枚举都是 public final 字段------这是 Java 枚举里少见的写法(通常用 getter)。好处是 this.getDistanceType().operator 可以直接取值;代价是外部代码能读到但不能改 (final),同时也把这三个字符串变成了公开 API 的一部分。

vector_cosine_distance() 这个函数在整个仓库里不存在。 用 grep -rn "vector_cosine_distance" . 会零命中。

17.2.2 ★ SQL 模板里的三个问号与"严格小于"

把模板字符串拆开看(COSINE_DISTANCE 那一条):

sql 复制代码
SELECT *, embedding <=> ? AS distance
FROM %s
WHERE embedding <=> ? < ? %s
ORDER BY distance LIMIT ?

四个 %s / ? 的填充顺序在 doSimilaritySearch 里(:372-375):

java 复制代码
return this.jdbcTemplate.query(
		String.format(this.getDistanceType().similaritySearchSqlTemplate, getFullyQualifiedTableName(),
				jsonPathFilter),
		this.documentRowMapper, queryEmbedding, queryEmbedding, distance, request.getTopK());
  • 第 1 个 %s → getFullyQualifiedTableName()(表名)
  • 第 2 个 %s → jsonPathFilter(过滤条件,形如 AND metadata::jsonb @@ '...'::jsonpath)
  • 第 1 个 ? → queryEmbedding(SELECT 里的距离计算)
  • 第 2 个 ? → queryEmbedding(WHERE 里的距离计算)
  • 第 3 个 ? → distance(阈值)
  • 第 4 个 ? → request.getTopK()

★ 注意 WHERE embedding <=> ? < ? 是严格小于 <,不是 <=。

这意味着:threshold = 0.8 → distance = 0.2 → 条件是 distance < 0.2。距离恰好等于 0.2(相似度恰好等于 0.8)的文档会被排除。

这是一个边界行为差异。如果你从某个 B 类实现(Java 侧 >= threshold)迁移到 PgVector,边界上的文档会从"包含"变成"排除"。实测影响很小(浮点相等本来就罕见),但在单元测试里用构造数据精确命中阈值时会踩到。

★ 另一个细节:同一个 queryEmbedding 被传了两次,因为 SELECT 和 WHERE 各算一次距离。PostgreSQL 的优化器通常会复用,但严格来说这是两次计算。

17.2.3 ★ doAdd 里的 O(n²) indexOf

java 复制代码
// PgVectorStore.java:276-307
private void insertOrUpdateBatch(List<Document> batch, List<Document> documents, List<float[]> embeddings) {
	String sql = "INSERT INTO " + getFullyQualifiedTableName()
			+ " (id, content, metadata, embedding) VALUES (?, ?, ?::jsonb, ?) " + "ON CONFLICT (id) DO "
			+ "UPDATE SET content = ? , metadata = ?::jsonb , embedding = ? ";

	this.jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {

		@Override
		public void setValues(PreparedStatement ps, int i) throws SQLException {

			var document = batch.get(i);
			var id = convertIdToPgType(document.getId());
			var content = document.getText();
			var json = toJson(document.getMetadata());
			var embedding = embeddings.get(documents.indexOf(document));
			var pGvector = new PGvector(embedding);
			...
		}
		...
	});
}

★ embeddings.get(documents.indexOf(document)) ------ 这是本章发现的一个真实性能缺陷。

三层问题:

(1) 复杂度 O(n²)。 documents.indexOf(document) 是线性扫描,外层又是一个循环。批量写入 10,000 条文档时,这是 10^8 次 equals 比较。

(2) ★ 依赖 Document.equals(),而 Document 的 equals 是按内容比较的。 如果列表里有两篇内容完全相同 的文档(去重前很常见),indexOf 会返回第一个 的下标,于是第二条文档会拿到第一条的 embedding。因为两条内容相同,embedding 也相同------恰好没问题 。但如果两条文档 equals 相等却因某种原因 embedding 不同(比如各自带了不同的 metadata,而 equals 不比较 metadata),就会静默写错向量。

需要先确认 Document.equals 是否包含 metadata------从 spring-ai-commons/.../Document.java 看,Spring AI 的 Document 是 record-like,equals 通常包含 id/text/media/metadata。若包含 metadata,则同内容不同 metadata 的文档 indexOf 不会串号;但 O(n²) 的问题依旧。

(3) documents 与 batch 是两个不同的列表。 batch 是 documents 按 maxDocumentBatchSize 切分的子列表,所以 indexOf 必须在完整的 documents 上做,不能简化成 i。

同样的写法在 S3VectorStore.doAdd 里原样出现了一次 (:101-102):

java 复制代码
for (Document document : documents) {
	float[] embs = embedding.get(documents.indexOf(document));
	...
}

★ 这是复制粘贴的证据 ------两个不同作者、不同数据库,犯了一模一样的错。正确写法是 for (int i = 0; i < documents.size(); i++) { ... embeddings.get(i) ... }。

17.2.4 两条独立的分批机制:maxDocumentBatchSize vs BatchingStrategy

PgVector 有两层分批,这是容易混淆的地方:

java 复制代码
// PgVectorStore.java:259-274
@Override
public void doAdd(List<Document> documents) {
	List<float[]> embeddings = this.embeddingModel.embed(documents, EmbeddingOptions.builder().build(),
			this.batchingStrategy);

	List<List<Document>> batchedDocuments = batchDocuments(documents);
	batchedDocuments.forEach(batchDocument -> insertOrUpdateBatch(batchDocument, documents, embeddings));
}

private List<List<Document>> batchDocuments(List<Document> documents) {
	List<List<Document>> batches = new ArrayList<>();
	for (int i = 0; i < documents.size(); i += this.maxDocumentBatchSize) {
		batches.add(documents.subList(i, Math.min(i + this.maxDocumentBatchSize, documents.size())));
	}
	return batches;
}
  • 第一层(token 分批) :embeddingModel.embed(documents, options, batchingStrategy) ------ 按 token 数切分,避免超出模型上下文(上一章 16.7 的 TokenCountBatchingStrategy,上限 7372)。
  • 第二层(JDBC 分批) :batchDocuments(documents) 按 maxDocumentBatchSize(默认 10,000 ,:175)切分,控制单条 batchUpdate 的规模。

★ 两层互不知情 :token 分批的结果是一个 List<float[]>(与输入顺序一一对应),JDBC 分批只管切文档列表。所以 embeddings 是完整的、与 documents 等长的------这也是 indexOf 必须跨列表查找的原因。

maxDocumentBatchSize 只在 PgVector 上有(其它实现没有这个参数),默认值 MAX_DOCUMENT_BATCH_SIZE = 10_000。

17.2.5 ★ 维度的三级推导与 1536 这个魔法数

java 复制代码
// PgVectorStore.java:498-515
int embeddingDimensions() {
	// The manually set dimensions have precedence over the computed one.
	if (this.dimensions > 0) {
		return this.dimensions;
	}

	try {
		int embeddingDimensions = this.embeddingModel.dimensions();
		if (embeddingDimensions > 0) {
			return embeddingDimensions;
		}
	}
	catch (Exception e) {
		logger.warn("Failed to obtain the embedding dimensions from the embedding model and fall backs to default:"
				+ OPENAI_EMBEDDING_DIMENSION_SIZE, e);
	}
	return OPENAI_EMBEDDING_DIMENSION_SIZE;
}

三级优先级:

  1. 手工设置的 dimensions (builder.dimensions(n))优先------但默认是 INVALID_EMBEDDING_DIMENSION = -1(:163、:669),所以未配置时走下一级;
  2. embeddingModel.dimensions() ------ 问嵌入模型;
  3. 兜底 OPENAI_EMBEDDING_DIMENSION_SIZE = 1536 (:161)。

★ 第三级是本章发现的第二个"隐藏假设" :如果你用的嵌入模型不是 OpenAI 的 text-embedding-ada-002(1536 维),而它又在 dimensions() 上抛了异常或返回 ≤ 0,那么建表时会静默按 1536 维建。

后果是什么?CREATE TABLE ... embedding vector(1536)。如果实际写入 768 维的向量,pgvector 会报 expected 1536 dimensions, not 768。这个错误只在运行时写数据时才出现,而建表已经成功------排查方向会被误导到"我的数据有问题"而不是"维度推导错了"。

好在第 2 级有 logger.warn(:511-512),会打出完整异常栈。如果你在启动日志里看到 "Failed to obtain the embedding dimensions from the embedding model",立刻去检查表结构。

★ 另外注意:catch (Exception e) 是吞掉了异常(只打日志),不是传播。这是刻意的降级,但也意味着配置错误不会让应用启动失败。

17.2.6 建表 SQL:四个值得注意的细节

java 复制代码
// PgVectorStore.java:428-457
// Enable the PGVector, JSONB and UUID support.
this.jdbcTemplate.execute("CREATE EXTENSION IF NOT EXISTS vector");
this.jdbcTemplate.execute("CREATE EXTENSION IF NOT EXISTS hstore");

if (this.idType == PgIdType.UUID) {
	this.jdbcTemplate.execute("CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\"");
}

this.jdbcTemplate.execute(String.format("CREATE SCHEMA IF NOT EXISTS %s", this.getSchemaName()));

// Remove existing VectorStoreTable
if (this.removeExistingVectorStoreTable) {
	this.jdbcTemplate.execute(String.format("DROP TABLE IF EXISTS %s", this.getFullyQualifiedTableName()));
}

this.jdbcTemplate.execute(String.format("""
		CREATE TABLE IF NOT EXISTS %s (
			id %s PRIMARY KEY,
			content text,
			metadata json,
			embedding vector(%d)
		)
		""", this.getFullyQualifiedTableName(), this.getColumnTypeName(), this.embeddingDimensions()));

if (this.createIndexMethod != PgIndexType.NONE) {
	this.jdbcTemplate.execute(String.format("""
			CREATE INDEX IF NOT EXISTS %s ON %s USING %s (embedding %s)
			""", this.getVectorIndexName(), this.getFullyQualifiedTableName(), this.createIndexMethod,
			this.getDistanceType().index));
}

四个细节:

(1) CREATE EXTENSION hstore 是一句无用的遗留。 表定义里用的是 metadata json(注意:是 json 不是 jsonb ),全文没有任何 hstore 类型。这句 CREATE EXTENSION IF NOT EXISTS hstore 从早期版本留下来,从没删。它要求数据库有安装 hstore 扩展的权限------如果你的 PostgreSQL 是托管服务(RDS / Cloud SQL)且不允许装扩展,这一句会让启动失败,即使你根本不需要 hstore。

★ 这是一个真实的部署阻塞点 :三句 CREATE EXTENSION(vector / hstore / uuid-ossp)里,只有 vector 是必需的,uuid-ossp 在 idType=UUID(默认值!)时必需,而 hstore 从来都不必需。

(2) metadata json 而不是 jsonb。 但过滤时用的是 metadata::jsonb @@ '...'::jsonpath(上一章 16.12 讲过),每次查询都要做一次 json → jsonb 的运行时转换。这意味着:

  • 元数据列无法 建 GIN 索引(GIN 只支持 jsonb);
  • 每次带过滤的查询都要全表转换 + 全表扫描 JSONPath;
  • ★ PgVector 的元数据过滤是 O(n) 全表扫描,即使你已经建了向量索引。

这是"向量索引很快、元数据过滤很慢"的经典组合。数据量大时,过滤会成为瓶颈。

(3) removeExistingVectorStoreTable 是 DROP TABLE IF EXISTS,不是 TRUNCATE。 上一章 16.13 警告过:设成 true = 每次启动清空向量库。

(4) 索引是 CREATE INDEX IF NOT EXISTS,但索引名是从表名推出来的 (:234-235):

java 复制代码
this.vectorIndexName = this.vectorTableName.equals(DEFAULT_TABLE_NAME) ? DEFAULT_VECTOR_INDEX_NAME
		: this.vectorTableName + "_index";

默认表名 vector_store → 索引名 spring_ai_vector_index;自定义表名 → <表名>_index。

★ 如果你改了 distanceType 但没改表名,索引不会被重建。 因为 CREATE INDEX IF NOT EXISTS 看到同名索引已存在就跳过------而索引的 opclass(vector_cosine_ops / vector_l2_ops / vector_ip_ops)是建索引时就固定的 。于是你会得到一个"用余弦建的索引 + 用欧氏查的 SQL",pgvector 会拒绝使用该索引(opclass 不匹配),退化成全表扫描------同样是静默的。

17.2.7 PgIndexType:默认 HNSW,以及那句"你会看到不同的结果"

java 复制代码
// PgVectorStore.java:539-566
/**
 * By default, pgvector performs exact nearest neighbor search, which provides perfect
 * recall. You can add an index to use approximate nearest neighbor search, which
 * trades some recall for speed. Unlike typical indexes, you will see different
 * results for queries after adding an approximate index.
 */
public enum PgIndexType {

	/**
	 * Performs exact nearest neighbor search, which provides perfect recall.
	 */
	NONE,
	/**
	 * An IVFFlat index divides vectors into lists, and then searches a subset of
	 * those lists that are closest to the query vector. It has faster build times and
	 * uses less memory than HNSW, but has lower query performance (in terms of
	 * speed-recall tradeoff).
	 */
	IVFFLAT,
	/**
	 * An HNSW index creates a multilayer graph. It has slower build times and uses
	 * more memory than IVFFlat, but has better query performance (in terms of
	 * speed-recall tradeoff). There's no training step like IVFFlat, so the index can
	 * be created without any data in the table.
	 */
	HNSW

}

builder 的默认值是 PgIndexType.HNSW (:675)。

★ 默认值不是 NONE。 也就是说,开箱即用的 PgVector 默认走近似检索(ANN),不是精确检索。

这段 javadoc 里最重要的一句是:

Unlike typical indexes, you will see different results for queries after adding an approximate index.

这是全仓库少见的、把"索引会改变查询结果"这件事写进文档的诚实表述。传统 B-tree 索引不影响结果集,只影响速度;向量索引(ANN)会影响结果集本身。

两个实际后果:

(1) similarityThreshold 与 ANN 的交互。 在 HNSW 下,pgvector 返回的是"近似最近的 topK",然后再套 WHERE distance < ?。如果 ANN 的候选集本身就有偏差,阈值过滤会在一个有偏的集合上做,进一步放大召回损失。

(2) IVFFlat 需要"训练数据"。 javadoc 说 HNSW "There's no training step like IVFFlat, so the index can be created without any data in the table "。反过来说:IVFFlat 在空表上建索引是无效的 (没有数据可聚类)。而 Spring AI 的 afterPropertiesSet() 是建表后立刻建索引 (:443-457),此时表一定是空的------

★ 所以如果你选 IVFFlat,你会得到一个在空表上建的、没有意义的索引。 必须事后手工 REINDEX,或者改用 HNSW。这个坑 javadoc 没直说,但 HNSW 那条注释的措辞已经暗示了。

17.2.8 PgIdType 五种,以及 convertIdToPgType 的异常

java 复制代码
// PgVectorStore.java:571-575
public enum PgIdType {

	UUID, TEXT, INTEGER, SERIAL, BIGSERIAL

}

默认是 PgIdType.UUID (:167)。

java 复制代码
// :313-320
private Object convertIdToPgType(String id) {
	return switch (getIdType()) {
		case UUID -> UUID.fromString(id);
		case TEXT -> id;
		case INTEGER, SERIAL -> Integer.valueOf(id);
		case BIGSERIAL -> Long.valueOf(id);
	};
}

★ UUID.fromString(id) 在传入非 UUID 字符串时抛 IllegalArgumentException (JDK 标准行为)。同理 Integer.valueOf(id) / Long.valueOf(id) 抛 NumberFormatException。

也就是说:Document 的 id 必须与配置的 idType 匹配,否则写入/删除时抛运行时异常。 而 Document 的默认 id 生成策略(如果没设 id)未必是 UUID 字符串------需要确认 Document 的默认 id 生成。

另外 getColumnTypeName()(:488-496):

java 复制代码
private String getColumnTypeName() {
	return switch (getIdType()) {
		case UUID -> "uuid DEFAULT uuid_generate_v4()";
		case TEXT -> "text";
		case INTEGER -> "integer";
		case SERIAL -> "serial";
		case BIGSERIAL -> "bigserial";
	};
}

注意 UUID 分支带了一个 DEFAULT uuid_generate_v4()------这是唯一一个带 DEFAULT 的分支 。它依赖 uuid-ossp 扩展(:432-434 那句 CREATE EXTENSION IF NOT EXISTS "uuid-ossp")。而 TEXT / INTEGER 分支没有 DEFAULT,所以插入时必须显式给 id。

17.2.9 getNativeClient() 返回的是 JdbcTemplate

java 复制代码
// :532-537
@Override
public <T> Optional<T> getNativeClient() {
	@SuppressWarnings("unchecked")
	T client = (T) this.jdbcTemplate;
	return Optional.of(client);
}

★ 上一章 16.2 讲过 getNativeClient() 默认返回 Optional.empty(),且是泛型逃逸(调用方说是什么就是什么)。

PgVector 返回的是 JdbcTemplate,而不是 DataSource。这意味着你可以拿到 JdbcTemplate 执行任意 SQL------但你必须自己知道表名 (getVectorTableName() 是 private,外部拿不到)。

同样地,S3VectorStore.getNativeClient()(:238-243)返回的是 S3VectorsClient。

★ 两者都用了 @SuppressWarnings("unchecked") 压制警告------这是该方法的固有设计缺陷(上一章已述):Optional<T> 的 T 完全由调用点推断,vectorStore.<DataSource>getNativeClient() 能通过编译并在运行时 ClassCastException(实际是在取值时才炸,因为 Optional 里已经存了错误类型的对象)。

17.2.10 观测上下文:PgVector 填满了六个字段

java 复制代码
// :517-530
@Override
public VectorStoreObservationContext.Builder createObservationContextBuilder(String operationName) {

	return VectorStoreObservationContext.builder(VectorStoreProvider.PG_VECTOR.value(), operationName)
		.collectionName(this.vectorTableName)
		.dimensions(this.embeddingDimensions())
		.namespace(this.schemaName)
		.similarityMetric(getSimilarityMetric());
}

private String getSimilarityMetric() {
	VectorStoreSimilarityMetric metric = SIMILARITY_TYPE_MAPPING.get(this.distanceType);
	return metric != null ? metric.value() : this.distanceType.name();
}

填了 collectionName(表名)、dimensions(维度)、namespace(schema)、similarityMetric(余弦/欧氏/点积)。

映射表(:179-182):

java 复制代码
private static final Map<PgDistanceType, VectorStoreSimilarityMetric> SIMILARITY_TYPE_MAPPING = Map.of(
		PgDistanceType.COSINE_DISTANCE, VectorStoreSimilarityMetric.COSINE, PgDistanceType.EUCLIDEAN_DISTANCE,
		VectorStoreSimilarityMetric.EUCLIDEAN, PgDistanceType.NEGATIVE_INNER_PRODUCT,
		VectorStoreSimilarityMetric.DOT);

★ 注意它的排版 :Map.of 的参数被 formatter 折成了"键值交错"的形式,读起来很费劲------COSINE_DISTANCE → COSINE、EUCLIDEAN_DISTANCE → EUCLIDEAN、NEGATIVE_INNER_PRODUCT → DOT。三个枚举全部覆盖。

getSimilarityMetric() 的兜底是 this.distanceType.name()------即直接用枚举名。因为 Map.of 已经覆盖全部三个枚举值,这个兜底是不可达的 ;但它的存在说明作者预期将来可能加新的 PgDistanceType 而忘记更新映射表。

对比 S3VectorStore.createObservationContextBuilder(:224-229):

java 复制代码
return VectorStoreObservationContext.builder(VectorStoreProvider.S3_VECTOR.value(), operationName)
	.collectionName(this.indexName)
	.dimensions(this.embeddingModel.dimensions());

★ 只填了两个字段(collectionName、dimensions),没有 namespace、没有 similarityMetric。 而且 dimensions 直接调 this.embeddingModel.dimensions() ------ 没有 PgVector 那种"手工维度优先 + 1536 兜底"的三级推导,如果模型返回 ≤ 0,观测里的维度就是 0 或负数。

这是新模块与成熟模块在细节完整度上的差距。


17.3 三家"异类":Oracle、Coherence、Typesense

17.1 那张表把 22 个实现分成了四类。但有三家不能简单归类------它们在阈值处理上有各自独特的行为,值得单独剖开。

17.3.1 ★ Oracle:全仓库唯一"设了阈值会抛异常"的实现

OracleVectorStore.doSimilaritySearch 用三个互斥分支 处理阈值(:363 / :394 / :415):

分支一:threshold == SIMILARITY_THRESHOLD_ACCEPT_ALL(0.0)

java 复制代码
if (request.getSimilarityThreshold() == SearchRequest.SIMILARITY_THRESHOLD_ACCEPT_ALL) {
	...
	final String sql = this.searchAccuracy == DEFAULT_SEARCH_ACCURACY ? String.format("""
			select id, content, metadata, embedding, %sVECTOR_DISTANCE(embedding, ?, %s)%s as distance
			from %s
			%sorder by distance
			fetch first %d rows only""", ...)
			: String.format("""
					select id, content, metadata, embedding, %sVECTOR_DISTANCE(embedding, ?, %s)%s as distance
					from %s
					%sorder by distance
					fetch APPROXIMATE first %d rows only WITH TARGET ACCURACY %d""", ...);
	return this.jdbcTemplate.query(sql, new DocumentRowMapper(), embeddingVector);
}

分支二:threshold == SIMILARITY_THRESHOLD_EXACT_MATCH(1.0)

java 复制代码
else if (request.getSimilarityThreshold() == SIMILARITY_THRESHOLD_EXACT_MATCH) {
	...
	final String sql = String.format("""
			select id, content, metadata, embedding, %sVECTOR_DISTANCE(embedding, ?, %s)%s as distance
			from %s
			%sorder by distance
			fetch EXACT first %d rows only""", ...);
	return this.jdbcTemplate.query(sql, new DocumentRowMapper(), embeddingVector);
}

★ 注意 fetch EXACT ------ Oracle 23ai 的语法,EXACT 表示强制走精确检索(绕过近似索引)。这是把 Spring AI 的 SIMILARITY_THRESHOLD_EXACT_MATCH 语义直接映射到了 SQL 关键字上,是全仓库唯一一处这么做的地方。

分支三:其它值(0 < threshold < 1)

java 复制代码
else {
	if (!this.forcedNormalization || (this.distanceType != OracleVectorStoreDistanceType.COSINE
			&& this.distanceType != OracleVectorStore.OracleVectorStoreDistanceType.DOT)) {
		throw new RuntimeException(
				"Similarity threshold filtering requires all vectors to be normalized, see the forcedNormalization parameter for this Vector store. Also only COSINE and DOT distance types are supported.");
	}

	final double distance = this.distanceType == OracleVectorStore.OracleVectorStoreDistanceType.DOT
			? (1d - request.getSimilarityThreshold()) * 2d - 1d : 1d - request.getSimilarityThreshold();
	...
}

★ 这是本章最重要的一条"能力边界":

在 Oracle 上,只要你设了 0 和 1 之外任何阈值,而 forcedNormalization 为 false 或距离类型不是 COSINE/DOT,就会抛 RuntimeException。

也就是说,下面这行在 Oracle 上是启动即炸(或首次检索即炸)的:

java 复制代码
vectorStore.similaritySearch(SearchRequest.builder().query("...").topK(10).similarityThreshold(0.8).build());

而同样的代码在 PgVector 上正常工作。这是"换库就崩"的又一例------只不过这次不是静默的,是显式抛异常,反而比静默失败好排查。

★ 那个 (1d - threshold) * 2d - 1d 是什么?

这是把"余弦相似度阈值"换算成负内积距离阈值的公式。推导:

  • Oracle 的 DOT 距离在源码里被包成了 (1+VECTOR_DISTANCE(...))/2(见 :373-375 的 "(1+" 与 ")/2"),即归一到 [0,1];
  • 设归一化后的距离为 d' = (1 + d)/2,其中 d 是原始内积距离的负值;
  • 要求"相似度 ≥ threshold",即 1 - d' ≥ threshold,即 d' ≤ 1 - threshold;
  • 代回:(1 + d)/2 ≤ 1 - threshold → d ≤ 2(1 - threshold) - 1 = (1 - threshold)*2 - 1。

所以 (1d - threshold) * 2d - 1d 是正确的。但注意它可以是负数 ------当 threshold > 0.5 时,(1-t)*2-1 < 0。这意味着阈值 0.8 对应的内积距离阈值是 -0.6。这符合数学,但对读代码的人来说极其反直觉,源码里也没有一行注释解释。

★ forcedNormalization 这个前置条件的含义 :Oracle 的相似度过滤要求所有向量已归一化(长度 1)。Spring AI 不会替你归一化,它只检查你有没有声明 自己归一化了(forcedNormalization 参数)。如果你设了 true 但实际向量没归一化,过滤结果就是错的------这个参数是一个"我保证"的声明,不是保证本身。

17.3.2 ★ Coherence:非余弦距离时阈值被完全绕过

java 复制代码
// CoherenceVectorStore.java:209-222
for (var r : results) {
	if (this.distanceType != DistanceType.COSINE || (1 - r.getDistance()) >= request.getSimilarityThreshold()) {
		DocumentChunk.Id id = r.getKey();
		DocumentChunk chunk = r.getValue();
		Map<String, Object> mergedMetadata = new HashMap<>(chunk.metadata());
		mergedMetadata.put(DocumentMetadata.DISTANCE.value(), r.getDistance());
		documents.add(Document.builder()
			.id(id.docId())
			.text(chunk.text())
			.metadata(mergedMetadata)
			.score(1 - r.getDistance())
			.build());
	}
}
return documents;

★ 关键在那个条件:

java 复制代码
this.distanceType != DistanceType.COSINE || (1 - r.getDistance()) >= threshold

短路求值:|| 的左边 distanceType != COSINE 为真时,右边根本不计算 ,整个条件为 true。

翻译成人话:

distanceType 阈值是否生效
COSINE ✅ 生效(1 - distance >= threshold)
其它任何值 ❌ 完全不生效------所有结果无条件通过

★ Coherence 是"S3VectorStore 之外第二个会丢弃阈值的实现" ,只不过它是有条件地丢弃:只在非余弦距离下丢弃。

这个设计意图是可以理解的------1 - distance 这个换算只对余弦距离成立,对欧氏/内积不成立,所以作者选择"不换算就不如不过滤"。但问题是:

  1. 它是静默的------没有日志、没有告警;
  2. 它和用户预期相反------用户设了阈值,期望"过滤得更严",实际得到"完全不过滤",召回的是一堆低质量结果;
  3. 它让 Coherence 的行为取决于 distanceType 这个与阈值无关的配置项------改一个配置项会悄悄改变另一个配置项的语义。

对比 Oracle 的做法:Oracle 在类似情形下抛异常 (17.3.1),Coherence 静默放行。两种处理哪种更好见仁见智,但至少 Oracle 那条会让用户第一时间知道。

顺带一提,Coherence 是使用 DistanceType 而不是 PgDistanceType ------ 它自己的枚举(来自 Coherence 的 com.tangosol...),与 Spring AI 的 VectorStoreSimilarityMetric 无映射。

17.3.3 ★ Typesense:整个查询是字符串拼接,含浮点精度问题

java 复制代码
// TypesenseVectorStore.java:243-247
Stream<Float> floatStream = IntStream.range(0, embedding.length).mapToObj(i -> embedding[i]);
// typesense uses only cosine similarity
String vectorQuery = EMBEDDING_FIELD_NAME + ":(" + "["
		+ String.join(",", floatStream.map(String::valueOf).toList()) + "], " + "k: " + request.getTopK() + ", "
		+ "distance_threshold: " + (1 - request.getSimilarityThreshold()) + ")";

multiSearchCollectionParameters.vectorQuery(vectorQuery);

三个问题:

(1) ★ 浮点拼接会产生"丑陋但合法"的数字。 1 - 0.8 在 Java double 下是 0.19999999999999996,会被原样拼进查询串:

yaml 复制代码
embedding:([0.1,0.2,...], k: 10, distance_threshold: 0.19999999999999996)

Typesense 能解析它,所以功能正常。但:

  • 日志和调试输出会很难读;
  • 如果 threshold 是 NaN 或 Infinity(浮点除法可能产生),拼出来的是 NaN,Typesense 会报语法错误------而这个错误会以"Typesense 服务端返回 400"的形式出现,排查方向会指向 Typesense 而不是你的阈值。

(2) k: 用的是 request.getTopK(),没有上界校验。 Typesense 对 k 有自己的上限(默认 100),超了会报错。

(3) ★ 整个向量(几百到几千个浮点数)被拼成一个字符串。 1536 维的向量意味着字符串里要有 1536 个 float 的十进制表示,按每个 ~10 字符算就是 15 KB 的单个查询串 。这在 HTTP 层、日志层、以及 Typesense 服务端的解析层都是负担。对比 PgVector 用的是 PreparedStatement 的参数绑定(? 占位符),二进制传输 PGvector。

★ 这是"字符串拼接式查询"与"参数绑定式查询"的典型差距 :PgVector / MariaDB / Oracle 用 ? 绑定(安全、高效),Typesense 用拼接(可读但笨重)。

源码里那句注释 // typesense uses only cosine similarity 解释了为什么可以直接用 1 - threshold------因为 Typesense 只支持余弦,不存在 17.2.1 讲的"欧氏距离下 1-threshold 不成立"的问题。这是唯一一个"因为功能少反而没踩坑"的案例。

17.3.4 小结:三种"处理不了阈值"的态度

实现 遇到"阈值换算不成立"时 用户体验
Oracle 抛 RuntimeException + 明确说明原因 立刻知道,有修复指引(forcedNormalization)
Coherence 静默跳过过滤(distanceType != COSINE 短路) 完全不知道,召回质量下降
S3VectorStore 根本没读这个字段 完全不知道,召回质量下降
PgVector 照样换算(欧氏下得到几乎必然为空的条件) 不知道,召回变空

四种态度里,只有 Oracle 是"显式失败" ,其余三种都是静默的。这印证了第 16 章反复出现的那个主题:Spring AI 的向量存储抽象在"能力边界"上缺乏统一的契约表达------接口层面没有任何机制让实现声明"我不支持这个特性"。


17.4 S3 Vectors:一个新物种,和它的两副面孔

S3 Vectors 是 AWS 在 2025 年推出的原生向量存储(S3 的一个新 bucket 类型)。Spring AI 的 spring-ai-s3-vector-store 模块是较新的实现(两个 author:Matej Nedic、Jewoo Shin),也是本章唯一一个"不走字符串过滤"的模块。

17.4.1 模块只有 793 行,但有两套过滤实现

arduino 复制代码
vector-stores/spring-ai-s3-vector-store/src/main/java/org/springframework/ai/vectorstore/s3/
├── S3VectorStore.java                              287 行
├── S3VectorStoreFilterExpressionEvaluator.java     158 行
├── S3VectorFilterSearchExpressionConverter.java    154 行
├── DocumentUtils.java                              133 行
├── S3VectorFilterExpressionConverter.java           38 行(接口)
└── package-info.java                                23 行

★ 注意它同时有 converter (下推)和 evaluator(内存求值)------这是全仓库唯一一个"双过滤栈"的实现。

为什么需要两套?看它们各自服务的路径:

路径 用哪个 原因
doSimilaritySearch(检索) filterExpressionConverter AWS 的 QueryVectors API 支持 filter 参数,可以下推
doDelete(Filter.Expression)(按过滤删除) filterExpressionEvaluator AWS 的 DeleteVectors API 只接受 key 列表,不支持按过滤删除

★ 正是因为 AWS 的删除 API 不支持过滤,Spring AI 才必须在 Java 侧自己实现一遍求值。

17.4.2 ★ doDelete(Filter.Expression) 是全量分页扫描

java 复制代码
// S3VectorStore.java:119-148
@Override
public void doDelete(Filter.Expression filterExpression) {
	Assert.notNull(filterExpression, "Filter expression must not be null");

	String nextToken = null;
	List<String> keys = new ArrayList<>();
	do {
		ListVectorsRequest.Builder requestBuilder = ListVectorsRequest.builder()
			.vectorBucketName(this.vectorBucketName)
			.indexName(this.indexName)
			.maxResults(LIST_VECTORS_MAX_RESULTS)
			.returnMetadata(true)
			.returnData(false);

		if (StringUtils.hasText(nextToken)) {
			requestBuilder.nextToken(nextToken);
		}

		ListVectorsResponse response = this.s3VectorsClient.listVectors(requestBuilder.build());
		for (ListOutputVector vector : response.vectors()) {
			if (matchesFilter(vector, filterExpression)) {
				keys.add(vector.key());
			}
		}
		nextToken = response.nextToken();
	}
	while (StringUtils.hasText(nextToken));

	deleteVectors(keys);
}

★ 这是一次全索引扫描 :分页遍历所有向量(每页 500 条,LIST_VECTORS_MAX_RESULTS = 500,:59),逐条拉取 metadata,在 Java 侧求值,收集匹配的 key,最后批量删除。

复杂度与成本:

维度 代价
API 调用次数 ceil(N / 500) + ceil(M / 500) 次(N = 总向量数,M = 匹配数)
网络传输 所有向量的 metadata(returnData(false) 所以不传向量本身,这点做得对)
时间 O(N)
★ 一致性 非原子------扫描期间如果有并发写入,可能漏删或误删

对比其它实现的"按过滤删除":

实现 做法
PgVector DELETE FROM ... WHERE <jsonpath> ------ 一条 SQL,数据库内完成,原子
S3VectorStore 全量扫描 + Java 侧求值 + 批量删 ------ O(N),非原子

★ 如果你在 S3 Vectors 上大量使用 delete(Filter.Expression),这是一个会随数据量线性恶化的操作。 100 万条向量意味着 2000 次 ListVectors 调用。

另外注意 returnData(false)(:131)------只拉 metadata 不拉向量,这是正确的优化。但 returnMetadata(true)(:130)意味着每条都要传 metadata。

17.4.3 ★ S3VectorStoreFilterExpressionEvaluator 是 SimpleVectorStoreFilterExpressionEvaluator 的逐字复制品

把两个类并排放(左边 Simple,右边 S3):

java 复制代码
// 两者完全相同的部分:

private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")
	.withZone(ZoneOffset.UTC);

private boolean evaluateExpression(Filter.Expression expression, Map<String, Object> metadata) {
	return switch (expression.type()) {
		case AND -> evaluateOperand(left(expression), metadata) && evaluateOperand(right(expression), metadata);
		case OR -> evaluateOperand(left(expression), metadata) || evaluateOperand(right(expression), metadata);
		case NOT -> !evaluateOperand(left(expression), metadata);
		case EQ -> compare(metadataValue(left(expression), metadata), filterValue(right(expression))) == 0;
		case NE -> compare(...) != 0;
		case GT -> compare(...) > 0;
		case GTE -> compare(...) >= 0;
		case LT -> compare(...) < 0;
		case LTE -> compare(...) <= 0;
		case IN -> { ... yield list.stream().anyMatch(item -> compare(metaVal, item) == 0); }
		case NIN -> { ... yield list.stream().noneMatch(item -> compare(metaVal, item) == 0); }
		case ISNULL -> metadataValue(left(expression), metadata) == null;
		case ISNOTNULL -> metadataValue(left(expression), metadata) != null;
	};
}

private Filter.Operand left(Filter.Expression expression) { /* 同 */ }
private Filter.Operand right(Filter.Expression expression) { /* 同 */ }
private @Nullable Object metadataValue(Filter.Operand operand, Map<String, Object> metadata) { /* 同:剥外层引号 */ }
private Object filterValue(Filter.Operand operand) { /* 同:Date → ISO 字符串 */ }
private int compare(@Nullable Object metaVal, @Nullable Object filterVal) {
	// 同:null 排序、Number → double 提升、Comparable 比较、ClassCastException → IllegalArgumentException
}
private List<?> asList(Object value, Filter.Expression expression) { /* 同 */ }

几乎逐字相同。 差异只有三处:

差异 SimpleVectorStoreFilterExpressionEvaluator S3VectorStoreFilterExpressionEvaluator
类可见性 final class(包级私有) final class(包级私有)
入口方法 public boolean evaluate(...) boolean evaluate(...)(包级私有)
行数 221 158

★ 这是 40 多行的重复代码,两个类各自维护。 任何一方的修复(比如上一章 16.6 讲的 double 精度问题)都不会自动同步到另一方。

为什么没抽公共基类?从 package 结构看,SimpleVectorStoreFilterExpressionEvaluator 在 spring-ai-vector-store(内核),而 S3VectorStoreFilterExpressionEvaluator 在 spring-ai-s3-vector-store(实现模块)。要抽公共基类就得把它放到内核并改成 public------这需要一次 API 变更,作者选择了复制。

这是一个典型的"实现模块自己造轮子"的技术债。全仓库只有这两个内存求值器,没有第三个。

17.4.4 ★ 同一个过滤条件,两条路径语义可能不一致

这是 17.4 节最需要记住的一条。

java 复制代码
// 检索路径:下推到 AWS
if (searchRequest.hasFilterExpression()) {
	Filter.Expression filterExpression = Objects.requireNonNull(searchRequest.getFilterExpression());
	software.amazon.awssdk.core.document.Document filter = this.filterExpressionConverter
		.convertExpression(filterExpression);
	requestBuilder.filter(filter);
}
java 复制代码
// 删除路径:Java 侧求值
private boolean matchesFilter(ListOutputVector vector, Filter.Expression filterExpression) {
	software.amazon.awssdk.core.document.Document metadataDocument = vector.metadata();
	Map<String, Object> metadata = (metadataDocument != null) ? DocumentUtils.fromDocument(metadataDocument) : null;
	return this.filterExpressionEvaluator.evaluate(filterExpression,
			(metadata != null) ? metadata : Collections.emptyMap());
}

★ 同一个 Filter.Expression:

  • 检索时 → S3VectorFilterSearchExpressionConverter 转成 AWS Document → AWS 服务端执行;
  • 删除时 → S3VectorStoreFilterExpressionEvaluator 在 Java 里对 Map 执行。

两条路径的执行者是完全不同的代码(一个是 AWS 服务端,一个是本 JVM),因此:

潜在不一致 说明
日期比较 converter 侧会把 ISO 字符串升格为 Date 再序列化(可能是时间戳数字);evaluator 侧把 Date 格式化回 ISO 字符串再比。两条路的表示形式不同
ISNULL evaluator 支持(13 个 case 里有);converter 支持与否取决于 AWS 的 filter DSL
类型提升 evaluator 侧 Number → double;AWS 侧有自己的类型规则
null 排序 evaluator 侧是 NULLS FIRST;AWS 侧未知

★ 后果:你用 delete(Filter.Expression) 删掉的文档集合,可能和你用 similaritySearch(SearchRequest.filterExpression(...)) 查出来的集合不完全一致。 删除可能多删(evaluator 更宽松)或少删(evaluator 更严格)。

这种"同一语义两套实现"的架构,在任何系统里都是 bug 温床。S3VectorStore 之所以这么设计是被 AWS API 逼的(删除不支持过滤),但它至少应该在 javadoc 里写明这个不一致------而源码里没有任何相关注释。

17.4.5 S3VectorFilterExpressionConverter 为什么是接口而不是类

java 复制代码
// S3VectorFilterExpressionConverter.java(全文 38 行,去掉 license 后约 22 行)
package org.springframework.ai.vectorstore.s3;

import software.amazon.awssdk.core.document.Document;

import org.springframework.ai.vectorstore.filter.Filter;

/**
 * FilterExpression DLS converter specific for AWS S3 Vector Store since SDK required AWS
 * Document object.
 *
 * @author Matej Nedic
 */
public interface S3VectorFilterExpressionConverter {

	/**
	 * Convert the given {@link Filter.Expression} into a {@link Document} representation.
	 * @param expression the expression to convert
	 * @return the converted expression
	 */
	Document convertExpression(Filter.Expression expression);

}

★ 它是一个独立接口 ,不继承 Spring AI 的 FilterExpressionConverter(那个返回 String),也不继承 AbstractFilterExpressionConverter。

原因写在 javadoc 的第一句:"since SDK required AWS Document object" 。AWS SDK v2 的 QueryVectorsRequest.filter() 接受的是 software.amazon.awssdk.core.document.Document(一个树形结构对象),不是字符串。要构造它只能直接建对象,不能拼字符串再让 SDK 解析。

★ 这代表了过滤转换体系的一个演进方向:从"字符串模板"到"对象树"。

对比一下:

时代 产物 代表
第一代 String(拼进 SQL / JSON / Lucene 串) AbstractFilterExpressionConverter 的 17 个子类
第二代 目标客户端的原生对象 QdrantFilterExpressionConverter(gRPC Filter)、CoherenceFilterExpressionConverter(com.tangosol.util.Filter)、BedrockKnowledgeBaseFilterExpressionConverter(RetrievalFilter)、S3VectorFilterExpressionConverter(AWS Document)

第二代的四个实现各自为战 :没有共同基类、没有共同接口、方法名也不统一(convertExpression vs convert)。它们只是碰巧都在做同一件事。

而 S3VectorFilterExpressionConverter 是这四个里唯一一个被设计成接口的------因为它的 Builder 允许替换实现:

java 复制代码
// S3VectorStore.java:253
private S3VectorFilterExpressionConverter filterExpressionConverter = new S3VectorFilterSearchExpressionConverter();

// :274-278
public Builder filterExpressionConverter(S3VectorFilterExpressionConverter converter) {
	Assert.notNull(converter, "s3VectorFilterExpressionConverter must not be null");
	this.filterExpressionConverter = converter;
	return this;
}

★ 默认实现是 S3VectorFilterSearchExpressionConverter(154 行),但你可以换成自己的------这是全仓库唯一一个允许替换过滤转换器 的 VectorStore。(其它实现的 converter 都是 private static final 或 final 字段,写死的。)

名字里的 "Search" 暗示未来可能有另一个实现(比如用于删除场景的),但当前只有一个。

17.4.6 DocumentUtils:133 行的类型搬运

DocumentUtils 负责在 Java 的 Map<String, Object> 与 AWS 的 Document 之间双向转换:

java 复制代码
// S3VectorStore.java:209-214
private static software.amazon.awssdk.core.document.Document constructMetadata(
		Map<String, Object> originalMetadata) {
	Map<String, software.amazon.awssdk.core.document.Document> metadata = new HashMap<>(originalMetadata.size());
	originalMetadata.forEach((k, v) -> metadata.put(k, DocumentUtils.toDocument(v)));
	return software.amazon.awssdk.core.document.Document.fromMap(metadata);
}

写入时 toDocument(v) 把每个 metadata 值转成 AWS Document;读取时 DocumentUtils.fromDocument(...) 转回来。

★ 这个往返转换是有损的。 AWS Document 的类型系统只有有限的几种(string / number / bool / list / map / null),而 Java 侧可能是任意 POJO、Date、UUID、BigDecimal......

具体损失:

  • Date → 可能被转成字符串(格式取决于 DocumentUtils 的实现);
  • BigDecimal → number,精度可能变化;
  • 嵌套 POJO → map,再读回来是 Map 而不是原类型。

这与上一章 16.6 讲的"日期三方不一致"是同一类问题的另一面:跨系统的类型转换链越长,语义保真度越低。

17.4.7 constructVectorData:float[] → List<Float> 的装箱开销

java 复制代码
// S3VectorStore.java:216-222
private static VectorData constructVectorData(float[] embedding) {
	ArrayList<Float> float32 = new ArrayList<>(embedding.length);
	for (float v : embedding) {
		float32.add(v);
	}
	return VectorData.builder().float32(float32).build();
}

★ 1536 维的向量 → 1536 次 Float 装箱 + 一个 ArrayList。每次检索都要做一次(doSimilaritySearch:169),每次写入每条文档都要做一次(doAdd:103)。

对比 PgVector 用的是 new PGvector(float[])------直接持有原始数组,零装箱。

这是"对象映射层"的典型代价。单次开销不大(1536 次装箱约几十微秒),但在高频检索场景下会累积。

17.4.8 afterPropertiesSet() 是空的

java 复制代码
// S3VectorStore.java:231-236
@Override
public void afterPropertiesSet() throws Exception {
	// Index requires distance and other stuff to be created. Not sure if this is
	// place to do.
	// I can provide rather Util Class like builder which creates index.
}

★ 空实现,只有两行自我怀疑的注释。

对比 PgVector 的 afterPropertiesSet()(17.2.6)做了建扩展、建 schema、建表、建索引四件事。

这意味着:S3VectorStore 不会为你创建 bucket 或 index。 你必须先用 AWS 控制台 / CLI / Terraform 建好 vectorBucketName 和 indexName,然后 Spring AI 才能用。

而那两行注释透露了作者的犹豫("Not sure if this is place to do")------他不确定该不该在这里创建索引,最后选择了什么都不做。

★ 这是一个启动期的静默失败点 :如果你配错了 indexName,应用在启动时不会报错 (因为没有任何校验),只有在第一次 doAdd 或 doSimilaritySearch 时才会收到 AWS 的 "index not found"。

17.4.9 Builder 只有三个配置项,且都是 @Nullable 但构造时 Assert

java 复制代码
// S3VectorStore.java:245-259
public static class Builder extends AbstractVectorStoreBuilder<Builder> {

	private final S3VectorsClient s3VectorsClient;

	private @Nullable String vectorBucketName;

	private @Nullable String indexName;

	private S3VectorFilterExpressionConverter filterExpressionConverter = new S3VectorFilterSearchExpressionConverter();

	public Builder(S3VectorsClient s3VectorsClient, EmbeddingModel embeddingModel) {
		super(embeddingModel);
		Assert.notNull(s3VectorsClient, "S3VectorsClient must not be null");
		this.s3VectorsClient = s3VectorsClient;
	}

★ 三个字段标了 @Nullable,但构造函数里 Assert.notNull(builder.vectorBucketName, ...) / Assert.notNull(builder.indexName, ...)(:81-83)会在build 时抛异常。

这是一个常见的 Spring 模式(允许 builder 阶段为 null,build 阶段校验),好处是错误发生在 build() 而不是运行时。但对于 vectorBucketName 和 indexName,Builder 的 setter 已经各自 Assert.notNull 了(:261-269)------★ 校验做了两遍(setter 一次、构造一次),第二遍是冗余的。

对比 PgVector 的 Builder:它有 11 个配置项(schemaName / vectorTableName / idType / dimensions / distanceType / removeExistingVectorStoreTable / indexType / initializeSchema / maxDocumentBatchSize / vectorTableValidationsEnabled + jdbcTemplate)。S3VectorStore 只有 3 个------这直观反映了两者在成熟度上的差距。


17.5 SimpleVectorStoreFilterExpressionEvaluator:唯一不走字符串的那条路

前面讲的都是"把 AST 翻译成目标数据库的原生查询语言"。但有一个实现根本不翻译 ------它直接拿 AST 在 Java 里对 Map<String, Object> 求值。

这就是 SimpleVectorStore 用的 SimpleVectorStoreFilterExpressionEvaluator(spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/SimpleVectorStoreFilterExpressionEvaluator.java,包级私有 final class)。

它的 javadoc 第一句就把定位说清楚了(:31-34):

Internal helper used by SimpleVectorStore to evaluate a Filter.Expression AST directly against a document metadata map, without converting to an intermediate string representation (e.g. SpEL or SQL).

注意括号里的举例------SpEL 。这说明 Spring AI 早期版本确实用过 SpEL 做内存过滤,后来换成了 AST 直译。为什么换?SpEL 有两个致命问题:一是表达式注入(用户可控的 metadata key 会变成可执行代码),二是无法支持 ISNULL / IN 这类结构化语义。AST 直译两个问题一起解决。

17.5.1 它是全仓库唯一支持全部 13 种 ExpressionType 的求值器

java 复制代码
// SimpleVectorStoreFilterExpressionEvaluator.java:94-121
private boolean evaluateExpression(Filter.Expression expression, Map<String, Object> metadata) {
	return switch (expression.type()) {
		case AND -> evaluateOperand(left(expression), metadata) && evaluateOperand(right(expression), metadata);
		case OR -> evaluateOperand(left(expression), metadata) || evaluateOperand(right(expression), metadata);
		// Unary operator: only the left operand is used. Ignore right operand
		case NOT -> !evaluateOperand(left(expression), metadata);
		case EQ -> compare(metadataValue(left(expression), metadata), filterValue(right(expression))) == 0;
		case NE -> compare(metadataValue(left(expression), metadata), filterValue(right(expression))) != 0;
		case GT -> compare(metadataValue(left(expression), metadata), filterValue(right(expression))) > 0;
		case GTE -> compare(metadataValue(left(expression), metadata), filterValue(right(expression))) >= 0;
		case LT -> compare(metadataValue(left(expression), metadata), filterValue(right(expression))) < 0;
		case LTE -> compare(metadataValue(left(expression), metadata), filterValue(right(expression))) <= 0;
		case IN -> { ... yield list.stream().anyMatch(item -> compare(metaVal, item) == 0); }
		case NIN -> { ... yield list.stream().noneMatch(item -> compare(metaVal, item) == 0); }
		// Unary operators: only the left operand (the key) is used.
		// A non-null right operand is silently ignored here.
		case ISNULL -> metadataValue(left(expression), metadata) == null;
		case ISNOTNULL -> metadataValue(left(expression), metadata) != null;
	};
}

13 个 case,一个不落,而且没有 default。 因为是穷举的 switch 表达式,编译器会保证覆盖------将来 ExpressionType 加新值,这里会编译失败 ,而不是静默漏掉。这比二十个方言转换器的 default -> throw 强得多。

★ 所以:

SimpleVectorStore 是全仓库唯一完整支持 ISNULL / ISNOTNULL 的向量存储。

对比:17 个走 AbstractFilterExpressionConverter 的方言里,只有 Elasticsearch 支持这两个类型。这是"本地开发用 Simple、上线换 PgVector 就炸"的直接原因。

还有一处细节:case ISNULL 的注释写着 "A non-null right operand is silently ignored here" 。如果你给 ISNULL 塞了右操作数,它会被静默丢弃 ,不报错。同理 NOT 也只用电 left。

17.5.2 ★ compare:数字一律提升到 double,null 一律排最前

java 复制代码
// SimpleVectorStoreFilterExpressionEvaluator.java:183-205
@SuppressWarnings("unchecked")
private int compare(@Nullable Object metaVal, @Nullable Object filterVal) {
	if (metaVal == null && filterVal == null) {
		return 0;
	}
	if (metaVal == null) {
		return -1;
	}
	if (filterVal == null) {
		return 1;
	}
	if (metaVal instanceof Number n1 && filterVal instanceof Number n2) {
		return Double.compare(n1.doubleValue(), n2.doubleValue());
	}
	if (Objects.equals(metaVal, filterVal)) {
		return 0;
	}
	if (metaVal instanceof Comparable comparable && filterVal instanceof Comparable) {
		...
	}
	...
}

三条规则:

(1) 数字跨类型比较靠 double 提升。 Integer(2020) 和 Double(2020.0) 和 Long(2020L) 互相比较都相等。这解决了 metadata 里数字类型不统一的老问题(JSON 反序列化可能给你 Integer,JDBC 可能给你 Long)。

但 ★ double 提升带来精度陷阱 :Long 超过 2^53 的值(比如雪花 ID、纳秒时间戳)在提升成 double 后会丢失精度,两个不同的 ID 可能被判定为相等。如果你用大整数做 metadata 过滤,这是个真实风险。javadoc 里只说了"Numbers are promoted to double so that mixed Integer/Long/Double metadata values compare correctly",没提精度损失。

(2) null 排序遵循 SQL 的 NULLS FIRST。 metaVal == null 返回 -1,即"metadata 里的值更小"。javadoc 把这个后果讲得很清楚(:176-181):

Null ordering follows SQL NULLS FIRST semantics: null is considered less than any non-null value. As a result, a missing metadata key causes ordered comparisons (GT, GTE, LT, LTE) to behave as if the key holds the smallest possible value --- e.g. year > 2020 returns false when year is absent.

★ 关键推论:year > 2020 在 year 字段缺失时返回 false,文档被过滤掉。

这是"过滤条件静默排除数据"的另一种形式。如果你的文档是分批导入的、早期批次没有 year 字段,那么 year > 2020 永远不会命中它们------你不会收到任何警告。

对应地,year <= 2020 会命中所有缺 year 的文档(因为 null 最小),这可能不是你想要的。

(3) 两个都是 null → 相等(返回 0)。 所以 EQ 在两边都不存在时返回 true。即 author == null 会匹配所有没有 author 字段的文档------这和 ISNULL 等价。但注意:DSL 里没法写 eq("author", null) (上一章 16.9 节讲过,Filter.Value 不接受 null),所以这个等价关系只能通过文本表达式或手工构造 AST 触发。

17.5.3 metadataValue 会剥掉 key 的外层引号

java 复制代码
// SimpleVectorStoreFilterExpressionEvaluator.java:146-156
private @Nullable Object metadataValue(Filter.Operand operand, Map<String, Object> metadata) {
	if (operand instanceof Filter.Key key) {
		String k = key.key();
		if (k.length() >= 2
				&& ((k.startsWith("\"") && k.endsWith("\"")) || (k.startsWith("'") && k.endsWith("'")))) {
			k = k.substring(1, k.length() - 1);
		}
		return metadata.get(k);
	}
	throw new IllegalArgumentException("Expected a Key operand but got: " + operand.getClass().getName());
}

为什么需要剥引号?因为上一章 16.9/16.10 讲过:中文 key 必须写成 '"国家" == "中国"' ,此时 ANTLR 解析出的 Key 是带引号的 "国家"。而 metadata map 里的键是不带引号的 国家。所以求值前必须剥一层。

这个剥壳逻辑只处理首尾成对 的 " 或 ',且长度 ≥ 2。它不处理转义 ------如果 key 是 "a\"b"(内部有转义引号),剥壳后会得到 a\"b 而不是 a"b。这是文本解析器与求值器之间的一处不一致,但影响面很小(没人会用带引号的 key)。

17.5.4 filterValue 把 Date 格式化回 ISO 字符串

java 复制代码
// SimpleVectorStoreFilterExpressionEvaluator.java:163-169
private Object filterValue(Filter.Operand operand) {
	if (operand instanceof Filter.Value filterValue) {
		Object value = filterValue.value();
		return (value instanceof Date date) ? DATE_FORMATTER.format(date.toInstant()) : value;
	}
	throw new IllegalArgumentException("Expected a Value operand but got: " + operand.getClass().getName());
}

注意它用的是 DATE_FORMATTER(:67-68):

java 复制代码
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")
	.withZone(ZoneOffset.UTC);

注释解释了为什么 'Z' 是字面量而不是偏移模式 X(:64-66):

'Z' is intentionally a literal suffix, not the offset pattern 'X'. Combined with withZone(UTC) this always produces the fixed form "yyyy-MM-dd'T'HH:mm:ss'Z'", matching the format used to store Date values in document metadata.

★ 这是 上一章 16.11.5 那条"日期陷阱"的另一半证据:Spring AI 的 Document metadata 里 Date 是以 ISO-8601 UTC 字符串形式存的。所以:

  • SimpleVectorStore 的内存求值:过滤值 Date → ISO 字符串 ↔ metadata 里的 ISO 字符串 → 匹配成功;
  • PgVector:doSingleValue 专门把 Date 格式化回 ISO → 匹配成功;
  • 其它方言(Milvus / Pinecone / Redis / MongoDB...):emitJsonValue(Date) → 毫秒时间戳数字 ↔ ISO 字符串 → 永远不匹配。

这就是为什么"日期过滤"在 Spring AI 里是个跨实现不一致的重灾区。

17.5.5 left() / right() 的 null 检查抛 IllegalArgumentException

java 复制代码
// SimpleVectorStoreFilterExpressionEvaluator.java:123-139
private Filter.Operand left(Filter.Expression expression) {
	Filter.Operand left = expression.left();
	if (left == null) {
		throw new IllegalArgumentException(
				"Expression of type %s requires a left operand".formatted(expression.type()));
	}
	return left;
}

用的是 String.formatted(...)(Java 15+ 的实例方法),不是 String.format(...)。异常类型是 IllegalArgumentException。

对比各地方言转换器的 Assert.state → IllegalStateException:又是一种新类型。所以"过滤条件缺少操作数"这件事,在不同实现上至少有三种异常类型 :IllegalArgumentException(求值器、FilterHelper)、IllegalStateException(PgVector/Pinecone/Milvus/Redis/ES 的 Assert.state)、RuntimeException(基类 convertOperand 的裸 throw)。


17.6 观测体系:AbstractObservationVectorStore 把三个操作包进 Micrometer

上一章 16.4 节讲了 AbstractObservationVectorStore 的模板方法骨架。这一节把它的观测部分讲透------这是生产环境排查向量检索性能问题的唯一抓手。

17.6.1 span 名是固定的 db.vector.client.operation

java 复制代码
// DefaultVectorStoreObservationConvention.java:36
public static final String DEFAULT_NAME = "db.vector.client.operation";

这个命名遵循 OpenTelemetry 的 数据库语义约定 (db.<system>.<operation> 的变体)。注意它不是 db.vector.query 或 vectorstore.search------如果你在 Grafana/Jaeger 里按名字搜,要搜 db.vector.client.operation。

而人类可读的那个名字 由 getContextualName 决定(:53-56):

java 复制代码
@Override
public String getContextualName(VectorStoreObservationContext context) {
	return "%s %s".formatted(context.getDatabaseSystem(), context.getOperationName());
}

比如 pgvector query、redis add、milvus delete。

operationName 来自枚举 VectorStoreObservationContext.Operation(:139-152):

java 复制代码
public enum Operation {
	ADD("add"),
	DELETE("delete"),
	QUERY("query");
	public final String value;
	...
}

★ 只有三个操作有观测:add / delete / query。

回忆 上一章 16.4 节:AbstractObservationVectorStore 的 doDelete(Filter.Expression) 默认抛 UnsupportedOperationException。而 delete 观测是存在的------说明框架预期各实现会支持按过滤删除。但默认不支持。

另外注意:similaritySearch(String) 这种便捷方法也会产生 query 观测 ,因为它最终走 doSimilaritySearch。而 getNativeClient() 这类方法没有观测。

17.6.2 低基数标签只有 3 个,高基数有 8 个

这是本节最实用的一张表。很多人在 Prometheus 里加标签导致基数爆炸(cardinality explosion),根源就是没分清这两类。

低基数(getLowCardinalityKeyValues,:58-73):

Key 值来源 典型取值
spring.ai.kind SpringAiKind.VECTOR_STORE.value() 常量 vector_store
db.system context.getDatabaseSystem() pgvector / redis / milvus ...
db.operation.name context.getOperationName() add / delete / query

只有三个,且取值集合有限------这就是"低基数"的含义,可以安全地作为 Prometheus metric 的 label。

高基数(getHighCardinalityKeyValues,:75-88):

Key 常量名 值来源 基数风险
db.collection.name DB_COLLECTION_NAME context.getCollectionName() 中(每张表一个值)
db.vector.dimension.count DB_VECTOR_DIMENSION_COUNT context.getDimensions() 低
db.vector.field.name DB_VECTOR_FIELD_NAME context.getFieldName() 低
db.vector.query.filter DB_VECTOR_QUERY_FILTER getQueryRequest().getFilterExpression().toString() ★ 高
db.namespace DB_NAMESPACE context.getNamespace() 低
db.vector.query.content DB_VECTOR_QUERY_CONTENT getQueryRequest().getQuery() ★ 极高
db.search.similarity.metric DB_SEARCH_SIMILARITY_METRIC context.getSimilarityMetric() 极低
db.vector.query.similarity.threshold DB_VECTOR_QUERY_SIMILARITY_THRESHOLD 阈值 低
db.vector.query.top.k DB_VECTOR_QUERY_TOP_K topK 低

★ 两个"极高/高"基数标签必须重点说。

(1) db.vector.query.content ------ 把用户查询原文写进标签。

java 复制代码
// DefaultVectorStoreObservationConvention.java:127-133
protected KeyValues queryContent(KeyValues keyValues, VectorStoreObservationContext context) {
	if (context.getQueryRequest() != null && StringUtils.hasText(context.getQueryRequest().getQuery())) {
		return keyValues.and(HighCardinalityKeyNames.DB_VECTOR_QUERY_CONTENT.asString(),
				context.getQueryRequest().getQuery());
	}
	return keyValues;
}

每一条不同的用户提问都会产生一个新的标签值 。如果这是 RAG 应用,用户问题几乎不重复 → 标签基数随时间无界增长 → Prometheus 内存爆炸。

规避 :Micrometer 的高基数标签默认不会 进入 Prometheus meter(Prometheus 只导出低基数);它们只出现在 span / trace 里。所以只要你不把高基数 key 加到 Prometheus 的 meter filter,metric 侧是安全的。但 trace 侧会存下全部用户提问------这在合规上是敏感数据,需要评估。

(2) db.vector.query.filter ------ 把过滤表达式写进标签。

java 复制代码
// DefaultVectorStoreObservationConvention.java:112-118
protected KeyValues metadataFilter(KeyValues keyValues, VectorStoreObservationContext context) {
	if (context.getQueryRequest() != null && context.getQueryRequest().getFilterExpression() != null) {
		return keyValues.and(HighCardinalityKeyNames.DB_VECTOR_QUERY_FILTER.asString(),
				context.getQueryRequest().getFilterExpression().toString());
	}
	return keyValues;
}

注意它调的是 getFilterExpression().toString()。回忆 上一章 16.5 节:SearchRequest.toString() 会打印 filterExpression。这里同理------过滤条件里如果含用户 ID、手机号等 PII,会原样进 span。

★ 另外注意:DB_VECTOR_QUERY_FILTER 这个 key 的实现(:167-172)和其它兄弟不一样:

java 复制代码
DB_VECTOR_QUERY_FILTER {
	@Override
	public String asString() {
		return "db.vector.query.filter";   // ← 硬编码字符串
	}
}

而其它 8 个都是 return VectorStoreObservationAttributes.XXX.value();。只有这一个偷懒硬编码了。功能上等价(值相同),但破坏了可维护性------如果统一常量将来改名,这里不会跟着变。

17.6.3 VectorStoreQueryResponseObservationHandler 会把检索到的正文打进 INFO 日志

这个 handler 值得单独警告:

java 复制代码
// VectorStoreQueryResponseObservationHandler.java:41-54
@Override
public void onStop(VectorStoreObservationContext context) {
	if (logger.isInfoEnabled()) {
		logger.info("Vector Store Query Response:/n" + ObservabilityHelper.concatenateStrings(documents(context)));
	}
}

private List<String> documents(VectorStoreObservationContext context) {
	if (CollectionUtils.isEmpty(context.getQueryResponse())) {
		return List.of();
	}
	return context.getQueryResponse().stream().map(Document::getText()).toList();
}

★ 它在每次 query 观测结束时,把所有命中文档的完整正文以 INFO 级别打进日志。

好在它是默认不启用 的------VectorStoreObservationAutoConfiguration 用 @ConditionalOnProperty 把它关住了(:65-66、:82-83):

java 复制代码
@ConditionalOnProperty(prefix = VectorStoreObservationProperties.CONFIG_PREFIX, name = "log-query-response",
		havingValue = "true")

而且只要你开启它,框架会主动打一条 WARN (:52-55):

java 复制代码
private static void logQueryResponseContentWarning() {
	logger.warn(
			"You have enabled logging out of the query response content with the risk of exposing sensitive or private information. Please, be careful!");
}

这是全仓库里少见的、把安全警告直接写进运行时日志的设计,值得称赞。

还有个结构细节值得注意------这个自动配置用了两个内部 @Configuration 做 Tracer 有无的分支(:57-89):

java 复制代码
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(Tracer.class)
@ConditionalOnBean(Tracer.class)
static class TracerPresentObservationConfiguration {
	@Bean
	@ConditionalOnMissingBean(value = VectorStoreQueryResponseObservationHandler.class,
			name = "vectorStoreQueryResponseObservationHandler")
	@ConditionalOnProperty(...)
	TracingAwareLoggingObservationHandler<VectorStoreObservationContext> vectorStoreQueryResponseObservationHandler(
			Tracer tracer) { ... }
}

@Configuration(proxyBeanMethods = false)
@ConditionalOnMissingClass("io.micrometer.tracing.Tracer")
static class TracerNotPresentObservationConfiguration {
	@Bean
	@ConditionalOnMissingBean
	@ConditionalOnProperty(...)
	VectorStoreQueryResponseObservationHandler vectorStoreQueryResponseObservationHandler() { ... }
}

有 Tracer 时包一层 TracingAwareLoggingObservationHandler(把日志关联到当前 trace),没有时直接用裸 handler。这是标准的 Spring Boot "optional dependency" 写法。

但注意一个不对称 :Tracer 分支的 @ConditionalOnMissingBean 带了 value + name 两个属性,非 Tracer 分支只有裸 @ConditionalOnMissingBean。这意味着两个分支的"用户自定义覆盖"判定条件不同。

17.6.4 VectorStoreObservationContext 的可变性与 Builder

java 复制代码
// VectorStoreObservationContext.java:60-65
public VectorStoreObservationContext(String databaseSystem, String operationName) {
	Assert.hasText(databaseSystem, "databaseSystem cannot be null or empty");
	Assert.hasText(operationName, "operationName cannot be null or empty");
	this.databaseSystem = databaseSystem;
	this.operationName = operationName;
}

只有 databaseSystem 和 operationName 是 final 且构造时必填;其余 7 个字段(collectionName / dimensions / fieldName / namespace / similarityMetric / queryRequest / queryResponse)都是 @Nullable + 公开 setter。

★ 这意味着 context 是可变的 ,而且 setter 是 public 的。由于 Observation.Context 在一次观测内是共享的,如果你在自定义 ObservationHandler 里改了这些字段,会影响后续 handler 看到的值。

Builder 有两个入口(:67-73):

java 复制代码
public static Builder builder(String databaseSystem, String operationName) {
	return new Builder(databaseSystem, operationName);
}

public static Builder builder(String databaseSystem, Operation operation) {
	return builder(databaseSystem, operation.value);
}

第二个是枚举便捷版。注意 Assert.hasText(x, "xxx cannot be null or empty") 的措辞------消息说 "null or empty" 但 hasText 实际还拒绝了纯空白字符串 (" ")。措辞与行为不完全一致,是个小瑕疵。

17.6.5 AI_VECTOR_STORE:一个枚举常量承载三种操作

java 复制代码
// VectorStoreObservationDocumentation.java:33-53
public enum VectorStoreObservationDocumentation implements ObservationDocumentation {

	AI_VECTOR_STORE {
		@Override
		public Class<? extends ObservationConvention<? extends Observation.Context>> getDefaultConvention() {
			return DefaultVectorStoreObservationConvention.class;
		}
		@Override
		public KeyName[] getLowCardinalityKeyNames() {
			return LowCardinalityKeyNames.values();
		}
		@Override
		public KeyName[] getHighCardinalityKeyNames() {
			return HighCardinalityKeyNames.values();
		}
	};

整个枚举只有一个常量 (对比 ChatModel 那边的观测文档通常是每个操作一个常量)。所以 add / delete / query 共用同一套 key 定义------这也是为什么 queryResponse 字段在 add 观测里是 null 但仍占位。

DB_VECTOR_QUERY_SIMILARITY_THRESHOLD 的 javadoc 值得引一段(:174-178):

Similarity threshold that accepts all search scores. A threshold value of 0.0 means any similarity is accepted or disable the similarity threshold filtering. A threshold value of 1.0 means an exact match is required.

这和 上一章 16.6 节讲的"三类阈值实现"是呼应的:观测文档描述的是"应然"(0.0 接受一切、1.0 要求精确匹配),而实际各实现是"实然"(有的下推、有的 Java 侧过滤、有的语义相反)。文档与实现的偏差,正是 上一章 16.6 节那张对照表存在的理由。


17.7 十七个方言逐个对照:谁支持什么,谁在撒谎

上一节讲了"翻译机"的公共骨架。这一节把二十个实现拉到一张表上对照。这是全仓库最实用的一张表------它直接回答"我这个过滤条件换数据库还能不能用"。

17.7.1 全景清单与继承关系

先列出全部实现(用 find . -name "*FilterExpressionConverter*.java" -not -path "*/test/*" 可得 23 个文件,去掉接口和抽象类后是 21 个具体实现 ,其中 S3VectorFilterExpressionConverter 是一个独立接口 (不继承 AbstractFilterExpressionConverter,产出 AWS SDK 的 Document 对象而非 String),所以真正继承基类的是 17 个):

# 转换器 模块 继承基类? 方法签名
1 PineconeFilterExpressionConverter spring-ai-vector-store(内核) ✅ String
2 PrintFilterExpressionConverter spring-ai-vector-store(内核) ✅ String
3 ChromaFilterExpressionConverter spring-ai-chroma-store ✅(继承 #1) String
4 PgVectorFilterExpressionConverter spring-ai-pgvector-store ✅ String
5 MilvusFilterExpressionConverter spring-ai-milvus-store ✅ String
6 RedisFilterExpressionConverter spring-ai-redis-store ✅ String
7 AzureAiSearchFilterExpressionConverter spring-ai-azure-store ✅ String
8 CassandraFilterExpressionConverter spring-ai-cassandra-store ✅ String
9 CouchbaseAiSearchFilterExpressionConverter spring-ai-couchbase-store ✅ String
10 ElasticsearchAiSearchFilterExpressionConverter spring-ai-elasticsearch-store ✅ String
11 GemFireAiSearchFilterExpressionConverter spring-ai-gemfire-store ✅ String
12 MariaDBFilterExpressionConverter spring-ai-mariadb-store ✅ String
13 MongoDBAtlasFilterExpressionConverter spring-ai-mongodb-atlas-store ✅ String
14 Neo4jVectorFilterExpressionConverter spring-ai-neo4j-store ✅ String
15 OpenSearchAiSearchFilterExpressionConverter spring-ai-opensearch-store ✅ String
16 SqlJsonPathFilterExpressionConverter(Oracle) spring-ai-oracle-store ✅ String
17 TypesenseFilterExpressionConverter spring-ai-typesense-store ✅ String
18 WeaviateFilterExpressionConverter spring-ai-weaviate-store ✅ String
--- QdrantFilterExpressionConverter spring-ai-qdrant-store ❌ 独立类 io.qdrant...Filter 对象
--- CoherenceFilterExpressionConverter spring-ai-coherence-store ❌ 独立类 com.tangosol.util.Filter<?> 对象
--- BedrockKnowledgeBaseFilterExpressionConverter spring-ai-bedrock-knowledgebase-store ❌ 独立类 AWS RetrievalFilter 对象
--- S3VectorFilterExpressionConverter spring-ai-s3-vector-store ❌ 是接口 AWS Document 对象

数一下:继承基类的 18 个(含 Chroma),不继承的 4 个(Qdrant / Coherence / Bedrock / S3Vector)。上一节标题我写"十七个方言"是按继承基类的 17 个非内核方言计的(排除内核的 Pinecone / Print,Chroma 算一个),这里给出完整 22 项以免歧义。

★ 四个"不守规矩"的异类值得单独说。

(1) QdrantFilterExpressionConverter:

java 复制代码
class QdrantFilterExpressionConverter {

	public Filter convertExpression(Expression expression) {
		return this.convertOperand(expression);
	}
	...
}

注意三件事:

  • 它没有 extends,也没有 implements ------和 Spring AI 的 FilterExpressionConverter 接口完全脱钩;
  • 它是 package-private (class 而非 public class),外部无法引用;
  • 它的方法叫 convertExpression 但返回的是 gRPC 的 io.qdrant.client.grpc.Common.Filter 对象,不是字符串。

后果:Qdrant 的过滤逻辑完全独立于 Spring AI 的转换器体系 ,FilterHelper.negate、基类 doNot 那一套德摩根消解它统统用不上。它自己在 :51-62 手写了 NOT / AND / OR 的三路分派(用 Qdrant 的 mustNotClauses / mustClauses / shouldClauses)。所以 上一章 16.11.3 讲的那些限制(比如 NOT(ISNULL) 抛异常)对 Qdrant 不成立------因为它根本不走那条路。

(2) CoherenceFilterExpressionConverter:

java 复制代码
public class CoherenceFilterExpressionConverter {

	public Filter<?> convert(Operand expression) {
		if (expression instanceof Expression) {
			return convert((Expression) expression);
		}
		return convert((Group) expression);
	}
	...
}

方法名是 convert 而不是 convertExpression,入口参数类型是 Operand 而不是 Expression。它产出 Oracle Coherence 的 com.tangosol.util.Filter<?> 对象。有意思的是它确实复用了 FilterHelper.negate (:56-57):

java 复制代码
if (expression.type() == ExpressionType.NOT) {
	return convert(FilterHelper.negate(expression));
}

------所以它是手工复刻 了基类的 doNot 策略,而不是继承来的。结论:Coherence 同样无法取反 ISNULL (negate 的 default 分支)。

(3) BedrockKnowledgeBaseFilterExpressionConverter :产出 AWS SDK 的 RetrievalFilter,switch 覆盖 AND/OR/NOT/EQ/NE/GT/GTE/LT/LTE/IN/NIN(:55-65)。它不支持 ISNULL/ISNOTNULL ------因为 AWS Bedrock 的 RetrievalFilter 语法里没有 null 检查。

(4) S3VectorFilterExpressionConverter :这是个接口,不是类:

java 复制代码
public interface S3VectorFilterExpressionConverter {

	/**
	 * Convert the given {@link Filter.Expression} into a {@link Document} representation.
	 */
	Document convertExpression(Filter.Expression expression);

}

它连 Spring AI 自己的 FilterExpressionConverter 都没实现(名字一样但不在同一继承链上),产出 AWS SDK v2 的 software.amazon.awssdk.core.document.Document。这是为 S3 Vectors 新增的(见下一章),属于"字符串不够用了,得直接构造 SDK 对象树"的演进方向。

17.7.2 操作符支持矩阵(17 个继承基类的方言)

下面这张表按 getOperationSymbol / doExpression 的实际分支整理。标记含义:

  • ✅ 有原生符号
  • 🔁 走 FilterHelper.negate 或 expandIn/expandNin 消解
  • ⚠️ 有实现但语义可疑
  • ❌ 落到 default -> throw,运行时抛异常
方言 AND OR NOT EQ NE GT/GTE LT/LTE IN NIN ISNULL ISNOTNULL
Pinecone ✅ $and ✅ $or 🔁 ✅ $eq ✅ $ne ✅ $gt/$gte ✅ $lt/$lte ✅ $in ✅ $nin ❌ ❌
Chroma(继承 Pinecone) ✅ ✅ 🔁 ✅ ✅ ✅ ✅ ✅ ✅ ❌ ❌
PgVector ✅ && ✅ ` ` 🔁 ✅ == ✅ != ✅ >/>= ✅ </<= ⚠️ 手工展开 ⚠️ 手工展开
Milvus ✅ && ✅ ` ` 🔁 ✅ == ✅ != ✅ ✅ ✅ in ✅ not in
Redis ✅ " " ✅ `" "` ✅ - 前缀 ✅ 区间 🔁→- ✅ 区间 ✅ 区间 ✅ TAG 🔁→- ❌
Azure AI Search ✅ and ✅ or 🔁 ✅ eq ✅ ne ✅ gt/ge ✅ lt/le ✅ search.in ✅ not search.in ❌ ❌
Cassandra ✅ and ✅ or ❌ 抛异常 ✅ = ✅ != ✅ ✅ ✅ IN ❌ 抛异常 ❌ ❌
Couchbase ✅ AND ✅ OR 🔁 ✅ == ✅ != ✅ ✅ ✅ IN ✅ NOT IN ❌ ❌
Elasticsearch ✅ AND ✅ OR 🔁 ✅(空符号) ✅ NOT ✅ </<= ✅ >/>= ✅ ✅ NOT ✅ -field:* ✅ field:*
GemFire ✅ AND ✅ OR 🔁 ✅ ✅ NOT ⚠️ {/[ ⚠️ }/] ✅ ✅ NOT ❌ ❌
MariaDB ✅ AND ✅ OR ⚠️ NOT IN ✅ = ✅ != ✅ ✅ ✅ IN ✅ NOT IN ❌ ❌
MongoDB Atlas ✅ $and ✅ $or 🔁 ✅ $eq ✅ $ne ✅ $gt/$gte ✅ $lt/$lte ✅ $in ✅ $nin ❌ ❌
Neo4j ✅ AND ✅ OR ✅ NOT ✅ = ⚠️ <> ✅ ✅ ✅ IN 🔁→NOT ❌ ❌
OpenSearch ✅ AND ✅ OR 🔁 ✅ ✅ NOT ✅ ✅ ✅ ✅ NOT ❌ ❌
Oracle (SqlJsonPath) ✅ && ✅ ` ` 🔁 ✅ == ✅ != ✅ ✅ ✅ in 🔁 手工
Typesense ✅ && ✅ ` ` 🔁 ⚠️ 空串 ⚠️ != ✅ ✅ ⚠️ 空串 ⚠️ !=
Weaviate ✅ operator:And ✅ operator:Or 🔁 ✅ Equal ✅ NotEqual ✅ ✅ ✅ ✅ ❌ ❌

从这张表能立刻读出五条结论。

结论一 ★:ISNULL / ISNOTNULL 全仓库只有 Elasticsearch 一家支持。

Filter.ExpressionType 定义了 13 种类型,其中 ISNULL 和 ISNOTNULL 在 17 个方言里的支持率是 1/17 。Elasticsearch 的实现(:56-64):

java 复制代码
else if (expression.type() == Filter.ExpressionType.ISNULL) {
	context.append("-");
	this.convertOperand(expression.left(), context);
	context.append("*");
}
else if (expression.type() == Filter.ExpressionType.ISNOTNULL) {
	this.convertOperand(expression.left(), context);
	context.append("*");
}

产出分别是 -metadata.author:*(Lucene 的"字段不存在"写法)和 metadata.author:*。

其它 16 家会在 getOperationSymbol 的 default 分支炸掉,抛 RuntimeException("Not supported expression type: ISNULL")。

而 SimpleVectorStore 是个例外------它不走转换器,走 17.5 节讲的 SimpleVectorStoreFilterExpressionEvaluator,支持全部 13 种类型。所以:

★ 同样的 ISNULL 过滤条件,在 SimpleVectorStore 上正常работа,在 PgVector 上抛异常。 这是"本地跑通、上线炸掉"的经典来源。

结论二 ★:Cassandra 显式拒绝 NOT 和 NIN。

java 复制代码
// CassandraFilterExpressionConverter.java:87
case NIN, NOT -> throw new UnsupportedOperationException(

这是唯一一家把 NOT 写进 switch 并主动抛异常 的。原因可以在旁边的注释里看到(:67-68):

java 复制代码
// case CONTAINS -> context.append(" CONTAINS ");
// case CONTAINS_KEY -> context.append(" CONTAINS_KEY ");

------Cassandra 的 CQL 有 CONTAINS / CONTAINS_KEY 语法,但 Spring AI 的 ExpressionType 里没有这两个(只有 13 种,不含它们),所以被注释掉了。而 CQL 的 NOT 只能用在 CREATE INDEX 之类特定上下文,不能用于任意 WHERE 谓词取反,所以干脆禁掉。

值得注意的是:Cassandra 抛的是 UnsupportedOperationException,而基类 doNot 的默认实现(消解)根本没机会执行 ------因为它覆写的不是 doNot,而是 doExpression。但 convertOperand 对 type()==NOT 只调 doNot,永不调 doExpression......

细看 CassandraFilterExpressionConverter:85-87:

java 复制代码
case AND -> doBinaryOperation(" and ", expression, context);
case OR -> doBinaryOperation(" or ", expression, context);
case NIN, NOT -> throw new UnsupportedOperationException(

这是在一个独立的 switch 里(:84-88 附近),而 doExpression 在 :57-70 是另一个 switch(只处理比较类)。所以这个 case NIN, NOT 分支很可能属于一个独立的 dispatch 方法 ,只有当 NOT 真的走到 doExpression 时才会触发。由于基类 convertOperand 把 NOT 路由给 doNot,NOT 在 Cassandra 上实际上会被 FilterHelper.negate 消解掉,不会抛异常 ;真正会抛的是 NIN (因为 NIN 不走 doNot)。

我标注为"需实测确认 ":静态看 NOT 走消解,NIN 命中 case NIN, NOT -> throw。用 Cassandra 的话,写 NIN 前请先跑一个最小用例验证。

结论三 ★:Redis 和 Neo4j 都在 doExpression 里给 NOT 留了位置,但只有 Neo4j 真的接得住。

Redis(:88-91):

java 复制代码
case NOT:
	context.append("-");
	convertOperand(expression.left(), context);
	break;

输出 -@country:{BG}(RediSearch 的"取反标签"前缀 -)。

Neo4j(:62):case NOT, NIN -> " NOT ";

这两家把 NOT 写进自己的 switch,但如结论二所述,NOT 会先被基类的 doNot 拦下走消解------除非该方言覆写了 doNot。

我核对的结果是:Redis 没有覆写 doNot,Neo4j 覆写了。

  • Redis 不覆写 → NOT 被基类 doNot + FilterHelper.negate 消解掉 → 它的 case NOT 是死分支。
  • Neo4j 覆写 → NOT 由它自己处理,case NOT 同样是死分支(路由根本不到这里)。

★ 修正后的通用结论:

只要一个方言不覆写 doNot,它在 doExpression / getOperationSymbol 里写的 case NOT 就是死代码 ------因为基类 convertOperand 在 :107-111 已经把 NOT 分流给 doNot 了,NOT 永远到不了 doExpression。

全仓库覆写 doNot 的只有 Neo4j 一家(Coherence 是独立类,不继承这个基类,不算)。

Neo4j 的 doNot:不做德摩根消解

java 复制代码
// Neo4jVectorFilterExpressionConverter.java:68-79
@Override
protected void doNot(Expression expression, StringBuilder context) {
	if (expression.type() != Filter.ExpressionType.NOT) {   // "should not happen, but better safe than sorry"
		throw new RuntimeException(
				"Unsupported expression type %s. Only NOT is supported here".formatted(expression.type()));
	}
	// explicitly prefix the embedded expression with NOT
	context.append("NOT ").append(this.convertOperand(expression.left()));
}

它不调 FilterHelper.negate ,而是直接前缀 NOT ------Cypher 原生支持 NOT (...),不需要把 NOT (a > 1) 改写成 a <= 1。

三个后果。

(1) NIN 的翻译是"手工包装 + 再调 doNot",而不是消解。

java 复制代码
// Neo4jVectorFilterExpressionConverter.java:37-42
if (expression.type() == Filter.ExpressionType.NIN) {
	// shift the "<left> not in <right>" into "not <left> in <right>"
	this.doNot(new Expression(Filter.ExpressionType.NOT,
			new Expression(Filter.ExpressionType.IN, expression.left(), expression.right())), context);
}
else { /* 常规分支:convertOperand(left) + 符号 + convertOperand(right) */ }

调用链:

scss 复制代码
doExpression(NIN)
  → 构造 NOT(IN(left, right))
  → this.doNot(...)          ← 覆写版
      → append("NOT ")
      → convertOperand(IN)   → doExpression(IN) → "node.metadata.country IN [\"USA\",\"UK\"]"  → 结果:NOT node.metadata.country IN ["USA","UK"]

没有递归,更没有无限递归。 覆写版的 doNot 直接把 NOT 消费掉了,不再回头调 FilterHelper.negate。

★ 这里要记录一次真实的误判。 我在静态分析阶段曾推断这里有 StackOverflowError,推理链是:

doExpression(NIN) → 构造 NOT(IN(...)) → doNot → negate(NOT(IN)) → negate(IN) → NIN → convertOperand(NIN) → doExpression(NIN) → 闭环。

这条推理的前提错了 :它假设 Neo4j 走基类的 doNot(基类才会调 negate ),但 :68-79 明确覆写了。保留它作为对照,是因为它说明了一个普遍陷阱------"这个 override 存不存在"必须回源码逐字确认。

(2) getOperationSymbol 里的 case NOT, NIN -> " NOT " 依然是死代码。 NIN 在 doExpression 开头就被拦截(:38),NOT 又被路由到 doNot,两条路都到不了这里(Redis 的 case NOT 同理)。

(3) 覆写 doNot 意味着失去"德摩根保护"。 基类用 negate 消解 NOT 时顺带把 NOT NOT a 规约成 a。Neo4j 不消解,not(not(eq("country","BG"))) 直接产出 NOT NOT node.metadata.country = "BG"------不是合法 Cypher。

结论四 ★:MariaDB 把 NOT 和 NIN 合并成同一个符号。

java 复制代码
// MariaDBFilterExpressionConverter.java:142
case NOT, NIN -> " NOT IN ";

这在语义上只对 NIN 正确 。如果 NOT 真的走到这里(同样,doNot 会先拦下,所以是死分支),产出 NOT IN 就是错的。而更实际的坑是:NIN 在 MariaDB 上生成 x NOT IN (...) ,而 MySQL/MariaDB 的 NOT IN 有一个著名的 NULL 语义陷阱 :x NOT IN (1, 2, NULL) 永远返回 UNKNOWN(即不匹配任何行),因为 SQL 的三值逻辑。如果你的 metadata 列表里混进了 null,NIN 会静默返回空结果集。

结论五 ★:Typesense 的 EQ 和 IN 产出空串。

java 复制代码
// TypesenseFilterExpressionConverter.java:43-50
case EQ -> " "; // in typesense "EQ" operator looks like -> country:USA
case IN -> " "; // in typesense "IN" operator looks like -> country: [USA, UK]
case NIN -> " != "; // in typesense "NIN" operator looks like -> country: ...

Typesense 的语法是 field:value,没有操作符 。所以 EQ 的"符号"就是一个空格 (:43)。而 IN 也用空格(靠值的方括号列表区分)。结果是:

yaml 复制代码
country: [USA, UK]     ← IN
country: USA           ← EQ
country: != USA        ← NE   ← ⚠️ 这个语法是错的

case NE -> " != " 产出 country: != USA ------ 这在 Typesense 里不是合法语法(Typesense 的否定应该用 country:!=USA,没有空格)。同样地,NIN 用 != 配合列表,产出 country: != [A,B],也是可疑的。

我把这两条标为 ⚠️ "语义可疑":TypeSense 的 NE / NIN 实现很可能无法正常工作,空格是多余的。同样建议实测。

17.7.3 三个"符号表"的写法差异:switch vs if-else

seventeen 个方言里,getOperationSymbol 有三种写法:

写法 A:switch 表达式 + default -> throw(主流,约 10 家)

java 复制代码
// PgVectorFilterExpressionConverter.java:102-114
private String getOperationSymbol(Expression exp) {
	return switch (exp.type()) {
		case AND -> " && ";
		case OR -> " || ";
		case EQ -> " == ";
		case NE -> " != ";
		case LT -> " < ";
		case LTE -> " <= ";
		case GT -> " > ";
		case GTE -> " >= ";
		default -> throw new RuntimeException("Not supported expression type: " + exp.type());
	};
}

写法 B:字符串拼接的 "$" + type.toLowerCase(Locale.ROOT)(Pinecone 独有)

java 复制代码
// PineconeFilterExpressionConverter.java:57-59
private String getOperationSymbol(Expression exp) {
	return "\"$" + exp.type().toString().toLowerCase(Locale.ROOT) + "\": ";
}

这是最省事也最危险 的写法:它不做任何白名单校验,直接把 ExpressionType 的枚举名小写后拼进 JSON。所以它"支持"的类型实际上等于枚举里存在的所有名字 ------包括 ISNULL!

★ 于是:Pinecone 对 ISNULL 会产出 {"$isnull": ...}。这不是 Pinecone 的合法语法(Pinecone 的元数据过滤支持 $eq/$ne/$gt/$gte/$lt/$lte/$in/$nin/$and/$or/$exists,没有 $isnull ),所以请求会被 Pinecone 服务端拒绝------但不抛异常,而是返回一个服务端错误。

对比之下,写法 A 会在本地立刻抛 RuntimeException,反馈更早更清晰。这是"省事的泛型写法"带来的可观测性退化。

注意它还用了 toLowerCase(Locale.ROOT) ------ 显式指定了 ROOT locale 。这是对的(避免土耳其 locale 下 I → ı 的经典 bug),值得肯定。但仓库里并非所有 toLowerCase 都这么做(第 13 章 MCP 部分讲的 HeaderUtils 就用了 toLowerCase(Locale.ROOT),MCP 那部分是对的)。

写法 C:MongoDB Atlas 的 switch + 全量 $xxx(与 A 同构,只是符号不同)

略。

17.7.4 doKey:谁转义了 key,谁没有

doKey 的安全性格局差异极大,这是注入风险的集中地。

A 档:JSON 转义(安全) ------ PgVector、Pinecone、Milvus。

java 复制代码
// PgVectorFilterExpressionConverter.java:137-144
protected void doKey(Key key, StringBuilder context) {
	StringBuilder jsonKey = new StringBuilder();
	emitJsonValue(key.key(), jsonKey);
	// emitJsonValue handles \ and " internally.
	// We just need to handle the SQL-specific single-quote escaping ('' instead of ').
	context.append("$.").append(jsonKey.toString().replace("'", "''"));
}

PgVector 做了两层转义 :先 JSON 转义(" 和 \),再把单引号 ' 替换成 ''(SQL 字面量转义)。因为最终产物是 metadata::jsonb @@ '$....'::jsonpath ------ 整个 JSONPath 被包在 SQL 单引号里,所以单引号必须翻倍。

java 复制代码
// PineconeFilterExpressionConverter.java:62-69
protected void doKey(Key key, StringBuilder context) {
	var identifier = key.key();
	// JSON-encode the key so characters such as " and \ cannot break out of the
	// property name and inject operators (e.g. $or) into the serialized filter document.
	emitJsonValue(identifier, context);
	context.append(": ");
}

Pinecone 的注释明确指出了威胁模型:防止通过 key 注入 $or 之类的操作符。这是一个被认真对待的安全问题。

B 档 ★:白名单校验(最安全) ------ Redis。

java 复制代码
// RedisFilterExpressionConverter.java:62-71
protected void doKey(Key key, StringBuilder context) {
	var identifier = key.key();
	// RediSearch field names are bare identifiers in the @field: query syntax
	// and have no escaping mechanism. Validate against the configured metadata
	// fields to prevent query injection through crafted field names.
	if (!this.metadataFields.containsKey(identifier)) {
		throw new IllegalArgumentException("Not allowed filter identifier name: " + identifier);
	}
	context.append("@").append(identifier).append(":");
}

Redis 的做法是根本不转义,而是拒绝 :因为 RediSearch 的 @field: 语法里字段名是裸标识符、没有任何转义机制 ,所以只能校验它是否在用户预先配置的 MetadataField 列表里。

★ 这带来一个重要的使用约束:

在 Redis 上,任何未在你配置的 metadataFields 里声明的过滤 key 都会被拒绝,抛 IllegalArgumentException("Not allowed filter identifier name: ...")。

这意味着 Redis 的过滤是强 schema 的:你得在建 RedisVectorStore 时就把所有可能过滤的字段列出来,并指定它们的类型(NUMERIC / TAG / TEXT)。这和其它所有方言(schema-less)的行为完全不同。

而且字段类型决定了比较的写法(:119-141):

java 复制代码
switch (field.fieldType()) {
	case NUMERIC:
		Numeric numeric = numeric(expression, value);
		context.append("[");
		context.append(numeric.lower());
		context.append(" ");
		context.append(numeric.upper());
		context.append("]");
		break;
	case TAG:
		context.append("{");
		context.append(tagStringValue(expression, value));
		context.append("}");
		break;
	case TEXT:
		context.append("(");
		context.append(textStringValue(expression, value));
		context.append(")");
		break;
	default:
		throw new UnsupportedOperationException(
				MessageFormat.format("Field type {0} not supported", field.fieldType()));
}
  • NUMERIC → @year:[2020 (2025](区间),EQ 用 [x x],GT 用 [(x +inf];
  • TAG → @country:{BG}(花括号);
  • TEXT → @content:(hello)(圆括号)。

以及数值边界的格式化(:221-258 的 record NumericBoundary):

java 复制代码
private static final NumericBoundary POSITIVE_INFINITY = new NumericBoundary(Double.POSITIVE_INFINITY, true);
private static final NumericBoundary NEGATIVE_INFINITY = new NumericBoundary(Double.NEGATIVE_INFINITY, true);
private static final String INFINITY = "inf";
private static final String MINUS_INFINITY = "-inf";
private static final String INCLUSIVE_FORMAT = "%s";
private static final String EXCLUSIVE_FORMAT = "(%s";

toString() 里用 this == NEGATIVE_INFINITY 做引用相等 判断来特判无穷。因为 POSITIVE_INFINITY / NEGATIVE_INFINITY 是 static final 的单例,这样写可行;但如果你手工 new NumericBoundary(Double.POSITIVE_INFINITY, true),就不会被识别为无穷,会输出 Infinity(String.format("%s", Double.POSITIVE_INFINITY) 的结果)而不是 inf ------ RediSearch 不认 Infinity,只认 inf。所幸这两条路径都被工厂方法封住了。

C 档:直接拼接(有风险) ------ PrintFilterExpressionConverter 之类(仅测试用,无所谓),以及若干方言的裸拼。

PrintFilterExpressionConverter.doKey(:42-44):

java 复制代码
public void doKey(Key key, StringBuilder context) {
	context.append(key.key());
}

它叫 "Print",是测试/调试用的转换器 (javadoc:"Converts Expression into test string format" )。如果你在日志或测试断言里见过 country EQ "BG" 这种输出,就是它产的。

D 档:加前缀 metadata. ------ Elasticsearch:

java 复制代码
// ElasticsearchAiSearchFilterExpressionConverter.java:104-108
public void doKey(Key key, StringBuilder context) {
	var fieldPath = "metadata." + key.key().trim();
	emitLuceneString(fieldPath, context);
	context.append(':');
}

注意两点:

  1. 硬编码 metadata. 前缀 ------Elasticsearch 的过滤字段一律在 metadata. 下,key 不支持嵌套路径(a.b 会被当成 metadata."a.b" 的转义形式,而不是 metadata.a.b);
  2. .trim() ------ 只有 ES 做了 trim,其它方言都不 trim 你的 key。

17.7.5 Assert.state vs Assert.isTrue:异常类型的不一致

十七个方言里,前置校验用了两种 Assert,产出不同的异常类型:

位置 断言 异常类型
PgVectorFilterExpressionConverter:58 Assert.state(expression.right() != null, "expression should have a right operand") IllegalStateException
PgVectorFilterExpressionConverter:79 同上(在 convertToConditions 里) IllegalStateException
PineconeFilterExpressionConverter:36 Assert.state(exp.right() != null, "Codepath expects exp.right to be non-null") IllegalStateException
MilvusFilterExpressionConverter:49 Assert.state(exp.right() != null, "expected expression.right to be non null") IllegalStateException
RedisFilterExpressionConverter:105 Assert.state(expression.right() != null, "expected an expression with a right operand") IllegalStateException
RedisFilterExpressionConverter:116 Assert.state(field != null, "No metadata field configured for: " + identifier) IllegalStateException
RedisFilterExpressionConverter:118 Assert.state(value != null, "expected an expression with a right operand") IllegalStateException
ElasticsearchAiSearchFilterExpressionConverter:49 Assert.state(expression.right() != null, "expression.right() must not be null") IllegalStateException
FilterHelper.expandIn:... Assert.isTrue(exp.type() == ExpressionType.IN, "Expected IN expressions but was: ...") IllegalArgumentException
FilterHelper.expandNin:... Assert.isTrue(exp.type() == NIN, ...) IllegalArgumentException

★ 同一个逻辑错误("右操作数为空"),在不同方言上抛的异常类型不一样 :PgVector / Pinecone / Milvus / Redis / ES 抛 IllegalStateException,而基类 convertOperand 的那条防御断言(上一章 16.11.2)抛 RuntimeException,FilterHelper 抛 IllegalArgumentException。

如果你想在业务代码里统一捕获"过滤条件构造错误",你只能 catch RuntimeException,无法精细化。

另外注意 PgVectorFilterExpressionConverter:83:

java 复制代码
throw new IllegalArgumentException("Expected a List, but got: " + value.getClass().getSimpleName());

------ IN 的右值不是 List 时抛这个。而 MilvusFilterExpressionConverter 完全没有这个校验(它直接把 IN 交给 doValue,单个值会被当标量处理)。所以:

b.in("country", "BG")(单值不是列表)在 PgVector 上抛 IllegalArgumentException,在 Milvus 上生成 country in "BG" 这种可疑语法。

17.7.6 换库时的可移植性速查

把上面的结论压缩成一张"迁移影响表":

你用了 换到 PgVector 换到 Elasticsearch 换到 Redis 换到 SimpleVectorStore
ISNULL / ISNOTNULL ❌ 抛异常 ✅ 唯一支持 ❌ 抛异常 ✅ 支持
NOT(...) ✅ 消解 ✅ 消解 ✅ 消解(- 前缀不可达) ✅ 原生
NOT(ISNULL(x)) ❌ IllegalArgumentException ❌ 同(除非直接写 ISNOTNULL) ❌ ❌ 同
NIN ✅(手工展开成 `!(a b)`) ✅ NOT
中文 metadata key ✅(JSON 转义) ✅(Lucene 转义) ❌ 白名单拒绝 ✅
未声明的 key ✅ 任意 ✅ 任意 ❌ 抛异常 ✅ 任意
Date 值 ✅ 格式化回 ISO ⚠️ 变毫秒时间戳 ⚠️ 变毫秒时间戳 ✅ ISO 字符串比较
小数秒非 3 位 ❌ IllegalArgumentException ❌ 同 ❌ 同 ❌ 同
AND/OR 混用 ✅ 有括号 ✅ 有括号 ✅ 有括号 ✅(AST 求值)
AND/OR 混用(MariaDB/Couchbase) --- --- --- ---
单值 in(k, v) ❌ 抛异常 ✅ ✅ ✅

17.8 陷阱总表(32 条)

按"踩中概率 × 排查难度"排序。★ 标记的是静默失败。

A. 阈值语义(17.1 / 17.3)

# 陷阱 症状 出处
1 ★ S3VectorStore 完全忽略 similarityThreshold 设了等于没设,召回一堆不相关结果 S3VectorStore:150-174
2 ★ Coherence 在非 COSINE 距离下跳过阈值过滤 distanceType != COSINE 短路,无条件通过 CoherenceVectorStore:210
3 ★ Oracle 上设 0/1 之外的阈值会抛 RuntimeException (除非 forcedNormalization=true 且 COSINE/DOT) 换库即崩 OracleVectorStore:415-420
4 ★ PgVector 在 EUCLIDEAN_DISTANCE 下用 1 - threshold 换算,得到几乎必然为空的条件 召回为空 PgVectorStore:368
5 B 类实现(Java 侧过滤)可能返回 0 条(topK 名额被不合格文档占满) 明明有相似文档却召回为空 Azure :253 / Pinecone :230 / Milvus :412
6 ★ 只有 Redis 和 Neo4j 校验 threshold ∈ [0,1],其余 20 家不校验 传 1.5 静默变空结果 RedisVectorStore:461 / Neo4jVectorStore:278
7 ★ PgVector 的 SQL 是严格小于 (<),边界上恰好等于阈值的文档被排除 单元测试构造精确数据时踩到 PgDistanceType:598
8 Oracle 的 (1d - t) * 2d - 1d(DOT)可以是负数,源码无注释 读代码的人看不懂 OracleVectorStore:423
9 Typesense 把阈值字符串拼接 进查询,1 - 0.8 → 0.19999999999999996 日志难读;NaN 会让 Typesense 报 400 TypesenseVectorStore:247
10 forcedNormalization 是"我保证已归一化"的声明,Spring AI 不替你归一化 声明了但没做 → 结果错误 OracleVectorStore:416

B. PgVector(17.2)

# 陷阱 症状 出处
11 ★ doAdd 用 embeddings.get(documents.indexOf(document)) → O(n²) 批量写入 1 万条时 10^8 次比较 PgVectorStore:290
12 ★ 同样的 indexOf 写法在 S3VectorStore.doAdd 里原样出现 复制粘贴式缺陷 S3VectorStore:102
13 ★ CREATE EXTENSION hstore 是无用遗留,但托管 PG 不允许装扩展时会让启动失败 部署阻塞 PgVectorStore:430
14 ★ metadata json(不是 jsonb)→ 不能建 GIN 索引,每次过滤全表扫描 + 运行时转换 元数据过滤 O(n) PgVectorStore:447
15 removeExistingVectorStoreTable=true = 每次启动 DROP TABLE 数据全丢 PgVectorStore:439-441
16 ★ 改 distanceType 不改表名 → CREATE INDEX IF NOT EXISTS 跳过重建,opclass 不匹配 → 索引被弃用 静默退化成全表扫描 PgVectorStore:452-457 + :234
17 ★ 默认 indexType = HNSW(不是 NONE)→ 开箱即用就是近似检索,结果集会被索引改变 召回不完整 PgVectorStore:675
18 ★ IVFFlat 在空表上建索引无意义 (需要训练数据),而 afterPropertiesSet 是建表后立刻建 索引无效 PgVectorStore:443-457
19 ★ 维度三级推导的兜底是 1536 (OpenAI ada-002),非 OpenAI 模型且 dimensions() 失败时会静默按 1536 建表 运行时才报"expected 1536 dimensions" PgVectorStore:514
20 catch (Exception e) 只打日志不传播 → 维度推导失败不让应用启动失败 配置错误被掩盖 PgVectorStore:510-513
21 idType 默认 UUID,convertIdToPgType 的 UUID.fromString(id) 对非法 id 抛 IllegalArgumentException 写入/删除时炸 PgVectorStore:315
22 PgIdType.UUID 的建表列带 DEFAULT uuid_generate_v4()(依赖 uuid-ossp),其余四种没有 DEFAULT 非 UUID 类型必须显式给 id PgVectorStore:490
23 ★ getNativeClient() 返回 JdbcTemplate,getVectorTableName() 是 private → 拿到客户端也拼不出表名 只能执行硬编码 SQL PgVectorStore:533-537

C. S3 Vectors(17.4)

# 陷阱 症状 出处
24 ★ doDelete(Filter.Expression) 是全索引分页扫描 (每页 500)+ Java 侧求值,非原子 100 万条 = 2000 次 API 调用;并发写入时漏删/误删 S3VectorStore:119-148
25 ★ 同一个过滤条件有两条路径:检索下推 AWS、删除 Java 侧求值 → 语义可能不一致 查出来和删除掉的不是同一批 S3VectorStore:163 vs :190
26 ★ Document 没有设 score ,距离塞在自定义 key SPRING_AI_S3_DISTANCE getScore() 永远是初始值 S3VectorStore:181-184
27 ★ afterPropertiesSet() 是空实现 → 不创建 bucket/index,配错 indexName 启动不报错 首次调用才收到 AWS 的 not found S3VectorStore:231-236
28 constructVectorData 把 float[] 装箱成 List<Float>(1536 次装箱/次) 高频检索下累积开销 S3VectorStore:216-222
29 vectorBucketName / indexName 的 null 校验做了两遍(setter + 构造) 冗余,无害 S3VectorStore:81-83 + :261-269
30 DocumentUtils 的 Document 往返转换有损 (Date/BigDecimal/POJO) 读回来的类型变了 DocumentUtils

D. 代码结构(17.4.3 / 17.1)

# 陷阱 症状 出处
31 ★ S3VectorStoreFilterExpressionEvaluator 是 SimpleVectorStoreFilterExpressionEvaluator 的逐字复制品(158 vs 221 行),各自维护 一处修复不会同步到另一处 两个文件
32 第二代的四个 converter(Qdrant / Coherence / Bedrock / S3Vector)没有共同基类、没有共同接口、方法名不统一 无法统一替换或编排 四个文件

17.9 小结

  1. similarityThreshold 没有统一语义。 22 个实现里 21 个读了它,但分成四类:A 下推、B Java 侧过滤、C 全量截断、D 静默丢弃 。第 16 章讲的三类之外,本章补上了第四类------S3VectorStore。

  2. S3VectorStore.doSimilaritySearch 里没有一行代码读阈值。 它只用了 topK、query、filterExpression。这是全仓库唯一一个把用户配置无声吃掉的实现。

  3. Coherence 是第二个会丢弃阈值的实现 ,只不过是有条件的:if (distanceType != COSINE || ...) 的短路让非余弦距离下的所有结果无条件通过。

  4. Oracle 是唯一"显式失败"的一家 :0/1 之外的阈值 + 未开 forcedNormalization 或非 COSINE/DOT → 抛 RuntimeException 并给出修复指引。其余三家(Coherence / S3Vector / PgVector 欧氏)都是静默的。

  5. PgVector 用的是操作符(<-> / <#> / <=>)配 opclass,不是函数。 vector_cosine_distance() 在整个仓库里零命中。

  6. PgVector 的阈值换算是 1 - threshold,且 SQL 里是严格小于。 这个换算只对余弦成立;在欧氏距离下 threshold=0.8 → distance < 0.2(欧氏),几乎必然返回空集。

  7. PgVector 默认就是近似检索 (indexType = HNSW)。javadoc 里那句 "you will see different results for queries after adding an approximate index" 是全仓库少见的诚实表述------向量索引会影响结果集本身,不只是速度。

  8. metadata json 而非 jsonb 意味着无法建 GIN 索引,每次元数据过滤都是全表扫描 + 运行时转换。向量索引再快也救不了。

  9. doAdd 里的 documents.indexOf(document) 是 O(n²),且在 PgVector 和 S3VectorStore 里各出现一次------同一缺陷被两个作者独立复制。

  10. S3VectorStore 有两套互不相干的过滤栈 :检索下推到 AWS(因为 QueryVectors 支持 filter),删除走 Java 侧求值(因为 DeleteVectors 只接受 key 列表)。同一个过滤条件在两条路径上语义可能不一致。

  11. S3VectorStoreFilterExpressionEvaluator 是 SimpleVectorStoreFilterExpressionEvaluator 的逐字复制品,因为要抽公共基类就得把它提到内核并改成 public------作者选择了复制。

  12. 过滤转换体系正在从"字符串模板"演进到"对象树" :AbstractFilterExpressionConverter 的 17 个子类产 String,而 Qdrant / Coherence / Bedrock / S3Vector 四个新一代实现各产自己的原生对象,彼此没有共同契约。

  13. ISNULL / ISNOTNULL 在方言层只有 Elasticsearch 支持,而两个内存求值器支持全部 13 种类型。这个"能力缺口"是"本地跑通、上线炸掉"的直接来源。

  14. 观测层面,PgVector 填满了 6 个字段 (含 similarityMetric),而 S3VectorStore 只填 2 个,且 dimensions 直接调 embeddingModel.dimensions() 没有三级推导兜底------成熟度的差距在细节上处处可见。


17.10 思考题

  1. S3VectorStore 忽略了 similarityThreshold。如果你要为它补上这个能力,应该在Java 侧过滤 (拿到 distance 后过滤)还是下推到 AWS(如果 API 支持)?各自的代价是什么?为什么"补上"可能比"不补"更危险?

  2. PgVector 的 1 - threshold 换算在 EUCLIDEAN_DISTANCE 下几乎必然返回空集。请给出两种修复方案(在 PgDistanceType 里加一个"阈值→距离"的换算函数?还是在 doSimilaritySearch 里按 distanceType 分支?),并说明哪种对现有用户的兼容性影响更小。

  3. CoherenceVectorStore:210 的 distanceType != COSINE || ... 短路,让非余弦距离下的阈值完全失效。如果改成"非余弦时抛异常"(Oracle 风格),会破坏哪些现有用法?如果改成"非余弦时按各自距离语义换算",需要为每个距离类型定义什么?

  4. doDelete(Filter.Expression) 在 PgVector 上是一条原子 SQL,在 S3VectorStore 上是 O(N) 的非原子扫描。请设计一个折中方案:能否利用 AWS 的其它 API(比如先 QueryVectors 用同样的 filter 查出 key,再删)来避免全表扫描?这个方案的语义是否与"按过滤删除"完全等价?

  5. S3VectorStore 的检索用 converter、删除用 evaluator,两条路径语义可能不一致。请列举一个具体的过滤条件(含类型或日期),使得两条路径给出不同结果。

  6. S3VectorStoreFilterExpressionEvaluator 与 SimpleVectorStoreFilterExpressionEvaluator 是逐字复制品。如果让你重构,公共基类应该放在哪个模块?方法签名怎么定?两个现有类的哪些差异(入口方法可见性、行数)会成为重构的阻碍?

  7. PgVector 的 metadata json 无法建 GIN 索引。改成 jsonb 需要哪些配套修改(过滤 converter 已经生成 metadata::jsonb @@ ... 了)?向后兼容如何保证(已有的 json 列表怎么办)?

  8. afterPropertiesSet() 在 PgVector 上建扩展/建表/建索引,在 S3VectorStore 上是空的(作者留言"Not sure if this is place to do")。如果你是 Spring AI 的维护者,你会让 S3VectorStore 在启动时创建 index 吗?考虑幂等性、IAM 权限、以及"基础设施即代码"的最佳实践。


17.11 文件索引

PgVector(743 行)

文件 路径 关键行
PgVectorStore vector-stores/spring-ai-pgvector-store/src/main/java/org/springframework/ai/vectorstore/pgvector/PgVectorStore.java :161 1536 / :163 -1 / :167 UUID / :175 10000 / :179-182 相似度映射 / :259-307 doAdd / :322-354 doDelete / :357-376 doSimilaritySearch / :403-460 afterPropertiesSet / :488-496 列类型 / :498-515 维度推导 / :517-530 观测上下文 / :545-566 PgIndexType / :571-575 PgIdType / :581-612 PgDistanceType / :657-741 Builder
PgVectorFilterExpressionConverter 同目录 :58/:79 Assert.state / :83 List 校验 / :102-114 符号表 / :116-120 @@ / :137-144 doKey / :164-174 doSingleValue(Date 特判)
PgVectorSchemaValidator 同目录 188 行,表名/结构校验
PgVectorStoreAutoConfiguration auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-pgvector/.../pgvector/autoconfigure/ :47 @ConditionalOnClass / :49-50 matchIfMissing / :53-57 BatchingStrategy / :78-79 ObjectProvider

S3 Vectors(793 行)

文件 路径 关键行
S3VectorStore vector-stores/spring-ai-s3-vector-store/src/main/java/org/springframework/ai/vectorstore/s3/S3VectorStore.java :59/:61 500 / :92-112 doAdd / :119-148 doDelete(Filter) / :150-174 doSimilaritySearch / :176-185 toDocument / :187-192 matchesFilter / :209-214 constructMetadata / :216-222 constructVectorData / :224-229 观测 / :231-236 空 afterPropertiesSet / :245-285 Builder
S3VectorFilterExpressionConverter 同目录(接口,38 行) convertExpression 返回 AWS Document
S3VectorFilterSearchExpressionConverter 同目录(154 行) 默认实现
S3VectorStoreFilterExpressionEvaluator 同目录(158 行) :55-79 13 case / :119-148 compare
DocumentUtils 同目录(133 行) toDocument / fromDocument

三家异类

文件 关键行
OracleVectorStore vector-stores/spring-ai-oracle-store/.../oracle/OracleVectorStore.java :363/:394/:415-423
CoherenceVectorStore vector-stores/spring-ai-coherence-store/.../coherence/CoherenceVectorStore.java :210
TypesenseVectorStore vector-stores/spring-ai-typesense-store/.../typesense/TypesenseVectorStore.java :243-247

内存求值器与观测(17.5 / 17.6)

文件 路径
SimpleVectorStoreFilterExpressionEvaluator spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/
DefaultVectorStoreObservationConvention .../vectorstore/observation/(:36 span 名、:58-88 标签)
VectorStoreObservationDocumentation 同目录(唯一枚举常量 AI_VECTOR_STORE)
VectorStoreObservationContext 同目录(:60-65 构造、:139-152 Operation)
VectorStoreQueryResponseObservationHandler 同目录(:41-54 INFO 打正文)
VectorStoreObservationAutoConfiguration auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-observation/.../observation/autoconfigure/

二十余个方言 converter

见 17.7.1 的完整清单,或执行 17.12 的复核命令 #12。


17.12 复核命令

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

bash 复制代码
# 1. ★ 谁读了 similarityThreshold(应命中 21 个文件,缺 s3-vector-store)
grep -rn "getSimilarityThreshold()" vector-stores/*/src/main/java --include=*.java | grep -v test | wc -l
grep -rn "getSimilarityThreshold()" vector-stores/*/src/main/java --include=*.java | grep -v test

# 2. ★ 确认 S3VectorStore 确实没读阈值 / 没设 score
grep -n "getSimilarityThreshold\|\.score(" vector-stores/spring-ai-s3-vector-store/src/main/java/org/springframework/ai/vectorstore/s3/S3VectorStore.java
grep -n "SPRING_AI_S3_DISTANCE" vector-stores/spring-ai-s3-vector-store/src/main/java/org/springframework/ai/vectorstore/s3/S3VectorStore.java

# 3. PgVector 用的是操作符还是函数(vector_cosine_distance 应零命中)
grep -rn "vector_cosine_distance" . --include=*.java | wc -l
grep -n "\"<->\"\|\"<#>\"\|\"<=>\"" vector-stores/spring-ai-pgvector-store/src/main/java/org/springframework/ai/vectorstore/pgvector/PgVectorStore.java
grep -n "vector_l2_ops\|vector_ip_ops\|vector_cosine_ops" vector-stores/spring-ai-pgvector-store/src/main/java/org/springframework/ai/vectorstore/pgvector/PgVectorStore.java

# 4. 阈值换算与严格小于
grep -n "1 - request.getSimilarityThreshold()" vector-stores/spring-ai-pgvector-store/src/main/java/org/springframework/ai/vectorstore/pgvector/PgVectorStore.java
grep -n "distance FROM %s WHERE embedding" vector-stores/spring-ai-pgvector-store/src/main/java/org/springframework/ai/vectorstore/pgvector/PgVectorStore.java

# 5. ★ O(n²) 的 indexOf(应命中 PgVector 与 S3VectorStore 两处)
grep -rn "embeddings.get(documents.indexOf(document))\|embedding.get(documents.indexOf(document))" vector-stores/*/src/main/java --include=*.java

# 6. PgVector 建表 SQL 的细节
sed -n '428,457p' vector-stores/spring-ai-pgvector-store/src/main/java/org/springframework/ai/vectorstore/pgvector/PgVectorStore.java
grep -n "hstore\|uuid-ossp\|metadata json" vector-stores/spring-ai-pgvector-store/src/main/java/org/springframework/ai/vectorstore/pgvector/PgVectorStore.java

# 7. 维度三级推导与 1536 兜底
grep -n "OPENAI_EMBEDDING_DIMENSION_SIZE\|INVALID_EMBEDDING_DIMENSION\|embeddingDimensions()" vector-stores/spring-ai-pgvector-store/src/main/java/org/springframework/ai/vectorstore/pgvector/PgVectorStore.java

# 8. 索引默认 HNSW 与 opclass 不匹配风险
grep -n "indexType = PgIndexType\|PgIndexType.NONE\|CREATE INDEX IF NOT EXISTS" vector-stores/spring-ai-pgvector-store/src/main/java/org/springframework/ai/vectorstore/pgvector/PgVectorStore.java

# 9. Oracle 三分支与 forcedNormalization
sed -n '358,430p' vector-stores/spring-ai-oracle-store/src/main/java/org/springframework/ai/vectorstore/oracle/OracleVectorStore.java

# 10. Coherence 的短路丢弃
grep -n "distanceType != DistanceType.COSINE" vector-stores/spring-ai-coherence-store/src/main/java/org/springframework/ai/vectorstore/coherence/CoherenceVectorStore.java

# 11. Typesense 的字符串拼接
sed -n '243,250p' vector-stores/spring-ai-typesense-store/src/main/java/org/springframework/ai/vectorstore/typesense/TypesenseVectorStore.java

# 12. 全部 converter 清单与继承关系(★ 第三条:应只命中 Neo4j 一家)
find . -name "*FilterExpressionConverter*.java" -not -path "*/test/*" | sort
grep -rn "extends AbstractFilterExpressionConverter" . --include=*FilterExpressionConverter.java
grep -rn "protected void doNot" vector-stores/*/src/main/java --include=*FilterExpressionConverter.java

# 13. ★ 两个求值器的逐字重复(对比同一方法体)
diff <(sed -n '94,121p' spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/SimpleVectorStoreFilterExpressionEvaluator.java) \
     <(sed -n '55,79p' vector-stores/spring-ai-s3-vector-store/src/main/java/org/springframework/ai/vectorstore/s3/S3VectorStoreFilterExpressionEvaluator.java)

# 14. S3 的双过滤栈
grep -n "filterExpressionConverter\|filterExpressionEvaluator" vector-stores/spring-ai-s3-vector-store/src/main/java/org/springframework/ai/vectorstore/s3/S3VectorStore.java

# 15. 观测:span 名与标签
grep -n "DEFAULT_NAME\|getLowCardinalityKeyValues\|getHighCardinalityKeyValues" spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/observation/DefaultVectorStoreObservationConvention.java
grep -rn "VectorStoreProvider\." vector-stores/*/src/main/java --include=*.java | grep -v test | head -25

# 16. 各家 db.system 的取值
grep -rn "createObservationContextBuilder" vector-stores/*/src/main/java --include=*.java | head -25

17.13 下一章预告

第 18 章:ETL 与 Embedding------切分、丰富与批处理。

向量存储讲完两章,接下来回到数据是怎么进去的。第 18 章处理四件事:

  • DocumentReader 体系 :2.x 里到底有多少个实现?PDF 读取(PagePdfDocumentReader)依赖哪个 PDF 库?以及那个反复被问的问题------Tika 相关 reader 在 2.0.1 里还在不在 (本章不预设答案,第 18 章按 find/ls 的实际结果给)。

  • DocumentTransformer 与切分策略 :TokenTextSplitter 有没有重叠窗口?它按 token 切分时的 token 怎么算------是否复用 TokenCountBatchingStrategy 那套编码器?

  • EmbeddingModel 的抽象 :第 16 章讲过 BatchingStrategy 只有一个实现,第 18 章要看 EmbeddingModel 本身------embed(String) / embed(List<Document>) / dimensions() 的默认实现与兜底,以及为什么 PgVector 的 embeddingDimensions() 要 catch (Exception e)(17.2.5 已埋下伏笔)。

  • ETL 流水线的编排 :DocumentReader → DocumentTransformer → DocumentWriter 在 spring-ai-rag 里怎么串起来?

以及贯穿两章的那个问题:从原始文档到数据库里的向量,中间经过多少次类型转换?每次损失了什么?

相关推荐
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 07 章 RAG 模块化架构:RetrievalAugmentationAdvisor流水线与 Query体系
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 09 章 工具调用(上):`@Tool` 注解、ToolDefinition 与 JSON Schema 生成
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 02 章 核心抽象层:`Model` / `StreamingModel` 与 ChatModel 体系
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 01 章 全景:模块拓扑、依赖层次与设计哲学
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 05 章 Advisor 架构:接口体系、栈式链与 context 传播
java·人工智能·开源
鶴哥只手遮天2 小时前
Spring AI 2.0.1 深度解析-第 04 章 `ChatClient` 全流程:Builder、两条链路与结构化输出
java·人工智能·开源
鶴哥只手遮天2 小时前
Spring AI 2.0.1 深度解析-第 08 章 RAG 组件逐个拆解:实现细节与八个可投产组件
java·人工智能·开源
Ward的AI产品实战2 小时前
多个订阅接多个 Agent 怎么管?我从 CC-Switch 迁移到了开源 AI 网关 Magpie(上篇)
开源·ai编程
匠测AI说2 小时前
AI for Testing 提效实战 · 执行自动化(五):这次该跑哪些用例,让 AI 圈回归范围
人工智能·测试