diff --git a/pom.xml b/pom.xml index ded4d85d02..5a7c5cc9db 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-mongodb-parent - 4.5.0-SNAPSHOT + 4.5.x-GH-4706-SNAPSHOT pom Spring Data MongoDB @@ -26,7 +26,7 @@ multi spring-data-mongodb - 3.5.0-SNAPSHOT + 3.5.0-GH-3193-SNAPSHOT 5.3.1 ${mongo} ${mongo} diff --git a/spring-data-mongodb-distribution/pom.xml b/spring-data-mongodb-distribution/pom.xml index 58c63dfc97..e382c300b9 100644 --- a/spring-data-mongodb-distribution/pom.xml +++ b/spring-data-mongodb-distribution/pom.xml @@ -15,7 +15,7 @@ org.springframework.data spring-data-mongodb-parent - 4.5.0-SNAPSHOT + 4.5.x-GH-4706-SNAPSHOT ../pom.xml diff --git a/spring-data-mongodb/pom.xml b/spring-data-mongodb/pom.xml index 98516a5ba9..86e96abb6a 100644 --- a/spring-data-mongodb/pom.xml +++ b/spring-data-mongodb/pom.xml @@ -13,7 +13,7 @@ org.springframework.data spring-data-mongodb-parent - 4.5.0-SNAPSHOT + 4.5.x-GH-4706-SNAPSHOT ../pom.xml @@ -131,6 +131,13 @@ true + + org.awaitility + awaitility + 4.2.2 + test + + io.reactivex.rxjava3 rxjava @@ -266,6 +273,20 @@ test + + org.testcontainers + junit-jupiter + ${testcontainers} + test + + + + org.testcontainers + mongodb + ${testcontainers} + test + + jakarta.transaction jakarta.transaction-api diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/DefaultIndexOperations.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/DefaultIndexOperations.java index c25804e8e5..2057e2f046 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/DefaultIndexOperations.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/DefaultIndexOperations.java @@ -20,7 +20,6 @@ import java.util.List; import org.bson.Document; - import org.springframework.dao.DataAccessException; import org.springframework.data.mongodb.MongoDatabaseFactory; import org.springframework.data.mongodb.UncategorizedMongoDbException; diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MappingMongoJsonSchemaCreator.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MappingMongoJsonSchemaCreator.java index 6bf8343ab1..86e01afc26 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MappingMongoJsonSchemaCreator.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MappingMongoJsonSchemaCreator.java @@ -185,6 +185,10 @@ private JsonSchemaProperty computeSchemaForProperty(List rawTargetType = computeTargetType(property); // target type before conversion Class targetType = converter.getTypeMapper().getWriteTargetTypeFor(rawTargetType); // conversion target type + if ((rawTargetType.isPrimitive() || ClassUtils.isPrimitiveArray(rawTargetType)) && targetType == Object.class) { + targetType = rawTargetType; + } + if (!isCollection(property) && ObjectUtils.nullSafeEquals(rawTargetType, targetType)) { if (property.isEntity() || mergeProperties.containsKey(stringPath)) { List targetProperties = new ArrayList<>(); @@ -334,8 +338,8 @@ private TypedJsonSchemaObject createSchemaObject(Object type, Collection poss private String computePropertyFieldName(PersistentProperty property) { - return property instanceof MongoPersistentProperty mongoPersistentProperty ? - mongoPersistentProperty.getFieldName() : property.getName(); + return property instanceof MongoPersistentProperty mongoPersistentProperty ? mongoPersistentProperty.getFieldName() + : property.getName(); } private boolean isRequiredProperty(PersistentProperty property) { diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoTemplate.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoTemplate.java index b984c379c6..fd05cd5b1f 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoTemplate.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoTemplate.java @@ -85,10 +85,13 @@ import org.springframework.data.mongodb.core.convert.MongoWriter; import org.springframework.data.mongodb.core.convert.QueryMapper; import org.springframework.data.mongodb.core.convert.UpdateMapper; +import org.springframework.data.mongodb.core.index.DefaultSearchIndexOperations; import org.springframework.data.mongodb.core.index.IndexOperations; import org.springframework.data.mongodb.core.index.IndexOperationsProvider; import org.springframework.data.mongodb.core.index.MongoMappingEventPublisher; import org.springframework.data.mongodb.core.index.MongoPersistentEntityIndexCreator; +import org.springframework.data.mongodb.core.index.SearchIndexOperations; +import org.springframework.data.mongodb.core.index.SearchIndexOperationsProvider; import org.springframework.data.mongodb.core.mapping.MongoMappingContext; import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity; import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty; @@ -182,8 +185,8 @@ * @author Michael Krog * @author Jakub Zurawa */ -public class MongoTemplate - implements MongoOperations, ApplicationContextAware, IndexOperationsProvider, ReadPreferenceAware { +public class MongoTemplate implements MongoOperations, ApplicationContextAware, IndexOperationsProvider, + SearchIndexOperationsProvider, ReadPreferenceAware { private static final Log LOGGER = LogFactory.getLog(MongoTemplate.class); private static final WriteResultChecking DEFAULT_WRITE_RESULT_CHECKING = WriteResultChecking.NONE; @@ -768,6 +771,21 @@ public IndexOperations indexOps(Class entityClass) { return indexOps(getCollectionName(entityClass), entityClass); } + @Override + public SearchIndexOperations searchIndexOps(String collectionName) { + return searchIndexOps(null, collectionName); + } + + @Override + public SearchIndexOperations searchIndexOps(Class type) { + return new DefaultSearchIndexOperations(this, type); + } + + @Override + public SearchIndexOperations searchIndexOps(@Nullable Class type, String collectionName) { + return new DefaultSearchIndexOperations(this, collectionName, type); + } + @Override public BulkOperations bulkOps(BulkMode mode, String collectionName) { return bulkOps(mode, null, collectionName); @@ -1313,7 +1331,7 @@ private WriteConcern potentiallyForceAcknowledgedWrite(@Nullable WriteConcern wc if (ObjectUtils.nullSafeEquals(WriteResultChecking.EXCEPTION, writeResultChecking)) { if (wc == null || wc.getWObject() == null - || (wc.getWObject()instanceof Number concern && concern.intValue() < 1)) { + || (wc.getWObject() instanceof Number concern && concern.intValue() < 1)) { return WriteConcern.ACKNOWLEDGED; } } @@ -1965,7 +1983,8 @@ public List mapReduce(Query query, Class domainType, String inputColle } if (mapReduceOptions.getOutputSharded().isPresent()) { - MongoCompatibilityAdapter.mapReduceIterableAdapter(mapReduce).sharded(mapReduceOptions.getOutputSharded().get()); + MongoCompatibilityAdapter.mapReduceIterableAdapter(mapReduce) + .sharded(mapReduceOptions.getOutputSharded().get()); } if (StringUtils.hasText(mapReduceOptions.getOutputCollection()) && !mapReduceOptions.usesInlineOutput()) { @@ -2064,7 +2083,7 @@ public List findAllAndRemove(Query query, Class entityClass, String co } @Override - public UpdateResult replace(Query query, T replacement, ReplaceOptions options, String collectionName){ + public UpdateResult replace(Query query, T replacement, ReplaceOptions options, String collectionName) { Assert.notNull(replacement, "Replacement must not be null"); return replace(query, (Class) ClassUtils.getUserClass(replacement), replacement, options, collectionName); @@ -2740,8 +2759,7 @@ protected T doFindAndModify(CollectionPreparer collectionPreparer, String co LOGGER.debug(String.format( "findAndModify using query: %s fields: %s sort: %s for class: %s and update: %s in collection: %s", serializeToJsonSafely(mappedQuery), fields, serializeToJsonSafely(sort), entityClass, - serializeToJsonSafely(mappedUpdate), - collectionName)); + serializeToJsonSafely(mappedUpdate), collectionName)); } return executeFindOneInternal( diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/Aggregation.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/Aggregation.java index f3984f3fdc..45de38ed21 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/Aggregation.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/Aggregation.java @@ -381,9 +381,9 @@ public static UnwindOperation unwind(String field, String arrayIndex) { } /** - * Factory method to create a new {@link UnwindOperation} for the field with the given name, including the name of a new - * field to hold the array index of the element as {@code arrayIndex} using {@code preserveNullAndEmptyArrays}. Note - * that extended unwind is supported in MongoDB version 3.2+. + * Factory method to create a new {@link UnwindOperation} for the field with the given name, including the name of a + * new field to hold the array index of the element as {@code arrayIndex} using {@code preserveNullAndEmptyArrays}. + * Note that extended unwind is supported in MongoDB version 3.2+. * * @param field must not be {@literal null} or empty. * @param arrayIndex must not be {@literal null} or empty. @@ -428,6 +428,20 @@ public static StartWithBuilder graphLookup(String fromCollection) { return GraphLookupOperation.builder().from(fromCollection); } + /** + * Creates a new {@link VectorSearchOperation} by starting from the {@code indexName} to use. + * + * @param indexName must not be {@literal null} or empty. + * @return new instance of {@link VectorSearchOperation.PathContributor}. + * @since 4.5 + */ + public static VectorSearchOperation.PathContributor vectorSearch(String indexName) { + + Assert.hasText(indexName, "Index name must not be null or empty"); + + return VectorSearchOperation.search(indexName); + } + /** * Factory method to create a new {@link SortOperation} for the given {@link Sort}. * @@ -669,14 +683,14 @@ public static LookupOperation lookup(Field from, Field localField, Field foreign /** * Entrypoint for creating {@link LookupOperation $lookup} using a fluent builder API. + * *
-	 * Aggregation.lookup().from("restaurants")
-	 * 	.localField("restaurant_name")
-	 * 	.foreignField("name")
-	 * 	.let(newVariable("orders_drink").forField("drink"))
-	 * 	.pipeline(match(ctx -> new Document("$expr", new Document("$in", List.of("$$orders_drink", "$beverages")))))
-	 * 	.as("matches")
+	 * Aggregation.lookup().from("restaurants").localField("restaurant_name").foreignField("name")
+	 * 		.let(newVariable("orders_drink").forField("drink"))
+	 * 		.pipeline(match(ctx -> new Document("$expr", new Document("$in", List.of("$$orders_drink", "$beverages")))))
+	 * 		.as("matches")
 	 * 
+ * * @return new instance of {@link LookupOperationBuilder}. * @since 4.1 */ diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/VectorSearchOperation.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/VectorSearchOperation.java new file mode 100644 index 0000000000..bcc5fbd7bc --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/VectorSearchOperation.java @@ -0,0 +1,519 @@ +/* + * Copyright 2024 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.mongodb.core.aggregation; + +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import org.bson.BinaryVector; +import org.bson.Document; + +import org.springframework.data.domain.Limit; +import org.springframework.data.domain.Vector; +import org.springframework.data.mongodb.core.mapping.MongoVector; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.CriteriaDefinition; +import org.springframework.lang.Contract; +import org.springframework.lang.Nullable; +import org.springframework.util.StringUtils; + +/** + * Performs a semantic search on data in your Atlas cluster. This stage is only available for Atlas Vector Search. + * Vector data must be less than or equal to 4096 dimensions in width. + *

Limitations

You cannot use this stage together with: + *
    + *
  • {@link org.springframework.data.mongodb.core.aggregation.LookupOperation Lookup} stages
  • + *
  • {@link org.springframework.data.mongodb.core.aggregation.FacetOperation Facet} stage
  • + *
+ * + * @author Christoph Strobl + * @author Mark Paluch + * @since 4.5 + */ +public class VectorSearchOperation implements AggregationOperation { + + private final SearchType searchType; + private final @Nullable CriteriaDefinition filter; + private final String indexName; + private final Limit limit; + private final @Nullable Integer numCandidates; + private final QueryPaths path; + private final Vector vector; + private final String score; + private final Consumer scoreCriteria; + + private VectorSearchOperation(SearchType searchType, @Nullable CriteriaDefinition filter, String indexName, + Limit limit, @Nullable Integer numCandidates, QueryPaths path, Vector vector, @Nullable String searchScore, + Consumer scoreCriteria) { + + this.searchType = searchType; + this.filter = filter; + this.indexName = indexName; + this.limit = limit; + this.numCandidates = numCandidates; + this.path = path; + this.vector = vector; + this.score = searchScore; + this.scoreCriteria = scoreCriteria; + } + + VectorSearchOperation(String indexName, QueryPaths path, Limit limit, Vector vector) { + this(SearchType.DEFAULT, null, indexName, limit, null, path, vector, null, null); + } + + /** + * Entrypoint to build a {@link VectorSearchOperation} starting from the {@code index} name to search. Atlas Vector + * Search doesn't return results if you misspell the index name or if the specified index doesn't already exist on the + * cluster. + * + * @param index must not be {@literal null} or empty. + * @return new instance of {@link VectorSearchOperation.PathContributor}. + */ + public static PathContributor search(String index) { + return new VectorSearchBuilder().index(index); + } + + /** + * Configure the search type to use. {@link SearchType#ENN} leads to an exact search while {@link SearchType#ANN} uses + * {@code exact=false}. + * + * @param searchType must not be null. + * @return a new {@link VectorSearchOperation} with {@link SearchType} applied. + */ + @Contract("_ -> new") + public VectorSearchOperation searchType(SearchType searchType) { + return new VectorSearchOperation(searchType, filter, indexName, limit, numCandidates, path, vector, score, + scoreCriteria); + } + + /** + * Criteria expression that compares an indexed field with a boolean, date, objectId, number (not decimals), string, + * or UUID to use as a pre-filter. + *

+ * Atlas Vector Search supports only the filters for the following MQL match expressions: + *

    + *
  • $gt
  • + *
  • $lt
  • + *
  • $gte
  • + *
  • $lte
  • + *
  • $eq
  • + *
  • $ne
  • + *
  • $in
  • + *
  • $nin
  • + *
  • $nor
  • + *
  • $not
  • + *
  • $and
  • + *
  • $or
  • + *
+ * + * @param filter must not be null. + * @return a new {@link VectorSearchOperation} with {@link CriteriaDefinition} applied. + */ + @Contract("_ -> new") + public VectorSearchOperation filter(CriteriaDefinition filter) { + return new VectorSearchOperation(searchType, filter, indexName, limit, numCandidates, path, vector, score, + scoreCriteria); + } + + /** + * Criteria expression that compares an indexed field with a boolean, date, objectId, number (not decimals), string, + * or UUID to use as a pre-filter. + *

+ * Atlas Vector Search supports only the filters for the following MQL match expressions: + *

    + *
  • $gt
  • + *
  • $lt
  • + *
  • $gte
  • + *
  • $lte
  • + *
  • $eq
  • + *
  • $ne
  • + *
  • $in
  • + *
  • $nin
  • + *
  • $nor
  • + *
  • $not
  • + *
  • $and
  • + *
  • $or
  • + *
+ * + * @param filter must not be null. + * @return a new {@link VectorSearchOperation} with {@link CriteriaDefinition} applied. + */ + @Contract("_ -> new") + public VectorSearchOperation filter(Document filter) { + + return filter(new CriteriaDefinition() { + @Override + public Document getCriteriaObject() { + return filter; + } + + @Nullable + @Override + public String getKey() { + return null; + } + }); + } + + /** + * Number of nearest neighbors to use during the search. Value must be less than or equal to (<=) {@code 10000}. You + * can't specify a number less than the number of documents to return (limit). This field is required if + * {@link #searchType(SearchType)} is {@link SearchType#ANN} or {@link SearchType#DEFAULT}. + * + * @param numCandidates number of nearest neighbors to use during the search + * @return a new {@link VectorSearchOperation} with {@code numCandidates} applied. + */ + @Contract("_ -> new") + public VectorSearchOperation numCandidates(int numCandidates) { + return new VectorSearchOperation(searchType, filter, indexName, limit, numCandidates, path, vector, score, + scoreCriteria); + } + + /** + * Add a {@link AddFieldsOperation} stage including the search score using {@code score} as field name. + * + * @return a new {@link VectorSearchOperation} with search score applied. + * @see #withSearchScore(String) + */ + @Contract("-> new") + public VectorSearchOperation withSearchScore() { + return withSearchScore("score"); + } + + /** + * Add a {@link AddFieldsOperation} stage including the search score using {@code scoreFieldName} as field name. + * + * @param scoreFieldName name of the score field. + * @return a new {@link VectorSearchOperation} with {@code scoreFieldName} applied. + * @see #withSearchScore() + */ + @Contract("_ -> new") + public VectorSearchOperation withSearchScore(String scoreFieldName) { + return new VectorSearchOperation(searchType, filter, indexName, limit, numCandidates, path, vector, scoreFieldName, + scoreCriteria); + } + + /** + * Add a {@link MatchOperation} stage targeting the score field name. Implies that the score field is present by + * either reusing a previous {@link AddFieldsOperation} from {@link #withSearchScore()} or + * {@link #withSearchScore(String)} or by adding a new {@link AddFieldsOperation} stage. + * + * @return a new {@link VectorSearchOperation} with search score filter applied. + */ + @Contract("_ -> new") + public VectorSearchOperation withFilterBySore(Consumer score) { + return new VectorSearchOperation(searchType, filter, indexName, limit, numCandidates, path, vector, + StringUtils.hasText(this.score) ? this.score : "score", score); + } + + @Override + public Document toDocument(AggregationOperationContext context) { + + Document $vectorSearch = new Document(); + + if (searchType != null && !searchType.equals(SearchType.DEFAULT)) { + $vectorSearch.append("exact", searchType.equals(SearchType.ENN)); + } + + if (filter != null) { + $vectorSearch.append("filter", context.getMappedObject(filter.getCriteriaObject())); + } + + $vectorSearch.append("index", indexName); + $vectorSearch.append("limit", limit.max()); + + if (numCandidates != null) { + $vectorSearch.append("numCandidates", numCandidates); + } + + Object path = this.path.getPathObject(); + + if (path instanceof String pathFieldName) { + Document mappedObject = context.getMappedObject(new Document(pathFieldName, 1)); + path = mappedObject.keySet().iterator().next(); + } + + Object source = vector.getSource(); + + if (source instanceof float[]) { + source = vector.toDoubleArray(); + } + + if (source instanceof double[] ds) { + source = Arrays.stream(ds).boxed().collect(Collectors.toList()); + } + + $vectorSearch.append("path", path); + $vectorSearch.append("queryVector", source); + + return new Document(getOperator(), $vectorSearch); + } + + @Override + public List toPipelineStages(AggregationOperationContext context) { + + if (!StringUtils.hasText(score)) { + return List.of(toDocument(context)); + } + + AddFieldsOperation $vectorSearchScore = Aggregation.addFields().addField(score) + .withValueOfExpression("{\"$meta\":\"vectorSearchScore\"}").build(); + + if (scoreCriteria == null) { + return List.of(toDocument(context), $vectorSearchScore.toDocument(context)); + } + + Criteria criteria = Criteria.where(score); + scoreCriteria.accept(criteria); + MatchOperation $filterByScore = Aggregation.match(criteria); + + return List.of(toDocument(context), $vectorSearchScore.toDocument(context), $filterByScore.toDocument(context)); + } + + @Override + public String getOperator() { + return "$vectorSearch"; + } + + /** + * Builder helper to create a {@link VectorSearchOperation}. + */ + private static class VectorSearchBuilder implements PathContributor, VectorContributor, LimitContributor { + + String index; + QueryPath paths; + Vector vector; + + PathContributor index(String index) { + this.index = index; + return this; + } + + @Override + public VectorContributor path(String path) { + + this.paths = QueryPath.path(path); + return this; + } + + @Override + public VectorSearchOperation limit(Limit limit) { + return new VectorSearchOperation(index, QueryPaths.of(paths), limit, vector); + } + + @Override + public LimitContributor vector(Vector vector) { + this.vector = vector; + return this; + } + } + + /** + * Search type, ANN as approximation or ENN for exact search. + */ + public enum SearchType { + + /** MongoDB Server default (value will be omitted) */ + DEFAULT, + /** Approximate Nearest Neighbour */ + ANN, + /** Exact Nearest Neighbour */ + ENN + } + + /** + * Value object capturing query paths. + */ + public static class QueryPaths { + + private final Set> paths; + + private QueryPaths(Set> paths) { + this.paths = paths; + } + + /** + * Factory method to create {@link QueryPaths} from a single {@link QueryPath}. + * + * @param path + * @return a new {@link QueryPaths} instance. + */ + public static QueryPaths of(QueryPath path) { + return new QueryPaths(Set.of(path)); + } + + Object getPathObject() { + + if (paths.size() == 1) { + return paths.iterator().next().value(); + } + return paths.stream().map(QueryPath::value).collect(Collectors.toList()); + } + } + + /** + * Interface describing a query path contract. Query paths might be simple field names, wildcard paths, or + * multi-paths. paths. + * + * @param + */ + public interface QueryPath { + + T value(); + + static QueryPath path(String field) { + return new SimplePath(field); + } + } + + public static class SimplePath implements QueryPath { + + String name; + + public SimplePath(String name) { + this.name = name; + } + + @Override + public String value() { + return name; + } + } + + /** + * Fluent API to configure a path on the VectorSearchOperation builder. + */ + public interface PathContributor { + + /** + * Indexed vector type field to search. + * + * @param path name of the search path. + * @return + */ + @Contract("_ -> this") + VectorContributor path(String path); + } + + /** + * Fluent API to configure a vector on the VectorSearchOperation builder. + */ + public interface VectorContributor { + + /** + * Array of float numbers that represent the query vector. The number type must match the indexed field value type. + * Otherwise, Atlas Vector Search doesn't return any results or errors. + * + * @param vector the query vector. + * @return + */ + @Contract("_ -> this") + default LimitContributor vector(float... vector) { + return vector(Vector.of(vector)); + } + + /** + * Array of byte numbers that represent the query vector. The number type must match the indexed field value type. + * Otherwise, Atlas Vector Search doesn't return any results or errors. + * + * @param vector the query vector. + * @return + */ + @Contract("_ -> this") + default LimitContributor vector(byte[] vector) { + return vector(BinaryVector.int8Vector(vector)); + } + + /** + * Array of double numbers that represent the query vector. The number type must match the indexed field value type. + * Otherwise, Atlas Vector Search doesn't return any results or errors. + * + * @param vector the query vector. + * @return + */ + @Contract("_ -> this") + default LimitContributor vector(double... vector) { + return vector(Vector.of(vector)); + } + + /** + * Array of numbers that represent the query vector. The number type must match the indexed field value type. + * Otherwise, Atlas Vector Search doesn't return any results or errors. + * + * @param vector the query vector. + * @return + */ + @Contract("_ -> this") + default LimitContributor vector(List vector) { + return vector(Vector.of(vector)); + } + + /** + * Binary vector (BSON BinData vector subtype float32, or BSON BinData vector subtype int1 or int8 type) that + * represent the query vector. The number type must match the indexed field value type. Otherwise, Atlas Vector + * Search doesn't return any results or errors. + * + * @param vector the query vector. + * @return + */ + @Contract("_ -> this") + default LimitContributor vector(BinaryVector vector) { + return vector(MongoVector.of(vector)); + } + + /** + * The query vector. The number type must match the indexed field value type. Otherwise, Atlas Vector Search doesn't + * return any results or errors. + * + * @param vector the query vector. + * @return + */ + @Contract("_ -> this") + LimitContributor vector(Vector vector); + } + + /** + * Fluent API to configure a limit on the VectorSearchOperation builder. + */ + public interface LimitContributor { + + /** + * Number (of type int only) of documents to return in the results. This value can't exceed the value of + * numCandidates if you specify numCandidates. + * + * @param limit + * @return + */ + @Contract("_ -> this") + default VectorSearchOperation limit(int limit) { + return limit(Limit.of(limit)); + } + + /** + * Number (of type int only) of documents to return in the results. This value can't exceed the value of + * numCandidates if you specify numCandidates. + * + * @param limit + * @return + */ + @Contract("_ -> this") + VectorSearchOperation limit(Limit limit); + } + +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MongoConverters.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MongoConverters.java index 46dc22d99a..9a658c44ba 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MongoConverters.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MongoConverters.java @@ -31,6 +31,9 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; +import org.bson.BinaryVector; +import org.bson.BsonArray; +import org.bson.BsonDouble; import org.bson.BsonReader; import org.bson.BsonTimestamp; import org.bson.BsonUndefined; @@ -44,6 +47,7 @@ import org.bson.types.Code; import org.bson.types.Decimal128; import org.bson.types.ObjectId; + import org.springframework.core.convert.ConversionFailedException; import org.springframework.core.convert.TypeDescriptor; import org.springframework.core.convert.converter.ConditionalConverter; @@ -51,7 +55,9 @@ import org.springframework.core.convert.converter.ConverterFactory; import org.springframework.data.convert.ReadingConverter; import org.springframework.data.convert.WritingConverter; +import org.springframework.data.domain.Vector; import org.springframework.data.mongodb.core.mapping.FieldName; +import org.springframework.data.mongodb.core.mapping.MongoVector; import org.springframework.data.mongodb.core.query.Term; import org.springframework.data.mongodb.core.script.NamedMongoScript; import org.springframework.util.Assert; @@ -106,6 +112,10 @@ static Collection getConvertersToRegister() { converters.add(BinaryToByteArrayConverter.INSTANCE); converters.add(BsonTimestampToInstantConverter.INSTANCE); + converters.add(VectorToBsonArrayConverter.INSTANCE); + converters.add(ListToVectorConverter.INSTANCE); + converters.add(BinaryVectorToMongoVectorConverter.INSTANCE); + converters.add(reading(BsonUndefined.class, Object.class, it -> null)); converters.add(reading(String.class, URI.class, URI::create).andWriting(URI::toString)); @@ -417,6 +427,94 @@ public T convert(Number source) { } } + @WritingConverter + enum VectorToBsonArrayConverter implements Converter { + + INSTANCE; + + @Override + public Object convert(Vector source) { + + if (source instanceof MongoVector mv) { + return mv.getSource(); + } + + double[] doubleArray = source.toDoubleArray(); + + BsonArray array = new BsonArray(doubleArray.length); + + for (double v : doubleArray) { + array.add(new BsonDouble(v)); + } + + return array; + } + } + + @ReadingConverter + enum ListToVectorConverter implements Converter, Vector> { + + INSTANCE; + + @Override + public Vector convert(List source) { + return Vector.of(source); + } + } + + @ReadingConverter + enum BinaryVectorToMongoVectorConverter implements Converter { + + INSTANCE; + + @Override + public Vector convert(BinaryVector source) { + return MongoVector.of(source); + } + } + + @WritingConverter + enum ByteArrayConverterFactory implements ConverterFactory, ConditionalConverter { + + INSTANCE; + + @Override + public Converter getConverter(Class targetType) { + return new ByteArrayConverter<>(targetType); + } + + @Override + public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) { + return targetType.getType() != Object.class && !sourceType.equals(targetType); + } + + private final static class ByteArrayConverter implements Converter { + + private final Class targetType; + + /** + * Creates a new {@link ByteArrayConverter} for the given target type. + * + * @param targetType must not be {@literal null}. + */ + public ByteArrayConverter(Class targetType) { + + Assert.notNull(targetType, "Target type must not be null"); + + this.targetType = targetType; + } + + @Override + public T convert(byte[] source) { + + if (this.targetType == BinaryVector.class) { + return (T) BinaryVector.int8Vector(source); + } + return (T) source; + } + } + } + /** * {@link ConverterFactory} implementation converting {@link AtomicLong} into {@link Long}. * diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/QueryMapper.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/QueryMapper.java index 39559b9979..cce809adc6 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/QueryMapper.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/QueryMapper.java @@ -1130,7 +1130,7 @@ public Class getFieldType() { * @author Oliver Gierke * @author Thomas Darimont */ - protected static class MetadataBackedField extends Field { + public static class MetadataBackedField extends Field { private static final Pattern POSITIONAL_PARAMETER_PATTERN = Pattern.compile("\\.\\$(\\[.*?\\])?"); private static final Pattern NUMERIC_SEGMENT = Pattern.compile("\\d+"); diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/DefaultSearchIndexOperations.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/DefaultSearchIndexOperations.java new file mode 100644 index 0000000000..225bb41ac8 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/DefaultSearchIndexOperations.java @@ -0,0 +1,125 @@ +/* + * 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.mongodb.core.index; + +import java.util.ArrayList; +import java.util.List; + +import org.bson.BsonString; +import org.bson.Document; +import org.springframework.data.mapping.context.MappingContext; +import org.springframework.data.mongodb.core.MongoOperations; +import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity; +import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty; +import org.springframework.data.util.TypeInformation; +import org.springframework.lang.Nullable; +import org.springframework.util.StringUtils; + +import com.mongodb.client.model.SearchIndexModel; +import com.mongodb.client.model.SearchIndexType; + +/** + * @author Christoph Strobl + * @author Mark Paluch + * @since 4.5 + */ +public class DefaultSearchIndexOperations implements SearchIndexOperations { + + private final MongoOperations mongoOperations; + private final String collectionName; + private final TypeInformation entityTypeInformation; + + public DefaultSearchIndexOperations(MongoOperations mongoOperations, Class type) { + this(mongoOperations, mongoOperations.getCollectionName(type), type); + } + + public DefaultSearchIndexOperations(MongoOperations mongoOperations, String collectionName, @Nullable Class type) { + + this.collectionName = collectionName; + + if (type != null) { + + MappingContext, MongoPersistentProperty> mappingContext = mongoOperations + .getConverter().getMappingContext(); + entityTypeInformation = mappingContext.getRequiredPersistentEntity(type).getTypeInformation(); + } else { + entityTypeInformation = null; + } + + this.mongoOperations = mongoOperations; + } + + @Override + public String createIndex(SearchIndexDefinition indexDefinition) { + + Document index = indexDefinition.getIndexDocument(entityTypeInformation, + mongoOperations.getConverter().getMappingContext()); + + mongoOperations.getCollection(collectionName) + .createSearchIndexes(List.of(new SearchIndexModel(indexDefinition.getName(), + index.get("definition", Document.class), SearchIndexType.of(new BsonString(indexDefinition.getType()))))); + + return indexDefinition.getName(); + } + + @Override + public void updateIndex(SearchIndexDefinition indexDefinition) { + + Document indexDocument = indexDefinition.getIndexDocument(entityTypeInformation, + mongoOperations.getConverter().getMappingContext()); + + mongoOperations.getCollection(collectionName).updateSearchIndex(indexDefinition.getName(), indexDocument); + } + + @Override + public boolean exists(String indexName) { + return getSearchIndex(indexName) != null; + } + + @Override + public SearchIndexStatus status(String indexName) { + + Document searchIndex = getSearchIndex(indexName); + return searchIndex != null ? SearchIndexStatus.valueOf(searchIndex.getString("status")) + : SearchIndexStatus.DOES_NOT_EXIST; + } + + @Override + public void dropAllIndexes() { + getSearchIndexes(null).forEach(indexInfo -> dropIndex(indexInfo.getString("name"))); + } + + @Override + public void dropIndex(String indexName) { + mongoOperations.getCollection(collectionName).dropSearchIndex(indexName); + } + + @Nullable + private Document getSearchIndex(String indexName) { + + List indexes = getSearchIndexes(indexName); + return indexes.isEmpty() ? null : indexes.iterator().next(); + } + + private List getSearchIndexes(@Nullable String indexName) { + + Document filter = StringUtils.hasText(indexName) ? new Document("name", indexName) : new Document(); + + return mongoOperations.getCollection(collectionName).aggregate(List.of(new Document("$listSearchIndexes", filter))) + .into(new ArrayList<>()); + } + +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/IndexField.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/IndexField.java index 3fff86a3ea..a5cbf6c896 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/IndexField.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/IndexField.java @@ -39,7 +39,12 @@ enum Type { /** * @since 3.3 */ - WILDCARD + WILDCARD, + + /** + * @since ?.? + */ + VECTOR } private final String key; @@ -58,7 +63,7 @@ private IndexField(String key, @Nullable Direction direction, @Nullable Type typ if (Type.GEO.equals(type) || Type.TEXT.equals(type)) { Assert.isNull(direction, "Geo/Text indexes must not have a direction"); } else { - if (!(Type.HASH.equals(type) || Type.WILDCARD.equals(type))) { + if (!(Type.HASH.equals(type) || Type.WILDCARD.equals(type) || Type.VECTOR.equals(type))) { Assert.notNull(direction, "Default indexes require a direction"); } } @@ -76,6 +81,10 @@ public static IndexField create(String key, Direction order) { return new IndexField(key, order, Type.DEFAULT); } + public static IndexField vector(String key) { + return new IndexField(key, null, Type.VECTOR); + } + /** * Creates a {@literal hashed} {@link IndexField} for the given key. * diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/IndexOperations.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/IndexOperations.java index 144e0aea4d..88e6d7a815 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/IndexOperations.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/IndexOperations.java @@ -33,9 +33,23 @@ public interface IndexOperations { * * @param indexDefinition must not be {@literal null}. * @return the index name. + * @deprecated since 4.5, in favor of {@link #createIndex(IndexDefinition)}. */ + @Deprecated(since = "4.5", forRemoval = true) String ensureIndex(IndexDefinition indexDefinition); + /** + * Create the index for the provided {@link IndexDefinition} exists for the collection indicated by the entity class. + * If not it will be created. + * + * @param indexDefinition must not be {@literal null}. + * @return the index name. + * @since 4.5 + */ + default String createIndex(IndexDefinition indexDefinition) { + return ensureIndex(indexDefinition); + } + /** * Alters the index with given {@literal name}. * diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/IndexOperationsProvider.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/IndexOperationsProvider.java index d86d90e3f6..ca3d951c94 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/IndexOperationsProvider.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/IndexOperationsProvider.java @@ -18,7 +18,7 @@ import org.springframework.lang.Nullable; /** - * Provider interface to obtain {@link IndexOperations} by MongoDB collection name. + * Provider interface to obtain {@link IndexOperations} by MongoDB collection name or entity type. * * @author Mark Paluch * @author Jens Schauder @@ -46,4 +46,5 @@ default IndexOperations indexOps(String collectionName) { * @since 3.2 */ IndexOperations indexOps(String collectionName, @Nullable Class type); + } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/ReactiveIndexOperations.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/ReactiveIndexOperations.java index c0fc065698..15b110c08a 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/ReactiveIndexOperations.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/ReactiveIndexOperations.java @@ -33,9 +33,23 @@ public interface ReactiveIndexOperations { * * @param indexDefinition must not be {@literal null}. * @return a {@link Mono} emitting the name of the index on completion. + * @deprecated since 4.5, in favor of {@link #createIndex(IndexDefinition)}. */ + @Deprecated(since = "4.5", forRemoval = true) Mono ensureIndex(IndexDefinition indexDefinition); + /** + * Create the index for the provided {@link IndexDefinition} exists for the collection indicated by the entity class. + * If not it will be created. + * + * @param indexDefinition must not be {@literal null}. + * @return the index name. + * @since 4.5 + */ + default Mono createIndex(IndexDefinition indexDefinition) { + return ensureIndex(indexDefinition); + } + /** * Alters the index with given {@literal name}. * diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/SearchIndexDefinition.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/SearchIndexDefinition.java new file mode 100644 index 0000000000..9d4315beae --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/SearchIndexDefinition.java @@ -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.mongodb.core.index; + +import org.bson.Document; +import org.springframework.data.mapping.context.MappingContext; +import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity; +import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty; +import org.springframework.data.util.TypeInformation; +import org.springframework.lang.Nullable; + +/** + * Definition for an Atlas Search Index (Search Index or Vector Index). + * + * @author Marcin Grzejszczak + * @author Mark Paluch + * @since 4.5 + */ +public interface SearchIndexDefinition { + + /** + * @return the name of the index. + */ + String getName(); + + /** + * @return the type of the index. Typically, {@code search} or {@code vectorSearch}. + */ + String getType(); + + /** + * Returns the index document for this index without any potential entity context resolving field name mappings. The + * resulting document contains the index name, type and {@link #getDefinition(TypeInformation, MappingContext) + * definition}. + * + * @return never {@literal null}. + */ + default Document getRawIndexDocument() { + return getIndexDocument(null, null); + } + + /** + * Returns the index document for this index in the context of a potential entity to resolve field name mappings. The + * resulting document contains the index name, type and {@link #getDefinition(TypeInformation, MappingContext) + * definition}. + * + * @param entity can be {@literal null}. + * @param mappingContext + * @return never {@literal null}. + */ + default Document getIndexDocument(@Nullable TypeInformation entity, + @Nullable MappingContext, MongoPersistentProperty> mappingContext) { + + Document document = new Document(); + document.put("name", getName()); + document.put("type", getType()); + document.put("definition", getDefinition(entity, mappingContext)); + + return document; + } + + /** + * Returns the actual index definition for this index in the context of a potential entity to resolve field name + * mappings. Entity and context can be {@literal null} to create a generic index definition without applying field + * name mapping. + * + * @param entity can be {@literal null}. + * @param mappingContext can be {@literal null}. + * @return never {@literal null}. + */ + Document getDefinition(@Nullable TypeInformation entity, + @Nullable MappingContext, MongoPersistentProperty> mappingContext); + +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/SearchIndexInfo.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/SearchIndexInfo.java new file mode 100644 index 0000000000..1a657ecf0b --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/SearchIndexInfo.java @@ -0,0 +1,129 @@ +/* + * 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.mongodb.core.index; + +import java.util.function.Supplier; + +import org.bson.Document; +import org.springframework.data.mapping.context.MappingContext; +import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity; +import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty; +import org.springframework.data.util.Lazy; +import org.springframework.data.util.TypeInformation; +import org.springframework.lang.Nullable; + +/** + * Index information for a MongoDB Search Index. + * + * @author Christoph Strobl + * @since 4.5 + */ +public class SearchIndexInfo { + + private final @Nullable Object id; + private final SearchIndexStatus status; + private final Lazy indexDefinition; + + SearchIndexInfo(@Nullable Object id, SearchIndexStatus status, Supplier indexDefinition) { + this.id = id; + this.status = status; + this.indexDefinition = Lazy.of(indexDefinition); + } + + /** + * Parse a BSON document describing an index into a {@link SearchIndexInfo}. + * + * @param source BSON document describing the index. + * @return a new {@link SearchIndexInfo} instance. + */ + public static SearchIndexInfo parse(String source) { + return of(Document.parse(source)); + } + + /** + * Create an index from its BSON {@link Document} representation into a {@link SearchIndexInfo}. + * + * @param indexDocument BSON document describing the index. + * @return a new {@link SearchIndexInfo} instance. + */ + public static SearchIndexInfo of(Document indexDocument) { + + Object id = indexDocument.get("id"); + SearchIndexStatus status = SearchIndexStatus + .valueOf(indexDocument.get("status", SearchIndexStatus.DOES_NOT_EXIST.name())); + + return new SearchIndexInfo(id, status, () -> readIndexDefinition(indexDocument)); + } + + /** + * The id of the index. Can be {@literal null}, eg. for an index not yet created. + * + * @return can be {@literal null}. + */ + @Nullable + public Object getId() { + return id; + } + + /** + * @return the current status of the index. + */ + public SearchIndexStatus getStatus() { + return status; + } + + /** + * @return the current index definition. + */ + public SearchIndexDefinition getIndexDefinition() { + return indexDefinition.get(); + } + + private static SearchIndexDefinition readIndexDefinition(Document document) { + + String type = document.get("type", "search"); + if (type.equals("vectorSearch")) { + return VectorIndex.of(document); + } + + return new SearchIndexDefinition() { + + @Override + public String getName() { + return document.getString("name"); + } + + @Override + public String getType() { + return type; + } + + @Override + public Document getDefinition(@Nullable TypeInformation entity, + @Nullable MappingContext, MongoPersistentProperty> mappingContext) { + if (document.containsKey("latestDefinition")) { + return document.get("latestDefinition", new Document()); + } + return document.get("definition", new Document()); + } + + @Override + public String toString() { + return getDefinition(null, null).toJson(); + } + }; + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/SearchIndexOperations.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/SearchIndexOperations.java new file mode 100644 index 0000000000..ee3f59cf95 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/SearchIndexOperations.java @@ -0,0 +1,75 @@ +/* + * 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.mongodb.core.index; + +import org.springframework.dao.DataAccessException; + +/** + * Search Index operations on a collection for Atlas Search. + * + * @author Christoph Strobl + * @author Mark Paluch + * @since 4.5 + * @see VectorIndex + */ +public interface SearchIndexOperations { + + /** + * Create the index for the given {@link SearchIndexDefinition} in the collection indicated by the entity class. + * + * @param indexDefinition must not be {@literal null}. + * @return the index name. + */ + String createIndex(SearchIndexDefinition indexDefinition); + + /** + * Alters the search index matching the index {@link SearchIndexDefinition#getName() name}. + *

+ * Atlas Search might not support updating indices which raises a {@link DataAccessException}. + * + * @param indexDefinition the index definition. + */ + void updateIndex(SearchIndexDefinition indexDefinition); + + /** + * Check whether an index with the given {@code indexName} exists for the collection indicated by the entity class. To + * ensure an existing index is queryable it is recommended to check its {@link #status(String) status}. + * + * @param indexName name of index to check for presence. + * @return {@literal true} if the index exists; {@literal false} otherwise. + */ + boolean exists(String indexName); + + /** + * Check the actual {@link SearchIndexStatus status} of an index. + * + * @param indexName name of index to get the status for. + * @return the current status of the index or {@link SearchIndexStatus#DOES_NOT_EXIST} if the index cannot be found. + */ + SearchIndexStatus status(String indexName); + + /** + * Drops an index from the collection indicated by the entity class. + * + * @param indexName name of index to drop. + */ + void dropIndex(String indexName); + + /** + * Drops all search indices from the collection indicated by the entity class. + */ + void dropAllIndexes(); +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/SearchIndexOperationsProvider.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/SearchIndexOperationsProvider.java new file mode 100644 index 0000000000..ee87c8d61e --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/SearchIndexOperationsProvider.java @@ -0,0 +1,51 @@ +/* + * 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.mongodb.core.index; + +/** + * Provider interface to obtain {@link SearchIndexOperations} by MongoDB collection name or entity type. + * + * @author Christoph Strobl + * @author Mark Paluch + * @since 4.5 + */ +public interface SearchIndexOperationsProvider { + + /** + * Returns the operations that can be performed on search indexes. + * + * @param collectionName name of the MongoDB collection, must not be {@literal null}. + * @return index operations on the named collection + */ + SearchIndexOperations searchIndexOps(String collectionName); + + /** + * Returns the operations that can be performed on search indexes. + * + * @param type the type used for field mapping. + * @return index operations on the named collection + */ + SearchIndexOperations searchIndexOps(Class type); + + /** + * Returns the operations that can be performed on search indexes. + * + * @param collectionName name of the MongoDB collection, must not be {@literal null}. + * @param type the type used for field mapping. Can be {@literal null}. + * @return index operations on the named collection + */ + SearchIndexOperations searchIndexOps(Class type, String collectionName); +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/SearchIndexStatus.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/SearchIndexStatus.java new file mode 100644 index 0000000000..91143d73c6 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/SearchIndexStatus.java @@ -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.mongodb.core.index; + +/** + * Representation of different conditions a search index can be in. + * + * @author Christoph Strobl + * @since 4.5 + */ +public enum SearchIndexStatus { + + /** building or re-building the index - might be queryable */ + BUILDING, + + /** nothing to be seen here - not queryable */ + DOES_NOT_EXIST, + + /** will cease to exist - no longer queryable */ + DELETING, + + /** well, this one is broken - not queryable */ + FAILED, + + /** busy with other things, check back later - not queryable */ + PENDING, + + /** ask me anything - queryable */ + READY, + + /** ask me anything about outdated data - still queryable */ + STALE +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/VectorIndex.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/VectorIndex.java new file mode 100644 index 0000000000..b46dbf4d0c --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/VectorIndex.java @@ -0,0 +1,349 @@ +/* + * Copyright 2024-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.mongodb.core.index; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +import org.bson.Document; +import org.springframework.data.mapping.context.MappingContext; +import org.springframework.data.mongodb.core.convert.QueryMapper; +import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity; +import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty; +import org.springframework.data.util.TypeInformation; +import org.springframework.lang.Contract; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * {@link SearchIndexDefinition} for creating MongoDB + * Vector Index required to + * run {@code $vectorSearch} queries. + * + * @author Christoph Strobl + * @author Mark Paluch + * @since 4.5 + */ +public class VectorIndex implements SearchIndexDefinition { + + private final String name; + private final List fields = new ArrayList<>(); + + /** + * Create a new {@link VectorIndex} instance. + * + * @param name The name of the index. + */ + public VectorIndex(String name) { + this.name = name; + } + + /** + * Add a filter field. + * + * @param path dot notation to field/property used for filtering. + * @return this. + */ + @Contract("_ -> this") + public VectorIndex addFilter(String path) { + + Assert.hasText(path, "Path must not be null or empty"); + + return addField(new VectorFilterField(path, "filter")); + } + + /** + * Add a vector field and accept a {@link VectorFieldBuilder} customizer. + * + * @param path dot notation to field/property used for filtering. + * @param customizer customizer function. + * @return this. + */ + @Contract("_, _ -> this") + public VectorIndex addVector(String path, Consumer customizer) { + + Assert.hasText(path, "Path must not be null or empty"); + + VectorFieldBuilder builder = new VectorFieldBuilder(path, "vector"); + customizer.accept(builder); + return addField(builder.build()); + } + + @Override + public String getName() { + return name; + } + + @Override + public String getType() { + return "vectorSearch"; + } + + @Override + public Document getDefinition(@Nullable TypeInformation entity, + @Nullable MappingContext, MongoPersistentProperty> mappingContext) { + + MongoPersistentEntity persistentEntity = entity != null + ? (mappingContext != null ? mappingContext.getPersistentEntity(entity) : null) + : null; + + Document definition = new Document(); + List fields = new ArrayList<>(); + definition.put("fields", fields); + + for (SearchField field : this.fields) { + + Document filter = new Document("type", field.type()); + filter.put("path", resolvePath(field.path(), persistentEntity, mappingContext)); + + if (field instanceof VectorIndexField vif) { + + filter.put("numDimensions", vif.dimensions()); + filter.put("similarity", vif.similarity()); + if (StringUtils.hasText(vif.quantization)) { + filter.put("quantization", vif.quantization()); + } + } + fields.add(filter); + } + + return definition; + } + + @Contract("_ -> this") + private VectorIndex addField(SearchField filterField) { + + fields.add(filterField); + return this; + } + + @Override + public String toString() { + return "VectorIndex{" + "name='" + name + '\'' + ", fields=" + fields + ", type='" + getType() + '\'' + '}'; + } + + /** + * Parse the {@link Document} into a {@link VectorIndex}. + */ + static VectorIndex of(Document document) { + + VectorIndex index = new VectorIndex(document.getString("name")); + + String definitionKey = document.containsKey("latestDefinition") ? "latestDefinition" : "definition"; + Document definition = document.get(definitionKey, Document.class); + + for (Object entry : definition.get("fields", List.class)) { + if (entry instanceof Document field) { + if (field.get("type").equals("vector")) { + index.addField(new VectorIndexField(field.getString("path"), "vector", field.getInteger("numDimensions"), + field.getString("similarity"), field.getString("quantization"))); + } else { + index.addField(new VectorFilterField(field.getString("path"), "filter")); + } + } + } + + return index; + } + + private String resolvePath(String path, @Nullable MongoPersistentEntity persistentEntity, + @Nullable MappingContext, MongoPersistentProperty> mappingContext) { + + if (persistentEntity == null || mappingContext == null) { + return path; + } + + QueryMapper.MetadataBackedField mbf = new QueryMapper.MetadataBackedField(path, persistentEntity, mappingContext); + + return mbf.getMappedKey(); + } + + interface SearchField { + + String path(); + + String type(); + } + + record VectorFilterField(String path, String type) implements SearchField { + } + + record VectorIndexField(String path, String type, int dimensions, @Nullable String similarity, + @Nullable String quantization) implements SearchField { + } + + /** + * Builder to create a vector field + */ + public static class VectorFieldBuilder { + + private final String path; + private final String type; + + private int dimensions; + private @Nullable String similarity; + private @Nullable String quantization; + + VectorFieldBuilder(String path, String type) { + + this.path = path; + this.type = type; + } + + /** + * Number of vector dimensions enforced at index- & query-time. + * + * @param dimensions value between {@code 0} and {@code 4096}. + * @return this. + */ + @Contract("_ -> this") + public VectorFieldBuilder dimensions(int dimensions) { + this.dimensions = dimensions; + return this; + } + + /** + * Use similarity based on the angle between vectors. + * + * @return new instance of {@link VectorIndex}. + */ + @Contract(" -> this") + public VectorFieldBuilder cosine() { + return similarity(SimilarityFunction.COSINE); + } + + /** + * Use similarity based the distance between vector ends. + */ + @Contract(" -> this") + public VectorFieldBuilder euclidean() { + return similarity(SimilarityFunction.EUCLIDEAN); + } + + /** + * Use similarity based on both angle and magnitude of the vectors. + * + * @return new instance of {@link VectorIndex}. + */ + @Contract(" -> this") + public VectorFieldBuilder dotProduct() { + return similarity(SimilarityFunction.DOT_PRODUCT); + } + + /** + * Similarity function used. + * + * @param similarity should be one of {@literal euclidean | cosine | dotProduct}. + * @return this. + * @see SimilarityFunction + * @see #similarity(SimilarityFunction) + */ + @Contract("_ -> this") + public VectorFieldBuilder similarity(String similarity) { + + this.similarity = similarity; + return this; + } + + /** + * Similarity function used. + * + * @param similarity must not be {@literal null}. + * @return this. + */ + @Contract("_ -> this") + public VectorFieldBuilder similarity(SimilarityFunction similarity) { + + return similarity(similarity.getFunctionName()); + } + + /** + * Quantization used. + * + * @param quantization should be one of {@literal none | scalar | binary}. + * @return this. + * @see Quantization + * @see #quantization(Quantization) + */ + public VectorFieldBuilder quantization(String quantization) { + + this.quantization = quantization; + return this; + } + + /** + * Quantization used. + * + * @param quantization must not be {@literal null}. + * @return this. + */ + public VectorFieldBuilder quantization(Quantization quantization) { + return quantization(quantization.getQuantizationName()); + } + + VectorIndexField build() { + return new VectorIndexField(this.path, this.type, this.dimensions, this.similarity, this.quantization); + } + } + + /** + * Similarity function used to calculate vector distance. + */ + public enum SimilarityFunction { + + DOT_PRODUCT("dotProduct"), COSINE("cosine"), EUCLIDEAN("euclidean"); + + final String functionName; + + SimilarityFunction(String functionName) { + this.functionName = functionName; + } + + public String getFunctionName() { + return functionName; + } + } + + /** + * Vector quantization. Quantization reduce vector sizes while preserving performance. + */ + public enum Quantization { + + NONE("none"), + + /** + * Converting a float point into an integer. + */ + SCALAR("scalar"), + + /** + * Converting a float point into a single bit. + */ + BINARY("binary"); + + final String quantizationName; + + Quantization(String quantizationName) { + this.quantizationName = quantizationName; + } + + public String getQuantizationName() { + return quantizationName; + } + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/MongoSimpleTypes.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/MongoSimpleTypes.java index 062b006c34..3b3a520bc3 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/MongoSimpleTypes.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/MongoSimpleTypes.java @@ -53,13 +53,13 @@ public abstract class MongoSimpleTypes { public static final Set> AUTOGENERATED_ID_TYPES = Set.of(ObjectId.class, String.class, BigInteger.class); private static final Set> MONGO_SIMPLE_TYPES = Set.of(Binary.class, DBRef.class, Decimal128.class, org.bson.Document.class, Code.class, CodeWScope.class, CodeWithScope.class, ObjectId.class, Pattern.class, - Symbol.class, UUID.class, Instant.class, BsonValue.class, BsonNumber.class, BsonType.class, BsonArray.class, - BsonSymbol.class, BsonUndefined.class, BsonMinKey.class, BsonMaxKey.class, BsonNull.class, BsonBinary.class, - BsonBoolean.class, BsonDateTime.class, BsonDbPointer.class, BsonDecimal128.class, BsonDocument.class, - BsonDouble.class, BsonInt32.class, BsonInt64.class, BsonJavaScript.class, BsonJavaScriptWithScope.class, - BsonObjectId.class, BsonRegularExpression.class, BsonString.class, BsonTimestamp.class, Geometry.class, - GeometryCollection.class, LineString.class, MultiLineString.class, MultiPoint.class, MultiPolygon.class, - Point.class, Polygon.class); + Symbol.class, UUID.class, Instant.class, BinaryVector.class, BsonValue.class, BsonNumber.class, BsonType.class, + BsonArray.class, BsonSymbol.class, BsonUndefined.class, BsonMinKey.class, BsonMaxKey.class, BsonNull.class, + BsonBinary.class, BsonBoolean.class, BsonDateTime.class, BsonDbPointer.class, BsonDecimal128.class, + BsonDocument.class, BsonDouble.class, BsonInt32.class, BsonInt64.class, BsonJavaScript.class, + BsonJavaScriptWithScope.class, BsonObjectId.class, BsonRegularExpression.class, BsonString.class, + BsonTimestamp.class, Geometry.class, GeometryCollection.class, LineString.class, MultiLineString.class, + MultiPoint.class, MultiPolygon.class, Point.class, Polygon.class); public static final SimpleTypeHolder HOLDER = new SimpleTypeHolder(MONGO_SIMPLE_TYPES, true) { diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/MongoVector.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/MongoVector.java new file mode 100644 index 0000000000..3b2e0a45f1 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/MongoVector.java @@ -0,0 +1,154 @@ +/* + * 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.mongodb.core.mapping; + +import org.bson.BinaryVector; +import org.bson.Float32BinaryVector; +import org.bson.Int8BinaryVector; +import org.bson.PackedBitBinaryVector; + +import org.springframework.data.domain.Vector; +import org.springframework.util.ObjectUtils; + +/** + * MongoDB-specific extension to {@link Vector} based on Mongo's {@link BinaryVector}. Note that only float32 and int8 + * variants can be represented as floating-point numbers. int1 returns an all-zero array for {@link #toFloatArray()} and + * {@link #toDoubleArray()}. + * + * @author Mark Paluch + * @since 4.5 + */ +public class MongoVector implements Vector { + + private final BinaryVector v; + + MongoVector(BinaryVector v) { + this.v = v; + } + + /** + * Creates a new {@link MongoVector} from the given {@link BinaryVector}. + * + * @param v binary vector representation. + * @return the {@link MongoVector} for the given vector values. + */ + public static MongoVector of(BinaryVector v) { + return new MongoVector(v); + } + + @Override + public Class getType() { + + if (v instanceof Float32BinaryVector) { + return Float.class; + } + + if (v instanceof Int8BinaryVector) { + return Byte.class; + } + + if (v instanceof PackedBitBinaryVector) { + return Byte.class; + } + + return Number.class; + } + + @Override + public BinaryVector getSource() { + return v; + } + + @Override + public int size() { + + if (v instanceof Float32BinaryVector f) { + return f.getData().length; + } + + if (v instanceof Int8BinaryVector i) { + return i.getData().length; + } + + if (v instanceof PackedBitBinaryVector p) { + return p.getData().length; + } + + return 0; + } + + @Override + public float[] toFloatArray() { + + if (v instanceof Float32BinaryVector f) { + + float[] result = new float[f.getData().length]; + System.arraycopy(f.getData(), 0, result, 0, result.length); + return result; + } + + if (v instanceof Int8BinaryVector i) { + + float[] result = new float[i.getData().length]; + System.arraycopy(i.getData(), 0, result, 0, result.length); + return result; + } + + return new float[size()]; + } + + @Override + public double[] toDoubleArray() { + + if (v instanceof Float32BinaryVector f) { + + float[] data = f.getData(); + double[] result = new double[data.length]; + for (int i = 0; i < data.length; i++) { + result[i] = data[i]; + } + + return result; + } + + if (v instanceof Int8BinaryVector i) { + + double[] result = new double[i.getData().length]; + System.arraycopy(i.getData(), 0, result, 0, result.length); + return result; + } + + return new double[size()]; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof MongoVector that)) { + return false; + } + return ObjectUtils.nullSafeEquals(v, that.v); + } + + @Override + public int hashCode() { + return ObjectUtils.nullSafeHashCode(v); + } + + @Override + public String toString() { + return "MV[" + v + "]"; + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/BsonUtils.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/BsonUtils.java index 7a70ac0445..cbbd4a37a9 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/BsonUtils.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/BsonUtils.java @@ -301,11 +301,19 @@ public static Object toJavaType(BsonValue value) { case BOOLEAN -> value.asBoolean().getValue(); case OBJECT_ID -> value.asObjectId().getValue(); case DB_POINTER -> new DBRef(value.asDBPointer().getNamespace(), value.asDBPointer().getId()); - case BINARY -> value.asBinary().getData(); + case BINARY -> { + + BsonBinary binary = value.asBinary(); + if(binary.getType() != BsonBinarySubType.VECTOR.getValue()) { + yield binary.getData(); + } + yield value.asBinary().asVector(); + } case DATE_TIME -> new Date(value.asDateTime().getValue()); case SYMBOL -> value.asSymbol().getSymbol(); case ARRAY -> value.asArray().toArray(); case DOCUMENT -> Document.parse(value.asDocument().toJson()); + default -> value; }; } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/aggregation/TestAggregationContext.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/aggregation/TestAggregationContext.java index e3be346039..344244717e 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/aggregation/TestAggregationContext.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/aggregation/TestAggregationContext.java @@ -59,6 +59,11 @@ public static AggregationOperationContext contextFor(@Nullable Class type, Mo new QueryMapper(mongoConverter)).continueOnMissingFieldReference()); } + @Override + public Document getMappedObject(Document document) { + return delegate.getMappedObject(document); + } + @Override public Document getMappedObject(Document document, @Nullable Class type) { return delegate.getMappedObject(document, type); diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/VectorSearchOperationUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/VectorSearchOperationUnitTests.java new file mode 100644 index 0000000000..4ce045fe6f --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/VectorSearchOperationUnitTests.java @@ -0,0 +1,114 @@ +/* + * Copyright 2024 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.mongodb.core.aggregation; + +import static org.assertj.core.api.Assertions.*; + +import java.util.List; + +import org.bson.Document; +import org.junit.jupiter.api.Test; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.aggregation.VectorSearchOperation.SearchType; +import org.springframework.data.mongodb.core.mapping.Field; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.util.aggregation.TestAggregationContext; + +/** + * Unit tests for {@link VectorSearchOperation}. + * + * @author Christoph Strobl + */ +class VectorSearchOperationUnitTests { + + static final Document $VECTOR_SEARCH = Document.parse( + "{'index' : 'vector_index', 'limit' : 10, 'path' : 'plot_embedding', 'queryVector' : [-0.0016261312, -0.028070757, -0.011342932]}"); + static final VectorSearchOperation SEARCH_OPERATION = VectorSearchOperation.search("vector_index") + .path("plot_embedding").vector(-0.0016261312, -0.028070757, -0.011342932).limit(10); + + @Test // GH-4706 + void requiredArgs() { + + List stages = SEARCH_OPERATION.toPipelineStages(Aggregation.DEFAULT_CONTEXT); + assertThat(stages).containsExactly(new Document("$vectorSearch", $VECTOR_SEARCH)); + } + + @Test // GH-4706 + void optionalArgs() { + + VectorSearchOperation $search = SEARCH_OPERATION.numCandidates(150).searchType(SearchType.ENN) + .filter(new Criteria().andOperator(Criteria.where("year").gt(1955), Criteria.where("year").lt(1975))); + + List stages = $search.toPipelineStages(Aggregation.DEFAULT_CONTEXT); + + Document filter = new Document("$and", + List.of(new Document("year", new Document("$gt", 1955)), new Document("year", new Document("$lt", 1975)))); + assertThat(stages).containsExactly(new Document("$vectorSearch", + new Document($VECTOR_SEARCH).append("exact", true).append("filter", filter).append("numCandidates", 150))); + } + + @Test // GH-4706 + void withScore() { + + List stages = SEARCH_OPERATION.withSearchScore().toPipelineStages(Aggregation.DEFAULT_CONTEXT); + assertThat(stages).containsExactly(new Document("$vectorSearch", $VECTOR_SEARCH), + new Document("$addFields", new Document("score", new Document("$meta", "vectorSearchScore")))); + } + + @Test // GH-4706 + void withScoreFilter() { + + List stages = SEARCH_OPERATION.withFilterBySore(score -> score.gt(50)) + .toPipelineStages(Aggregation.DEFAULT_CONTEXT); + assertThat(stages).containsExactly(new Document("$vectorSearch", $VECTOR_SEARCH), + new Document("$addFields", new Document("score", new Document("$meta", "vectorSearchScore"))), + new Document("$match", new Document("score", new Document("$gt", 50)))); + } + + @Test // GH-4706 + void withScoreFilterOnCustomFieldName() { + + List stages = SEARCH_OPERATION.withFilterBySore(score -> score.gt(50)).withSearchScore("s-c-o-r-e") + .toPipelineStages(Aggregation.DEFAULT_CONTEXT); + assertThat(stages).containsExactly(new Document("$vectorSearch", $VECTOR_SEARCH), + new Document("$addFields", new Document("s-c-o-r-e", new Document("$meta", "vectorSearchScore"))), + new Document("$match", new Document("s-c-o-r-e", new Document("$gt", 50)))); + } + + @Test // GH-4706 + void mapsCriteriaToDomainType() { + + VectorSearchOperation $search = SEARCH_OPERATION + .filter(new Criteria().andOperator(Criteria.where("y").gt(1955), Criteria.where("y").lt(1975))); + + List stages = $search.toPipelineStages(TestAggregationContext.contextFor(Movie.class)); + + Document filter = new Document("$and", + List.of(new Document("year", new Document("$gt", 1955)), new Document("year", new Document("$lt", 1975)))); + assertThat(stages) + .containsExactly(new Document("$vectorSearch", new Document($VECTOR_SEARCH).append("filter", filter))); + } + + static class Movie { + + @Id String id; + String title; + + @Field("year") String y; + } + +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/VectorSearchTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/VectorSearchTests.java new file mode 100644 index 0000000000..18991c1768 --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/VectorSearchTests.java @@ -0,0 +1,242 @@ +/* + * Copyright 2024 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.mongodb.core.aggregation; + +import static org.assertj.core.api.Assertions.*; + +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import org.assertj.core.api.InstanceOfAssertFactories; +import org.bson.BinaryVector; +import org.bson.Document; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import org.springframework.data.domain.Vector; +import org.springframework.data.mongodb.core.aggregation.VectorSearchOperation.SearchType; +import org.springframework.data.mongodb.core.index.VectorIndex; +import org.springframework.data.mongodb.core.index.VectorIndex.SimilarityFunction; +import org.springframework.data.mongodb.core.mapping.MongoVector; +import org.springframework.data.mongodb.test.util.AtlasContainer; +import org.springframework.data.mongodb.test.util.MongoTestTemplate; + +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; + +/** + * Integration tests using Vector Search and Vector Indexes through local MongoDB Atlas. + * + * @author Christoph Strobl + * @author Mark Paluch + */ +@Testcontainers(disabledWithoutDocker = true) +public class VectorSearchTests { + + private static final String SCORE_FIELD = "vector-search-tests"; + private static final @Container AtlasContainer atlasLocal = AtlasContainer.bestMatch(); + private static final String COLLECTION_NAME = "collection-1"; + + static MongoClient client; + static MongoTestTemplate template; + + @BeforeAll + static void beforeAll() throws InterruptedException { + + client = MongoClients.create(atlasLocal.getConnectionString()); + template = new MongoTestTemplate(client, SCORE_FIELD); + + Thread.sleep(250); // just wait a little or the index will be broken + + initDocuments(); + initIndexes(); + } + + @AfterAll + static void afterAll() { + template.dropCollection(WithVectorFields.class); + } + + @ParameterizedTest // GH-4706 + @MethodSource("vectorAggregations") + void searchUsingArraysAddingScore(VectorSearchOperation searchOperation) { + + VectorSearchOperation $search = searchOperation.withSearchScore(SCORE_FIELD); + + AggregationResults results = template.aggregate(Aggregation.newAggregation($search), + WithVectorFields.class, Document.class); + + assertThat(results).hasSize(10); + assertScoreIsDecreasing(results); + assertThat(results.iterator().next()).containsKey(SCORE_FIELD) + .extracting(it -> it.get(SCORE_FIELD), InstanceOfAssertFactories.DOUBLE).isEqualByComparingTo(1D); + } + + @ParameterizedTest // GH-4706 + @MethodSource("binaryVectorAggregations") + void searchUsingBinaryVectorAddingScore(VectorSearchOperation searchOperation) { + + VectorSearchOperation $search = searchOperation.withSearchScore(SCORE_FIELD); + + AggregationResults results = template.aggregate(Aggregation.newAggregation($search), + WithVectorFields.class, Document.class); + + assertThat(results).hasSize(10); + assertScoreIsDecreasing(results); + assertThat(results.iterator().next()).containsKey(SCORE_FIELD) + .extracting(it -> it.get(SCORE_FIELD), InstanceOfAssertFactories.DOUBLE).isEqualByComparingTo(1D); + } + + private static Stream binaryVectorAggregations() { + + return Stream.of(// + Arguments.arguments(VectorSearchOperation.search("raw-index").path("rawInt8vector") // + .vector(new byte[] { 0, 1, 2, 3, 4 }) // + .limit(10)// + .numCandidates(20) // + .searchType(SearchType.ANN)), + Arguments.arguments(VectorSearchOperation.search("wrapper-index").path("int8vector") // + .vector(BinaryVector.int8Vector(new byte[] { 0, 1, 2, 3, 4 })) // + .limit(10)// + .numCandidates(20) // + .searchType(SearchType.ANN)), + Arguments.arguments(VectorSearchOperation.search("wrapper-index").path("float32vector") // + .vector(BinaryVector.floatVector(new float[] { 0.0001f, 1.12345f, 2.23456f, 3.34567f, 4.45678f })) // + .limit(10)// + .numCandidates(20) // + .searchType(SearchType.ANN))); + } + + private static Stream vectorAggregations() { + + return Stream.of(// + Arguments.arguments(VectorSearchOperation.search("raw-index").path("rawFloat32vector") // + .vector(0.0001f, 1.12345f, 2.23456f, 3.34567f, 4.45678f) // + .limit(10)// + .numCandidates(20) // + .searchType(SearchType.ANN)), + Arguments.arguments(VectorSearchOperation.search("raw-index").path("rawFloat64vector") // + .vector(1.0001d, 2.12345d, 3.23456d, 4.34567d, 5.45678d) // + .limit(10)// + .numCandidates(20) // + .searchType(SearchType.ANN)), + Arguments.arguments(VectorSearchOperation.search("wrapper-index").path("float64vector") // + .vector(Vector.of(1.0001d, 2.12345d, 3.23456d, 4.34567d, 5.45678d)) // + .limit(10)// + .numCandidates(20) // + .searchType(SearchType.ANN))); + } + + static void initDocuments() { + IntStream.range(0, 10).mapToObj(WithVectorFields::instance).forEach(template::save); + } + + static void initIndexes() { + + VectorIndex rawIndex = new VectorIndex("raw-index") + .addVector("rawInt8vector", it -> it.similarity(SimilarityFunction.COSINE).dimensions(5)) + .addVector("rawFloat32vector", it -> it.similarity(SimilarityFunction.COSINE).dimensions(5)) + .addVector("rawFloat64vector", it -> it.similarity(SimilarityFunction.COSINE).dimensions(5)) + .addFilter("justSomeArgument"); + + VectorIndex wrapperIndex = new VectorIndex("wrapper-index") + .addVector("int8vector", it -> it.similarity(SimilarityFunction.COSINE).dimensions(5)) + .addVector("float32vector", it -> it.similarity(SimilarityFunction.COSINE).dimensions(5)) + .addVector("float64vector", it -> it.similarity(SimilarityFunction.COSINE).dimensions(5)) + .addFilter("justSomeArgument"); + + template.searchIndexOps(WithVectorFields.class).createIndex(rawIndex); + template.searchIndexOps(WithVectorFields.class).createIndex(wrapperIndex); + + template.awaitIndexCreation(WithVectorFields.class, rawIndex.getName()); + template.awaitIndexCreation(WithVectorFields.class, wrapperIndex.getName()); + } + + private static void assertScoreIsDecreasing(Iterable documents) { + + double previousScore = Integer.MAX_VALUE; + for (Document document : documents) { + + Double vectorSearchScore = document.getDouble(SCORE_FIELD); + assertThat(vectorSearchScore).isGreaterThan(0D); + assertThat(vectorSearchScore).isLessThan(previousScore); + previousScore = vectorSearchScore; + } + } + + @org.springframework.data.mongodb.core.mapping.Document(COLLECTION_NAME) + static class WithVectorFields { + + String id; + + Vector int8vector; + Vector float32vector; + Vector float64vector; + + BinaryVector rawInt8vector; + float[] rawFloat32vector; + double[] rawFloat64vector; + + int justSomeArgument; + + static WithVectorFields instance(int offset) { + + WithVectorFields instance = new WithVectorFields(); + instance.id = "id-%s".formatted(offset); + instance.rawFloat32vector = new float[5]; + instance.rawFloat64vector = new double[5]; + + byte[] int8 = new byte[5]; + for (int i = 0; i < 5; i++) { + + int v = i + offset; + int8[i] = (byte) v; + } + instance.rawInt8vector = BinaryVector.int8Vector(int8); + + if (offset == 0) { + instance.rawFloat32vector[0] = 0.0001f; + instance.rawFloat64vector[0] = 0.0001d; + } else { + instance.rawFloat32vector[0] = Float.parseFloat("%s.000%s".formatted(offset, offset)); + instance.rawFloat64vector[0] = Double.parseDouble("%s.000%s".formatted(offset, offset)); + } + instance.rawFloat32vector[1] = Float.parseFloat("%s.12345".formatted(offset + 1)); + instance.rawFloat64vector[1] = Double.parseDouble("%s.12345".formatted(offset + 1)); + instance.rawFloat32vector[2] = Float.parseFloat("%s.23456".formatted(offset + 2)); + instance.rawFloat64vector[2] = Double.parseDouble("%s.23456".formatted(offset + 2)); + instance.rawFloat32vector[3] = Float.parseFloat("%s.34567".formatted(offset + 3)); + instance.rawFloat64vector[3] = Double.parseDouble("%s.34567".formatted(offset + 3)); + instance.rawFloat32vector[4] = Float.parseFloat("%s.45678".formatted(offset + 4)); + instance.rawFloat64vector[4] = Double.parseDouble("%s.45678".formatted(offset + 4)); + + instance.justSomeArgument = offset; + + instance.int8vector = MongoVector.of(instance.rawInt8vector); + instance.float32vector = MongoVector.of(BinaryVector.floatVector(instance.rawFloat32vector)); + instance.float64vector = Vector.of(instance.rawFloat64vector); + + return instance; + } + } + +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/MappingMongoConverterUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/MappingMongoConverterUnitTests.java index f44e094705..b5d1f72e1c 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/MappingMongoConverterUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/MappingMongoConverterUnitTests.java @@ -33,6 +33,7 @@ import java.util.stream.Stream; import org.assertj.core.data.Percentage; +import org.bson.BsonDouble; import org.bson.BsonUndefined; import org.bson.types.Binary; import org.bson.types.Code; @@ -49,6 +50,7 @@ import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; + import org.springframework.aop.framework.ProxyFactory; import org.springframework.beans.ConversionNotSupportedException; import org.springframework.beans.factory.annotation.Autowired; @@ -70,6 +72,7 @@ import org.springframework.data.convert.ReadingConverter; import org.springframework.data.convert.ValueConverter; import org.springframework.data.convert.WritingConverter; +import org.springframework.data.domain.Vector; import org.springframework.data.geo.Box; import org.springframework.data.geo.Circle; import org.springframework.data.geo.Distance; @@ -3323,11 +3326,43 @@ void shouldReadNonIdFieldCalledIdFromSource() { org.bson.Document document = write(source); assertThat(document).containsEntry("_id", source.abc).containsEntry("id", source.id); - WithRenamedIdPropertyAndAnotherPropertyNamedId target = converter.read(WithRenamedIdPropertyAndAnotherPropertyNamedId.class, document); + WithRenamedIdPropertyAndAnotherPropertyNamedId target = converter + .read(WithRenamedIdPropertyAndAnotherPropertyNamedId.class, document); assertThat(target.abc).isEqualTo(source.abc); assertThat(target.id).isEqualTo(source.id); } + @Test // GH-4706 + void shouldWriteVectorValues() { + + WithVector source = new WithVector(); + source.embeddings = Vector.of(1.1d, 2.2d, 3.3d); + + org.bson.Document document = write(source); + assertThat(document.getList("embeddings", BsonDouble.class)).hasSize(3); + } + + @Test // GH-4706 + void shouldReadVectorValues() { + + org.bson.Document document = new org.bson.Document("embeddings", List.of(1.1d, 2.2d, 3.3d)); + WithVector withVector = converter.read(WithVector.class, document); + assertThat(withVector.embeddings.toDoubleArray()).contains(1.1d, 2.2d, 3.3d); + } + + @Test // GH-4706 + void writesByteArrayAsIsIfNoFieldInstructionsGiven() { + + WithArrays source = new WithArrays(); + source.arrayOfPrimitiveBytes = new byte[] { 0, 1, 2 }; + + org.bson.Document target = new org.bson.Document(); + converter.write(source, target); + + assertThat(target.get("arrayOfPrimitiveBytes", byte[].class)).isSameAs(source.arrayOfPrimitiveBytes); + + } + org.bson.Document write(Object source) { org.bson.Document target = new org.bson.Document(); @@ -3335,6 +3370,11 @@ org.bson.Document write(Object source) { return target; } + static class WithVector { + + Vector embeddings; + } + static class GenericType { T content; } @@ -3866,6 +3906,7 @@ public WithArrayInConstructor(String[] array) { static class WithArrays { String[] arrayOfStrings; + byte[] arrayOfPrimitiveBytes; } // DATAMONGO-1898 diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/MongoConvertersIntegrationTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/MongoConvertersIntegrationTests.java index dd7d454f3d..b57ab35ea1 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/MongoConvertersIntegrationTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/MongoConvertersIntegrationTests.java @@ -23,17 +23,22 @@ import java.util.Objects; import java.util.UUID; +import org.bson.BinaryVector; import org.bson.types.Binary; +import org.bson.types.ObjectId; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.data.annotation.Id; +import org.springframework.data.domain.Vector; import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.MongoVector; import org.springframework.data.mongodb.core.query.Criteria; import org.springframework.data.mongodb.core.query.Query; import org.springframework.data.mongodb.test.util.MongoTemplateExtension; import org.springframework.data.mongodb.test.util.MongoTestTemplate; import org.springframework.data.mongodb.test.util.Template; +import org.springframework.util.ObjectUtils; /** * Integration tests for {@link MongoConverters}. @@ -101,6 +106,78 @@ public void shouldReadBinaryType() { assertThat(template.findOne(query(where("id").is(wbd.id)), WithBinaryDataType.class)).isEqualTo(wbd); } + @Test // GH-4706 + public void shouldReadAndWriteVectors() { + + WithVectors source = new WithVectors(); + source.vector = Vector.of(1.1, 2.2, 3.3); + + template.save(source); + + WithVectors loaded = template.findOne(query(where("id").is(source.id)), WithVectors.class); + assertThat(loaded).isEqualTo(source); + } + + @Test // GH-4706 + public void shouldReadAndWriteFloatVectors() { + + WithVectors source = new WithVectors(); + source.vector = Vector.of(1.1f, 2.2f, 3.3f); + + template.save(source); + + WithVectors loaded = template.findOne(query(where("id").is(source.id)), WithVectors.class); + + // top-level arrays are converted into doubles by MongoDB with all their conversion imprecisions + assertThat(loaded.vector.getClass().getName()).contains("DoubleVector"); + assertThat(loaded.vector).isNotEqualTo(source.vector); + } + + @Test // GH-4706 + public void shouldReadAndWriteBinFloat32Vectors() { + + WithVectors source = new WithVectors(); + source.binVector = BinaryVector.floatVector(new float[] { 1.1f, 2.2f, 3.3f }); + source.vector = MongoVector.of(source.binVector); + + template.save(source); + + WithVectors loaded = template.findOne(query(where("id").is(source.id)), WithVectors.class); + + assertThat(loaded.vector).isEqualTo(source.vector); + assertThat(loaded.binVector).isEqualTo(source.binVector); + } + + @Test // GH-4706 + public void shouldReadAndWriteBinInt8Vectors() { + + WithVectors source = new WithVectors(); + source.binVector = BinaryVector.int8Vector(new byte[] { 1, 2, 3 }); + source.vector = MongoVector.of(source.binVector); + + template.save(source); + + WithVectors loaded = template.findOne(query(where("id").is(source.id)), WithVectors.class); + + assertThat(loaded.vector).isEqualTo(source.vector); + assertThat(loaded.binVector).isEqualTo(source.binVector); + } + + @Test // GH-4706 + public void shouldReadAndWriteBinPackedVectors() { + + WithVectors source = new WithVectors(); + source.binVector = BinaryVector.packedBitVector(new byte[] { 1, 2, 3 }, (byte) 1); + source.vector = MongoVector.of(source.binVector); + + template.save(source); + + WithVectors loaded = template.findOne(query(where("id").is(source.id)), WithVectors.class); + + assertThat(loaded.vector).isEqualTo(source.vector); + assertThat(loaded.binVector).isEqualTo(source.binVector); + } + @Document(COLLECTION) static class Wrapper { @@ -108,6 +185,33 @@ static class Wrapper { UUID uuid; } + @Document(COLLECTION) + static class WithVectors { + + ObjectId id; + Vector vector; + BinaryVector binVector; + + @Override + public boolean equals(Object o) { + if (!(o instanceof WithVectors that)) { + return false; + } + if (!ObjectUtils.nullSafeEquals(id, that.id)) { + return false; + } + if (!ObjectUtils.nullSafeEquals(vector, that.vector)) { + return false; + } + return ObjectUtils.nullSafeEquals(binVector, that.binVector); + } + + @Override + public int hashCode() { + return ObjectUtils.nullSafeHash(id, vector, binVector); + } + } + @Document(COLLECTION) static class WithBinaryDataInArray { diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/index/MongoPersistentEntityIndexResolverUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/index/MongoPersistentEntityIndexResolverUnitTests.java index 1e7e1ffe84..aa26445f2d 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/index/MongoPersistentEntityIndexResolverUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/index/MongoPersistentEntityIndexResolverUnitTests.java @@ -15,9 +15,8 @@ */ package org.springframework.data.mongodb.core.index; -import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.*; -import static org.springframework.data.mongodb.test.util.Assertions.assertThatExceptionOfType; +import static org.springframework.data.mongodb.test.util.Assertions.*; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; @@ -32,6 +31,7 @@ import org.junit.runner.RunWith; import org.junit.runners.Suite; import org.junit.runners.Suite.SuiteClasses; + import org.springframework.core.annotation.AliasFor; import org.springframework.dao.InvalidDataAccessApiUsageException; import org.springframework.data.annotation.Id; @@ -328,7 +328,8 @@ class IndexOnLevelOneWithExplicitlyNamedField { class IndexOnLevelZeroWithExplicityNamedField { - @Indexed @Field("customFieldName") String namedProperty; + @Indexed + @Field("customFieldName") String namedProperty; } @Document @@ -441,7 +442,8 @@ class WithPartialFilter { @Document class IndexOnMetaAnnotatedField { - @Field("_name") @IndexedFieldAnnotation String lastname; + @Field("_name") + @IndexedFieldAnnotation String lastname; } /** @@ -839,7 +841,8 @@ class CompoundIndexWithCollation {} class WithCompoundCollationFromDocument {} @Document(collation = "{'locale': 'en_US', 'strength': 2}") - @CompoundIndex(name = "compound_index_with_collation", def = "{'foo': 1}", collation = "#{{ 'locale' : 'de' + '_' + 'AT' }}") + @CompoundIndex(name = "compound_index_with_collation", def = "{'foo': 1}", + collation = "#{{ 'locale' : 'de' + '_' + 'AT' }}") class WithEvaluatedCollationFromCompoundIndex {} } @@ -1474,9 +1477,9 @@ public void indexedWithCollation() { WithCollationFromIndexedAnnotation.class); IndexDefinition indexDefinition = indexDefinitions.get(0).getIndexDefinition(); - assertThat(indexDefinition.getIndexOptions()).isEqualTo(new org.bson.Document().append("name", "value") - .append("unique", true) - .append("collation", new org.bson.Document().append("locale", "en_US").append("strength", 2))); + assertThat(indexDefinition.getIndexOptions()) + .isEqualTo(new org.bson.Document().append("name", "value").append("unique", true).append("collation", + new org.bson.Document().append("locale", "en_US").append("strength", 2))); } @Test // GH-3002 @@ -1486,9 +1489,9 @@ public void indexedWithCollationFromDocumentAnnotation() { WithCollationFromDocumentAnnotation.class); IndexDefinition indexDefinition = indexDefinitions.get(0).getIndexDefinition(); - assertThat(indexDefinition.getIndexOptions()).isEqualTo(new org.bson.Document().append("name", "value") - .append("unique", true) - .append("collation", new org.bson.Document().append("locale", "en_US").append("strength", 2))); + assertThat(indexDefinition.getIndexOptions()) + .isEqualTo(new org.bson.Document().append("name", "value").append("unique", true).append("collation", + new org.bson.Document().append("locale", "en_US").append("strength", 2))); } @Test // GH-3002 @@ -1591,7 +1594,8 @@ class ValueObject { @Document class SimilarityHolingBean { - @Indexed @Field("norm") String normalProperty; + @Indexed + @Field("norm") String normalProperty; @Field("similarityL") private List listOfSimilarilyNamedEntities = null; } @@ -1754,7 +1758,8 @@ class EntityWithGenericTypeWrapperAsElement { @Document class WithHashedIndexOnId { - @HashIndexed @Id String id; + @HashIndexed + @Id String id; } @Document diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/index/SearchIndexInfoUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/index/SearchIndexInfoUnitTests.java new file mode 100644 index 0000000000..1d7e5b63b6 --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/index/SearchIndexInfoUnitTests.java @@ -0,0 +1,90 @@ +/* + * 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.mongodb.core.index; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * @author Christoph Strobl + */ +class SearchIndexInfoUnitTests { + + @ParameterizedTest + @ValueSource(strings = { """ + { + "id": "679b7637a580c270015ef6fb", + "name": "vector_index", + "type": "vectorSearch", + "status": "READY", + "queryable": true, + "latestVersion": 0, + "latestDefinition": { + "fields": [ + { + "type": "vector", + "path": "plot_embedding", + "numDimensions": 1536, + "similarity": "euclidean" + } + ] + } + }""", """ + { + id: '648b4ad4d697b73bf9d2e5e1', + name: 'search-index', + status: 'PENDING', + queryable: false, + latestDefinition: { + mappings: { dynamic: false, fields: { text: { type: 'string' } } } + } + }""", """ + { + name: 'search-index-not-yet-created', + definition: { + mappings: { dynamic: false, fields: { text: { type: 'string' } } } + } + }""", """ + { + name: 'vector-index-with-filter', + type: "vectorSearch", + definition: { + fields: [ + { + type: "vector", + path: "plot_embedding", + numDimensions: 1536, + similarity: "euclidean" + }, { + type: "filter", + path: "year" + } + ] + } + }""" }) + void parsesIndexInfo(String indexInfoSource) { + + SearchIndexInfo indexInfo = SearchIndexInfo.parse(indexInfoSource); + + if (indexInfo.getId() != null) { + assertThat(indexInfo.getId()).isInstanceOf(String.class); + } + assertThat(indexInfo.getStatus()).isNotNull(); + assertThat(indexInfo.getIndexDefinition()).isNotNull(); + } +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/index/VectorIndexIntegrationTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/index/VectorIndexIntegrationTests.java new file mode 100644 index 0000000000..dcd447f81a --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/index/VectorIndexIntegrationTests.java @@ -0,0 +1,223 @@ +/* + * 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.mongodb.core.index; + +import static org.assertj.core.api.Assertions.*; +import static org.awaitility.Awaitility.*; +import static org.springframework.data.mongodb.test.util.Assertions.assertThat; + +import java.util.List; + +import org.bson.Document; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.VectorIndex.SimilarityFunction; +import org.springframework.data.mongodb.core.mapping.Field; +import org.springframework.data.mongodb.test.util.AtlasContainer; +import org.springframework.data.mongodb.test.util.MongoTestTemplate; +import org.springframework.data.mongodb.test.util.MongoTestUtils; +import org.springframework.lang.Nullable; + +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import com.mongodb.ConnectionString; +import com.mongodb.client.AggregateIterable; + +/** + * Integration tests for vector index creation. + * + * @author Christoph Strobl + * @author Mark Paluch + */ +@Testcontainers(disabledWithoutDocker = true) +class VectorIndexIntegrationTests { + + private static final @Container AtlasContainer atlasLocal = AtlasContainer.bestMatch(); + + MongoTestTemplate template = new MongoTestTemplate(cfg -> { + cfg.configureDatabaseFactory(ctx -> { + ctx.client(MongoTestUtils.client(new ConnectionString(atlasLocal.getConnectionString()))); + }); + cfg.configureMappingContext(ctx -> { + ctx.initialEntitySet(Movie.class); + }); + }); + + SearchIndexOperations indexOps; + + @BeforeEach + void init() { + template.createCollection(Movie.class); + indexOps = template.searchIndexOps(Movie.class); + } + + @AfterEach + void cleanup() { + + template.searchIndexOps(Movie.class).dropAllIndexes(); + template.dropCollection(Movie.class); + } + + @ParameterizedTest // GH-4706 + @ValueSource(strings = { "euclidean", "cosine", "dotProduct" }) + void createsSimpleVectorIndex(String similarityFunction) { + + VectorIndex idx = new VectorIndex("vector_index").addVector("plotEmbedding", + builder -> builder.dimensions(1536).similarity(similarityFunction)); + + indexOps.createIndex(idx); + + await().untilAsserted(() -> { + Document raw = readRawIndexInfo(idx.getName()); + assertThat(raw).containsEntry("name", idx.getName()) // + .containsEntry("type", "vectorSearch") // + .containsEntry("latestDefinition.fields.[0].type", "vector") // + .containsEntry("latestDefinition.fields.[0].path", "plot_embedding") // + .containsEntry("latestDefinition.fields.[0].numDimensions", 1536) // + .containsEntry("latestDefinition.fields.[0].similarity", similarityFunction); // + }); + } + + @Test // GH-4706 + void dropIndex() { + + VectorIndex idx = new VectorIndex("vector_index").addVector("plotEmbedding", + builder -> builder.dimensions(1536).similarity("cosine")); + + indexOps.createIndex(idx); + + template.awaitIndexCreation(Movie.class, idx.getName()); + + indexOps.dropIndex(idx.getName()); + + assertThat(readRawIndexInfo(idx.getName())).isNull(); + } + + @Test // GH-4706 + void statusChanges() throws InterruptedException { + + String indexName = "vector_index"; + assertThat(indexOps.status(indexName)).isEqualTo(SearchIndexStatus.DOES_NOT_EXIST); + + VectorIndex idx = new VectorIndex(indexName).addVector("plotEmbedding", + builder -> builder.dimensions(1536).similarity("cosine")); + + indexOps.createIndex(idx); + + // without synchronization, the container might crash. + Thread.sleep(500); + + assertThat(indexOps.status(indexName)).isIn(SearchIndexStatus.PENDING, SearchIndexStatus.BUILDING, + SearchIndexStatus.READY); + } + + @Test // GH-4706 + void exists() throws InterruptedException { + + String indexName = "vector_index"; + assertThat(indexOps.exists(indexName)).isFalse(); + + VectorIndex idx = new VectorIndex(indexName).addVector("plotEmbedding", + builder -> builder.dimensions(1536).similarity("cosine")); + + indexOps.createIndex(idx); + + // without synchronization, the container might crash. + Thread.sleep(500); + + assertThat(indexOps.exists(indexName)).isTrue(); + } + + @Test // GH-4706 + void updatesVectorIndex() throws InterruptedException { + + String indexName = "vector_index"; + VectorIndex idx = new VectorIndex(indexName).addVector("plotEmbedding", + builder -> builder.dimensions(1536).similarity("cosine")); + + indexOps.createIndex(idx); + + // without synchronization, the container might crash. + Thread.sleep(500); + + await().untilAsserted(() -> { + Document raw = readRawIndexInfo(idx.getName()); + assertThat(raw).containsEntry("name", idx.getName()) // + .containsEntry("type", "vectorSearch") // + .containsEntry("latestDefinition.fields.[0].type", "vector") // + .containsEntry("latestDefinition.fields.[0].path", "plot_embedding") // + .containsEntry("latestDefinition.fields.[0].numDimensions", 1536) // + .containsEntry("latestDefinition.fields.[0].similarity", "cosine"); // + }); + + VectorIndex updatedIdx = new VectorIndex(indexName).addVector("plotEmbedding", + builder -> builder.dimensions(1536).similarity(SimilarityFunction.DOT_PRODUCT)); + + // updating vector index does currently not work, one needs to delete and recreat + assertThatRuntimeException().isThrownBy(() -> indexOps.updateIndex(updatedIdx)); + } + + @Test // GH-4706 + void createsVectorIndexWithFilters() throws InterruptedException { + + VectorIndex idx = new VectorIndex("vector_index") + .addVector("plotEmbedding", builder -> builder.dimensions(1536).cosine()).addFilter("description") + .addFilter("year"); + + indexOps.createIndex(idx); + + // without synchronization, the container might crash. + Thread.sleep(500); + + await().untilAsserted(() -> { + Document raw = readRawIndexInfo(idx.getName()); + assertThat(raw).containsEntry("name", idx.getName()) // + .containsEntry("type", "vectorSearch") // + .containsEntry("latestDefinition.fields.[0].type", "vector") // + .containsEntry("latestDefinition.fields.[1].type", "filter") // + .containsEntry("latestDefinition.fields.[1].path", "plot") // + .containsEntry("latestDefinition.fields.[2].type", "filter") // + .containsEntry("latestDefinition.fields.[2].path", "year"); // + }); + } + + @Nullable + private Document readRawIndexInfo(String name) { + + AggregateIterable indexes = template.execute(Movie.class, collection -> { + return collection.aggregate(List.of(new Document("$listSearchIndexes", new Document("name", name)))); + }); + + return indexes.first(); + } + + static class Movie { + + @Id String id; + String title; + + @Field("plot") String description; + int year; + + @Field("plot_embedding") Double[] plotEmbedding; + } +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/AtlasContainer.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/AtlasContainer.java new file mode 100644 index 0000000000..c3a97a03bc --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/AtlasContainer.java @@ -0,0 +1,58 @@ +/* + * Copyright 2024 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.mongodb.test.util; + +import org.springframework.core.env.StandardEnvironment; + +import org.testcontainers.mongodb.MongoDBAtlasLocalContainer; +import org.testcontainers.utility.DockerImageName; + +/** + * Extension to MongoDBAtlasLocalContainer. + * + * @author Christoph Strobl + */ +public class AtlasContainer extends MongoDBAtlasLocalContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("mongodb/mongodb-atlas-local"); + private static final String DEFAULT_TAG = "8.0.0"; + private static final String LATEST = "latest"; + + private AtlasContainer(String dockerImageName) { + super(DockerImageName.parse(dockerImageName)); + } + + private AtlasContainer(DockerImageName dockerImageName) { + super(dockerImageName); + } + + public static AtlasContainer bestMatch() { + return tagged(new StandardEnvironment().getProperty("mongodb.atlas.version", DEFAULT_TAG)); + } + + public static AtlasContainer latest() { + return tagged(LATEST); + } + + public static AtlasContainer version8() { + return tagged(DEFAULT_TAG); + } + + public static AtlasContainer tagged(String tag) { + return new AtlasContainer(DEFAULT_IMAGE_NAME.withTag(tag)); + } + +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/EnableIfVectorSearchAvailable.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/EnableIfVectorSearchAvailable.java new file mode 100644 index 0000000000..da008d9ee4 --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/EnableIfVectorSearchAvailable.java @@ -0,0 +1,37 @@ +/* + * Copyright 2024 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.mongodb.test.util; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * @author Christoph Strobl + */ +@Target({ ElementType.TYPE, ElementType.METHOD }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Tag("vector-search") +@ExtendWith(MongoServerCondition.class) +public @interface EnableIfVectorSearchAvailable { + +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/MongoServerCondition.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/MongoServerCondition.java index 0afd0ea643..d811e0a1ef 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/MongoServerCondition.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/MongoServerCondition.java @@ -42,6 +42,12 @@ public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext con } } + if(context.getTags().contains("vector-search")) { + if(!atlasEnvironment(context)) { + return ConditionEvaluationResult.disabled("Disabled for servers not supporting Vector Search."); + } + } + if (context.getTags().contains("version-specific") && context.getElement().isPresent()) { EnableIfMongoServerVersion version = AnnotatedElementUtils.findMergedAnnotation(context.getElement().get(), @@ -83,4 +89,9 @@ private Version serverVersion(ExtensionContext context) { return context.getStore(NAMESPACE).getOrComputeIfAbsent(Version.class, (key) -> MongoTestUtils.serverVersion(), Version.class); } + + private boolean atlasEnvironment(ExtensionContext context) { + return context.getStore(NAMESPACE).getOrComputeIfAbsent(Version.class, (key) -> MongoTestUtils.isVectorSearchEnabled(), + Boolean.class); + } } diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/MongoTestTemplate.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/MongoTestTemplate.java index 8e837b2599..40948a0e22 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/MongoTestTemplate.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/MongoTestTemplate.java @@ -15,7 +15,10 @@ */ package org.springframework.data.mongodb.test.util; +import java.time.Duration; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; import java.util.function.Consumer; import java.util.function.Supplier; import java.util.stream.Collectors; @@ -26,6 +29,7 @@ import org.springframework.data.mapping.context.PersistentEntities; import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.util.MongoCompatibilityAdapter; +import org.testcontainers.shaded.org.awaitility.Awaitility; import com.mongodb.MongoWriteException; import com.mongodb.client.MongoClient; @@ -59,14 +63,11 @@ public MongoTestTemplate(MongoClient client, String database, Class... initia public MongoTestTemplate(Consumer cfg) { - this(new Supplier() { - @Override - public MongoTestTemplateConfiguration get() { + this(() -> { - MongoTestTemplateConfiguration config = new MongoTestTemplateConfiguration(); - cfg.accept(config); - return config; - } + MongoTestTemplateConfiguration config = new MongoTestTemplateConfiguration(); + cfg.accept(config); + return config; }); } @@ -111,7 +112,7 @@ public void flush(Iterable collections) { } public void flush(Class... entities) { - flush(Arrays.asList(entities).stream().map(this::getCollectionName).collect(Collectors.toList())); + flush(Arrays.stream(entities).map(this::getCollectionName).collect(Collectors.toList())); } public void flush(String... collections) { @@ -120,7 +121,7 @@ public void flush(String... collections) { public void flush(Object... objects) { - flush(Arrays.asList(objects).stream().map(it -> { + flush(Arrays.stream(objects).map(it -> { if (it instanceof String) { return (String) it; @@ -154,4 +155,25 @@ public void doInCollection(Class entityClass, Consumer type, String indexName) { + awaitIndexCreation(getCollectionName(type), indexName, Duration.ofSeconds(10)); + } + + public void awaitIndexCreation(String collectionName, String indexName, Duration timeout) { + + Awaitility.await().atMost(timeout).pollInterval(Duration.ofMillis(200)).until(() -> { + + List execute = this.execute(collectionName, + coll -> coll + .aggregate(List.of(Document.parse("{'$listSearchIndexes': { 'name' : '%s'}}".formatted(indexName)))) + .into(new ArrayList<>())); + for (Document doc : execute) { + if (doc.getString("name").equals(indexName)) { + return doc.getString("status").equals("READY"); + } + } + return false; + }); + } } diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/MongoTestUtils.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/MongoTestUtils.java index 26153f79f0..f88caf80dd 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/MongoTestUtils.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/MongoTestUtils.java @@ -64,8 +64,10 @@ public static MongoClient client() { } public static MongoClient client(String host, int port) { + return client(new ConnectionString(String.format(CONNECTION_STRING_PATTERN, host, port))); + } - ConnectionString connectionString = new ConnectionString(String.format(CONNECTION_STRING_PATTERN, host, port)); + public static MongoClient client(ConnectionString connectionString) { return com.mongodb.client.MongoClients.create(connectionString, SpringDataMongoDB.driverInformation()); } @@ -262,6 +264,22 @@ public static boolean serverIsReplSet() { } } + @SuppressWarnings("unchecked") + public static boolean isVectorSearchEnabled() { + try (MongoClient client = MongoTestUtils.client()) { + + return client.getDatabase("admin").runCommand(new Document("getCmdLineOpts", "1")).get("argv", List.class) + .stream().anyMatch(it -> { + if(it instanceof String cfgString) { + return cfgString.startsWith("searchIndexManagementHostAndPort"); + } + return false; + }); + } catch (Exception e) { + return false; + } + } + public static Duration getTimeout() { return ObjectUtils.nullSafeEquals("jenkins", ENV.getProperty("user.name")) ? Duration.ofMillis(100) diff --git a/src/main/antora/antora-playbook.yml b/src/main/antora/antora-playbook.yml index e04a7a4188..9f842fe401 100644 --- a/src/main/antora/antora-playbook.yml +++ b/src/main/antora/antora-playbook.yml @@ -36,5 +36,5 @@ runtime: format: pretty ui: bundle: - url: https://github.com/spring-io/antora-ui-spring/releases/download/v0.4.16/ui-bundle.zip + url: https://github.com/spring-io/antora-ui-spring/releases/download/v0.4.18/ui-bundle.zip snapshot: true diff --git a/src/main/antora/modules/ROOT/nav.adoc b/src/main/antora/modules/ROOT/nav.adoc index 9411a025ad..7414dfcfbb 100644 --- a/src/main/antora/modules/ROOT/nav.adoc +++ b/src/main/antora/modules/ROOT/nav.adoc @@ -33,6 +33,7 @@ ** xref:mongodb/change-streams.adoc[] ** xref:mongodb/tailable-cursors.adoc[] ** xref:mongodb/sharding.adoc[] +** xref:mongodb/mongo-search-indexes.adoc[] ** xref:mongodb/mongo-encryption.adoc[] // Repository diff --git a/src/main/antora/modules/ROOT/pages/mongodb/mongo-search-indexes.adoc b/src/main/antora/modules/ROOT/pages/mongodb/mongo-search-indexes.adoc new file mode 100644 index 0000000000..9b6bfcf095 --- /dev/null +++ b/src/main/antora/modules/ROOT/pages/mongodb/mongo-search-indexes.adoc @@ -0,0 +1,124 @@ +[[mongo.search]] += MongoDB Search + +MongoDB enables users to do keyword or lexical search as well as vector search data using dedicated search indexes. + +[[mongo.search.vector]] +== Vector Search + +MongoDB Vector Search uses the `$vectorSearch` aggregation stage to run queries against specialized indexes. +Please refer to the MongoDB documentation to learn more about requirements and restrictions of `vectorSearch` indexes. + +[[mongo.search.vector.index]] +=== Managing Vector Indexes + +`SearchIndexOperationsProvider` implemented by `MongoTemplate` are the entrypoint to `SearchIndexOperations` offering various methods for managing vector indexes. + +The following snippet shows how to create a vector index for a collection + +.Create a Vector Index +[tabs] +====== +Java:: ++ +==== +[source,java,indent=0,subs="verbatim,quotes",role="primary"] +---- +VectorIndex index = new VectorIndex("vector_index") + .addVector("plotEmbedding"), vector -> vector.dimensions(1536).similarity(COSINE)) <1> + .addFilter("year"); <2> + +mongoTemplate.searchIndexOps(Movie.class) <3> + .createIndex(index); +---- +<1> A vector index may cover multiple vector embeddings that can be added via the `addVector` method. +<2> Vector indexes can contain additional fields to narrow down search results when running queries. +<3> Obtain `SearchIndexOperations` bound to the `Movie` type which is used for field name mapping. +==== + +Mongo Shell:: ++ +==== +[source,console,indent=0,subs="verbatim,quotes",role="secondary"] +---- +db.movie.createSearchIndex("movie", "vector_index", + { + "fields": [ + { + "type": "vector", + "numDimensions": 1536, + "path": "plot_embedding", <1> + "similarity": "cosine" + }, + { + "type": "filter", + "path": "year" + } + ] + } +) +---- +<1> Field name `plotEmbedding` got mapped to `plot_embedding` considering a `@Field(name = "...")` annotation. +==== +====== + +Once created, vector indexes are not immediately ready to use although the `exists` check returns `true`. +The actual status of a search index can be obtained via `SearchIndexOperations#status(...)`. +The `READY` state indicates the index is ready to accept queries. + +[[mongo.search.vector.query]] +=== Querying Vector Indexes + +Vector indexes can be queried by issuing an aggregation using a `VectorSearchOperation` via `MongoOperations` as shown in the following example + +.Query a Vector Index +[tabs] +====== +Java:: ++ +==== +[source,java,indent=0,subs="verbatim,quotes",role="primary"] +---- +VectorSearchOperation search = VectorSearchOperation.search("vector_index") <1> + .path("plotEmbedding") <2> + .vector( ... ) + .numCandidates(150) + .limit(10) + .quantization(SCALAR) + .withSearchScore("score"); <3> + +AggregationResults results = mongoTemplate + .aggregate(newAggregation(Movie.class, search), MovieWithSearchScore.class); +---- +<1> Provide the name of the vector index to query since a collection may hold multiple ones. +<2> The name of the path used for comparison. +<3> Optionally add the search score with given name to the result document. +==== + +Mongo Shell:: ++ +==== +[source,console,indent=0,subs="verbatim,quotes",role="secondary"] +---- +db.embedded_movies.aggregate([ + { + "$vectorSearch": { + "index": "vector_index", + "path": "plot_embedding", <1> + "queryVector": [ ... ], + "numCandidates": 150, + "limit": 10, + "quantization": "scalar" + } + }, + { + "$addFields": { + "score": { $meta: "vectorSearchScore" } + } + } +]) +---- +<1> Field name `plotEmbedding` got mapped to `plot_embedding` considering a `@Field(name = "...")` annotation. +==== +====== +