Browse Source

Introduce SearchResult, SearchResults, Score and Similarity.

Closes: #3285
pull/3304/head
Mark Paluch 8 months ago
parent
commit
ecb2592493
No known key found for this signature in database
GPG Key ID: 55BC6374BAA9D973
  1. 1
      src/main/antora/modules/ROOT/nav.adoc
  2. 167
      src/main/antora/modules/ROOT/pages/repositories/vector-search.adoc
  3. 1
      src/main/java/org/springframework/data/domain/Page.java
  4. 4
      src/main/java/org/springframework/data/domain/Range.java
  5. 118
      src/main/java/org/springframework/data/domain/Score.java
  6. 87
      src/main/java/org/springframework/data/domain/ScoringFunction.java
  7. 128
      src/main/java/org/springframework/data/domain/SearchResult.java
  8. 130
      src/main/java/org/springframework/data/domain/SearchResults.java
  9. 133
      src/main/java/org/springframework/data/domain/Similarity.java
  10. 46
      src/main/java/org/springframework/data/domain/UnspecifiedScoringFunction.java
  11. 92
      src/main/java/org/springframework/data/domain/VectorScoringFunctions.java
  12. 21
      src/main/java/org/springframework/data/geo/Distance.java
  13. 49
      src/main/java/org/springframework/data/repository/query/Parameter.java
  14. 58
      src/main/java/org/springframework/data/repository/query/ParameterAccessor.java
  15. 81
      src/main/java/org/springframework/data/repository/query/Parameters.java
  16. 33
      src/main/java/org/springframework/data/repository/query/ParametersParameterAccessor.java
  17. 21
      src/main/java/org/springframework/data/repository/query/QueryMethod.java
  18. 5
      src/main/java/org/springframework/data/repository/query/ResultProcessor.java
  19. 5
      src/main/java/org/springframework/data/repository/util/QueryExecutionConverters.java
  20. 69
      src/test/java/org/springframework/data/domain/SearchResultUnitTests.java
  21. 69
      src/test/java/org/springframework/data/domain/SearchResultsUnitTests.java
  22. 89
      src/test/java/org/springframework/data/domain/SimilarityUnitTests.java
  23. 23
      src/test/java/org/springframework/data/repository/query/ParametersUnitTests.java
  24. 26
      src/test/java/org/springframework/data/repository/query/QueryMethodUnitTests.java
  25. 28
      src/test/java/org/springframework/data/repository/query/SimpleParameterAccessorUnitTests.java

1
src/main/antora/modules/ROOT/nav.adoc

@ -7,6 +7,7 @@ @@ -7,6 +7,7 @@
** xref:repositories/query-methods.adoc[]
** xref:repositories/definition.adoc[]
** xref:repositories/query-methods-details.adoc[]
** xref:repositories/vector-search.adoc[]
** xref:repositories/create-instances.adoc[]
** xref:repositories/custom-implementations.adoc[]
** xref:repositories/core-domain-events.adoc[]

167
src/main/antora/modules/ROOT/pages/repositories/vector-search.adoc

@ -0,0 +1,167 @@ @@ -0,0 +1,167 @@
[[vector-search]]
= Vector Search
With the rise of Generative AI, Vector databases have gained strong traction in the world of databases.
These databases enable efficient storage and querying of high-dimensional vectors, making them well-suited for tasks such as semantic search, recommendation systems, and natural language understanding.
Vector search is a technique that retrieves semantically similar data by comparing vector representations (also known as embeddings) rather than relying on traditional exact-match queries.
This approach enables intelligent, context-aware applications that go beyond keyword-based retrieval.
In the context of Spring Data, vector search opens new possibilities for building intelligent, context-aware applications, particularly in domains like natural language processing, recommendation systems, and generative AI.
By modelling vector-based querying using familiar repository abstractions, Spring Data allows developers to seamlessly integrate similarity-based vector-capable databases with the simplicity and consistency of the Spring Data programming model.
ifdef::vector-search-intro-include[]
include::{vector-search-intro-include}[]
endif::[]
[[vector-search.model]]
== Vector Model
To support vector search in a type-safe and idiomatic way, Spring Data introduces the following core abstractions:
* <<vector-search.model.vector,`Vector`>>
* <<vector-search.model.search-result,`SearchResults<T>` and `SearchResult<T>`>>
* <<vector-search.model.scoring,`Score`, `Similarity` and Scoring Functions>>
[[vector-search.model.vector]]
=== `Vector`
The `Vector` type represents an n-dimensional numerical embedding, typically produced by embedding models.
In Spring Data, it is defined as a lightweight wrapper around an array of floating-point numbers, ensuring immutability and consistency.
This type can be used as an input for search queries or as a property on a domain entity to store the associated vector representation.
====
[source,java]
----
Vector vector = Vector.of(0.23f, 0.11f, 0.77f);
----
====
Using `Vector` in your domain model removes the need to work with raw arrays or lists of numbers, providing a more type-safe and expressive way to handle vector data.
This abstraction also allows for easy integration with various vector databases and libraries.
It also allows for implementing vendor-specific optimizations such as binary or quantized vectors that do not map to a standard floating point (`float` and `double` as of https://en.wikipedia.org/wiki/IEEE_754[IEEE 754]) representation.
A domain object can have a vector property, which can be used for similarity searches.
Consider the following example:
ifdef::vector-search-model-include[]
include::{vector-search-model-include}[]
endif::[]
NOTE: Associating a vector with a domain object results in the vector being loaded and stored as part of the entity lifecycle, which may introduce additional overhead on retrieval and persistence operations.
[[vector-search.model.search-result]]
=== Search Results
The `SearchResult<T>` type encapsulates the results of a vector similarity query.
It includes both the matched domain object and a relevance score that indicates how closely it matches the query vector.
This abstraction provides a structured way to handle result ranking and enables developers to easily work with both the data and its contextual relevance.
ifdef::vector-search-repository-include[]
include::{vector-search-repository-include}[]
endif::[]
In this example, the `searchByCountryAndEmbeddingNear` method returns a `SearchResults<Comment>` object, which contains a list of `SearchResult<Comment>` instances.
Each result includes the matched `Comment` entity and its relevance score.
Relevance score is a numerical value that indicates how closely the matched vector aligns with the query vector.
Depending on whether a score represents distance or similarity a higher score can mean a closer match or a more distant one.
The scoring function used to calculate this score can vary based on the underlying database, index or input parameters.
[[vector-search.model.scoring]]
=== Score, Similarity, and Scoring Functions
The `Score` type holds a numerical value indicating the relevance of a search result.
It can be used to rank results based on their similarity to the query vector.
The `Score` type is typically a floating-point number, and its interpretation (higher is better or lower is better) depends on the specific similarity function used.
Scores are a by-product of vector search and are not required for a successful search operation.
Score values are not part of a domain model and therefore represented best as out-of-band data.
Generally, a Score is computed by a `ScoringFunction`.
The actual scoring function used to calculate this score can depends on the underlying database and can be obtained from a search index or input parameters.
Spring Data supports declares constants for commonly used functions such as:
Euclidean distance:: Calculates the straight-line distance in n-dimensional space involving the square root of the sum of squared differences.
Cosine similarity:: Measures the angle between two vectors by calculating the Dot product first and then normalizing its result by dividing by the product of their lengths.
Dot product:: Computes the sum of element-wise multiplications.
The choice of similarity function can impact both the performance and semantics of the search and is often determined by the underlying database or index being used.
Spring Data adopts to the database's native scoring function capabilities and whether the score can be used to limit results.
ifdef::vector-search-scoring-include[]
include::{vector-search-scoring-include}[]
endif::[]
[[vector-search.methods]]
== Vector Search Methods
Vector search methods are defined in repositories using the same conventions as standard Spring Data query methods.
These methods return `SearchResults<T>` and require a `Vector` parameter to define the query vector.
The actual implementation depends on the actual internals of the underlying data store and its capabilities around vector search.
NOTE: If you are new to Spring Data repositories, make sure to familiarize yourself with the xref:repositories/core-concepts.adoc[basics of repository definitions and query methods].
Generally, you have the choice of declaring a search method using two approaches:
* Query Derivation
* Declaring a String-based Query
Generally, Vector Search methods must declare a `Vector` parameter to define the query vector.
[[vector-search.method.derivation]]
=== Derived Search Methods
A derived search method uses the name of the method to derive the query.
Vector Search supports the following keywords to run a Vector search when declaring a search method:
.Query predicate keywords
[options="header",cols="1,3"]
|===============
|Logical keyword|Keyword expressions
|`NEAR`|`Near`, `IsNear`
|`WITHIN`|`Within`, `IsWithin`
|===============
ifdef::vector-search-method-derived-include[]
include::{vector-search-method-derived-include}[]
endif::[]
Derived search methods are typically easier to read and maintain, as they rely on the method name to express the query intent.
However, a derived search method requires either to declare a `Score`, `Range<Score>` or `ScoreFunction` as second argument to the `Near`/`Within` keyword to limit search results by their score.
[[vector-search.method.string]]
=== Annotated Search Methods
Annotated methods provide full control over the query semantics and parameters.
Unlike derived methods, they do not rely on method name conventions.
ifdef::vector-search-method-annotated-include[]
include::{vector-search-method-annotated-include}[]
endif::[]
With more control over the actual query, Spring Data can make fewer assumptions about the query and its parameters.
For example, `Similarity` normalization uses the native score function within the query to normalize the given similarity into a score predicate value and vice versa.
If an annotated query doesn't define e.g. the score, then the score value in the returned `SearchResult<T>` will be zero.
[[vector-search.method.sorting]]
=== Sorting
By default, search results are ordered according to their score.
You can override sorting by using the `Sort` parameter:
.Using `Sort` in Repository Search Methods
====
[source,java]
----
interface CommentRepository extends Repository<Comment, String> {
SearchResults<Comment> searchByEmbeddingNearOrderByCountry(Vector vector, Score score);
SearchResults<Comment> searchByEmbeddingWithin(Vector vector, Score score, Sort sort);
}
----
====
Please note that custom sorting does not allow expressing the score as a sorting criteria.
You can only refer to domain properties.

1
src/main/java/org/springframework/data/domain/Page.java

@ -69,4 +69,5 @@ public interface Page<T> extends Slice<T> { @@ -69,4 +69,5 @@ public interface Page<T> extends Slice<T> {
*/
@Override
<U> Page<U> map(Function<? super T, ? extends U> converter);
}

4
src/main/java/org/springframework/data/domain/Range.java

@ -223,7 +223,7 @@ public final class Range<T> { @@ -223,7 +223,7 @@ public final class Range<T> {
/**
* Apply a mapping {@link Function} to the lower and upper boundary values.
*
* @param mapper must not be {@literal null}. If the mapper returns {@code null}, then the corresponding boundary
* @param mapper must not be {@literal null}. If the mapper returns {@literal null}, then the corresponding boundary
* value represents an {@link Bound#unbounded()} boundary.
* @return a new {@link Range} after applying the value to the mapper.
* @param <R> target type of the mapping function.
@ -430,7 +430,7 @@ public final class Range<T> { @@ -430,7 +430,7 @@ public final class Range<T> {
/**
* Apply a mapping {@link Function} to the boundary value.
*
* @param mapper must not be {@literal null}. If the mapper returns {@code null}, then the boundary value
* @param mapper must not be {@literal null}. If the mapper returns {@literal null}, then the boundary value
* corresponds with {@link Bound#unbounded()}.
* @return a new {@link Bound} after applying the value to the mapper.
* @param <R>

118
src/main/java/org/springframework/data/domain/Score.java

@ -0,0 +1,118 @@ @@ -0,0 +1,118 @@
/*
* Copyright 2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.domain;
import java.io.Serializable;
import org.springframework.util.ObjectUtils;
/**
* Value object representing a search result score computed via a {@link ScoringFunction}.
* <p>
* Encapsulates the numeric score and the scoring function used to derive it. Scores are primarily used to rank search
* results. Depending on the used {@link ScoringFunction} higher scores can indicate either a higher distance or a
* higher similarity. Use the {@link Similarity} class to indicate usage of a normalized score across representing
* effectively the similarity.
* <p>
* Instances of this class are immutable and suitable for use in comparison, sorting, and range operations.
*
* @author Mark Paluch
* @since 4.0
* @see Similarity
*/
public sealed class Score implements Serializable permits Similarity {
private final double value;
private final ScoringFunction function;
Score(double value, ScoringFunction function) {
this.value = value;
this.function = function;
}
/**
* Creates a new {@link Score} from a plain {@code score} value using {@link ScoringFunction#unspecified()}.
*
* @param score the score value without a specific {@link ScoringFunction}.
* @return the new {@link Score}.
*/
public static Score of(double score) {
return of(score, ScoringFunction.unspecified());
}
/**
* Creates a new {@link Score} from a {@code score} value using the given {@link ScoringFunction}.
*
* @param score the score value.
* @param function the scoring function that has computed the {@code score}.
* @return the new {@link Score}.
*/
public static Score of(double score, ScoringFunction function) {
return new Score(score, function);
}
/**
* Creates a {@link Range} from the given minimum and maximum {@code Score} values.
*
* @param min the lower score value, must not be {@literal null}.
* @param max the upper score value, must not be {@literal null}.
* @return a {@link Range} over {@link Score} bounds.
*/
public static Range<Score> between(Score min, Score max) {
return Range.from(Range.Bound.inclusive(min)).to(Range.Bound.inclusive(max));
}
/**
* Returns the raw numeric value of the score.
*
* @return the score value.
*/
public double getValue() {
return value;
}
/**
* Returns the {@link ScoringFunction} that was used to compute this score.
*
* @return the associated scoring function.
*/
public ScoringFunction getFunction() {
return function;
}
@Override
public boolean equals(Object o) {
if (!(o instanceof Score other)) {
return false;
}
if (value != other.value) {
return false;
}
return ObjectUtils.nullSafeEquals(function, other.function);
}
@Override
public int hashCode() {
return ObjectUtils.nullSafeHash(value, function);
}
@Override
public String toString() {
return function instanceof UnspecifiedScoringFunction ? Double.toString(value)
: "%s (%s)".formatted(Double.toString(value), function.getName());
}
}

87
src/main/java/org/springframework/data/domain/ScoringFunction.java

@ -0,0 +1,87 @@ @@ -0,0 +1,87 @@
/*
* Copyright 2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.domain;
/**
* Strategy interface for scoring functions.
* <p>
* Implementations define how score (distance or similarity) between two vectors is computed, allowing control over
* ranking behavior in search queries.
* <p>
* Provides commonly used scoring variants via static factory methods. See {@link VectorScoringFunctions} for the
* concrete implementations.
*
* @author Mark Paluch
* @since 4.0
* @see Score
* @see Similarity
*/
public interface ScoringFunction {
/**
* Returns the default {@code ScoringFunction} to be used when none is explicitly specified.
* <p>
* This is typically used to indicate the absence of a scoring definition.
*
* @return the default {@code ScoringFunction} instance.
*/
static ScoringFunction unspecified() {
return UnspecifiedScoringFunction.INSTANCE;
}
/**
* Return the Euclidean distance scoring function.
* <p>
* Calculates the L2 norm (straight-line distance) between two vectors.
*
* @return the {@code ScoringFunction} based on Euclidean distance.
*/
static ScoringFunction euclidean() {
return VectorScoringFunctions.EUCLIDEAN;
}
/**
* Return the cosine similarity scoring function.
* <p>
* Measures the cosine of the angle between two vectors, independent of magnitude.
*
* @return the {@code ScoringFunction} based on cosine similarity.
*/
static ScoringFunction cosine() {
return VectorScoringFunctions.COSINE;
}
/**
* Return the dot product (also known as inner product) scoring function.
* <p>
* Computes the algebraic product of two vectors, considering both direction and magnitude.
*
* @return the {@code ScoringFunction} based on dot product.
*/
static ScoringFunction dotProduct() {
return VectorScoringFunctions.DOT_PRODUCT;
}
/**
* Return the name of the scoring function.
* <p>
* Typically used for display or configuration purposes.
*
* @return the identifying name of this scoring function.
*/
String getName();
}

128
src/main/java/org/springframework/data/domain/SearchResult.java

@ -0,0 +1,128 @@ @@ -0,0 +1,128 @@
/*
* Copyright 2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.domain;
import java.io.Serial;
import java.io.Serializable;
import java.util.function.Function;
import org.jspecify.annotations.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;
/**
* Immutable value object representing a search result consisting of a content item and an associated {@link Score}.
* <p>
* Typically used in the context of similarity-based or vector search operations where each result carries a relevance
* {@link Score}. Provides accessor methods for the content and its score, along with transformation support via
* {@link #map(Function)}.
*
* @param <T> the type of the content object
* @author Mark Paluch
* @since 4.0
* @see Score
* @see Similarity
*/
public final class SearchResult<T> implements Serializable {
private static final @Serial long serialVersionUID = 1637452570977581370L;
private final T content;
private final Score score;
/**
* Creates a new {@link SearchResult} with the given content and {@link Score}.
*
* @param content the result content, must not be {@literal null}.
* @param score the result score, must not be {@literal null}.
*/
public SearchResult(T content, Score score) {
Assert.notNull(content, "Content must not be null");
Assert.notNull(score, "Score must not be null");
this.content = content;
this.score = score;
}
/**
* Create a new {@link SearchResult} with the given content and a raw score value.
*
* @param content the result content, must not be {@literal null}.
* @param score the score value.
*/
public SearchResult(T content, double score) {
this(content, Score.of(score));
}
/**
* Returns the content associated with this result.
*/
public T getContent() {
return this.content;
}
/**
* Returns the {@link Score} associated with this result.
*/
public Score getScore() {
return this.score;
}
/**
* Creates a new {@link SearchResult} by applying the given mapping {@link Function} to this result's content.
*
* @param converter the mapping function to apply to the content, must not be {@literal null}.
* @return a new {@link SearchResult} instance with converted content.
* @param <U> the target type of the mapped content.
*/
public <U> SearchResult<U> map(Function<? super T, ? extends U> converter) {
Assert.notNull(converter, "Function must not be null");
return new SearchResult<>(converter.apply(getContent()), getScore());
}
@Override
public boolean equals(@Nullable Object o) {
if (this == o) {
return true;
}
if (!(o instanceof SearchResult<?> result)) {
return false;
}
if (!ObjectUtils.nullSafeEquals(content, result.content)) {
return false;
}
return ObjectUtils.nullSafeEquals(score, result.score);
}
@Override
public int hashCode() {
return ObjectUtils.nullSafeHash(content, score);
}
@Override
public String toString() {
return String.format("SearchResult [content: %s, score: %s]", content, score);
}
}

130
src/main/java/org/springframework/data/domain/SearchResults.java

@ -0,0 +1,130 @@ @@ -0,0 +1,130 @@
/*
* Copyright 2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.domain;
import java.io.Serializable;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.springframework.data.util.Streamable;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
/**
* Value object encapsulating a collection of {@link SearchResult} instances.
* <p>
* Typically used as the result type for search or similarity queries, exposing access to the result content and
* supporting mapping operations to transform the result content type.
*
* @param <T> the type of content contained within each {@link SearchResult}.
* @author Mark Paluch
* @since 4.0
* @see SearchResult
*/
public class SearchResults<T> implements Iterable<SearchResult<T>>, Serializable {
private final List<? extends SearchResult<T>> results;
/**
* Creates a new {@link SearchResults} instance from the given list of {@link SearchResult} items.
*
* @param results the search results to encapsulate, must not be {@code null}
*/
public SearchResults(List<? extends SearchResult<T>> results) {
this.results = results;
}
/**
* Return the actual content of the {@link SearchResult} items as an unmodifiable list.
*/
public List<SearchResult<T>> getContent() {
return Collections.unmodifiableList(results);
}
@Override
@SuppressWarnings("unchecked")
public Iterator<SearchResult<T>> iterator() {
return (Iterator<SearchResult<T>>) results.iterator();
}
/**
* Returns a sequential {@link Stream} containing {@link SearchResult} items in this {@code SearchResults} instance.
*
* @return a sequential {@link Stream} containing {@link SearchResult} items in this {@code SearchResults} instance.
*/
public Stream<SearchResult<T>> stream() {
return Streamable.of(this).stream();
}
/**
* Returns a sequential {@link Stream} containing {@link #getContent() unwrapped content} items in this
* {@code SearchResults} instance.
*
* @return a sequential {@link Stream} containing {@link #getContent() unwrapped content} items in this
* {@code SearchResults} instance.
*/
public Stream<T> contentStream() {
return getContent().stream().map(SearchResult::getContent);
}
/**
* Creates a new {@code SearchResults} instance with the content of the current results mapped via the given
* {@link Function}.
*
* @param converter the mapping function to apply to the content of each {@link SearchResult}, must not be
* {@literal null}.
* @param <U> the target type of the mapped content.
* @return a new {@code SearchResults} instance containing mapped result content.
*/
public <U> SearchResults<U> map(Function<? super T, ? extends U> converter) {
Assert.notNull(converter, "Function must not be null");
List<SearchResult<U>> result = results.stream().map(it -> it.<U> map(converter)).collect(Collectors.toList());
return new SearchResults<>(result);
}
@Override
public boolean equals(Object o) {
if (o == this) {
return true;
}
if (!(o instanceof SearchResults<?> that)) {
return false;
}
return ObjectUtils.nullSafeEquals(results, that.results);
}
@Override
public int hashCode() {
return ObjectUtils.nullSafeHashCode(results);
}
@Override
public String toString() {
return results.isEmpty() ? "SearchResults: [empty]"
: String.format("SearchResults: [results: %s]", StringUtils.collectionToCommaDelimitedString(results));
}
}

133
src/main/java/org/springframework/data/domain/Similarity.java

@ -0,0 +1,133 @@ @@ -0,0 +1,133 @@
/*
* Copyright 2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.domain;
import org.springframework.util.Assert;
/**
* Value object representing a normalized similarity score determined by a {@link ScoringFunction}.
* <p>
* Similarity values are constrained to the range {@code [0.0, 1.0]}, where {@code 0.0} denotes the least similarity and
* {@code 1.0} the maximum similarity. This normalization allows for consistent comparison of similarity scores across
* different scoring models and systems.
* <p>
* Primarily used in vector search and approximate nearest neighbor arrangements where results are ranked based on
* normalized relevance. Vector searches typically return a collection of results ordered by their similarity to the
* query vector.
* <p>
* This class is designed for use in information retrieval contexts, recommendation systems, and other applications
* requiring normalized comparison of results.
* <p>
* A {@code Similarity} instance includes both the similarity {@code value} and information about the
* {@link ScoringFunction} used to generate it, providing context for proper interpretation of the score.
* <p>
* Instances are immutable and support range-based comparisons, making them suitable for filtering and ranking
* operations. The class extends {@link Score} to inherit common scoring functionality while adding similarity-specific
* semantics.
*
* @author Mark Paluch
* @since 4.0
* @see Score
*/
public final class Similarity extends Score {
private Similarity(double value, ScoringFunction function) {
super(value, function);
}
/**
* Creates a new {@link Similarity} from a plain {@code similarity} value using {@link ScoringFunction#unspecified()}.
*
* @param similarity the similarity value without a specific {@link ScoringFunction}, ranging between {@code 0} and
* {@code 1}.
* @return the new {@link Similarity}.
*/
public static Similarity of(double similarity) {
return of(similarity, ScoringFunction.unspecified());
}
/**
* Creates a new {@link Similarity} from a raw value and the associated {@link ScoringFunction}.
*
* @param similarity the similarity value in the {@code [0.0, 1.0]} range.
* @param function the scoring function that produced this similarity.
* @return a new {@link Similarity} instance.
* @throws IllegalArgumentException if the value is outside the allowed range.
*/
public static Similarity of(double similarity, ScoringFunction function) {
Assert.isTrue(similarity >= 0.0 && similarity <= 1.0, "Similarity must be in [0,1] range.");
return new Similarity(similarity, function);
}
/**
* Create a raw {@link Similarity} value without validation.
* <p>
* Intended for use when accepting similarity values from trusted sources such as search engines or databases.
*
* @param similarity the similarity value in the {@code [0.0, 1.0]} range.
* @param function the scoring function that produced this similarity.
* @return a new {@link Similarity} instance.
*/
public static Similarity raw(double similarity, ScoringFunction function) {
return new Similarity(similarity, function);
}
/**
* Creates a {@link Range} between the given {@link Similarity}.
*
* @param min lower value.
* @param max upper value.
* @return the {@link Range} between the given values.
*/
public static Range<Similarity> between(Similarity min, Similarity max) {
return Range.from(Range.Bound.inclusive(min)).to(Range.Bound.inclusive(max));
}
/**
* Creates a new {@link Range} by creating minimum and maximum {@link Similarity} from the given values
* {@link ScoringFunction#unspecified() without specifying} a specific scoring function.
*
* @param minValue lower value, ranging between {@code 0} and {@code 1}.
* @param maxValue upper value, ranging between {@code 0} and {@code 1}.
* @return the {@link Range} between the given values.
*/
public static Range<Similarity> between(double minValue, double maxValue) {
return between(minValue, maxValue, ScoringFunction.unspecified());
}
/**
* Creates a {@link Range} of {@link Similarity} values using raw values and a specified scoring function.
*
* @param minValue the lower similarity value.
* @param maxValue the upper similarity value.
* @param function the scoring function to associate with the values.
* @return a {@link Range} of {@link Similarity} values.
*/
public static Range<Similarity> between(double minValue, double maxValue, ScoringFunction function) {
return between(Similarity.of(minValue, function), Similarity.of(maxValue, function));
}
@Override
public boolean equals(Object o) {
if (!(o instanceof Similarity other)) {
return false;
}
return super.equals(other);
}
}

46
src/main/java/org/springframework/data/domain/UnspecifiedScoringFunction.java

@ -0,0 +1,46 @@ @@ -0,0 +1,46 @@
/*
* Copyright 2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.domain;
import java.io.Serializable;
class UnspecifiedScoringFunction implements ScoringFunction, Serializable {
static final UnspecifiedScoringFunction INSTANCE = new UnspecifiedScoringFunction();
private UnspecifiedScoringFunction() {}
@Override
public String getName() {
return "Unspecified";
}
@Override
public boolean equals(Object o) {
return o instanceof UnspecifiedScoringFunction;
}
@Override
public int hashCode() {
return 32;
}
@Override
public String toString() {
return "UNSPECIFIED";
}
}

92
src/main/java/org/springframework/data/domain/VectorScoringFunctions.java

@ -0,0 +1,92 @@ @@ -0,0 +1,92 @@
/*
* Copyright 2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.domain;
/**
* Commonly used {@link ScoringFunction} implementations for vector-based similarity computations.
* <p>
* Provides a set of standard scoring strategies for comparing vectors in search or matching operations. Includes
* options such as Euclidean distance, cosine similarity, and dot product.
* <p>
* These constants are intended for reuse across components requiring vector scoring semantics. Each scoring function
* represents a mathematical approach to quantifying the similarity or distance between vectors in a multidimensional
* space.
* <p>
* When selecting a scoring function, consider the specific requirements of your application domain:
* <ul>
* <li>For spatial distance measurements where magnitude matters, use {@link #EUCLIDEAN}.</li>
* <li>For directional similarity irrespective of magnitude, use {@link #COSINE}.</li>
* <li>For efficient high-dimensional calculations, use {@link #DOT_PRODUCT}.</li>
* <li>For grid-based or axis-aligned problems, use {@link #TAXICAB}.</li>
* <li>For binary vector or string comparisons, use {@link #HAMMING}.</li>
* </ul>
* The choice of scoring function can significantly impact the relevance of the results returned by a Vector Search
* query. {@code ScoringFunction} and score values are typically subject to fine-tuning during the development to
* achieve optimal performance and accuracy.
*
* @author Mark Paluch
* @since 4.0
*/
public enum VectorScoringFunctions implements ScoringFunction {
/**
* Scoring based on the <a href="https://en.wikipedia.org/wiki/Euclidean_distance">Euclidean distance</a> between two
* vectors.
* <p>
* Computes the L2 norm, involving a square root operation. Typically more computationally expensive than
* {@link #COSINE} or {@link #DOT_PRODUCT}, but precise in spatial distance measurement.
*/
EUCLIDEAN,
/**
* Scoring based on <a href="https://en.wikipedia.org/wiki/Cosine_distance">cosine similarity</a> between two vectors.
* <p>
* Measures the angle between vectors, independent of their magnitude. Involves a {@link #DOT_PRODUCT} and
* normalization, offering a balance between precision and performance.
*/
COSINE,
/**
* Scoring based on the <a href="https://en.wikipedia.org/wiki/Dot_product">dot product</a> (also known as inner
* product) between two vectors.
* <p>
* Efficient to compute and particularly useful in high-dimensional vector spaces.
*/
DOT_PRODUCT,
/**
* Scoring based on <a href="https://en.wikipedia.org/wiki/Taxicab_geometry">taxicab (Manhattan) distance</a>.
* <p>
* Computes the sum of absolute differences across dimensions. Useful in contexts where axis-aligned movement or L1
* norms are preferred.
*/
TAXICAB,
/**
* Scoring based on the <a href="https://en.wikipedia.org/wiki/Hamming_distance">Hamming distance</a> between two
* vectors or strings.
* <p>
* Counts the number of differing positions. Suitable for binary (bitwise) vectors or fixed-length character
* sequences.
*/
HAMMING;
@Override
public String getName() {
return name();
}
}

21
src/main/java/org/springframework/data/geo/Distance.java

@ -71,6 +71,27 @@ public final class Distance implements Serializable, Comparable<Distance> { @@ -71,6 +71,27 @@ public final class Distance implements Serializable, Comparable<Distance> {
this.metric = metric;
}
/**
* Creates a new {@link Distance} with a neutral metric. This means the provided value needs to be in normalized form.
*
* @param value distance value.
* @since 4.0
*/
public static Distance of(double value) {
return new Distance(value);
}
/**
* Creates a new {@link Distance} with the given {@link Metric}.
*
* @param value distance value.
* @param metric must not be {@literal null}.
* @since 4.0
*/
public static Distance of(double value, Metric metric) {
return new Distance(value, metric);
}
/**
* Creates a {@link Range} between the given {@link Distance}.
*

49
src/main/java/org/springframework/data/repository/query/Parameter.java

@ -28,8 +28,11 @@ import org.springframework.core.ParameterNameDiscoverer; @@ -28,8 +28,11 @@ import org.springframework.core.ParameterNameDiscoverer;
import org.springframework.core.ResolvableType;
import org.springframework.data.domain.Limit;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Range;
import org.springframework.data.domain.Score;
import org.springframework.data.domain.ScrollPosition;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Vector;
import org.springframework.data.repository.util.QueryExecutionConverters;
import org.springframework.data.repository.util.ReactiveWrapperConverters;
import org.springframework.data.util.ClassUtils;
@ -55,6 +58,7 @@ public class Parameter { @@ -55,6 +58,7 @@ public class Parameter {
private final MethodParameter parameter;
private final Class<?> parameterType;
private final boolean isScoreRange;
private final boolean isDynamicProjectionParameter;
private final Lazy<Optional<String>> name;
@ -71,6 +75,7 @@ public class Parameter { @@ -71,6 +75,7 @@ public class Parameter {
TYPES = Collections.unmodifiableList(types);
}
/**
* Creates a new {@link Parameter} for the given {@link MethodParameter} and domain {@link TypeInformation}.
*
@ -84,9 +89,11 @@ public class Parameter { @@ -84,9 +89,11 @@ public class Parameter {
Assert.notNull(domainType, "TypeInformation must not be null!");
this.parameter = parameter;
this.isScoreRange = Range.class.isAssignableFrom(parameter.getParameterType())
&& ResolvableType.forMethodParameter(parameter).getGeneric(0).isAssignableFrom(Score.class);
this.parameterType = potentiallyUnwrapParameterType(parameter);
this.isDynamicProjectionParameter = isDynamicProjectionParameter(parameter, domainType);
this.name = isSpecialParameterType(parameter.getParameterType()) ? Lazy.of(Optional.empty()) : Lazy.of(() -> {
this.name = Lazy.of(() -> {
Param annotation = parameter.getParameterAnnotation(Param.class);
return Optional.ofNullable(annotation == null ? parameter.getParameterName() : annotation.value());
});
@ -209,9 +216,31 @@ public class Parameter { @@ -209,9 +216,31 @@ public class Parameter {
}
/**
* Returns whether the {@link Parameter} is a {@link ScrollPosition} parameter.
*
* @return
* @return {@literal true} if the {@link Parameter} is a {@link Vector} parameter.
* @since 4.0
*/
boolean isVector() {
return Vector.class.isAssignableFrom(getType());
}
/**
* @return {@literal true} if the {@link Parameter} is a {@link Score} parameter.
* @since 4.0
*/
boolean isScore() {
return Score.class.isAssignableFrom(getType());
}
/**
* @return {@literal true} if the {@link Parameter} is a {@link Range} of {@link Score} parameter.
* @since 4.0
*/
boolean isScoreRange() {
return isScoreRange;
}
/**
* @return {@literal true} if the {@link Parameter} is a {@link ScrollPosition} parameter.
* @since 3.1
*/
boolean isScrollPosition() {
@ -219,27 +248,21 @@ public class Parameter { @@ -219,27 +248,21 @@ public class Parameter {
}
/**
* Returns whether the {@link Parameter} is a {@link Pageable} parameter.
*
* @return
* @return {@literal true} if the {@link Parameter} is a {@link Pageable} parameter.
*/
boolean isPageable() {
return Pageable.class.isAssignableFrom(getType());
}
/**
* Returns whether the {@link Parameter} is a {@link Sort} parameter.
*
* @return
* @return {@literal true} if the {@link Parameter} is a {@link Sort} parameter.
*/
boolean isSort() {
return Sort.class.isAssignableFrom(getType());
}
/**
* Returns whether the {@link Parameter} is a {@link Limit} parameter.
*
* @return
* @return {@literal true} if the {@link Parameter} is a {@link Limit} parameter.
* @since 3.2
*/
boolean isLimit() {

58
src/main/java/org/springframework/data/repository/query/ParameterAccessor.java

@ -21,8 +21,11 @@ import org.jspecify.annotations.Nullable; @@ -21,8 +21,11 @@ import org.jspecify.annotations.Nullable;
import org.springframework.data.domain.Limit;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Range;
import org.springframework.data.domain.Score;
import org.springframework.data.domain.ScrollPosition;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Vector;
/**
* Interface to access method parameters. Allows dedicated access to parameters of special types
@ -33,35 +36,48 @@ import org.springframework.data.domain.Sort; @@ -33,35 +36,48 @@ import org.springframework.data.domain.Sort;
public interface ParameterAccessor extends Iterable<Object> {
/**
* Returns the {@link ScrollPosition} of the parameters, if available. Returns {@code null} otherwise.
*
* @return
* @return the {@link Vector} of the parameters, if available; {@literal null} otherwise.
* @since 4.0
*/
@Nullable
Vector getVector();
/**
* @return the {@link Score} of the parameters, if available; {@literal null} otherwise.
* @since 4.0
*/
@Nullable
Score getScore();
/**
* @return the {@link Range} of {@link Score} of the parameters, if available; {@literal null} otherwise.
* @since 4.0
*/
@Nullable
Range<Score> getScoreRange();
/**
* @return the {@link ScrollPosition} of the parameters, if available; {@literal null} otherwise.
*/
@Nullable
ScrollPosition getScrollPosition();
/**
* Returns the {@link Pageable} of the parameters, if available. Returns {@link Pageable#unpaged()} otherwise.
*
* @return
* @return the {@link Pageable} of the parameters, if available; {@link Pageable#unpaged()} otherwise.
*/
Pageable getPageable();
/**
* Returns the sort instance to be used for query creation. Will use a {@link Sort} parameter if available or the
* {@link Sort} contained in a {@link Pageable} if available. Returns {@link Sort#unsorted()} if no {@link Sort} can
* @return the sort instance to be used for query creation. Will use a {@link Sort} parameter if available or the
* {@link Sort} contained in a {@link Pageable} if available. {@link Sort#unsorted()} if no {@link Sort} can
* be found.
*
* @return
*/
Sort getSort();
/**
* Returns the {@link Limit} instance to be used for query creation. If no {@link java.lang.reflect.Parameter}
* assignable to {@link Limit} can be found {@link Limit} will be created out of {@link Pageable#getPageSize()} if
* present.
*
* @return
* @return the {@link Limit} instance to be used for query creation. If no {@link java.lang.reflect.Parameter}
* assignable to {@link Limit} can be found {@link Limit} will be created out of
* {@link Pageable#getPageSize()} if present.
* @since 3.2
*/
default Limit getLimit() {
@ -69,9 +85,7 @@ public interface ParameterAccessor extends Iterable<Object> { @@ -69,9 +85,7 @@ public interface ParameterAccessor extends Iterable<Object> {
}
/**
* Returns the dynamic projection type to be used when executing the query or {@literal null} if none is defined.
*
* @return
* @return the dynamic projection type to be used when executing the query or {@literal null} if none is defined.
* @since 2.2
*/
@Nullable
@ -83,7 +97,7 @@ public interface ParameterAccessor extends Iterable<Object> { @@ -83,7 +97,7 @@ public interface ParameterAccessor extends Iterable<Object> {
* {@link String}, {@code #getBindableParameter(1)} would return the second {@link String} value.
*
* @param index
* @return
* @return the bindable value with the given index
*/
@Nullable
Object getBindableValue(int index);
@ -91,7 +105,7 @@ public interface ParameterAccessor extends Iterable<Object> { @@ -91,7 +105,7 @@ public interface ParameterAccessor extends Iterable<Object> {
/**
* Returns whether one of the bindable parameter values is {@literal null}.
*
* @return
* @return {@literal true} if one of the bindable parameter values is {@literal null}.
*/
boolean hasBindableNullValue();
@ -99,7 +113,9 @@ public interface ParameterAccessor extends Iterable<Object> { @@ -99,7 +113,9 @@ public interface ParameterAccessor extends Iterable<Object> {
* Returns an iterator over all <em>bindable</em> parameters. This means parameters implementing {@link Pageable} or
* {@link Sort} will not be included in this {@link Iterator}.
*
* @return
* @return iterator over all <em>bindable</em> parameters.
*/
@Override
Iterator<Object> iterator();
}

81
src/main/java/org/springframework/data/repository/query/Parameters.java

@ -26,10 +26,14 @@ import java.util.function.Function; @@ -26,10 +26,14 @@ import java.util.function.Function;
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.core.MethodParameter;
import org.springframework.core.ParameterNameDiscoverer;
import org.springframework.core.ResolvableType;
import org.springframework.data.domain.Limit;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Range;
import org.springframework.data.domain.Score;
import org.springframework.data.domain.ScrollPosition;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Vector;
import org.springframework.data.util.Lazy;
import org.springframework.data.util.Streamable;
import org.springframework.util.Assert;
@ -54,6 +58,9 @@ public abstract class Parameters<S extends Parameters<S, T>, T extends Parameter @@ -54,6 +58,9 @@ public abstract class Parameters<S extends Parameters<S, T>, T extends Parameter
private static final ParameterNameDiscoverer PARAMETER_NAME_DISCOVERER = new DefaultParameterNameDiscoverer();
private final int vectorIndex;
private final int scoreIndex;
private final int scoreRangeIndex;
private final int scrollPositionIndex;
private final int pageableIndex;
private final int sortIndex;
@ -71,8 +78,7 @@ public abstract class Parameters<S extends Parameters<S, T>, T extends Parameter @@ -71,8 +78,7 @@ public abstract class Parameters<S extends Parameters<S, T>, T extends Parameter
* @param parameterFactory must not be {@literal null}.
* @since 3.2.1
*/
protected Parameters(ParametersSource parametersSource,
Function<MethodParameter, T> parameterFactory) {
protected Parameters(ParametersSource parametersSource, Function<MethodParameter, T> parameterFactory) {
Assert.notNull(parametersSource, "ParametersSource must not be null");
Assert.notNull(parameterFactory, "Parameter factory must not be null");
@ -83,6 +89,9 @@ public abstract class Parameters<S extends Parameters<S, T>, T extends Parameter @@ -83,6 +89,9 @@ public abstract class Parameters<S extends Parameters<S, T>, T extends Parameter
this.parameters = new ArrayList<>(parameterCount);
this.dynamicProjectionIndex = -1;
int vectorIndex = -1;
int scoreIndex = -1;
int scoreRangeIndex = -1;
int scrollPositionIndex = -1;
int pageableIndex = -1;
int sortIndex = -1;
@ -105,6 +114,19 @@ public abstract class Parameters<S extends Parameters<S, T>, T extends Parameter @@ -105,6 +114,19 @@ public abstract class Parameters<S extends Parameters<S, T>, T extends Parameter
this.dynamicProjectionIndex = parameter.getIndex();
}
if (Vector.class.isAssignableFrom(parameter.getType())) {
vectorIndex = i;
}
if (Score.class.isAssignableFrom(parameter.getType())) {
scoreIndex = i;
}
if (Range.class.isAssignableFrom(parameter.getType())
&& Score.class.isAssignableFrom(ResolvableType.forMethodParameter(methodParameter).getGeneric(0).toClass())) {
scoreRangeIndex = i;
}
if (ScrollPosition.class.isAssignableFrom(parameter.getType())) {
scrollPositionIndex = i;
}
@ -124,6 +146,9 @@ public abstract class Parameters<S extends Parameters<S, T>, T extends Parameter @@ -124,6 +146,9 @@ public abstract class Parameters<S extends Parameters<S, T>, T extends Parameter
parameters.add(parameter);
}
this.vectorIndex = vectorIndex;
this.scoreIndex = scoreIndex;
this.scoreRangeIndex = scoreRangeIndex;
this.scrollPositionIndex = scrollPositionIndex;
this.pageableIndex = pageableIndex;
this.sortIndex = sortIndex;
@ -142,6 +167,9 @@ public abstract class Parameters<S extends Parameters<S, T>, T extends Parameter @@ -142,6 +167,9 @@ public abstract class Parameters<S extends Parameters<S, T>, T extends Parameter
this.parameters = new ArrayList<>(originals.size());
int vectorIndexTemp = -1;
int scoreIndexTemp = -1;
int scoreRangeIndexTemp = -1;
int scrollPositionIndexTemp = -1;
int pageableIndexTemp = -1;
int sortIndexTemp = -1;
@ -153,6 +181,9 @@ public abstract class Parameters<S extends Parameters<S, T>, T extends Parameter @@ -153,6 +181,9 @@ public abstract class Parameters<S extends Parameters<S, T>, T extends Parameter
T original = originals.get(i);
this.parameters.add(original);
vectorIndexTemp = original.isVector() ? i : -1;
scoreIndexTemp = original.isScore() ? i : -1;
scoreRangeIndexTemp = original.isScoreRange() ? i : -1;
scrollPositionIndexTemp = original.isScrollPosition() ? i : -1;
pageableIndexTemp = original.isPageable() ? i : -1;
sortIndexTemp = original.isSort() ? i : -1;
@ -160,6 +191,9 @@ public abstract class Parameters<S extends Parameters<S, T>, T extends Parameter @@ -160,6 +191,9 @@ public abstract class Parameters<S extends Parameters<S, T>, T extends Parameter
dynamicProjectionTemp = original.isDynamicProjectionParameter() ? i : -1;
}
this.vectorIndex = vectorIndexTemp;
this.scoreIndex = scoreIndexTemp;
this.scoreRangeIndex = scoreRangeIndexTemp;
this.scrollPositionIndex = scrollPositionIndexTemp;
this.pageableIndex = pageableIndexTemp;
this.sortIndex = sortIndexTemp;
@ -182,6 +216,49 @@ public abstract class Parameters<S extends Parameters<S, T>, T extends Parameter @@ -182,6 +216,49 @@ public abstract class Parameters<S extends Parameters<S, T>, T extends Parameter
return createFrom(bindables);
}
/**
* Returns whether the method the {@link Parameters} was created for contains a {@link Vector} argument.
*
* @return
* @since 4.0
*/
public boolean hasVectorParameter() {
return vectorIndex != -1;
}
public int getVectorIndex() {
return vectorIndex;
}
/**
* Returns whether the method the {@link Parameters} was created for contains a {@link Score} argument.
*
* @return
* @since 4.0
*/
public boolean hasScoreParameter() {
return scoreIndex != -1;
}
public int getScoreIndex() {
return scoreIndex;
}
/**
* Returns whether the method the {@link Parameters} was created for contains a {@link Range} of {@link Score}
* argument.
*
* @return
* @since 4.0
*/
public boolean hasScoreRangeParameter() {
return scoreRangeIndex != -1;
}
public int getScoreRangeIndex() {
return scoreRangeIndex;
}
/**
* Returns whether the method the {@link Parameters} was created for contains a {@link ScrollPosition} argument.
*

33
src/main/java/org/springframework/data/repository/query/ParametersParameterAccessor.java

@ -22,8 +22,11 @@ import org.jspecify.annotations.Nullable; @@ -22,8 +22,11 @@ import org.jspecify.annotations.Nullable;
import org.springframework.data.domain.Limit;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Range;
import org.springframework.data.domain.Score;
import org.springframework.data.domain.ScrollPosition;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Vector;
import org.springframework.data.repository.util.QueryExecutionConverters;
import org.springframework.data.repository.util.ReactiveWrapperConverters;
import org.springframework.util.Assert;
@ -95,6 +98,36 @@ public class ParametersParameterAccessor implements ParameterAccessor { @@ -95,6 +98,36 @@ public class ParametersParameterAccessor implements ParameterAccessor {
return this.values;
}
@Override
public @Nullable Vector getVector() {
if (parameters.getVectorIndex() == -1) {
return null;
}
return (Vector) values[parameters.getVectorIndex()];
}
@Override
public @Nullable Score getScore() {
if (!parameters.hasScoreParameter()) {
return null;
}
return (Score) values[parameters.getScoreIndex()];
}
@Override
public @Nullable Range<Score> getScoreRange() {
if (!parameters.hasScoreRangeParameter()) {
return null;
}
return (Range<Score>) values[parameters.getScoreRangeIndex()];
}
@Override
public @Nullable ScrollPosition getScrollPosition() {

21
src/main/java/org/springframework/data/repository/query/QueryMethod.java

@ -26,6 +26,8 @@ import org.springframework.data.domain.Limit; @@ -26,6 +26,8 @@ import org.springframework.data.domain.Limit;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.ScrollPosition;
import org.springframework.data.domain.SearchResult;
import org.springframework.data.domain.SearchResults;
import org.springframework.data.domain.Slice;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Window;
@ -41,6 +43,7 @@ import org.springframework.data.util.ReflectionUtils; @@ -41,6 +43,7 @@ import org.springframework.data.util.ReflectionUtils;
import org.springframework.data.util.TypeInformation;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
/**
* Abstraction of a method that is designated to execute a finder query. Enriches the standard {@link Method} interface
@ -280,6 +283,24 @@ public class QueryMethod { @@ -280,6 +283,24 @@ public class QueryMethod {
return org.springframework.util.ClassUtils.isAssignable(Page.class, unwrappedReturnType);
}
/**
* Returns whether the finder will return a {@link SearchResults} (or collection of {@link SearchResult}) of results.
*
* @return
* @since 4.0
*/
public boolean isSearchQuery() {
if (ClassUtils.isAssignable(SearchResults.class, unwrappedReturnType)) {
return true;
}
TypeInformation<?> returnType = metadata.getReturnType(method);
TypeInformation<?> componentType = returnType.getComponentType();
return componentType != null && SearchResult.class.isAssignableFrom(componentType.getType());
}
/**
* Returns whether the query method is a modifying one.
*

5
src/main/java/org/springframework/data/repository/query/ResultProcessor.java

@ -28,6 +28,7 @@ import org.springframework.core.CollectionFactory; @@ -28,6 +28,7 @@ import org.springframework.core.CollectionFactory;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.converter.Converter;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.data.domain.SearchResults;
import org.springframework.data.domain.Slice;
import org.springframework.data.domain.Window;
import org.springframework.data.projection.ProjectionFactory;
@ -154,6 +155,10 @@ public class ResultProcessor { @@ -154,6 +155,10 @@ public class ResultProcessor {
return (T) ((Slice<?>) source).map(converter::convert);
}
if (source instanceof SearchResults<?> results && method.isSearchQuery()) {
return (T) results.map(converter::convert);
}
if (source instanceof Collection<?> collection && method.isCollectionQuery()) {
Collection<Object> target = createCollectionFor(collection);

5
src/main/java/org/springframework/data/repository/util/QueryExecutionConverters.java

@ -38,6 +38,8 @@ import org.springframework.core.convert.converter.GenericConverter; @@ -38,6 +38,8 @@ import org.springframework.core.convert.converter.GenericConverter;
import org.springframework.core.convert.support.ConfigurableConversionService;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.SearchResult;
import org.springframework.data.domain.SearchResults;
import org.springframework.data.domain.Slice;
import org.springframework.data.domain.Window;
import org.springframework.data.geo.GeoResults;
@ -98,6 +100,7 @@ public abstract class QueryExecutionConverters { @@ -98,6 +100,7 @@ public abstract class QueryExecutionConverters {
ALLOWED_PAGEABLE_TYPES.add(Page.class);
ALLOWED_PAGEABLE_TYPES.add(List.class);
ALLOWED_PAGEABLE_TYPES.add(Window.class);
ALLOWED_PAGEABLE_TYPES.add(SearchResults.class);
WRAPPER_TYPES.add(NullableWrapperToCompletableFutureConverter.getWrapperType());
@ -253,6 +256,8 @@ public abstract class QueryExecutionConverters { @@ -253,6 +256,8 @@ public abstract class QueryExecutionConverters {
boolean needToUnwrap = type.isCollectionLike() //
|| Slice.class.isAssignableFrom(rawType) //
|| GeoResults.class.isAssignableFrom(rawType) //
|| SearchResult.class.isAssignableFrom(rawType) //
|| SearchResults.class.isAssignableFrom(rawType) //
|| rawType.isArray() //
|| supports(rawType) //
|| Stream.class.isAssignableFrom(rawType);

69
src/test/java/org/springframework/data/domain/SearchResultUnitTests.java

@ -0,0 +1,69 @@ @@ -0,0 +1,69 @@
/*
* Copyright 2011-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.domain;
import static org.assertj.core.api.Assertions.*;
import org.junit.jupiter.api.Test;
import org.springframework.util.SerializationUtils;
/**
* Unit tests for {@link SearchResult}.
*
* @author Mark Paluch
*/
class SearchResultUnitTests {
SearchResult<String> first = new SearchResult<>("Foo", Score.of(2.5));
SearchResult<String> second = new SearchResult<>("Foo", Score.of(2.5));
SearchResult<String> third = new SearchResult<>("Bar", Score.of(2.5));
SearchResult<String> fourth = new SearchResult<>("Foo", Score.of(5.2));
@Test // GH-
void considersSameInstanceEqual() {
assertThat(first.equals(first)).isTrue();
}
@Test // GH-
void considersSameValuesAsEqual() {
assertThat(first.equals(second)).isTrue();
assertThat(second.equals(first)).isTrue();
assertThat(first.equals(third)).isFalse();
assertThat(third.equals(first)).isFalse();
assertThat(first.equals(fourth)).isFalse();
assertThat(fourth.equals(first)).isFalse();
}
@Test
@SuppressWarnings({ "rawtypes", "unchecked" })
// GH-
void rejectsNullContent() {
assertThatIllegalArgumentException().isThrownBy(() -> new SearchResult(null, Score.of(2.5)));
}
@Test // GH-
@SuppressWarnings("unchecked")
void testSerialization() {
var result = new SearchResult<>("test", Score.of(2d));
var serialized = (SearchResult<String>) SerializationUtils.deserialize(SerializationUtils.serialize(result));
assertThat(serialized).isEqualTo(result);
}
}

69
src/test/java/org/springframework/data/domain/SearchResultsUnitTests.java

@ -0,0 +1,69 @@ @@ -0,0 +1,69 @@
/*
* Copyright 2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.domain;
import static org.assertj.core.api.Assertions.*;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.util.SerializationUtils;
/**
* Unit tests for {@link SearchResults}.
*
* @author Mark Paluch
*/
class SearchResultsUnitTests {
@SuppressWarnings("unchecked")
@Test // GH-
void testSerialization() {
var result = new SearchResult<>("test", Score.of(2));
var searchResults = new SearchResults<>(Collections.singletonList(result));
var serialized = (SearchResults<String>) SerializationUtils
.deserialize(SerializationUtils.serialize(searchResults));
assertThat(serialized).isEqualTo(searchResults);
}
@SuppressWarnings("unchecked")
@Test // GH-
void testStream() {
var result = new SearchResult<>("test", Score.of(2));
var searchResults = new SearchResults<>(Collections.singletonList(result));
List<SearchResult<String>> list = searchResults.stream().toList();
assertThat(list).isEqualTo(searchResults.getContent());
}
@SuppressWarnings("unchecked")
@Test // GH-
void testContentStream() {
var result = new SearchResult<>("test", Score.of(2));
var searchResults = new SearchResults<>(Collections.singletonList(result));
List<String> list = searchResults.contentStream().toList();
assertThat(list).isEqualTo(Arrays.asList(result.getContent()));
}
}

89
src/test/java/org/springframework/data/domain/SimilarityUnitTests.java

@ -0,0 +1,89 @@ @@ -0,0 +1,89 @@
/*
* Copyright 2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.domain;
import static org.assertj.core.api.Assertions.*;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link Similarity}.
*
* @author Mark Paluch
*/
class SimilarityUnitTests {
@Test
void shouldBeBounded() {
assertThatIllegalArgumentException().isThrownBy(() -> Similarity.of(-1));
assertThatIllegalArgumentException().isThrownBy(() -> Similarity.of(1.01));
}
@Test
void shouldConstructRawSimilarity() {
Similarity similarity = Similarity.raw(2, ScoringFunction.unspecified());
assertThat(similarity.getValue()).isEqualTo(2);
}
@Test
void shouldConstructGenericSimilarity() {
Similarity similarity = Similarity.of(1);
assertThat(similarity).isEqualTo(Similarity.of(1)).isNotEqualTo(Score.of(1)).isNotEqualTo(Similarity.of(0.5));
assertThat(similarity).hasToString("1.0");
assertThat(similarity.getFunction()).isEqualTo(ScoringFunction.unspecified());
}
@Test
void shouldConstructMeteredSimilarity() {
Similarity similarity = Similarity.of(1, VectorScoringFunctions.COSINE);
assertThat(similarity).isEqualTo(Similarity.of(1, VectorScoringFunctions.COSINE))
.isNotEqualTo(Score.of(1, VectorScoringFunctions.COSINE)).isNotEqualTo(Similarity.of(1));
assertThat(similarity).hasToString("1.0 (COSINE)");
assertThat(similarity.getFunction()).isEqualTo(VectorScoringFunctions.COSINE);
}
@Test
void shouldConstructRange() {
Range<Similarity> range = Similarity.between(0.5, 1);
assertThat(range.getLowerBound().getValue()).contains(Similarity.of(0.5));
assertThat(range.getLowerBound().isInclusive()).isTrue();
assertThat(range.getUpperBound().getValue()).contains(Similarity.of(1));
assertThat(range.getUpperBound().isInclusive()).isTrue();
}
@Test
void shouldConstructRangeWithFunction() {
Range<Similarity> range = Similarity.between(0.5, 1, VectorScoringFunctions.COSINE);
assertThat(range.getLowerBound().getValue()).contains(Similarity.of(0.5, VectorScoringFunctions.COSINE));
assertThat(range.getLowerBound().isInclusive()).isTrue();
assertThat(range.getUpperBound().getValue()).contains(Similarity.of(1, VectorScoringFunctions.COSINE));
assertThat(range.getUpperBound().isInclusive()).isTrue();
}
}

23
src/test/java/org/springframework/data/repository/query/ParametersUnitTests.java

@ -31,6 +31,9 @@ import org.springframework.data.domain.Limit; @@ -31,6 +31,9 @@ import org.springframework.data.domain.Limit;
import org.springframework.data.domain.OffsetScrollPosition;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Range;
import org.springframework.data.domain.Score;
import org.springframework.data.domain.Similarity;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Window;
import org.springframework.data.repository.Repository;
@ -230,6 +233,22 @@ class ParametersUnitTests { @@ -230,6 +233,22 @@ class ParametersUnitTests {
assertThat(parameters.getParameter(0).getType()).isEqualTo(Long.class);
}
@Test // GH-
void considersScoreRange() throws Exception {
var parameters = getParametersFor("methodWithScoreRange", Range.class);
assertThat(parameters.hasScoreRangeParameter()).isTrue();
}
@Test // GH-
void considersSimilarityRange() throws Exception {
var parameters = getParametersFor("methodWithSimilarityRange", Range.class);
assertThat(parameters.hasScoreRangeParameter()).isTrue();
}
private Parameters<?, Parameter> getParametersFor(String methodName, Class<?>... parameterTypes)
throws SecurityException, NoSuchMethodException {
@ -268,6 +287,10 @@ class ParametersUnitTests { @@ -268,6 +287,10 @@ class ParametersUnitTests {
void methodWithSingle(Single<String> single);
void methodWithScoreRange(Range<Score> single);
void methodWithSimilarityRange(Range<Similarity> single);
Page<Object> customPageable(SomePageable pageable);
Window<Object> customScrollPosition(OffsetScrollPosition request);

26
src/test/java/org/springframework/data/repository/query/QueryMethodUnitTests.java

@ -34,14 +34,16 @@ import org.eclipse.collections.api.list.ImmutableList; @@ -34,14 +34,16 @@ import org.eclipse.collections.api.list.ImmutableList;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestFactory;
import org.springframework.data.domain.Limit;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.ScrollPosition;
import org.springframework.data.domain.SearchResult;
import org.springframework.data.domain.SearchResults;
import org.springframework.data.domain.Slice;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Window;
import org.springframework.data.domain.Window;
import org.springframework.data.projection.ProjectionFactory;
import org.springframework.data.projection.SpelAwareProxyProjectionFactory;
import org.springframework.data.repository.Repository;
@ -388,6 +390,24 @@ class QueryMethodUnitTests { @@ -388,6 +390,24 @@ class QueryMethodUnitTests {
});
}
@Test // GH-
void considersSearchResults() throws NoSuchMethodException {
var method = SampleRepository.class.getMethod("searchTop5By");
QueryMethod queryMethod = new QueryMethod(method, metadata, factory);
assertThat(queryMethod.isSearchQuery()).isTrue();
}
@Test // GH-
void considersSearchResult() throws NoSuchMethodException {
var method = SampleRepository.class.getMethod("searchListTop5By");
QueryMethod queryMethod = new QueryMethod(method, metadata, factory);
assertThat(queryMethod.isSearchQuery()).isTrue();
}
interface SampleRepository extends Repository<User, Serializable> {
String pagingMethodWithInvalidReturnType(Pageable pageable);
@ -460,6 +480,10 @@ class QueryMethodUnitTests { @@ -460,6 +480,10 @@ class QueryMethodUnitTests {
List<User> findTop5By(Limit limit);
List<User> findTop5By(Pageable page);
SearchResults<User> searchTop5By();
List<SearchResult<User>> searchListTop5By();
}
class User {

28
src/test/java/org/springframework/data/repository/query/SimpleParameterAccessorUnitTests.java

@ -21,7 +21,10 @@ import org.junit.jupiter.api.BeforeEach; @@ -21,7 +21,10 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Range;
import org.springframework.data.domain.Score;
import org.springframework.data.domain.ScrollPosition;
import org.springframework.data.domain.Similarity;
import org.springframework.data.domain.Sort;
/**
@ -32,7 +35,7 @@ import org.springframework.data.domain.Sort; @@ -32,7 +35,7 @@ import org.springframework.data.domain.Sort;
*/
class SimpleParameterAccessorUnitTests {
Parameters<?, ?> parameters, cursorRequestParameters, sortParameters, pageableParameters;
Parameters<?, ?> parameters, cursorRequestParameters, sortParameters, pageableParameters, scoreParameters;
@BeforeEach
void setUp() throws SecurityException, NoSuchMethodException {
@ -44,6 +47,9 @@ class SimpleParameterAccessorUnitTests { @@ -44,6 +47,9 @@ class SimpleParameterAccessorUnitTests {
ParametersSource.of(Sample.class.getMethod("sample1", String.class, Sort.class)));
pageableParameters = new DefaultParameters(
ParametersSource.of(Sample.class.getMethod("sample2", String.class, Pageable.class)));
scoreParameters = new DefaultParameters(
ParametersSource.of(Sample.class.getMethod("sample", String.class, Score.class, Range.class)));
}
@Test
@ -122,12 +128,32 @@ class SimpleParameterAccessorUnitTests { @@ -122,12 +128,32 @@ class SimpleParameterAccessorUnitTests {
assertThat(accessor.getSort()).isEqualTo(sort);
}
@Test
void returnsScoreIfAvailable() {
Score score = Score.of(1);
ParameterAccessor accessor = new ParametersParameterAccessor(scoreParameters, new Object[] { "test", score, null });
assertThat(accessor.getScore()).isEqualTo(score);
}
@Test
void returnsScoreRangeIfAvailable() {
Range<Similarity> range = Similarity.between(0, 1);
ParameterAccessor accessor = new ParametersParameterAccessor(scoreParameters, new Object[] { "test", null, range });
assertThat(accessor.getScoreRange()).isEqualTo(range);
}
interface Sample {
void sample(String firstname);
void sample(ScrollPosition scrollPosition);
void sample(String firstname, Score score, Range<Score> range);
void sample1(String firstname, Sort sort);
void sample2(String firstname, Pageable pageable);

Loading…
Cancel
Save