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 本章的五句总纲
-
similarityThreshold不是一个有统一语义的参数。 22 个实现里有 21 个 读了它,但处理方式分成四类:下推到数据库、Java 侧流过滤、全量过滤后截断、以及------静默丢弃。第 16 章讲的三类之外,本章补上了第四类。 -
S3VectorStore完全忽略similarityThreshold。 它的doSimilaritySearch只用了topK,没有一行代码读阈值。这是全仓库唯一一个把用户配置的阈值无声吃掉的实现------不报错、不告警,只是"设了等于没设"。 -
PgVector 用的是操作符(
<->/<#>/<=>),不是函数。 README 里的问题有了答案:vector_cosine_distance()从未出现在源码里,用的是<=>配vector_cosine_ops索引。阈值换算是double distance = 1 - request.getSimilarityThreshold(),且 SQL 里是严格小于 (<),不是<=。 -
S3VectorStore有两套互不相干的过滤路径。 检索时走 converter 下推到 AWS,删除时走S3VectorStoreFilterExpressionEvaluator做全量分页扫描 + Java 侧求值。同一个过滤条件,在两条路径上语义可能不一致。 -
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→ SQLWHERE 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();
注意两点:
- 距离被塞进 metadata key
SPRING_AI_S3_DISTANCE(一个字面量字符串,不是DocumentMetadata.DISTANCE),而不是Document.getScore(); - ★
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;
}
三级优先级:
- 手工设置的
dimensions(builder.dimensions(n))优先------但默认是INVALID_EMBEDDING_DIMENSION = -1(:163、:669),所以未配置时走下一级; embeddingModel.dimensions()------ 问嵌入模型;- 兜底
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 这个换算只对余弦距离成立,对欧氏/内积不成立,所以作者选择"不换算就不如不过滤"。但问题是:
- 它是静默的------没有日志、没有告警;
- 它和用户预期相反------用户设了阈值,期望"过滤得更严",实际得到"完全不过滤",召回的是一堆低质量结果;
- 它让 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转成 AWSDocument→ 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
SimpleVectorStoreto evaluate aFilter.ExpressionAST 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 FIRSTsemantics:nullis 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 > 2020returnsfalsewhenyearis 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(':');
}
注意两点:
- 硬编码
metadata.前缀 ------Elasticsearch 的过滤字段一律在metadata.下,key 不支持嵌套路径(a.b会被当成metadata."a.b"的转义形式,而不是metadata.a.b); .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 小结
-
similarityThreshold没有统一语义。 22 个实现里 21 个读了它,但分成四类:A 下推、B Java 侧过滤、C 全量截断、D 静默丢弃 。第 16 章讲的三类之外,本章补上了第四类------S3VectorStore。 -
S3VectorStore.doSimilaritySearch里没有一行代码读阈值。 它只用了topK、query、filterExpression。这是全仓库唯一一个把用户配置无声吃掉的实现。 -
Coherence 是第二个会丢弃阈值的实现 ,只不过是有条件的:
if (distanceType != COSINE || ...)的短路让非余弦距离下的所有结果无条件通过。 -
Oracle 是唯一"显式失败"的一家 :0/1 之外的阈值 + 未开
forcedNormalization或非 COSINE/DOT → 抛RuntimeException并给出修复指引。其余三家(Coherence / S3Vector / PgVector 欧氏)都是静默的。 -
PgVector 用的是操作符(
<->/<#>/<=>)配 opclass,不是函数。vector_cosine_distance()在整个仓库里零命中。 -
PgVector 的阈值换算是
1 - threshold,且 SQL 里是严格小于。 这个换算只对余弦成立;在欧氏距离下threshold=0.8→distance < 0.2(欧氏),几乎必然返回空集。 -
PgVector 默认就是近似检索 (
indexType = HNSW)。javadoc 里那句 "you will see different results for queries after adding an approximate index" 是全仓库少见的诚实表述------向量索引会影响结果集本身,不只是速度。 -
metadata json而非jsonb意味着无法建 GIN 索引,每次元数据过滤都是全表扫描 + 运行时转换。向量索引再快也救不了。 -
doAdd里的documents.indexOf(document)是 O(n²),且在 PgVector 和 S3VectorStore 里各出现一次------同一缺陷被两个作者独立复制。 -
S3VectorStore 有两套互不相干的过滤栈 :检索下推到 AWS(因为
QueryVectors支持 filter),删除走 Java 侧求值(因为DeleteVectors只接受 key 列表)。同一个过滤条件在两条路径上语义可能不一致。 -
S3VectorStoreFilterExpressionEvaluator是SimpleVectorStoreFilterExpressionEvaluator的逐字复制品,因为要抽公共基类就得把它提到内核并改成 public------作者选择了复制。 -
过滤转换体系正在从"字符串模板"演进到"对象树" :
AbstractFilterExpressionConverter的 17 个子类产String,而 Qdrant / Coherence / Bedrock / S3Vector 四个新一代实现各产自己的原生对象,彼此没有共同契约。 -
ISNULL/ISNOTNULL在方言层只有 Elasticsearch 支持,而两个内存求值器支持全部 13 种类型。这个"能力缺口"是"本地跑通、上线炸掉"的直接来源。 -
观测层面,PgVector 填满了 6 个字段 (含
similarityMetric),而S3VectorStore只填 2 个,且dimensions直接调embeddingModel.dimensions()没有三级推导兜底------成熟度的差距在细节上处处可见。
17.10 思考题
-
S3VectorStore忽略了similarityThreshold。如果你要为它补上这个能力,应该在Java 侧过滤 (拿到distance后过滤)还是下推到 AWS(如果 API 支持)?各自的代价是什么?为什么"补上"可能比"不补"更危险? -
PgVector 的
1 - threshold换算在EUCLIDEAN_DISTANCE下几乎必然返回空集。请给出两种修复方案(在PgDistanceType里加一个"阈值→距离"的换算函数?还是在doSimilaritySearch里按distanceType分支?),并说明哪种对现有用户的兼容性影响更小。 -
CoherenceVectorStore:210的distanceType != COSINE || ...短路,让非余弦距离下的阈值完全失效。如果改成"非余弦时抛异常"(Oracle 风格),会破坏哪些现有用法?如果改成"非余弦时按各自距离语义换算",需要为每个距离类型定义什么? -
doDelete(Filter.Expression)在 PgVector 上是一条原子 SQL,在 S3VectorStore 上是 O(N) 的非原子扫描。请设计一个折中方案:能否利用 AWS 的其它 API(比如先QueryVectors用同样的 filter 查出 key,再删)来避免全表扫描?这个方案的语义是否与"按过滤删除"完全等价? -
S3VectorStore的检索用 converter、删除用 evaluator,两条路径语义可能不一致。请列举一个具体的过滤条件(含类型或日期),使得两条路径给出不同结果。 -
S3VectorStoreFilterExpressionEvaluator与SimpleVectorStoreFilterExpressionEvaluator是逐字复制品。如果让你重构,公共基类应该放在哪个模块?方法签名怎么定?两个现有类的哪些差异(入口方法可见性、行数)会成为重构的阻碍? -
PgVector 的
metadata json无法建 GIN 索引。改成jsonb需要哪些配套修改(过滤 converter 已经生成metadata::jsonb @@ ...了)?向后兼容如何保证(已有的json列表怎么办)? -
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里怎么串起来?
以及贯穿两章的那个问题:从原始文档到数据库里的向量,中间经过多少次类型转换?每次损失了什么?