org.springframework.ai.vectorstore.SearchRequest

一、SearchRequest 在 Spring AI 向量检索体系里到底是什么

从 Spring AI 官方 API 设计来看,SearchRequest 不是一个孤立类,而是 VectorStoreRetriever / VectorStore 检索契约的核心参数对象VectorStoreRetriever 暴露了两个检索入口:一个是 similaritySearch(String query),另一个是 similaritySearch(SearchRequest request)。前者只是便捷方法,内部会把 String query 封装成 SearchRequest.builder().query(query).build();真正完整、可控的检索入口,是传入 SearchRequest 的那个重载。也就是说,只要你要控制 topK、相似度阈值、元数据过滤条件,那么你最终都绕不开 SearchRequest

Spring AI 在包级文档里把 org.springframework.ai.vectorstore 定义为“面向向量数据库工作的接口与实现集合”,其中 SearchRequest 的职责被明确描述为:配置 similarity search 的查询文本、结果数量限制、相似度阈值以及元数据过滤条件。这一定义很关键,因为它说明 SearchRequest 不是在做“索引配置”,不是在做“文档写入参数”,也不是在做“模型推理参数”;它只服务于 读路径(retrieval path)。你可以把它理解为向量检索阶段的“查询说明书”。

再往下看实现层,Spring AI 大多数向量库实现都通过 AbstractObservationVectorStore 统一承接检索,再把 SearchRequest 传给各自的 doSimilaritySearch(SearchRequest request)。官方类使用页显示,SearchRequest 被 Azure、Cassandra、Chroma、Coherence、CosmosDB、Elasticsearch、GemFire、Hana、MariaDB、Milvus、MongoDB Atlas、Neo4j、OpenSearch、Oracle、PgVector、Pinecone、Qdrant、Redis、SimpleVectorStore、Typesense、Weaviate 等实现直接使用。这说明 SearchRequest 不是某一个 Vector Store 的私有参数,而是 Spring AI 跨向量库的统一检索抽象

把它放进执行链路里看,会更容易理解:

用户问题/检索文本
        │
        ▼
SearchRequest.query(...)
        │
        ▼
EmbeddingModel 生成查询向量
        │
        ▼
VectorStore / VectorStoreRetriever
        │
        ├─ topK:要多少候选结果
        ├─ filterExpression:元数据过滤
        └─ similarityThreshold:结果再过滤
        ▼
返回 Document 列表
        │
        ▼
供 RAG / Advisor / 上层业务使用

这条链路里,SearchRequest 的本质作用,是把“我要查什么”“最多要多少”“过滤哪些文档”“最低相关度多少”四类信息,统一交给检索层。它既是面向业务调用者的参数对象,也是连接上层 RAG 和底层向量库实现的桥梁。没有它,Spring AI 很难做到“同一套检索 API,适配多种底层存储”的抽象目标。

这一点在 RAG 场景尤其重要。官方 RAG 文档明确说明,QuestionAnswerAdvisor 在执行时会查询向量数据库,而限制查询范围、设置阈值、控制返回条数,依赖的正是 SearchRequest。所以它不是“只给 VectorStore 直接用的工具类”,而是 Spring AI 检索增强链路中的一等公民。你甚至可以说:如果 Document 是向量库中的“数据单元”,那么 SearchRequest 就是向量库中的“检索意图单元”。


二、类结构与对象模型:它有哪些成员,默认值是什么,设计意图是什么

先看类的公开结构。当前 Spring AI 1.1.x 的 Javadoc 显示,SearchRequestorg.springframework.ai.vectorstore 包下的一个普通类,直接已知子类只有 MilvusSearchRequest;它包含一个嵌套静态最终类 SearchRequest.Builder,并暴露两个常量:DEFAULT_TOP_KSIMILARITY_THRESHOLD_ACCEPT_ALL。常量值在官方常量页中给得非常明确:DEFAULT_TOP_K = 4SIMILARITY_THRESHOLD_ACCEPT_ALL = 0.0。这两个默认值本身就透露了 Spring AI 的默认检索哲学:默认返回 4 条候选文档,默认不设置相似度阈值门槛(接受所有相似度分数)

再看内部状态。官方参考文档给出了 SearchRequest 的核心字段源码片段:query 默认是空字符串 ""topK 默认是 DEFAULT_TOP_KsimilarityThreshold 默认是 SIMILARITY_THRESHOLD_ACCEPT_ALLfilterExpression 默认是 null。也就是说,一个“什么都不配”的 SearchRequest,本质上表达的是:使用空查询文本、最多取 4 条、不做阈值过滤、不做元数据过滤。这里最值得注意的是 query 的默认值不是 null,而是空串,这和 Builder 的非空校验结合起来看,说明框架作者希望避免“空引用语义”,而把“未显式设置查询”表示为“空字符串查询”。

构造器方面,Javadoc 显示它有两个构造器:一个 public 无参构造器 SearchRequest(),一个受保护的复制构造器 SearchRequest(SearchRequest original)。但官方文档在类说明中明确写的是:使用 builder() 创建实例。这意味着,虽然 public 无参构造器存在,但官方推荐路径不是直接 new SearchRequest(),而是通过 Builder 组装。结合字段私有、无 public setter 的事实可以推断:外部代码直接 new SearchRequest() 虽然合法,但除了得到一个“默认值请求”之外,几乎没有什么可配置空间;真正让这个类变得有用的,是 Builder。这个判断是对公开结构的合理推论。

方法面上,当前版本公开的核心实例方法包括:getQuery()getTopK()getSimilarityThreshold()getFilterExpression()hasFilterExpression(),以及从 Object 覆盖而来的 equals()hashCode()toString()。静态方法则包括 builder()from(SearchRequest originalSearchRequest)。这组 API 的含义非常清晰:对象创建走 Builder,读取状态走 getter,判定是否带过滤条件走 hasFilterExpression(),复制修改走 from() 这里尤其要注意 from() 的返回值不是 SearchRequest,而是 SearchRequest.Builder。也就是说,它不是“复制后立刻得到新对象”,而是“复制后继续修改”的入口。

MilvusSearchRequest 则说明了另一个设计点:SearchRequest 是“通用检索请求”,但 Spring AI 允许某些底层实现做 受控扩展。官方 Javadoc 说明 MilvusSearchRequest 在通用字段之外增加了两个 Milvus 专用字段:nativeExpression(原生 Milvus 过滤表达式)和 searchParamsJson(Milvus 的 JSON 搜索参数)。这说明 SearchRequest 的定位不是“包打天下、绝不扩展”的封闭对象,而是:先提供通用跨库抽象,再允许个别后端在不破坏通用接口的前提下增加原生能力。 这也是 Spring AI 在“可移植性”和“利用原生能力”之间做的典型平衡。

从对象模型角度总结,SearchRequest 可以这样理解:它不是 DTO 式的“随便 set 字段”的对象,也不是完全不可变的 record,而是一个 “由 Builder 组装、对外只读暴露状态、作为检索链路统一输入的请求对象”。这个定位很重要,因为后面你理解 build()from()、Builder 复用、副作用边界时,都会依赖这个基础认识。


三、Builder 逐方法深解:每个方法到底在表达什么,边界条件是什么

这一部分是最核心的,因为 SearchRequest 的“真正可编程接口”几乎都集中在 SearchRequest.Builder 上。当前 Builder 对外暴露的方法主要有:query(String query)topK(int topK)similarityThreshold(double threshold)similarityThresholdAll()filterExpression(Filter.Expression expression)filterExpression(String textExpression)build()。Javadoc 和参考文档把这些方法的语义和部分源码都给出来了,所以我们可以把它们讲得很细。

1)query(String query)

这是最基础的方法,它设置“用于向量相似度比较的文本”。参考文档源码明确显示,这个方法内部调用了 Assert.notNull(query, "Query can not be null.")。因此,query 不能是 null,但可以是空字符串——因为断言只禁止 null,没有禁止 ""。这两个概念必须分清:null 表示根本没有值,会直接触发参数校验;空串则是一个合法但语义上往往不太有用的查询。

这意味着你在使用时应当区分两种场景。第一种是直接面向 VectorStore API 的调用,此时通常都应显式给出有效查询文本。第二种是 Advisor / Retriever 把查询文本在运行时填进来,这种情况下 Builder 里暂时不设置 query 也可能成立,但那是因为上层组件会接管查询注入,不是因为空查询本身就是好主意。换句话说,“可为空串”不等于“推荐为空串”。 这是使用语义,而不是单纯的类型语义。

2)topK(int topK)

Javadoc 说它设置“返回 top k 相似结果”。参考文档源码则更细:内部断言是 Assert.isTrue(topK >= 0, "TopK should be positive.");。这里有一个非常值得源码阅读者注意的细节:断言条件是 >= 0,但提示文案写的是 positive。 按照数学语言,positive 通常意味着 > 0;而按当前源码,0 实际上是允许的。也就是说,从 API 实现上看,topK = 0 是合法输入。这不一定有业务意义,但它没有被框架禁止。

这件事对写框架封装的人尤其重要。很多人会想当然地在外层也写成“必须 > 0”,然后把 Spring AI 自己允许的值屏蔽掉。更稳妥的理解方式是:topK 的语义是“最多取多少候选”,当前实现允许它为 0,因此在语义上可以理解为“不要返回任何结果”。至于底层存储是否对 0 有特殊行为,那是具体 store 的实现问题,不是 SearchRequest 本身的问题。这里你要区分 请求对象允许的取值域后端是否有实际语义价值

3)similarityThreshold(double threshold)

这是整个类最容易被误解的方法。Javadoc 明确写到:只有相似度分数“大于等于 threshold”的文档才会返回;注意这一步是客户端执行的后处理,不是服务端执行的过滤。并且阈值必须在 [0,1] 区间内,0.0 表示接受任何相似度,1.0 表示要求精确匹配。参考文档源码同样给出断言:threshold >= 0 && threshold <= 1。这几条信息必须一起理解。

“客户端后处理”四个字非常关键。它意味着:底层向量库通常先按向量相似度和过滤条件取回候选文档,然后 Spring AI 再在客户端把低于阈值的结果剔掉。因此你不能简单把 similarityThreshold 等同于“后端向量库的原生阈值条件”。这会带来两个直接后果。第一,你最终得到的文档条数可能少于 topK,因为先拿回来的候选在客户端又被阈值裁掉了。第二,同样的 threshold 在不同后端上表现相近,但并不意味着它们底层查询计划完全一致,因为阈值不是都在后端生效。

4)similarityThresholdAll()

这个方法本质上只是一个语义糖衣。Javadoc 说明它会把阈值设为 0.0,即“接受所有结果”。源码片段也印证了这一点:this.searchRequest.similarityThreshold = 0.0;。所以它不是“比 0.0 更特别的模式”,而只是一个更可读的方法名。使用它的价值主要在于表达意图:你不是“凑巧写了个 0.0”,而是在明确声明“关闭相似度阈值过滤”。在代码审阅中,这种可读性是有价值的。

5)filterExpression(Filter.Expression expression)

这是“程序化 DSL”入口。它接收一个 Filter.Expression 抽象语法树对象,代表元数据过滤条件。Javadoc 特别强调:null 表示不应用任何元数据过滤;这种表达式是 portable across all vector stores 的。也就是说,它不是 Redis 专用、PgVector 专用或 Elasticsearch 专用,而是 Spring AI 定义的一套可移植中间表达。后续不同 VectorStore 再把它转换为自己的本地方言。

这个方法适合哪些场景?是:你的过滤条件不是用户直接输入的,而是由代码动态拼接出来的;或者你想避免字符串解析错误,希望把字段名、比较操作、列表值等都以 Java 对象方式组装。对于框架开发、复杂条件拼装、单元测试可控性来说,这个入口通常比字符串更稳。

6)filterExpression(String textExpression)

这是“文本 DSL”入口。Javadoc 说明它接受一种 声明式、可移植、类 SQL 的过滤语法,例如 country == 'UK' && year >= 2020 && isActive == true,或 country == 'BG' && (city NOT IN ['Sofia', 'Plovdiv'] || price < 134.34)。参考文档源码显示,它内部通过 new FilterExpressionTextParser().parse(textExpression) 把字符串解析成 Filter.Expression;如果传入 null,则表示无过滤。

这个重载最大的优点是简洁、直观,尤其适合配置式、运行时动态传参、或者直接从用户侧/业务侧传一个过滤字符串的场景。它的本质并不是“跳过了 AST”,而是 把 AST 的构造工作交给解析器。因此,字符串 DSL 和对象 DSL 在表达能力上不是两个体系,而是通往同一个 Filter.Expression 中间表示的两条路径。明白这一点后,你就不会误以为“字符串方式更底层”或“对象方式更高级”;它们只是不同入口。

7)build()

从表面上看,build() 就是“返回 SearchRequest 实例”。但如果你只停留在这个层面,就会漏掉一个非常重要的源码语义。参考文档源码写得很清楚:Builder 内部有一个 private final SearchRequest searchRequest = new SearchRequest();,而 build() 直接 return this.searchRequest;。这意味着 Builder 并不会在 build() 时复制出一个新对象,它返回的是自己内部一直持有的那一个对象

因此可以做出一个很重要的推断:如果你复用同一个 Builder,多次修改再多次 build(),得到的很可能是同一个 SearchRequest 对象引用。 例如下面这种写法,在当前实现语义下就要非常谨慎:

SearchRequest.Builder builder = SearchRequest.builder().query("Spring AI");
SearchRequest r1 = builder.build();
SearchRequest r2 = builder.topK(10).build();

从当前源码实现可以合理推断,r1r2 不是“两份独立快照”,而是 Builder 内部同一对象在不同时间点的返回结果。也正因为如此,最稳妥的使用方式是:Builder 构建完一个请求后就不再复用;如果要改造已有请求,优先使用 SearchRequest.from(existing) 重新起一个新 Builder。 这是读源码后才能真正掌握的使用边界。


四、filterExpression 背后的完整体系:不是一个字符串参数,而是一整套可移植过滤语言

如果说 query/topK/threshold 比较直观,那么 filterExpression 的理解深度,基本决定你是否真正读懂了 SearchRequest。因为它背后不是“随便传个 where 字符串”,而是一整套官方定义的 跨向量库元数据过滤表达式体系。包级文档明确说明,Filter 子包提供元数据过滤能力;其中包括 FilterFilterExpressionBuilderFilterExpressionTextParserFilterExpressionConverter 等角色。也就是说,SearchRequest 只是这个过滤体系的消费入口,真正的表达、解析、转换工作由 filter 子包承担。

1)抽象语法树:Filter

官方 Filter Javadoc 说明,它是一个“可移植的运行时生成式”,用于定义 store-agnostic 的过滤表达式,后续再转换为具体向量库的原生表达式。它支持常量比较(== != < <= > >=)、IN/NIN 检查,以及 AND/OR 组合;示例里还展示了分组 Group。在对象模型上,Filter 涉及 ExpressionKeyValueGroup 这些成分:Key 表示元数据字段名,Value 表示常量或常量数组,Expression 表示“左操作数—操作符—右操作数”的表达式节点,Group 表示括号分组。

这意味着当你写:

new Expression(EQ, new Key("country"), new Value("BG"))

你并不是在写某个数据库的原生查询,而是在构建 Spring AI 自己的中间表达树。这个设计非常像编译器前端:先有一棵通用 AST,再由后端翻译成各数据库方言。也正因为如此,Spring AI 才能在 SearchRequest 中承诺“过滤表达式可移植”。这里的“可移植”不是说所有库底层语法一样,而是说 调用者写出来的是统一表达,转换器负责适配差异

2)程序化 DSL:FilterExpressionBuilder

直接手写 new Expression(...) 太底层,所以 Spring AI 提供了 FilterExpressionBuilder。官方 Javadoc 列出的 DSL 方法非常完整:eqnegtgteltlteinninandornotgroup,另外还有 isNullisNotNull。这组方法已经足以覆盖绝大多数元数据过滤场景。示例中也展示了从简单条件到复杂组合条件的多种写法。

比如下面这种写法:

FilterExpressionBuilder b = new FilterExpressionBuilder();

Filter.Expression exp = b.and(
        b.eq("type", "article"),
        b.and(
                b.gte("year", 2023),
                b.in("region", "CN", "JP", "SG")
        )
).build();

它的价值不在于“比字符串长”,而在于 类型化、安全、便于程序拼装。当过滤条件由多个 if/else 分支、枚举、配置项、权限条件拼接出来时,这种对象式 DSL 往往更稳定,也更利于测试。你可以对中间步骤做抽象,也可以在测试里直接比较构造逻辑,而不必反复拼字符串。

3)文本 DSL:FilterExpressionTextParser

另一条路是文本表达式。FilterExpressionTextParser 的 Javadoc 说得很清楚:它负责把一种 vector-store agnostic、类 SQL 的过滤语言 解析成 Filter.Expression,并且这套语言由 ANTLR4 语法定义。官方示例展示了 ==>=&&||INNOT IN、括号分组、布尔值、整数、浮点数等写法。例如:

var parser = new FilterExpressionTextParser();
var exp = parser.parse("country == 'UK' && isActive == true && year >= 2020");

这说明字符串 DSL 并不是“弱版本”,它背后同样会得到正式的表达式树。

文本 DSL 的最佳使用场景,一般是 配置驱动运行时动态注入。例如上层应用允许租户管理员配置过滤条件、RAG 请求按上下文动态追加过滤字符串、Advisor 参数在调用时传入过滤表达式等。在这些场景里,字符串入口比对象 DSL 更自然,因为条件来源本身就是文本。此时 SearchRequest.filterExpression(String) 的价值就非常高:它让“可配置过滤”成为一等能力,而不是让业务层去手写解析器。

4)转换器与多向量库落地

过滤体系真正厉害的地方,不是“你能写出条件”,而是 写出来的条件能跨库落地。官方 Filter.Expression 的使用页列出了大量实现:Elasticsearch、MariaDB、Milvus、MongoDB Atlas、Neo4j、OpenSearch、Oracle、PgVector、Pinecone、Qdrant、Redis、Typesense 等都接收 Filter.Expression;同时还有对应的 FilterExpressionConverter 实现,把统一 AST 转换成各自的原生表示。包级文档也明确说,FilterExpressionConverter 的职责就是把通用表达转换为 VectorStore 特定语言。

这也是为什么 SearchRequestfilterExpression(Filter.Expression) 这么关键:它不是单纯保存一棵树,而是在承接一个 跨后端的查询中间层协议。从架构设计上看,这非常像 Spring Data 的查询抽象思路:调用方写统一条件,底层模块自己把条件翻译成 Redis/PG/ES/Mongo 各自理解的形式。理解这一层后,你就会发现 SearchRequest 的“难点”其实不在 Java 语法,而在 抽象边界设计


五、执行语义:一个 SearchRequest 发出去以后,真正发生了什么

很多人读 SearchRequest 只停留在“字段 + Builder”,但真正理解它,必须知道它在执行期怎么被消费。先看官方接口契约:VectorStoreRetriever.similaritySearch(SearchRequest request) 的说明是,“通过查询向量相似度和元数据过滤来检索文档,以获得满足请求条件的最近邻结果”。而 AbstractObservationVectorStore 的 Javadoc 又表明,具体实现类会通过 doSimilaritySearch(SearchRequest request) 承接真正搜索逻辑。也就是说,SearchRequest 是从统一接口一路传到底层实现的原始检索描述,不会在中途被拆成零散参数传递。

在抽象层面,这个过程大致可以分成四步。第一步,读取 query,交给 EmbeddingModel 生成查询向量;AbstractObservationVectorStore 本身就持有 embeddingModel。第二步,结合 topKfilterExpression 发起向量检索,由具体 VectorStore 把通用表达转换成后端原生查询。第三步,取回候选 Document 集合。第四步,如果设置了 similarityThreshold,再由 Spring AI 在客户端把低于阈值的结果过滤掉。之所以能这么判断,是因为官方已经明确写出 threshold 是客户端后处理,而 doSimilaritySearch 又是具体后端真正执行搜索的扩展点。

这就带来一个非常关键的理解:SearchRequest 里的几个参数并不是都在同一个阶段生效。query 决定查询向量,filterExpression 参与候选集约束,topK 控制候选规模,similarityThreshold 则是候选返回后的再筛选。 所以它不是一个“所有字段都一起交给数据库”的平铺对象。它更像一个多阶段检索流程的统一配置载体。这个认识能解释很多表面看起来矛盾的现象。

例子就是:为什么有时我设置了 topK(10),最后却只拿到 3 条? 如果你只从接口文案“取最近邻结果”出发,可能会觉得奇怪;但一旦你知道 similarityThreshold 是客户端后处理,这就完全合理了。底层先按向量相似度拿回一批候选,随后客户端再把低于阈值的裁掉,最后剩下的条数当然可能少于 topK。因此,topK 更准确地说是“候选上限”,而不是“最终一定返回的条数承诺”。这是结合接口契约与阈值实现语义得出的正确理解。

再比如 filterExpression。很多人会把它和 similarityThreshold 统称为“过滤”,但它们不是一回事。filterExpression 过滤的是 文档元数据维度,例如文档类型、年份、租户、地域、状态等;similarityThreshold 过滤的是 向量相似度分数维度。前者是“这篇文档属于不属于搜索空间”,后者是“这篇文档和查询够不够像”。一个是语义范围控制,一个是相关度质量控制。它们共同存在时,先约束搜索空间,再做相关度裁剪,才是更合理的 mental model。

还要看到观察性(observability)这一层。SearchRequest 不只是业务参数,也会进入观察上下文。官方类使用页显示,VectorStoreObservationContext 可以 getQueryRequest(),其 Builder 也能接收 queryRequest(SearchRequest request)。这说明框架把 SearchRequest 视为需要被观测、记录、关联的检索上下文对象,而不只是临时局部变量。对排查检索效果、统计调用模式、观察 RAG 请求来说,这很有价值。

所以,真正准确的执行模型不是“构一个 SearchRequest,底层库自己全搞定”,而是:

SearchRequest
   ├─ query                -> 生成查询 embedding
   ├─ filterExpression     -> 限定候选文档空间
   ├─ topK                 -> 限定候选结果规模
   └─ similarityThreshold  -> 客户端最终裁剪

把这四层作用分开理解,你之后读任何 VectorStore 的 doSimilaritySearch(),就不会再混淆“谁在后端做,谁在客户端做,谁影响候选集,谁影响最终结果”。这恰恰是把 SearchRequest 讲“透”的关键。


六、围绕类/方法的典型用法:不是实战堆砌,而是帮助你建立正确手感

下面这部分用几段尽量简洁的代码,把上面的语义落到手上。注意重点不是“项目怎么搭”,而是 SearchRequest 怎样被正确表达和理解

1)最基础的检索请求

import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;

import java.util.List;

public class BasicSearchDemo {

    private final VectorStore vectorStore;

    public BasicSearchDemo(VectorStore vectorStore) {
        this.vectorStore = vectorStore;
    }

    public List<Document> searchSpringAiDocs() {
        SearchRequest request = SearchRequest.builder()
                .query("Spring AI 向量检索的基本原理")
                .topK(5)
                .build();

        return vectorStore.similaritySearch(request);
    }
}

这段代码只做了两件事:设置查询文本,设置候选条数。它对应的就是 SearchRequest 最基础的职责。这里没有设置 similarityThreshold,因此默认阈值仍然是 0.0;也没有设置 filterExpression,因此默认不做元数据过滤。换言之,这是一条“只按向量相似度检索,最多返回 5 条候选”的请求。

2)显式设置相似度阈值

SearchRequest request = SearchRequest.builder()
        .query("如何理解 SearchRequest 的设计")
        .topK(8)
        .similarityThreshold(0.78)
        .build();

这里最容易误解的点,不是 0.78 这个数字,而是它什么时候生效。按照官方 Javadoc,这个阈值不是服务端必须原生支持的后端条件,而是 Spring AI 客户端的后处理阈值。因此这段代码真正表达的是:“先要 8 个候选,再把相似度低于 0.78 的结果裁掉。” 这也解释了为什么你最终拿到的结果数未必是 8。

3)使用对象 DSL 构造过滤条件

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

public class FilterDslDemo {

    public SearchRequest buildRequest() {
        FilterExpressionBuilder b = new FilterExpressionBuilder();

        Filter.Expression filter = b.and(
                b.eq("docType", "manual"),
                b.and(
                        b.gte("year", 2024),
                        b.eq("published", true)
                )
        ).build();

        return SearchRequest.builder()
                .query("Spring AI SearchRequest builder ")
                .topK(6)
                .filterExpression(filter)
                .build();
    }
}

这段代码最适合帮助你理解 filterExpression(Filter.Expression) 的含义。你传进去的不是某个具体数据库的查询字符串,而是一棵由 Spring AI DSL 构造出来的通用表达式树。后端具体如何执行,是转换器和对应 VectorStore 的职责。换句话说,SearchRequest 在这里承接的不是“原生 where 子句”,而是“可移植的元数据过滤语义”。

4)使用文本 DSL 构造过滤条件

SearchRequest request = SearchRequest.builder()
        .query("向量数据库过滤表达式")
        .topK(6)
        .filterExpression("docType == 'manual' && year >= 2024 && published == true")
        .build();

这和上一段不是两套不同能力,而是同一能力的另一种入口。当前写法会被 FilterExpressionTextParser 解析成 Filter.Expression,然后再由底层 VectorStore 转换成各自原生表达。字符串方式更适合运行时传参、配置项驱动、或者从上层业务直接输入过滤条件的场景。程序化 DSL 更适合 Java 代码内部拼装。它们最终都回到 Filter.Expression 这层统一表示。

5)基于已有请求复制并调整

SearchRequest base = SearchRequest.builder()
        .query("Spring AI")
        .topK(4)
        .similarityThreshold(0.6)
        .build();

SearchRequest refined = SearchRequest.from(base)
        .topK(10)
        .similarityThreshold(0.8)
        .build();

这段代码体现的是 from() 的真正价值:不是重新手写所有字段,而是在已有请求基础上做 增量调整。尤其当你在多轮检索、AB 测试、不同召回策略之间切换时,这种写法很自然。注意 from() 返回的是 Builder,因此它天然适合“复制后修改”。这比复用原 Builder 安全得多,也更符合当前实现语义。

6)在 RAG/Advisor 中使用 SearchRequest

var qaAdvisor = QuestionAnswerAdvisor.builder(vectorStore)
        .searchRequest(SearchRequest.builder()
                .topK(6)
                .similarityThreshold(0.8d)
                .build())
        .build();

官方 RAG 文档明确给出了这种写法。这里 SearchRequest 的作用不是直接对 vectorStore.similaritySearch(...) 发请求,而是作为 QuestionAnswerAdvisor 的检索策略模板:规定默认阈值和返回条数。与此同时,官方还支持在运行时通过 FILTER_EXPRESSION advisor 参数动态更新过滤表达式。也就是说,在 RAG 链路里,SearchRequest 既可以作为 静态默认检索配置,也可以和 动态过滤参数 搭配使用。

7)运行时动态注入过滤表达式

String content = chatClient.prompt()
        .user("请回答我的问题")
        .advisors(a -> a.param(QuestionAnswerAdvisor.FILTER_EXPRESSION,
                "type == 'Spring'"))
        .call()
        .content();

这段代码的意义不在于 Advisor API 本身,而在于帮助你理解:SearchRequest 并不总是“请求时一次性写死”的静态对象。在 Spring AI 的 RAG 体系中,它可以与动态过滤表达式协同工作,形成“默认检索参数 + 运行时元数据约束”的模式。这就是为什么 SearchRequest.filterExpression(String) 的文本入口非常重要——因为它天然适合动态注入。


七、版本演进与迁移:为什么你会在旧资料里看到完全不同的 SearchRequest 写法

如果你查旧博客、旧 issue、早期示例,很容易看到这样的写法:

SearchRequest request = SearchRequest.defaults()
        .withTopK(4)
        .withSimilarityThreshold(0.7)
        .withFilterExpression("type == 'Spring'")
        .withQuery("Spring AI");

这不是别人写错了,而是 Spring AI 早期版本(如 0.7.1-SNAPSHOT)里的 SearchRequest API 确实是另一套风格。旧版 Javadoc 显示,当时它提供 defaults()query(String)from(SearchRequest) 这类静态工厂方法,以及 withQuerywithTopKwithSimilarityThresholdwithSimilarityThresholdAllwithFilterExpression(...) 这样的链式“with”方法。也就是说,早期的 SearchRequest 更像一个“自身可链式修改”的对象,而不是今天这种“显式分离出 Builder”的风格。

当前 1.1.x 版本则已经切换为更标准的 Builder 模式:SearchRequest.builder()SearchRequest.from(existing)builder.query(...).topK(...).build()。这背后的设计变化非常明显:把“请求对象本身”和“构建过程”分离开。从 API 可读性、一致性和后续扩展性上看,Builder 风格更符合现代 Java 库设计习惯。旧版并不是错,只是新版本更强调“构建时可变、构建后作为请求对象读取”的边界。

你可以这样理解这次迁移:

旧版风格:
SearchRequest.defaults()
    .withQuery(...)
    .withTopK(...)
    .withSimilarityThreshold(...)
    .withFilterExpression(...)

新版风格:
SearchRequest.builder()
    .query(...)
    .topK(...)
    .similarityThreshold(...)
    .filterExpression(...)
    .build()

表面看只是命名变化,实质上是 “请求对象即构建器” 变成了 “Builder 构建请求对象”。这会影响你对对象复用、副作用和代码组织方式的理解。新版 API 更容易让人意识到“构建阶段”和“使用阶段”是两回事。

再看 from()。旧版 from(SearchRequest) 直接返回 SearchRequest 本身,属于复制请求对象;新版 from(SearchRequest) 返回 SearchRequest.Builder,表示“基于旧请求起一个新的构建过程”。这其实比旧版更灵活,因为你很少只是“复制不改”,更多时候是“复制再调几个参数”。新版 API 直接顺着这个常见需求设计。

还有一个值得单独说的是 MilvusSearchRequest。它并不是早期遗留,而是当前版本下对 SearchRequest 的专门扩展。官方 Javadoc 明确指出,它增加了 nativeExpressionsearchParamsJson,并通过 milvusBuilder() 来构建。也就是说,Spring AI 的总体策略不是把所有后端能力都硬塞进 SearchRequest,而是保留通用抽象,同时在确有必要时让具体实现提供扩展请求类型。这种演进方向很合理:通用请求保证统一接口,扩展请求保证原生能力不被压扁。

因此,当你在不同资料里看到两套不同写法,不要简单判断“谁新谁旧”就结束了,最好能从设计思想上看懂变化:Spring AI 正在把 SearchRequest 从早期的链式配置对象,演进成一个更清晰的“统一检索请求 + Builder + 可选后端扩展”的模型。这个认知会让你在迁移代码时不只是“机械替换方法名”,而是真正理解为什么要这样迁。


八、最容易踩坑和最值得记住的细节:把这个类真正“吃透”的关键

1)topK 当前实现允许 0

这件事值得再强调一次,因为它是“读源码”和“只看方法名”会得到不同结论的典型例子。当前源码断言是 topK >= 0,虽然提示文案写的是 “TopK should be positive.”。因此,从实现约束看 0 是允许值。这不是让你业务里大量写 0,而是提醒你:不要把自己的外层封装写得比底层框架更“武断”,除非你有明确业务规则。

2)similarityThreshold 是客户端后处理,不是“数据库阈值开关”

只要这一点没搞清楚,你就很容易误读检索结果数量、性能表现和跨库一致性。similarityThreshold 的 Javadoc 写得非常直接:这是客户端后处理步骤。所以它更像“Spring AI 在结果集上再做一刀”,而不是保证所有向量库都以同一种原生机制执行阈值过滤。理解了这一点,你就不会再困惑“为什么 topK=10 最后只有 2 条”。

3)Builder 不要复用着玩

根据官方参考文档给出的源码,Builder 内部持有一个 final SearchRequestbuild() 直接把它返回出去,而不是构建新副本。因此,同一个 Builder 多次修改、多次 build,有对象复用/共享状态的风险。从当前实现语义出发,最稳妥的习惯就是:一个 Builder 只负责生成一个请求;要改现有请求,用 SearchRequest.from(existing) 起新 Builder。 这是一个非常实用的阅读源码结论。

4)filterExpression(String)filterExpression(Filter.Expression) 没有高低贵贱

很多开发者会把字符串入口看成“简化版”,把对象 DSL 看成“完整版”。这其实不对。文本入口会经过 FilterExpressionTextParser 解析成 Filter.Expression;对象入口则是你直接生成 Filter.Expression。它们最终落在同一层抽象上。真正的区别不在能力,而在 条件来源:如果条件源自用户/配置/运行时上下文,字符串更自然;如果条件源自 Java 代码拼装,DSL 更稳。

5)public 无参构造器存在,但官方推荐你用 builder()

Javadoc 顶部已经给了清晰建议:用 builder() 创建实例。再结合字段私有、缺少外部 setter、以及当前 API 风格,可以合理推断:无参构造器更多是为了对象模型完整性、默认请求场景或框架内部使用而保留;面向业务代码的主路径仍然是 Builder。 换言之,不是“不能 new SearchRequest()”,而是“那通常不是你真正想要的构造姿势”。这是对公开 API 设计的合理解读。

6)SearchRequest 的本质不是“参数袋”,而是“统一检索意图”

这是全文最值得记住的一句话。它统一封装了:查询文本、候选规模、相似度质量门槛、元数据范围约束,并能在 VectorStore、Advisor、Observation 等多个层面被消费。它既服务于直接检索,也服务于 RAG;既可移植到多种后端,又允许像 Milvus 这样做受控扩展。你只有把它当成 检索意图对象,而不是普通 Java Bean,才能真正理解为什么它的 API 会设计成今天这个样子。


结语

如果把 Spring AI 的向量检索层压缩成一句话,那就是:Document 代表“库里有什么”,SearchRequest 代表“这次想怎么取”。 SearchRequest 本身代码量不大,但它背后连着 Builder 设计、过滤表达式 AST、文本 DSL 解析、跨向量库转换、客户端阈值裁剪、RAG Advisor 集成、可观测性上下文,以及具体后端扩展模型。正因为它小而关键,所以很值得系统拆开理解。

你真正应该带走的,不只是几个方法名,而是这套完整认识:

  1. 它是检索请求对象,不是写入对象。
  2. Builder 是主入口,from() 是复制后调整的正统方式。
  3. topK 管候选规模,filterExpression 管搜索空间,similarityThreshold 管最终裁剪。
  4. 过滤体系是完整抽象,不只是一个字符串参数。
  5. 当前源码下,Builder 复用与 topK=0 都是需要读源码才能真正掌握的细节。
  6. 在 RAG 场景里,它不是边缘工具,而是默认检索策略的核心承载体。

下面继续,作为第二部分,重点把 SearchRequest 周围那一圈真正决定其语义的相关类型讲透:FilterFilterExpressionBuilderFilterExpressionTextParser、转换器体系、MilvusSearchRequest,以及它和 QuestionAnswerAdvisor / RAG 的关系。


九、SearchRequest 不是孤立类:它所依赖的相关类型全景图

如果把 SearchRequest 单独拿出来看,很容易只看到四个输入:querytopKsimilarityThresholdfilterExpression。但从 Spring AI 当前 API 结构看,这个类真正有意思的地方在于:它不是“自己解决一切”的类,而是站在一个完整检索抽象体系的中心org.springframework.ai.vectorstore 包把 SearchRequest 定义为相似度搜索参数的配置对象;而 org.springframework.ai.vectorstore.filter 子包则专门提供元数据过滤表达式的建模、解析与转换能力。也就是说,SearchRequest 负责承载检索意图,而 filter 子包负责表达和转换过滤意图

从当前官方 API 可见,SearchRequestfilterExpression 字段类型不是某个数据库的原生查询对象,而是 Filter.Expression。与此同时,SearchRequest.Builder 同时提供了两个入口:一个接收 Filter.Expression,另一个接收文本字符串,然后内部用 FilterExpressionTextParser 把字符串解析成 Filter.Expression。这表明 Spring AI 在设计上明确选择了“统一中间表达”路线:无论你是用 Java DSL 构造条件,还是用 SQL-like 文本写条件,最后都会落到同一棵表达式树上。

如果继续沿着这条链往下看,还能看到第三层:FilterExpressionConverter。filter 包摘要明确写到,它的职责是把通用、可移植的 Filter.Expression 转成具体 VectorStore 所支持的表达式语言。Filter.Expression 的使用页也显示,Azure、Elasticsearch、MariaDB、Milvus、MongoDB Atlas、Neo4j、OpenSearch、Oracle、PgVector、Pinecone、Qdrant、Redis、Typesense、Weaviate 等实现都围绕这个类型提供了相应的 doExpression(...) 转换逻辑。换句话说,SearchRequest 并不自己理解“过滤字符串怎么在 Redis 或 PgVector 上执行”,它只承接通用表达;真正的“翻译”工作在转换器和各个 VectorStore 实现里完成。

再往应用层看,Filter.Expression 也不只被 SearchRequest 使用。当前 API 使用页显示,它同样被 QuestionAnswerAdvisorVectorStoreDocumentRetriever.BuilderVectorStore.delete(Filter.Expression)、以及各类向量库实现所使用。这说明 Filter.Expression 不只是“SearchRequest 的内部细节”,而是 Spring AI 检索与删除等文档选择操作的统一条件语言。因此你理解 SearchRequest.filterExpression(...),实际上也是在理解 Spring AI 整体的“文档筛选语义”。

这个体系可以概括成下面这样:

Java 代码 / 配置 / 运行时参数
        │
        ├─ FilterExpressionBuilder  —— 程序化 DSL
        ├─ FilterExpressionTextParser —— 文本 DSL 解析
        ▼
        Filter.Expression
        │
        ▼
     SearchRequest.filterExpression(...)
        │
        ▼
FilterExpressionConverter / 各 VectorStore 实现
        │
        ▼
后端原生过滤表达式

这个图的意义在于帮助你建立一个正确前提:SearchRequest 的难点不在“它有一个 filterExpression 字段”,而在“这个字段背后站着一套可移植的过滤抽象”。 如果你只把它当字符串参数,就会低估它;如果你把它看成“统一检索协议的一部分”,就能更准确地理解 Spring AI 为什么能在多种向量数据库之间维持一致的调用方式。


十、Filter 族:SearchRequest.filterExpression 背后的抽象语法树

Filter 这个类在当前 API 中的官方定位非常明确:它是可移植的运行时元数据过滤表达式模型,用于定义 store-agnostic 的过滤条件,之后再由具体向量库转成其原生表达式。官方描述还明确列出了它支持的能力:常量比较(== != < <= > >=)、IN/NON-IN 检查,以及 AND/OR 的复合表达。这个定义非常重要,因为它说明 Spring AI 的过滤不是“某个库特有的小技巧”,而是一种平台级的中间语言

10.1 Filter.Expression:真正的表达式节点

在 filter 包摘要里,Filter.Expression 被定义为一个 record,表示 left type right 形式的三元表达。它实现了 Filter.Operand。这意味着一条过滤条件在抽象层上就是一个节点:左边是操作数,右边也是操作数,中间是操作类型。由于左、右本身也可以是表达式或其他操作数,所以表达式天然可以递归嵌套。比如 (A AND B) 的左右就是两个子表达式;country == 'UK' 的左边是 Key,右边是 Value

官方 Filter 页面给出的示例正是这样构造的:
country == "BG" 会被表达为 new Expression(EQ, new Key("country"), new Value("BG"))
genre == "drama" AND year >= 2020 会被表达为一个 AND 表达式,其左右分别是两个比较表达式;
genre in [...] 则是 IN 表达式;
更复杂的 year >= 2020 OR country == "BG" AND city != "Sofia" 则嵌套了 ORAND。这说明 Filter.Expression 本质上就是一棵布尔表达式树的节点类型。

从设计上看,这种表达式树的价值非常大。因为一旦表达式已经变成结构化对象,后续无论是打印、转换、组合、扩展,都会比直接处理字符串容易得多。你可以把它类比成 SQL 解析后的 AST,或编译器前端把源代码转出来的语法树。Spring AI 在这里做的事情,本质就是:先把“过滤条件”从数据库方言提升到统一结构,再让后端实现负责“降落”到自己的方言。 这正是可移植性的基础。

10.2 Filter.KeyFilter.ValueFilter.GroupFilter.Operand

filter 包摘要把 Filter.Key 描述为“字符串标识的表达式键”,把 Filter.Value 描述为“表达式值常量或常量数组”,把 Filter.Group 描述为“分组(例如括号)”,同时 Filter.Operand 是一个标记接口,统一代表 KeyValueExpressionGroup 这些支持的操作数类型。这个设计很精炼,但含义很深。

Key 的语义很直白:它代表元数据字段名,例如 countryyeartypetenantId。之所以单独建模成一个类型,而不是直接拿字符串 everywhere 用,原因在于它在表达式树里有“字段引用”的语义,而不是纯文本。Value 则代表字面量值,它可以是单个常量,也可以是数组,这正对应了比较操作和 IN/NIN 操作的需要。比如 country == 'UK' 用单值;country IN ['UK','JP'] 用数组值。

Group 则是很多人第一次看容易忽略、但其实非常关键的组件。它代表显式分组,也就是括号语义。例如 (year >= 2020 OR country == 'BG') AND city != 'Sofia' 这类表达式中,如果没有 group,后续转换器可能只能依赖操作符优先级;而有了 group,表达式树本身就能精确保留调用者的分组意图。Filter.Group.content() 返回的是一个 Filter.Expression,这也印证了 group 是“把一棵子表达式树整体包起来”的节点。

Operand 的意义则体现在抽象统一性上。因为表达式左边右边不总是“字段-值”这种简单形式;在逻辑组合时,左右可以都是表达式;在分组时,一个 group 也要能作为操作数参与更高层组合。于是 Spring AI 用 Operand 把这几类东西统一起来。这样 Expression(type,left,right) 的建模才能通用而不失类型约束。这不是“为了面向对象而面向对象”,而是为了让整个过滤语义可以递归表达。

10.3 Filter.ExpressionType:运算符集合不是随意拼出来的

filter 包摘要还给出了 Filter.ExpressionType,它是“过滤表达式操作”的枚举。结合 Filter 页面、FilterExpressionBuilder 页面和参考文档,可以确认当前体系至少覆盖:相等、不等、大于、大于等于、小于、小于等于、AND、OR、IN、NIN、NOT、IS NULL、IS NOT NULL 等类别;而参考文档特别提醒,IS NULLIS NOT NULL 尚未在所有 vector store 中实现。这一条非常关键,因为它说明“表达式语言可写”不必然等于“所有后端都 100% 支持”。

这正体现了 Spring AI 过滤抽象的一个成熟之处:它并没有简单承诺“所有库能力完全一致”,而是把语言层和后端实现层分开。语言层允许你写 IS NULLIS NOT NULL,这是统一抽象的完整性;但参考文档同时提醒,有些后端实现还没有覆盖。这对框架使用者是很重要的信号:抽象存在,不等于实现覆盖度在所有 VectorStore 上完全相同。 如果你写的是跨库代码,这类差异要心里有数。

所以,Filter 族的正确理解不是“几个 record 和 enum 而已”,而是:它定义了 SearchRequest.filterExpression 所依赖的统一语义层。这个语义层比数据库方言更高,比字符串更结构化,比单个 store 更可移植。你读懂这层,才算真正读懂了 SearchRequest 为何能够在 Spring AI 中扮演跨库检索请求的统一入口。


十一、FilterExpressionBuilder :自然的过滤 DSL

如果说 Filter 是底层表达式树,那么 FilterExpressionBuilder 就是面向 Java 开发者的高层工具。官方 Javadoc 对它的定义非常直接:它是 Filter.Expression 的 DSL builder。示例中展示了从简单比较到组合逻辑、IN/NIN、group 分组等多种典型构造方式;并且还明确提到“这个 builder DSL 模仿常见的 Criteria Queries 语法”。也就是说,Spring AI 并不要求你手写 new Expression(...) 树,而是提供了一套更贴近日常 Java 代码编写习惯的 DSL。

11.1 方法全景:它提供了哪些能力

从当前方法摘要看,FilterExpressionBuilder 至少提供了这些入口:eqnegtgteltltein(key, Object...)in(key, List<Object>)nin(key, Object...)nin(key, List<Object>)andornotgroupisNullisNotNull。这些方法返回值不是直接的 Filter.Expression,而是 FilterExpressionBuilder.Op。而 Filter.Expression 的使用页显示,FilterExpressionBuilder.Op 提供 build(),可以最终生成 Filter.Expression

这个返回 Op 的设计很值得注意。它意味着 builder 的每一步并不是立刻固定成最终表达式,而是先得到一个“可继续参与更高层组合的 DSL 节点包装”。直到你调用 build(),才把它落成最终的 Filter.Expression。这种设计让如下写法成为可能:

FilterExpressionBuilder b = new FilterExpressionBuilder();

Filter.Expression exp = b.and(
        b.eq("type", "manual"),
        b.or(
                b.gte("year", 2024),
                b.eq("priority", "high")
        )
).build();

这里 eq/gte/or/and 都先返回 Op,最后由最外层 .build() 统一产出表达式树。这个 API 形态很像一些查询 DSL 或谓词 DSL,使用体验通常比直接 new 记录类舒服得多。

11.2 为什么程序化 DSL 值得用

很多人看到字符串 DSL 后会想:“既然可以直接写 type == 'manual' && year >= 2024,为什么还要用 builder?” 这就涉及两种入口各自的适用边界。程序化 DSL 最大的优势不在“更短”,而在 更适合由代码动态拼装。当过滤条件不是用户直接输入,而是由权限、租户、环境、业务开关、多个 if/else 分支共同决定时,builder 往往更安全。你不需要自己处理括号、引号、转义和运算符优先级,而是把这些交给 DSL 结构本身。

再比如你要写这样一种逻辑:
“如果是管理员,不加租户限制;否则必须 tenantId == 当前租户;如果是草稿模式,再额外加 status == 'draft';如果要求近一年内容,再加 year >= 2025。”
这类条件如果用字符串拼接,很容易出现括号缺失、逻辑短路错误或引号问题;而用 builder,你可以把每段条件先建成 Op,再组合成最终表达式。builder 的核心价值是结构性,而不是炫技。

还有一点是测试友好性。虽然官方文档没专门讲测试,但从 API 形态可以合理推断:结构化 DSL 更便于单元测试构建逻辑,因为你可以把条件拼装封装成方法,最后比较 build() 结果或者把它交给打印型转换器/对应 store 的转换器去观察输出。这个优势来自结构化对象天然比字符串更适合程序处理。这里属于基于 API 形态的合理工程推论。

11.3 典型方法逐个理解

先看比较类:eq/ne/gt/gte/lt/lte。它们都对应最基本的字段-值比较。比如 eq("country","UK") 表示 country == 'UK'gte("year",2020) 表示 year >= 2020。这些方法的存在,使你无需手工构造 new Expression(EQ, new Key("country"), new Value("UK"))。也就是说,它们是对底层 Filter.Expression 的语义包装,而不是新的运算语义。

再看集合类:innin 分别有可变参数版本和 List<Object> 版本。这一点很实用。可变参数版本适合静态少量值;List 版本适合运行时动态集合。比如从数据库查出一批允许地域后,再拼成过滤条件,这时 List 版本更自然。当前 API 方法摘要清楚列出了这两组重载。

逻辑类则包括 andornotgroup。其中 group 很容易被低估。很多人觉得既然表达式树已经有结构,group 是否多余?其实不然。group 的存在是为了显式保留调用者想要的括号优先级语义。例如 and(group(or(...)), nin(...)) 这种写法,就比单纯依赖默认优先级更明确,也更有利于后续转换器稳定输出正确方言表达。官方示例中就有 (year >= 2020 OR country == "BG") AND city NIN ["Sofia", "Plovdiv"] 这种 group 用法。

最后是 isNull/isNotNull。参考文档确认了这两个能力的存在,并提醒它们还没有在所有 vector store 中实现。所以对于写跨库代码的人来说,这两个方法要特别谨慎:它们在抽象层合法、在 DSL 层可写,但在具体后端上的支持度需看目标存储实现。

11.4 一个服务于讲解的完整示例

下面这段代码,目的是把 builder 真正“用出它的价值”,而不是只演示单个方法:

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

public class SearchRequestDslExample {

    public SearchRequest buildRequest(String tenantId, boolean draftOnly) {
        FilterExpressionBuilder b = new FilterExpressionBuilder();

        var tenantFilter = b.eq("tenantId", tenantId);
        var typeFilter = b.in("docType", "manual", "guide", "faq");
        var publishedFilter = draftOnly
                ? b.eq("status", "draft")
                : b.eq("published", true);

        Filter.Expression filter = b.and(
                tenantFilter,
                b.and(
                        typeFilter,
                        b.and(
                                publishedFilter,
                                b.gte("year", 2024)
                        )
                )
        ).build();

        return SearchRequest.builder()
                .query("SearchRequest filterExpression 详细原理")
                .topK(8)
                .similarityThreshold(0.75)
                .filterExpression(filter)
                .build();
    }
}

这段代码有三个讲解重点。第一,FilterExpressionBuilder 特别适合把多个业务子条件拆开再合并。第二,build() 的产物是 Filter.Expression,可以直接交给 SearchRequest.filterExpression(Filter.Expression)。第三,过滤条件和 query/topK/threshold 并列存在,但语义不同:前者限定元数据范围,后者控制语义检索行为。也正是在这种组合场景下,SearchRequest 的统一价值才最明显。


十二、FilterExpressionTextParser :文本 DSL、ANTLR 语法和运行时过滤字符串

如果你从配置、用户输入、运行时上下文或 Advisor 参数里拿到的是一个字符串,那么 Spring AI 提供的正统入口就是 FilterExpressionTextParser。当前 Javadoc 对它的定义很清楚:它用于把一种vector-store agnostic 的文本过滤语言解析成 Filter.Expression;这套语言由正式的 ANTLR4 grammar(Filters.g4 定义,并且“看起来和感觉上像 SQL WHERE 的一个子集”。这不是随便写的说明,它实际上告诉你三件事:第一,这门语言是正式语法,不是 ad-hoc 字符串拼接;第二,它是通用的,不绑定某个库;第三,它故意选择了一种大多数后端开发者都容易理解的 SQL-like 风格。

12.1 这门文本语言到底支持什么

当前官方示例展示得很丰富:
country == 'BG'
genre == 'drama' && year >= 2020
genre in ['comedy', 'documentary', 'drama']
year >= 2020 || country == 'BG' && city != 'Sofia'
(year >= 2020 || country == "BG") && city NOT IN ['Sofia', "Plovdiv"]
isOpen == true && year >= 2020 && country IN ['BG', 'NL', 'US']
price >= 15.6 && price <= 20.13
这些例子说明它支持字符串、整数、浮点数、布尔值、列表常量、括号分组、逻辑与/或、比较操作以及 IN/NOT IN

参考文档进一步总结了操作符集合:
比较操作包括 ==>>=<<=!=
逻辑组合支持 AND/and/&&OR/or/||
还支持 IN/inNIN/ninNOT/notIS NULLIS NOT NULL。同时文档提醒,IS NULLIS NOT NULL 还没有被所有 vector store 实现。这里要特别注意:文本 DSL 层面“能写”,不等于所有后端都“能执行得一致”。

这个语言设计对 SearchRequest 很重要,因为 SearchRequest.Builder.filterExpression(String) 并不只是“存下字符串”。官方 Builder 文档明确写到:文本 filter syntax 是可移植的,FilterExpressionTextParser 会把文本表达式转换成 Filter.Expression。这意味着字符串入口并没有绕过统一抽象层,而只是把构造表达式树的工作从调用者转移给了解析器

12.2 解析器本身的类型结构

FilterExpressionTextParser 当前公开 API 并不复杂,但每个点都很有意义。Javadoc 显示它有两个构造器:无参构造器,以及接收 org.antlr.v4.runtime.ANTLRErrorStrategy 的构造器;公开方法包括 parse(String textFilterExpression)clearCache();嵌套类型包括 DescriptiveErrorListenerFilterExpressionParseExceptionFilterExpressionVisitor。filter 包层次树还显示:FilterExpressionVisitor 继承自 ANTLR 生成的 FiltersBaseVisitorDescriptiveErrorListener 继承自 BaseErrorListenerFilterExpressionParseException 继承自 RuntimeException。这几条合起来,已经把它的工作机制勾勒得很清楚了。

这说明 FilterExpressionTextParser 的内部思路并不是“正则切一切”,而是标准的 ANTLR 词法/语法分析 + Visitor 构树。这对于理解其可靠性和可维护性非常重要。因为一旦语言是 formal grammar 驱动的,就意味着:括号优先级、关键字、标识符、字面量、布尔表达式等都不是靠手工字符串规则硬凑出来的,而是由正式语法定义和 Visitor 逻辑驱动。你在生产代码里依赖它时,可以把它看成一门小型查询语言的正规解析器。

clearCache() 这个方法也值得注意。Javadoc 没有展开细节,但既然公开暴露了 clearCache(),至少可以确认解析器内部存在某种缓存行为或可清理状态。由于当前公开文档没有进一步说明缓存范围或策略,最稳妥的说法是:Spring AI 允许你显式清理解析器缓存/内部状态,但文档未在当前页面展开其内部实现细节。 这类点应当谨慎表述,不夸大。

12.3 文本 DSL 最适合哪些场景

文本 DSL 的第一类强场景,是运行时动态过滤。RAG 文档直接给出了例子:QuestionAnswerAdvisor 可以在默认 SearchRequest 基础上,通过 advisor 上下文参数 FILTER_EXPRESSION 在运行时更新过滤表达式。这里最自然的输入形式就是字符串,因为它来自请求上下文,而不是 Java 代码编译期。Spring AI 正是靠 SearchRequest.filterExpression(String)FilterExpressionTextParser 才把这条路径打通。

第二类强场景,是配置式过滤。比如某个租户在配置中心写了一条“知识库检索只看 type == 'faq' && published == true”;或者业务后台让管理员用表单/脚本配置文档筛选规则。这些输入天然是文本,没必要为了“程序化”再先拼成 DSL 对象。文本 DSL 的优势就在于它让“过滤规则本身成为配置”变得很自然。这个结论是基于当前 API 用途和 Advisor 动态参数示例的合理工程延伸。

第三类强场景,是跨边界传递过滤语义。当一个上层模块并不直接依赖 FilterExpressionBuilder,但它需要把过滤条件传给下游检索模块时,字符串是一种更轻量的边界表示。下游模块再用 FilterExpressionTextParser 转成 Filter.Expression 即可。也就是说,字符串 DSL 是更适合“模块之间以文本方式携带过滤意图”的格式。

12.4 一个使用示例

import org.springframework.ai.vectorstore.SearchRequest;

public class TextFilterExample {

    public SearchRequest request() {
        return SearchRequest.builder()
                .query("Spring AI 过滤表达式")
                .topK(10)
                .similarityThreshold(0.7)
                .filterExpression("""
                    docType == 'manual'
                    && published == true
                    && (year >= 2024 || priority == 'high')
                """)
                .build();
    }
}

这段代码的重点不是语法本身,而是它体现了 SearchRequest.Builder.filterExpression(String) 的真实工作方式:调用者只负责表达过滤意图;字符串会被解析器转成 Filter.Expression;后续再交给具体 VectorStore 转换和执行。你可以把它理解成“把过滤规则当数据传入”,这在配置化和运行时控制上非常有优势。


十三、转换器体系与可移植性:为什么 SearchRequest 的过滤可以“跨库说同一种话”

SearchRequest.filterExpression(...) 真正厉害的地方,不在于它能存一棵树,而在于这棵树能被不同向量库各自翻译。filter 包摘要明确给出 FilterExpressionConverter 的职责:把一个通用、可移植的 Filter.Expression 转成特定 VectorStore 的表达式语言格式。Filter.Expression 使用页则进一步列出了大量实际消费者:Azure、Elasticsearch、GemFire、MariaDB、Milvus、MongoDB Atlas、Neo4j、OpenSearch、Oracle、Pinecone 等都提供了相应的 doExpression(...) 处理逻辑。

这说明 Spring AI 在过滤能力上采用的是典型的“两阶段架构”:

第一阶段:统一表达。
调用者只生产 Filter.Expression,不碰后端方言。

第二阶段:后端转换。
由对应 FilterExpressionConverter / VectorStore 实现把 Filter.Expression 转成原生表达。

这种架构的直接收益,就是上层业务代码不必知道后端是 Redis 还是 PgVector、是 Milvus 还是 Elasticsearch。对上层来说,SearchRequest 的过滤语义始终一致。

还有一个很容易忽略、但特别能体现体系成熟度的点:Filter.Expression 不只用于搜索,也用于删除。当前使用页显示,VectorStore.delete(Filter.Expression) 也使用这套表达式类型。也就是说,Spring AI 不是把 Filter.Expression 做成“检索专用一次性结构”,而是把它提升成了文档选择条件的统一模型。这会让整个框架在 API 设计上更一致:搜索和删除都基于同一套条件语言。

另外,FilterHelper 也出现在当前使用页中,并暴露 expandIn(...)expandNin(...) 等能力,官方描述它们会把 IN 展开成语义等价的多个 OR + EQ,把 NIN 做对应展开。这至少说明 Spring AI 内部在某些转换过程中并不是机械直译,而会进行布尔等价变换,以适配目标存储的表达能力差异。对使用者来说,这进一步解释了“为什么我能写同一种抽象表达式,但后端实现仍然能各自落地”。

不过这里必须保持一个专业而克制的判断:可移植不等于所有能力、所有边界、所有实现覆盖度都完全一致。 官方参考文档已经明确提示过,IS NULL / IS NOT NULL 还未被所有 vector store 实现。因此,正确理解 Spring AI 的可移植性应当是:
它提供了一套统一过滤抽象和转换框架;
常见操作在多种向量库上可共用;
但某些特性仍可能因后端支持度不同而存在差异。

这也正是 SearchRequest 值得重视的地方。它的 filterExpression 并不是“一个简单的 where 参数”,而是整套跨库检索条件协议的入口。你在业务代码中写下的,是通用过滤语义;Spring AI 替你承担了大部分方言适配成本。这就是它在抽象层面的真正价值。


十四、MilvusSearchRequest :为什么 SearchRequest 允许特定后端做受控扩展

当前 SearchRequest 的直接已知子类只有一个:MilvusSearchRequest。官方 Javadoc 对它的定位是:一个面向 Milvus 向量搜索的专门化 SearchRequest,在基础请求之上引入 Milvus 专用参数。新增的字段有两个:nativeExpression,表示原生 Milvus 过滤表达式;searchParamsJson,表示 JSON 编码的搜索参数,例如 {"nprobe":128}。并且官方要求使用 MilvusSearchRequest.MilvusBuilder 来构造。

这一设计很值得单独讨论。因为它回答了一个架构层面的问题:
既然 Spring AI 已经有通用 SearchRequest,为什么还允许专门子类?
答案是:通用抽象和原生能力之间需要平衡。

如果把所有 VectorStore 的原生参数都硬塞进 SearchRequest,那这个类会迅速膨胀,变成一个混杂各种后端私货的大对象;可如果完全不允许扩展,又会逼得使用者放弃 Spring AI 抽象,直接绕到底层客户端去用。MilvusSearchRequest 恰恰代表 Spring AI 选择了一个折中方案:大多数场景走统一抽象;确实需要后端特性时,提供受控扩展。

14.1 它保留了哪些基类能力

MilvusSearchRequest 的类页面可以看到,它继承自 SearchRequest,因此天然继承了 getQuery()getTopK()getSimilarityThreshold()getFilterExpression()hasFilterExpression()builder()from()equals()hashCode()toString() 等基础能力。换句话说,Milvus 的扩展不是“另起炉灶做一套完全不同的请求模型”,而是在通用检索语义之上加了 Milvus 专属能力。这对于 API 一致性非常重要。

MilvusSearchRequest.MilvusBuilder 也体现了这种思路。当前方法页显示,它依然提供 query(...)topK(...)similarityThreshold(...)similarityThresholdAll()filterExpression(String)filterExpression(Filter.Expression),也就是说,基础 SearchRequest.Builder 的主要能力都被沿用;只是额外新增了 nativeExpression(String)searchParamsJson(String)。因此你可以把它理解为“带 Milvus 增强项的 SearchRequest.Builder”。(Home)

14.2 nativeExpressionfilterExpression 的区别

这是使用 MilvusSearchRequest 时最值得分清的一点。filterExpression(...) 仍然走 Spring AI 的统一抽象:

  • 你可以传 Filter.Expression
  • 也可以传可移植的文本 DSL;
  • 这些最终都属于 Spring AI 的通用过滤语义。

nativeExpression(...) 则是直接写 Milvus 原生过滤表达式,官方示例给出的形态是类似 "city LIKE 'New%'"。这说明 nativeExpression 已经不再追求“跨库可移植”,而是明确选择“我要用 Milvus 自己的语言”。

因此二者的本质区别在于:

  • filterExpression:站在 Spring AI 抽象层,强调统一与可移植。
  • nativeExpression:站在 Milvus 原生能力层,强调特性利用与后端定制。

这两者不是谁替代谁,而是面向不同诉求。你写的是跨库业务代码,优先考虑 filterExpression;你要吃到 Milvus 特定语法或功能,才考虑 nativeExpression。这是理解 MilvusSearchRequest 的关键。

14.3 searchParamsJson 的含义

官方 Javadoc 把 searchParamsJson 定义为“JSON 编码的搜索参数”,示例值为 {"nprobe":128}。这说明 Milvus 某些检索调优参数并不适合纳入通用 SearchRequest 字段,因为它们明显带有后端专属性。Spring AI 在这里没有试图把这类参数“抽象成所有后端都通用的字段”,而是原样以 JSON 字符串让使用者传入。这种做法很务实。

同时,MilvusBuilder.build() 的 Javadoc 写的是“Builds and returns a new MilvusSearchRequest instance”。这点和基础 SearchRequest.Builder.build() 在参考文档源码里表现出的“直接返回内部持有对象”语义不同,因此在理解两个 builder 的行为时不要混为一谈:基础 Builder 的内部实现细节需要格外谨慎,而 MilvusBuilder 的 Javadoc 明确强调返回新对象。 这是当前文档层面能确认的区别。

14.4 一个最有代表性的示例

import org.springframework.ai.vectorstore.milvus.MilvusSearchRequest;

public class MilvusSearchRequestExample {

    public MilvusSearchRequest request() {
        return MilvusSearchRequest.milvusBuilder()
                .query("Milvus native expression and search params")
                .topK(12)
                .similarityThreshold(0.72)
                .filterExpression("docType == 'manual' && published == true")
                .nativeExpression("city LIKE 'New%'")
                .searchParamsJson("{\"nprobe\":128}")
                .build();
    }
}

这段代码的意义不在于推荐把通用过滤和原生过滤混在一起长期使用,而在于帮助你看清 MilvusSearchRequest 的定位:它仍然保留 SearchRequest 的共性部分,但允许你在需要时附加 Milvus 专用能力。也就是说,它不是推翻统一抽象,而是对统一抽象做“后端特化增强”


十五、SearchRequestQuestionAnswerAdvisor / RAG 中的真实地位

很多开发者会误以为 SearchRequest 只是你手工调用 vectorStore.similaritySearch(...) 时才需要关心的类。当前 Spring AI 文档明确表明这并不对。ChatClient 文档说明,QuestionAnswerAdvisor 是用来做 RAG 的 advisor,会把与用户文本相关的上下文追加到 prompt 中;而 RAG 文档则进一步指出,QuestionAnswerAdvisor 在底层会对向量数据库做 similarity search,并且要限制搜索文档类型时,使用的正是 SearchRequest 的 SQL-like 可移植过滤表达式。

更具体地说,RAG 文档给出了一个非常典型的配置方式:

var qaAdvisor = QuestionAnswerAdvisor.builder(vectorStore)
        .searchRequest(SearchRequest.builder()
                .similarityThreshold(0.8d)
                .topK(6)
                .build())
        .build();

官方同时解释,这样做时 QuestionAnswerAdvisor 会用 threshold 为 0.8、返回 top 6 结果的检索策略去做搜索。也就是说,在 RAG 里,SearchRequest 不再只是一次 API 调用的临时参数,而是Advisor 的默认检索策略对象

这一点非常关键。因为它意味着:
你对 SearchRequest 的理解,不只是“会不会写 builder”;
而是关系到你如何控制 RAG 的召回数量、相关度下限、以及文档筛选范围。

在很多实际系统里,RAG 效果的差异并不一定来自模型本身,而往往来自检索参数设置是否合理。SearchRequest 恰恰是 Spring AI 把这些参数集中表达出来的地方。

文档还给出了动态过滤表达式场景:先把 QuestionAnswerAdvisor 配置为 .searchRequest(SearchRequest.builder().build()),然后在某次具体调用中,通过 advisor 上下文参数 QuestionAnswerAdvisor.FILTER_EXPRESSION 注入例如 "type == 'Spring'" 的过滤条件。官方说明这个参数允许你在运行时动态过滤搜索结果。这个能力非常值得重视,因为它表明 SearchRequest 在 Advisor 体系中并非彻底静态,而是可以与调用上下文协作。

再从 Filter.Expression 使用页看,QuestionAnswerAdvisor 本身就有 doGetFilterExpression(Map<String,Object> context) 这样的受保护方法;而 VectorStoreDocumentRetriever.Builder 也支持 filterExpression(Filter.Expression) 以及 filterExpression(Supplier<Filter.Expression>)。这进一步说明 Spring AI 在更通用的 RAG 子模块里,也把过滤表达式视为一等可配置能力,而 SearchRequest 则是这一能力在向量检索层的标准承载对象。

因此,若要用一句话概括 SearchRequest 在 RAG 里的地位,那就是:

它既是底层向量检索的请求对象,也是上层检索增强策略的参数模型。

这也是为什么前文一直强调,不能把它看成一个“简单参数袋”。在 Spring AI 中,它其实承担着把应用层检索意图传递到向量存储层执行逻辑的关键桥梁角色。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐