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 extends MongoPersistentEntity>, 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 extends MongoPersistentEntity>, 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 extends MongoPersistentEntity>, 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 extends Number> 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.
+====
+======
+