From 042b64f855cdd35f507107057e75f4194ec12785 Mon Sep 17 00:00:00 2001 From: Ross Lawley Date: Wed, 6 May 2026 13:03:07 +0100 Subject: [PATCH 1/2] Add vectorSearch operator for $search pipeline stage JAVA-6130 --- .../client/model/search/SearchOperator.java | 51 ++++++++ .../model/search/VectorSearchOperator.java | 56 ++++++++ ...earchOperatorConstructibleBsonElement.java | 54 ++++++++ .../model/search/SearchOperatorTest.java | 120 ++++++++++++++++++ .../scala/model/search/SearchOperator.scala | 36 ++++++ .../mongodb/scala/model/search/package.scala | 10 ++ .../model/search/SearchOperatorSpec.scala | 30 ++++- 7 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 driver-core/src/main/com/mongodb/client/model/search/VectorSearchOperator.java create mode 100644 driver-core/src/main/com/mongodb/client/model/search/VectorSearchOperatorConstructibleBsonElement.java diff --git a/driver-core/src/main/com/mongodb/client/model/search/SearchOperator.java b/driver-core/src/main/com/mongodb/client/model/search/SearchOperator.java index aa8b01b29d4..6e2e2df6b32 100644 --- a/driver-core/src/main/com/mongodb/client/model/search/SearchOperator.java +++ b/driver-core/src/main/com/mongodb/client/model/search/SearchOperator.java @@ -633,6 +633,57 @@ static RegexSearchOperator regex(final Iterable paths, fin .append("query", queryIterator.hasNext() ? queries : firstQuery)); } + /** + * Returns a {@link SearchOperator} that performs vector search within the {@code $search} pipeline stage. + * This is the approximate (ANN) variant with {@code numCandidates}. + * + * @param path The indexed vector field to search. + * @param queryVector The query vector. The number of dimensions must match the index field. + * @param limit The number of results to return. + * @param numCandidates The number of nearest neighbors to consider during ANN search. + * Must be greater than or equal to {@code limit}. The server may impose an upper bound. + * @return The requested {@link VectorSearchOperator}. + * @mongodb.atlas.manual atlas-search/vector-search/ vectorSearch operator + * @since 5.8 + */ + static VectorSearchOperator vectorSearch( + final FieldSearchPath path, + final Iterable queryVector, + final int limit, + final int numCandidates) { + notNull("path", path); + notNull("queryVector", queryVector); + isTrueArgument("numCandidates must be >= limit", numCandidates >= limit); + return new VectorSearchOperatorConstructibleBsonElement("vectorSearch", + new Document("path", path.toValue()) + .append("queryVector", queryVector) + .append("limit", limit) + .append("numCandidates", numCandidates)); + } + + /** + * Returns a {@link SearchOperator} that performs exact (ENN) vector search within the {@code $search} pipeline stage. + * + * @param path The indexed vector field to search. + * @param queryVector The query vector. The number of dimensions must match the index field. + * @param limit The number of results to return. + * @return The requested {@link VectorSearchOperator}. + * @mongodb.atlas.manual atlas-search/vector-search/ vectorSearch operator + * @since 5.8 + */ + static VectorSearchOperator vectorSearchExact( + final FieldSearchPath path, + final Iterable queryVector, + final int limit) { + notNull("path", path); + notNull("queryVector", queryVector); + return new VectorSearchOperatorConstructibleBsonElement("vectorSearch", + new Document("path", path.toValue()) + .append("queryVector", queryVector) + .append("limit", limit) + .append("exact", true)); + } + /** * Creates a {@link SearchOperator} from a {@link Bson} in situations when there is no builder method that better satisfies your needs. * This method cannot be used to validate the syntax. diff --git a/driver-core/src/main/com/mongodb/client/model/search/VectorSearchOperator.java b/driver-core/src/main/com/mongodb/client/model/search/VectorSearchOperator.java new file mode 100644 index 00000000000..448a6fb62bc --- /dev/null +++ b/driver-core/src/main/com/mongodb/client/model/search/VectorSearchOperator.java @@ -0,0 +1,56 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * 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 + * + * http://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 com.mongodb.client.model.search; + +import com.mongodb.annotations.Beta; +import com.mongodb.annotations.Reason; +import com.mongodb.annotations.Sealed; + +/** + * A {@link SearchOperator} that performs vector search within the {@code $search} pipeline stage. + * + * @mongodb.atlas.manual atlas-search/operators-and-collectors/#operators Search operators + * @since 5.8 + */ +@Sealed +@Beta(Reason.CLIENT) +public interface VectorSearchOperator extends SearchOperator { + + /** + * Creates a new {@link VectorSearchOperator} with the exact search option. + * + * @param exact Whether to use exact (ENN) search. If {@code true}, runs exact nearest neighbor search. + * @return A new {@link VectorSearchOperator}. + */ + VectorSearchOperator exact(boolean exact); + + /** + * Creates a new {@link VectorSearchOperator} with the filter specified. + * + * @param filter A search operator to filter documents. + * @return A new {@link VectorSearchOperator}. + */ + VectorSearchOperator filter(SearchOperator filter); + + /** + * Creates a new {@link VectorSearchOperator} with the scoring modifier specified. + * + * @param modifier The scoring modifier. + * @return A new {@link VectorSearchOperator}. + */ + @Override + VectorSearchOperator score(SearchScore modifier); +} diff --git a/driver-core/src/main/com/mongodb/client/model/search/VectorSearchOperatorConstructibleBsonElement.java b/driver-core/src/main/com/mongodb/client/model/search/VectorSearchOperatorConstructibleBsonElement.java new file mode 100644 index 00000000000..439ab8d208e --- /dev/null +++ b/driver-core/src/main/com/mongodb/client/model/search/VectorSearchOperatorConstructibleBsonElement.java @@ -0,0 +1,54 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * 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 + * + * http://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 com.mongodb.client.model.search; + +import com.mongodb.internal.client.model.AbstractConstructibleBsonElement; +import org.bson.conversions.Bson; + +import static com.mongodb.assertions.Assertions.notNull; + +final class VectorSearchOperatorConstructibleBsonElement + extends AbstractConstructibleBsonElement + implements VectorSearchOperator { + + VectorSearchOperatorConstructibleBsonElement(final String name, final Bson value) { + super(name, value); + } + + private VectorSearchOperatorConstructibleBsonElement(final Bson baseElement, final Bson appendedElementValue) { + super(baseElement, appendedElementValue); + } + + @Override + protected VectorSearchOperatorConstructibleBsonElement newSelf(final Bson baseElement, final Bson appendedElementValue) { + return new VectorSearchOperatorConstructibleBsonElement(baseElement, appendedElementValue); + } + + @Override + public VectorSearchOperator exact(final boolean exact) { + return newWithAppendedValue("exact", exact); + } + + @Override + public VectorSearchOperator filter(final SearchOperator filter) { + return newWithAppendedValue("filter", notNull("filter", filter)); + } + + @Override + public VectorSearchOperatorConstructibleBsonElement score(final SearchScore modifier) { + return newWithAppendedValue("score", notNull("modifier", modifier)); + } +} diff --git a/driver-core/src/test/unit/com/mongodb/client/model/search/SearchOperatorTest.java b/driver-core/src/test/unit/com/mongodb/client/model/search/SearchOperatorTest.java index ccf5a44cd1f..aaf1b1db49d 100644 --- a/driver-core/src/test/unit/com/mongodb/client/model/search/SearchOperatorTest.java +++ b/driver-core/src/test/unit/com/mongodb/client/model/search/SearchOperatorTest.java @@ -16,6 +16,7 @@ package com.mongodb.client.model.search; import com.mongodb.MongoClientSettings; +import com.mongodb.client.model.Aggregates; import com.mongodb.client.model.geojson.Point; import com.mongodb.client.model.geojson.Position; import org.bson.BsonArray; @@ -1002,6 +1003,125 @@ void regex() { ); } + @Test + void vectorSearch() { + assertAll( + () -> assertThrows(IllegalArgumentException.class, () -> + // path must not be null + SearchOperator.vectorSearch(null, asList(1.0), 10, 50) + ), + () -> assertThrows(IllegalArgumentException.class, () -> + // queryVector must not be null + SearchOperator.vectorSearch(fieldPath("embedding"), null, 10, 50) + ), + () -> assertThrows(IllegalArgumentException.class, () -> + // numCandidates must be >= limit + SearchOperator.vectorSearch(fieldPath("embedding"), asList(1.0), 100, 50) + ), + () -> assertEquals( + new BsonDocument("vectorSearch", + new BsonDocument("path", new BsonString("embedding")) + .append("queryVector", new BsonArray(asList( + new BsonDouble(1.0), new BsonDouble(2.0), new BsonDouble(3.0)))) + .append("limit", new BsonInt32(10)) + .append("numCandidates", new BsonInt32(100))), + SearchOperator.vectorSearch( + fieldPath("embedding"), + asList(1.0, 2.0, 3.0), + 10, + 100 + ).toBsonDocument() + ), + () -> assertEquals( + new BsonDocument("vectorSearch", + new BsonDocument("path", new BsonString("embedding")) + .append("queryVector", new BsonArray(asList( + new BsonDouble(1.0), new BsonDouble(2.0)))) + .append("limit", new BsonInt32(10)) + .append("numCandidates", new BsonInt32(50)) + .append("filter", new BsonDocument("text", + new BsonDocument("query", new BsonString("hello")) + .append("path", new BsonString("title")))) + .append("score", new BsonDocument("boost", + new BsonDocument("value", new BsonDouble(2.0))))), + SearchOperator.vectorSearch( + fieldPath("embedding"), + asList(1.0, 2.0), + 10, + 50 + ).filter(SearchOperator.text(fieldPath("title"), "hello")) + .score(boost(2f)) + .toBsonDocument() + ) + ); + } + + @Test + void vectorSearchExact() { + assertAll( + () -> assertThrows(IllegalArgumentException.class, () -> + // path must not be null + SearchOperator.vectorSearchExact(null, asList(1.0), 10) + ), + () -> assertThrows(IllegalArgumentException.class, () -> + // queryVector must not be null + SearchOperator.vectorSearchExact(fieldPath("embedding"), null, 10) + ), + () -> assertEquals( + new BsonDocument("vectorSearch", + new BsonDocument("path", new BsonString("embedding")) + .append("queryVector", new BsonArray(asList( + new BsonDouble(1.0), new BsonDouble(2.0), new BsonDouble(3.0)))) + .append("limit", new BsonInt32(5)) + .append("exact", BsonBoolean.TRUE)), + SearchOperator.vectorSearchExact( + fieldPath("embedding"), + asList(1.0, 2.0, 3.0), + 5 + ).toBsonDocument() + ), + () -> assertEquals( + new BsonDocument("vectorSearch", + new BsonDocument("path", new BsonString("embedding")) + .append("queryVector", new BsonArray(asList( + new BsonDouble(1.0), new BsonDouble(2.0)))) + .append("limit", new BsonInt32(10)) + .append("numCandidates", new BsonInt32(50)) + .append("exact", BsonBoolean.FALSE)), + SearchOperator.vectorSearch( + fieldPath("embedding"), + asList(1.0, 2.0), + 10, + 50 + ).exact(false) + .toBsonDocument() + ) + ); + } + + @Test + void vectorSearchInsideSearchStage() { + assertEquals( + new BsonDocument("$search", + new BsonDocument("index", new BsonString("myIndex")) + .append("vectorSearch", + new BsonDocument("path", new BsonString("embedding")) + .append("queryVector", new BsonArray(asList( + new BsonDouble(1.0), new BsonDouble(2.0), new BsonDouble(3.0)))) + .append("limit", new BsonInt32(10)) + .append("numCandidates", new BsonInt32(100)))), + Aggregates.search( + SearchOperator.vectorSearch( + fieldPath("embedding"), + asList(1.0, 2.0, 3.0), + 10, + 100 + ), + SearchOptions.searchOptions().index("myIndex") + ).toBsonDocument() + ); + } + private static SearchOperator docExamplePredefined() { return SearchOperator.exists( fieldPath("fieldName")); diff --git a/driver-scala/src/main/scala/org/mongodb/scala/model/search/SearchOperator.scala b/driver-scala/src/main/scala/org/mongodb/scala/model/search/SearchOperator.scala index 1fa47a54e1b..4f2398a3313 100644 --- a/driver-scala/src/main/scala/org/mongodb/scala/model/search/SearchOperator.scala +++ b/driver-scala/src/main/scala/org/mongodb/scala/model/search/SearchOperator.scala @@ -495,6 +495,42 @@ object SearchOperator { def regex(paths: Iterable[_ <: SearchPath], queries: Iterable[String]): RegexSearchOperator = JSearchOperator.regex(paths.asJava, queries.asJava) + /** + * Returns a `SearchOperator` that performs vector search within the `\$search` pipeline stage. + * This is the approximate (ANN) variant with `numCandidates`. + * + * @param path The indexed vector field to search. + * @param queryVector The query vector. The number of dimensions must match the index field. + * @param limit The number of results to return. + * @param numCandidates The number of nearest neighbors to consider during ANN search. + * Must be greater than or equal to `limit`. The server may impose an upper bound. + * @return The requested `VectorSearchOperator`. + * @see [[https://www.mongodb.com/docs/atlas/atlas-search/vector-search/ vectorSearch operator]] + * @since 5.8 + */ + def vectorSearch( + path: FieldSearchPath, + queryVector: Iterable[Double], + limit: Int, + numCandidates: Int): VectorSearchOperator = + JSearchOperator.vectorSearch(path, queryVector.map(Double.box).asJava, limit, numCandidates) + + /** + * Returns a `SearchOperator` that performs exact (ENN) vector search within the `\$search` pipeline stage. + * + * @param path The indexed vector field to search. + * @param queryVector The query vector. The number of dimensions must match the index field. + * @param limit The number of results to return. + * @return The requested `VectorSearchOperator`. + * @see [[https://www.mongodb.com/docs/atlas/atlas-search/vector-search/ vectorSearch operator]] + * @since 5.8 + */ + def vectorSearchExact( + path: FieldSearchPath, + queryVector: Iterable[Double], + limit: Int): VectorSearchOperator = + JSearchOperator.vectorSearchExact(path, queryVector.map(Double.box).asJava, limit) + /** * Creates a `SearchOperator` from a `Bson` in situations when there is no builder method that better satisfies your needs. * This method cannot be used to validate the syntax. diff --git a/driver-scala/src/main/scala/org/mongodb/scala/model/search/package.scala b/driver-scala/src/main/scala/org/mongodb/scala/model/search/package.scala index baa454b1ee7..35886914424 100644 --- a/driver-scala/src/main/scala/org/mongodb/scala/model/search/package.scala +++ b/driver-scala/src/main/scala/org/mongodb/scala/model/search/package.scala @@ -234,6 +234,16 @@ package object search { @Beta(Array(Reason.CLIENT)) type QueryStringSearchOperator = com.mongodb.client.model.search.QueryStringSearchOperator + /** + * A `SearchOperator` that performs vector search within the `\$search` pipeline stage. + * + * @see `SearchOperator.vectorSearch` + * @since 5.8 + */ + @Sealed + @Beta(Array(Reason.CLIENT, Reason.SERVER)) + type VectorSearchOperator = com.mongodb.client.model.search.VectorSearchOperator + /** * Fuzzy search options that may be used with some [[SearchOperator]]s. * diff --git a/driver-scala/src/test/scala/org/mongodb/scala/model/search/SearchOperatorSpec.scala b/driver-scala/src/test/scala/org/mongodb/scala/model/search/SearchOperatorSpec.scala index 3d5481d8368..5bc020572f9 100644 --- a/driver-scala/src/test/scala/org/mongodb/scala/model/search/SearchOperatorSpec.scala +++ b/driver-scala/src/test/scala/org/mongodb/scala/model/search/SearchOperatorSpec.scala @@ -28,7 +28,9 @@ import org.mongodb.scala.model.search.SearchOperator.{ exists, near, numberRange, - text + text, + vectorSearch, + vectorSearchExact } import org.mongodb.scala.model.search.SearchPath.{ fieldPath, wildcardPath } import org.mongodb.scala.model.search.SearchScore.function @@ -98,6 +100,32 @@ class SearchOperatorSpec extends BaseSpec { ) } + it should "render vectorSearch operator" in { + toDocument( + vectorSearch(fieldPath("embedding"), Seq(1.0, 2.0, 3.0), 10, 100) + ) should equal( + Document("""{ "vectorSearch": { "path": "embedding", "queryVector": [1.0, 2.0, 3.0], "limit": 10, "numCandidates": 100 } }""") + ) + } + + it should "render vectorSearchExact operator" in { + toDocument( + vectorSearchExact(fieldPath("embedding"), Seq(1.0, 2.0, 3.0), 5) + ) should equal( + Document("""{ "vectorSearch": { "path": "embedding", "queryVector": [1.0, 2.0, 3.0], "limit": 5, "exact": true } }""") + ) + } + + it should "render vectorSearch with filter and score" in { + toDocument( + vectorSearch(fieldPath("embedding"), Seq(1.0, 2.0), 10, 50) + .filter(text(fieldPath("title"), "hello")) + .score(SearchScore.boost(2f)) + ) should equal( + Document("""{ "vectorSearch": { "path": "embedding", "queryVector": [1.0, 2.0], "limit": 10, "numCandidates": 50, "filter": { "text": { "query": "hello", "path": "title" } }, "score": { "boost": { "value": 2.0 } } } }""") + ) + } + def toDocument(bson: Bson): Document = Document(bson.toBsonDocument(classOf[BsonDocument], MongoClient.DEFAULT_CODEC_REGISTRY)) } From 45914b17794233a673b347de1dfa087a82022015 Mon Sep 17 00:00:00 2001 From: Ross Lawley Date: Wed, 6 May 2026 14:48:25 +0100 Subject: [PATCH 2/2] Static check and copilot fixes --- .../mongodb/scala/model/search/SearchOperator.scala | 6 ++++-- .../org/mongodb/scala/model/search/package.scala | 2 +- .../scala/model/search/SearchOperatorSpec.scala | 12 +++++++++--- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/driver-scala/src/main/scala/org/mongodb/scala/model/search/SearchOperator.scala b/driver-scala/src/main/scala/org/mongodb/scala/model/search/SearchOperator.scala index 4f2398a3313..82b5ea4a05f 100644 --- a/driver-scala/src/main/scala/org/mongodb/scala/model/search/SearchOperator.scala +++ b/driver-scala/src/main/scala/org/mongodb/scala/model/search/SearchOperator.scala @@ -512,7 +512,8 @@ object SearchOperator { path: FieldSearchPath, queryVector: Iterable[Double], limit: Int, - numCandidates: Int): VectorSearchOperator = + numCandidates: Int + ): VectorSearchOperator = JSearchOperator.vectorSearch(path, queryVector.map(Double.box).asJava, limit, numCandidates) /** @@ -528,7 +529,8 @@ object SearchOperator { def vectorSearchExact( path: FieldSearchPath, queryVector: Iterable[Double], - limit: Int): VectorSearchOperator = + limit: Int + ): VectorSearchOperator = JSearchOperator.vectorSearchExact(path, queryVector.map(Double.box).asJava, limit) /** diff --git a/driver-scala/src/main/scala/org/mongodb/scala/model/search/package.scala b/driver-scala/src/main/scala/org/mongodb/scala/model/search/package.scala index 35886914424..01ddaffb29f 100644 --- a/driver-scala/src/main/scala/org/mongodb/scala/model/search/package.scala +++ b/driver-scala/src/main/scala/org/mongodb/scala/model/search/package.scala @@ -241,7 +241,7 @@ package object search { * @since 5.8 */ @Sealed - @Beta(Array(Reason.CLIENT, Reason.SERVER)) + @Beta(Array(Reason.CLIENT)) type VectorSearchOperator = com.mongodb.client.model.search.VectorSearchOperator /** diff --git a/driver-scala/src/test/scala/org/mongodb/scala/model/search/SearchOperatorSpec.scala b/driver-scala/src/test/scala/org/mongodb/scala/model/search/SearchOperatorSpec.scala index 5bc020572f9..52795a63fe5 100644 --- a/driver-scala/src/test/scala/org/mongodb/scala/model/search/SearchOperatorSpec.scala +++ b/driver-scala/src/test/scala/org/mongodb/scala/model/search/SearchOperatorSpec.scala @@ -104,7 +104,9 @@ class SearchOperatorSpec extends BaseSpec { toDocument( vectorSearch(fieldPath("embedding"), Seq(1.0, 2.0, 3.0), 10, 100) ) should equal( - Document("""{ "vectorSearch": { "path": "embedding", "queryVector": [1.0, 2.0, 3.0], "limit": 10, "numCandidates": 100 } }""") + Document( + """{ "vectorSearch": { "path": "embedding", "queryVector": [1.0, 2.0, 3.0], "limit": 10, "numCandidates": 100 } }""" + ) ) } @@ -112,7 +114,9 @@ class SearchOperatorSpec extends BaseSpec { toDocument( vectorSearchExact(fieldPath("embedding"), Seq(1.0, 2.0, 3.0), 5) ) should equal( - Document("""{ "vectorSearch": { "path": "embedding", "queryVector": [1.0, 2.0, 3.0], "limit": 5, "exact": true } }""") + Document( + """{ "vectorSearch": { "path": "embedding", "queryVector": [1.0, 2.0, 3.0], "limit": 5, "exact": true } }""" + ) ) } @@ -122,7 +126,9 @@ class SearchOperatorSpec extends BaseSpec { .filter(text(fieldPath("title"), "hello")) .score(SearchScore.boost(2f)) ) should equal( - Document("""{ "vectorSearch": { "path": "embedding", "queryVector": [1.0, 2.0], "limit": 10, "numCandidates": 50, "filter": { "text": { "query": "hello", "path": "title" } }, "score": { "boost": { "value": 2.0 } } } }""") + Document( + """{ "vectorSearch": { "path": "embedding", "queryVector": [1.0, 2.0], "limit": 10, "numCandidates": 50, "filter": { "text": { "query": "hello", "path": "title" } }, "score": { "boost": { "value": 2.0 } } } }""" + ) ) }