Skip to content

Commit 2fa7f04

Browse files
committed
Initial sketch for unified hybrid search API
Signed-off-by: Soby Chacko <[email protected]>
1 parent 28f86bf commit 2fa7f04

File tree

8 files changed

+478
-63
lines changed

8 files changed

+478
-63
lines changed
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package org.springframework.ai.vectorstore;
2+
3+
import java.util.List;
4+
5+
import org.springframework.ai.document.Document;
6+
7+
/**
8+
* Defines a pluggable advisor for reranking search results in
9+
* {@link SearchMode#HYBRID_RERANKED} mode. Implementations refine the order and selection
10+
* of documents based on the search request.
11+
*/
12+
public interface RerankingAdvisor {
13+
14+
/**
15+
* Reranks the provided search results according to the search request.
16+
* @param results The initial list of documents to rerank.
17+
* @param request The search request containing query and mode information.
18+
* @return A reranked list of documents.
19+
*/
20+
List<Document> rerank(List<Document> results, SearchRequest request);
21+
22+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package org.springframework.ai.vectorstore;
2+
3+
/**
4+
* Enum defining the search modes supported by the {@link VectorStore} interface. Each
5+
* mode specifies a different type of search operation for querying documents.
6+
*/
7+
public enum SearchMode {
8+
9+
/**
10+
* Vector-based similarity search using embeddings (e.g., cosine similarity).
11+
*/
12+
VECTOR,
13+
/**
14+
* Keyword-based full-text search (e.g., TF-IDF or BM25).
15+
*/
16+
FULL_TEXT,
17+
/**
18+
* Hybrid search combining vector and full-text search (e.g., using rank fusion).
19+
*/
20+
HYBRID,
21+
/**
22+
* Hybrid search with additional reranking for enhanced relevance.
23+
*/
24+
HYBRID_RERANKED
25+
26+
}

spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/SearchRequest.java

Lines changed: 103 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,15 @@ public class SearchRequest {
5959
@Nullable
6060
private Filter.Expression filterExpression;
6161

62+
private SearchMode searchMode = SearchMode.VECTOR;
63+
64+
@Nullable
65+
private RerankingAdvisor rerankingAdvisor;
66+
67+
private double scoreThreshold = SIMILARITY_THRESHOLD_ACCEPT_ALL;
68+
69+
private int resultLimit = topK;
70+
6271
/**
6372
* Copy an existing {@link SearchRequest.Builder} instance.
6473
* @param originalSearchRequest {@link SearchRequest} instance to copy.
@@ -68,7 +77,11 @@ public static Builder from(SearchRequest originalSearchRequest) {
6877
return builder().query(originalSearchRequest.getQuery())
6978
.topK(originalSearchRequest.getTopK())
7079
.similarityThreshold(originalSearchRequest.getSimilarityThreshold())
71-
.filterExpression(originalSearchRequest.getFilterExpression());
80+
.filterExpression(originalSearchRequest.getFilterExpression())
81+
.searchMode(originalSearchRequest.getSearchMode())
82+
.rerankingAdvisor(originalSearchRequest.getRerankingAdvisor())
83+
.scoreThreshold(originalSearchRequest.getScoreThreshold())
84+
.resultLimit(originalSearchRequest.getResultLimit());
7285
}
7386

7487
public SearchRequest() {
@@ -79,6 +92,10 @@ protected SearchRequest(SearchRequest original) {
7992
this.topK = original.topK;
8093
this.similarityThreshold = original.similarityThreshold;
8194
this.filterExpression = original.filterExpression;
95+
this.searchMode = original.searchMode;
96+
this.rerankingAdvisor = original.rerankingAdvisor;
97+
this.scoreThreshold = original.scoreThreshold;
98+
this.resultLimit = original.resultLimit;
8299
}
83100

84101
public String getQuery() {
@@ -102,10 +119,45 @@ public boolean hasFilterExpression() {
102119
return this.filterExpression != null;
103120
}
104121

122+
/**
123+
* Returns the search mode.
124+
* @return The search mode.
125+
*/
126+
public SearchMode getSearchMode() {
127+
return this.searchMode;
128+
}
129+
130+
/**
131+
* Returns the reranking advisor.
132+
* @return The reranking advisor, or null if none set.
133+
*/
134+
@Nullable
135+
public RerankingAdvisor getRerankingAdvisor() {
136+
return this.rerankingAdvisor;
137+
}
138+
139+
/**
140+
* Returns the score threshold for filtering results.
141+
* @return The score threshold.
142+
*/
143+
public double getScoreThreshold() {
144+
return this.scoreThreshold;
145+
}
146+
147+
/**
148+
* Returns the maximum number of results to return.
149+
* @return The result limit.
150+
*/
151+
public int getResultLimit() {
152+
return this.resultLimit;
153+
}
154+
105155
@Override
106156
public String toString() {
107157
return "SearchRequest{" + "query='" + this.query + '\'' + ", topK=" + this.topK + ", similarityThreshold="
108-
+ this.similarityThreshold + ", filterExpression=" + this.filterExpression + '}';
158+
+ this.similarityThreshold + ", filterExpression=" + this.filterExpression + ", searchMode="
159+
+ this.searchMode + ", rerankingAdvisor=" + this.rerankingAdvisor + ", scoreThreshold="
160+
+ this.scoreThreshold + ", resultLimit=" + this.resultLimit + '}';
109161
}
110162

111163
@Override
@@ -118,13 +170,16 @@ public boolean equals(Object o) {
118170
}
119171
SearchRequest that = (SearchRequest) o;
120172
return this.topK == that.topK && Double.compare(that.similarityThreshold, this.similarityThreshold) == 0
173+
&& Double.compare(that.scoreThreshold, this.scoreThreshold) == 0 && this.resultLimit == that.resultLimit
121174
&& Objects.equals(this.query, that.query)
122-
&& Objects.equals(this.filterExpression, that.filterExpression);
175+
&& Objects.equals(this.filterExpression, that.filterExpression) && this.searchMode == that.searchMode
176+
&& Objects.equals(this.rerankingAdvisor, that.rerankingAdvisor);
123177
}
124178

125179
@Override
126180
public int hashCode() {
127-
return Objects.hash(this.query, this.topK, this.similarityThreshold, this.filterExpression);
181+
return Objects.hash(this.query, this.topK, this.similarityThreshold, this.filterExpression, this.searchMode,
182+
this.rerankingAdvisor, this.scoreThreshold, this.resultLimit);
128183
}
129184

130185
/**
@@ -287,6 +342,50 @@ public Builder filterExpression(@Nullable String textExpression) {
287342
return this;
288343
}
289344

345+
/**
346+
* Sets the search mode (e.g., vector, full-text, hybrid, or reranked hybrid).
347+
* @param searchMode The search mode.
348+
* @return This builder for chaining.
349+
*/
350+
public Builder searchMode(SearchMode searchMode) {
351+
this.searchRequest.searchMode = searchMode != null ? searchMode : SearchMode.VECTOR;
352+
return this;
353+
}
354+
355+
/**
356+
* Sets the reranking advisor.
357+
* @param rerankingAdvisor The reranking advisor, or null for no reranking.
358+
* @return This builder.
359+
*/
360+
public Builder rerankingAdvisor(@Nullable RerankingAdvisor rerankingAdvisor) {
361+
this.searchRequest.rerankingAdvisor = rerankingAdvisor;
362+
return this;
363+
}
364+
365+
/**
366+
* Sets the score threshold for filtering results.
367+
* @param scoreThreshold The lower bound of the score.
368+
* @return This builder.
369+
* @throws IllegalArgumentException if threshold is negative.
370+
*/
371+
public Builder scoreThreshold(double scoreThreshold) {
372+
Assert.isTrue(scoreThreshold >= 0, "Score threshold must be non-negative.");
373+
this.searchRequest.scoreThreshold = scoreThreshold;
374+
return this;
375+
}
376+
377+
/**
378+
* Sets the maximum number of results to return.
379+
* @param resultLimit The maximum number of results.
380+
* @return This builder.
381+
* @throws IllegalArgumentException if resultLimit is negative.
382+
*/
383+
public Builder resultLimit(int resultLimit) {
384+
Assert.isTrue(resultLimit >= 0, "Result limit must be positive.");
385+
this.searchRequest.resultLimit = resultLimit;
386+
return this;
387+
}
388+
290389
public SearchRequest build() {
291390
return this.searchRequest;
292391
}

spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/VectorStore.java

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -84,26 +84,52 @@ default void delete(String filterExpression) {
8484
this.delete(textExpression);
8585
}
8686

87+
/**
88+
* Retrieves documents based on the specified search mode, query, and criteria.
89+
* Supports vector similarity, full-text, hybrid, and reranked hybrid searches as
90+
* defined by {@link SearchRequest#getSearchMode()}.
91+
* @param request Search request specifying query text, search mode, topK, similarity
92+
* threshold, and optional metadata filter expressions.
93+
* @return List of documents matching the request criteria, or empty list if no
94+
* matches.
95+
*/
96+
List<Document> search(SearchRequest request);
97+
8798
/**
8899
* Retrieves documents by query embedding similarity and metadata filters to retrieve
89100
* exactly the number of nearest-neighbor results that match the request criteria.
101+
* Delegates to {@link #search(SearchRequest)} with {@link SearchMode#VECTOR}.
90102
* @param request Search request for set search parameters, such as the query text,
91103
* topK, similarity threshold and metadata filter expressions.
92-
* @return Returns documents th match the query request conditions.
104+
* @return Returns documents that match the query request conditions, or null if no
105+
* matches.
106+
* @deprecated Use {@link #search(SearchRequest)} with {@link SearchMode#VECTOR}
107+
* instead.
93108
*/
94-
@Nullable
95-
List<Document> similaritySearch(SearchRequest request);
109+
@Deprecated
110+
default List<Document> similaritySearch(SearchRequest request) {
111+
return search(SearchRequest.builder()
112+
.query(request.getQuery())
113+
.topK(request.getTopK())
114+
.similarityThreshold(request.getSimilarityThreshold())
115+
.filterExpression(request.getFilterExpression())
116+
.searchMode(SearchMode.VECTOR)
117+
.build());
118+
}
96119

97120
/**
98121
* Retrieves documents by query embedding similarity using the default
99-
* {@link SearchRequest}'s' search criteria.
122+
* {@link SearchRequest}'s search criteria. Delegates to
123+
* {@link #search(SearchRequest)} with {@link SearchMode#VECTOR}.
100124
* @param query Text to use for embedding similarity comparison.
101125
* @return Returns a list of documents that have embeddings similar to the query text
102-
* embedding.
126+
* embedding, or null if no matches.
127+
* @deprecated Use {@link #search(SearchRequest)} with {@link SearchMode#VECTOR}
128+
* instead.
103129
*/
104-
@Nullable
130+
@Deprecated
105131
default List<Document> similaritySearch(String query) {
106-
return this.similaritySearch(SearchRequest.builder().query(query).build());
132+
return search(SearchRequest.builder().query(query).searchMode(SearchMode.VECTOR).build());
107133
}
108134

109135
/**

spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/observation/AbstractObservationVectorStore.java

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
import org.springframework.ai.embedding.BatchingStrategy;
2525
import org.springframework.ai.embedding.EmbeddingModel;
2626
import org.springframework.ai.vectorstore.AbstractVectorStoreBuilder;
27+
import org.springframework.ai.vectorstore.RerankingAdvisor;
28+
import org.springframework.ai.vectorstore.SearchMode;
2729
import org.springframework.ai.vectorstore.SearchRequest;
2830
import org.springframework.ai.vectorstore.VectorStore;
2931
import org.springframework.ai.vectorstore.filter.Filter;
@@ -111,9 +113,9 @@ public void delete(Filter.Expression filterExpression) {
111113
}
112114

113115
@Override
116+
@Deprecated
114117
@Nullable
115118
public List<Document> similaritySearch(SearchRequest request) {
116-
117119
VectorStoreObservationContext searchObservationContext = this
118120
.createObservationContextBuilder(VectorStoreObservationContext.Operation.QUERY.value())
119121
.queryRequest(request)
@@ -123,12 +125,50 @@ public List<Document> similaritySearch(SearchRequest request) {
123125
.observation(this.customObservationConvention, DEFAULT_OBSERVATION_CONVENTION,
124126
() -> searchObservationContext, this.observationRegistry)
125127
.observe(() -> {
126-
var documents = this.doSimilaritySearch(request);
128+
SearchRequest vectorRequest = SearchRequest.builder()
129+
.query(request.getQuery())
130+
.topK(request.getTopK())
131+
.similarityThreshold(request.getSimilarityThreshold())
132+
.filterExpression(request.getFilterExpression())
133+
.searchMode(SearchMode.VECTOR)
134+
.build();
135+
var documents = search(vectorRequest);
127136
searchObservationContext.setQueryResponse(documents);
128137
return documents;
129138
});
130139
}
131140

141+
/**
142+
* Retrieves documents based on the specified search mode, query, and criteria.
143+
* Default implementation supports {@link SearchMode#VECTOR} by delegating to
144+
* {@link #doSimilaritySearch(SearchRequest)}. Other modes throw an exception.
145+
* @param request Search request specifying query text, search mode, topK, similarity
146+
* threshold, and optional metadata filter expressions.
147+
* @return List of documents matching the request criteria, or empty list if no
148+
* matches.
149+
* @throws UnsupportedOperationException if the search mode is not supported.
150+
*/
151+
@Override
152+
public List<Document> search(SearchRequest request) {
153+
VectorStoreObservationContext searchObservationContext = this
154+
.createObservationContextBuilder(VectorStoreObservationContext.Operation.QUERY.value())
155+
.queryRequest(request)
156+
.build();
157+
158+
return VectorStoreObservationDocumentation.AI_VECTOR_STORE
159+
.observation(this.customObservationConvention, DEFAULT_OBSERVATION_CONVENTION,
160+
() -> searchObservationContext, this.observationRegistry)
161+
.observe(() -> {
162+
if (request.getSearchMode() != SearchMode.VECTOR) {
163+
throw new UnsupportedOperationException(
164+
"Search mode " + request.getSearchMode() + " not supported");
165+
}
166+
var documents = doSimilaritySearch(request);
167+
searchObservationContext.setQueryResponse(documents);
168+
return documents != null ? documents : List.of();
169+
});
170+
}
171+
132172
/**
133173
* Perform the actual add operation.
134174
* @param documents the documents to add
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package org.springframework.ai.vectorstore.azure;
2+
3+
import java.util.Comparator;
4+
import java.util.List;
5+
import java.util.stream.Collectors;
6+
7+
import org.springframework.ai.document.Document;
8+
import org.springframework.ai.vectorstore.RerankingAdvisor;
9+
import org.springframework.ai.vectorstore.SearchRequest;
10+
11+
/**
12+
* Reranking advisor for Azure AI Search's
13+
* {@link org.springframework.ai.vectorstore.SearchMode#HYBRID_RERANKED} mode, filtering
14+
* and sorting results based on reranker scores.
15+
*/
16+
public class AzureSemanticRerankingAdvisor implements RerankingAdvisor {
17+
18+
/**
19+
* Reranks search results by filtering documents based on the similarity threshold and
20+
* sorting by score in descending order.
21+
* @param results The initial list of documents.
22+
* @param request The search request.
23+
* @return The reranked list of documents.
24+
*/
25+
@Override
26+
public List<Document> rerank(List<Document> results, SearchRequest request) {
27+
return results.stream()
28+
.filter(doc -> doc.getScore() >= request.getScoreThreshold())
29+
.sorted(Comparator.comparingDouble(Document::getScore).reversed())
30+
.collect(Collectors.toList());
31+
}
32+
33+
}

0 commit comments

Comments
 (0)