From 5f0123cc9228b1befff8bbf5fd76411f7683c14e Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Sat, 4 Nov 2023 11:47:34 -0400 Subject: [PATCH] Add MongoDB Atlas Vector store - Add VectorSearchAggregation used to actually preform the search on a given collection with embeddings. - add MongoDBVectorStore - Add MongoDBVectorStoreIT. Integration test runs fine given... - You have a mongo atlas cluster to connect to (local or remote) - You have the search index "spring_ai_vector_search" setup correctly - Need to explore getting around this - Need to filter results using threshold - Add postfilter for threshold values - While a post filter is not ideal, it gets the job done. The mongo team seems to be working on having it availible as a prefilter option, in which this implementation can be updated to use later. - implement filtering threshold - fix a few sonar issues - formatting - use higher default num_candidates - use builder for configuration - add documentation and some refactor - use consistent property in integration test - finish implementing filter support - add documentation to filter converter - add vector search index auto creation - Add to BOM. - Fix version to 1.0.0-SN. - Move expresion converter from core to models/mongodb. - Fix style and license headers --- pom.xml | 1 + spring-ai-bom/pom.xml | 6 + .../spring-ai-mongodb-atlas-store/pom.xml | 59 ++++ ...MongoDBAtlasFilterExpressionConverter.java | 97 +++++++ .../vectorstore/MongoDBAtlasVectorStore.java | 271 ++++++++++++++++++ .../vectorstore/VectorSearchAggregation.java | 42 +++ .../ai/vectorstore/MongoDBAtlasContainer.java | 39 +++ .../MongoDBAtlasFilterConverterTest.java | 125 ++++++++ .../MongoDBAtlasVectorStoreIT.java | 216 ++++++++++++++ .../VectorSearchAggregationTest.java | 63 ++++ 10 files changed, 919 insertions(+) create mode 100644 vector-stores/spring-ai-mongodb-atlas-store/pom.xml create mode 100644 vector-stores/spring-ai-mongodb-atlas-store/src/main/java/org/springframework/ai/vectorstore/MongoDBAtlasFilterExpressionConverter.java create mode 100644 vector-stores/spring-ai-mongodb-atlas-store/src/main/java/org/springframework/ai/vectorstore/MongoDBAtlasVectorStore.java create mode 100644 vector-stores/spring-ai-mongodb-atlas-store/src/main/java/org/springframework/ai/vectorstore/VectorSearchAggregation.java create mode 100644 vector-stores/spring-ai-mongodb-atlas-store/src/test/java/org/springframework/ai/vectorstore/MongoDBAtlasContainer.java create mode 100644 vector-stores/spring-ai-mongodb-atlas-store/src/test/java/org/springframework/ai/vectorstore/MongoDBAtlasFilterConverterTest.java create mode 100644 vector-stores/spring-ai-mongodb-atlas-store/src/test/java/org/springframework/ai/vectorstore/MongoDBAtlasVectorStoreIT.java create mode 100644 vector-stores/spring-ai-mongodb-atlas-store/src/test/java/org/springframework/ai/vectorstore/VectorSearchAggregationTest.java diff --git a/pom.xml b/pom.xml index 83bd0e1ff..5b73838a2 100644 --- a/pom.xml +++ b/pom.xml @@ -60,6 +60,7 @@ spring-ai-spring-boot-starters/spring-ai-starter-bedrock-ai spring-ai-spring-boot-starters/spring-ai-starter-mistral-ai spring-ai-retry + vector-stores/spring-ai-mongodb-atlas-store diff --git a/spring-ai-bom/pom.xml b/spring-ai-bom/pom.xml index 03655b644..26ab0d414 100644 --- a/spring-ai-bom/pom.xml +++ b/spring-ai-bom/pom.xml @@ -174,6 +174,12 @@ ${project.version} + + org.springframework.ai + spring-ai-mongodb-atlas-store + ${project.version} + + org.springframework.ai diff --git a/vector-stores/spring-ai-mongodb-atlas-store/pom.xml b/vector-stores/spring-ai-mongodb-atlas-store/pom.xml new file mode 100644 index 000000000..691bebece --- /dev/null +++ b/vector-stores/spring-ai-mongodb-atlas-store/pom.xml @@ -0,0 +1,59 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../pom.xml + + spring-ai-mongodb-atlas-store + jar + Spring AI MongoDB Atlas Vector Store + Spring AI MongoDB Atlas Vector Store + https://github.com/spring-projects-experimental/spring-ai + + + https://github.com/spring-projects/spring-ai + git://github.com/spring-projects/spring-ai.git + git@github.com:spring-projects/spring-ai.git + + + + + org.springframework.ai + spring-ai-core + ${parent.version} + + + + org.springframework.data + spring-data-mongodb + + + org.mongodb + mongodb-driver-sync + + + + + org.springframework.ai + spring-ai-openai-spring-boot-starter + ${parent.version} + test + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.testcontainers + junit-jupiter + ${testcontainers.version} + test + + + diff --git a/vector-stores/spring-ai-mongodb-atlas-store/src/main/java/org/springframework/ai/vectorstore/MongoDBAtlasFilterExpressionConverter.java b/vector-stores/spring-ai-mongodb-atlas-store/src/main/java/org/springframework/ai/vectorstore/MongoDBAtlasFilterExpressionConverter.java new file mode 100644 index 000000000..f541b0002 --- /dev/null +++ b/vector-stores/spring-ai-mongodb-atlas-store/src/main/java/org/springframework/ai/vectorstore/MongoDBAtlasFilterExpressionConverter.java @@ -0,0 +1,97 @@ +/* + * Copyright 2023 - 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.ai.vectorstore; + +import org.springframework.ai.vectorstore.filter.Filter; +import org.springframework.ai.vectorstore.filter.converter.AbstractFilterExpressionConverter; + +import static org.springframework.ai.vectorstore.filter.Filter.ExpressionType.AND; +import static org.springframework.ai.vectorstore.filter.Filter.ExpressionType.OR; + +/** + * Converts {@link Filter.Expression} into MongDB Atlas metadata filter expression format. + * (https://www.mongodb.com/docs/atlas/atlas-vector-search/vector-search-stage/#std-label-vectorSearch-agg-pipeline-filter) + * + * @author Chris Smith + * @since 1.0.0 + */ +public class MongoDBAtlasFilterExpressionConverter extends AbstractFilterExpressionConverter { + + @Override + protected void doExpression(Filter.Expression expression, StringBuilder context) { + // Handling AND/OR + if (AND.equals(expression.type()) || OR.equals(expression.type())) { + doCompoundExpressionType(expression, context); + } + else { + doSingleExpressionType(expression, context); + } + } + + private void doCompoundExpressionType(Filter.Expression expression, StringBuilder context) { + context.append("{"); + context.append(getOperationSymbol(expression)); + context.append(":["); + this.convertOperand(expression.left(), context); + context.append(","); + this.convertOperand(expression.right(), context); + context.append("]}"); + } + + private void doSingleExpressionType(Filter.Expression expression, StringBuilder context) { + context.append("{"); + this.convertOperand(expression.left(), context); + context.append(":{"); + context.append(getOperationSymbol(expression)); + context.append(":"); + this.convertOperand(expression.right(), context); + context.append("}}"); + } + + private String getOperationSymbol(Filter.Expression exp) { + switch (exp.type()) { + case AND: + return "$and"; + case OR: + return "$or"; + case EQ: + return "$eq"; + case NE: + return "$ne"; + case LT: + return "$lt"; + case LTE: + return "$lte"; + case GT: + return "$gt"; + case GTE: + return "$gte"; + case IN: + return "$in"; + case NIN: + return "$nin"; + default: + throw new RuntimeException("Not supported expression type:" + exp.type()); + } + } + + @Override + protected void doKey(Filter.Key filterKey, StringBuilder context) { + var identifier = (hasOuterQuotes(filterKey.key())) ? removeOuterQuotes(filterKey.key()) : filterKey.key(); + context.append("\"metadata." + identifier + "\""); + } + +} diff --git a/vector-stores/spring-ai-mongodb-atlas-store/src/main/java/org/springframework/ai/vectorstore/MongoDBAtlasVectorStore.java b/vector-stores/spring-ai-mongodb-atlas-store/src/main/java/org/springframework/ai/vectorstore/MongoDBAtlasVectorStore.java new file mode 100644 index 000000000..928cbb140 --- /dev/null +++ b/vector-stores/spring-ai-mongodb-atlas-store/src/main/java/org/springframework/ai/vectorstore/MongoDBAtlasVectorStore.java @@ -0,0 +1,271 @@ +/* + * Copyright 2023 - 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.ai.vectorstore; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import com.mongodb.BasicDBObject; + +import org.springframework.ai.document.Document; +import org.springframework.ai.embedding.EmbeddingClient; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.util.Assert; + +import static org.springframework.data.mongodb.core.query.Criteria.where; + +/** + * @author Chris Smith + * @since 1.0.0 + */ +public class MongoDBAtlasVectorStore implements VectorStore, InitializingBean { + + public static final String ID_FIELD_NAME = "_id"; + + public static final String METADATA_FIELD_NAME = "metadata"; + + public static final String CONTENT_FIELD_NAME = "content"; + + public static final String SCORE_FIELD_NAME = "score"; + + private static final String DEFAULT_VECTOR_COLLECTION_NAME = "vector_store"; + + private static final String DEFAULT_VECTOR_INDEX_NAME = "vector_index"; + + private static final String DEFAULT_PATH_NAME = "embedding"; + + private static final int DEFAULT_NUM_CANDIDATES = 200; + + private final MongoTemplate mongoTemplate; + + private final EmbeddingClient embeddingClient; + + private final MongoDBVectorStoreConfig config; + + private final MongoDBAtlasFilterExpressionConverter filterExpressionConverter = new MongoDBAtlasFilterExpressionConverter(); + + public MongoDBAtlasVectorStore(MongoTemplate mongoTemplate, EmbeddingClient embeddingClient) { + this(mongoTemplate, embeddingClient, MongoDBVectorStoreConfig.defaultConfig()); + } + + public MongoDBAtlasVectorStore(MongoTemplate mongoTemplate, EmbeddingClient embeddingClient, + MongoDBVectorStoreConfig config) { + this.mongoTemplate = mongoTemplate; + this.embeddingClient = embeddingClient; + this.config = config; + + } + + @Override + public void afterPropertiesSet() throws Exception { + // Create the collection if it does not exist + if (!mongoTemplate.collectionExists(this.config.collectionName)) { + mongoTemplate.createCollection(this.config.collectionName); + } + // Create search index, command doesn't do anything if already existing + mongoTemplate.executeCommand(createSearchIndex()); + } + + /** + * Provides the Definition for the search index + */ + private org.bson.Document createSearchIndex() { + List vectorFields = new ArrayList<>(); + vectorFields.add(new org.bson.Document().append("type", "vector") + .append("path", this.config.pathName) + .append("numDimensions", 1536) + .append("similarity", "cosine")); + vectorFields.addAll(this.config.metadataFieldsToFilter.stream() + .map(fieldName -> new org.bson.Document().append("type", "filter").append("path", "metadata." + fieldName)) + .toList()); + + return new org.bson.Document().append("createSearchIndexes", this.config.collectionName) + .append("indexes", + List.of(new org.bson.Document().append("name", this.config.vectorIndexName) + .append("type", "vectorSearch") + .append("definition", new org.bson.Document("fields", vectorFields)))); + } + + /** + * Maps a BasicDBObject to a Spring AI Document + * @param basicDBObject the basicDBObject to map to a spring ai document + * @return the spring ai document + */ + @SuppressWarnings("unchecked") + private Document mapBasicDbObject(BasicDBObject basicDBObject) { + String id = basicDBObject.getString(ID_FIELD_NAME); + String content = basicDBObject.getString(CONTENT_FIELD_NAME); + Map metadata = (Map) basicDBObject.get(METADATA_FIELD_NAME); + List embedding = (List) basicDBObject.get(this.config.pathName); + + Document document = new Document(id, content, metadata); + document.setEmbedding(embedding); + + return document; + } + + @Override + public void add(List documents) { + for (Document document : documents) { + List embedding = this.embeddingClient.embed(document); + document.setEmbedding(embedding); + this.mongoTemplate.save(document, this.config.collectionName); + } + } + + @Override + public Optional delete(List idList) { + Query query = new Query(where(ID_FIELD_NAME).in(idList)); + + var deleteRes = this.mongoTemplate.remove(query, this.config.collectionName); + long deleteCount = deleteRes.getDeletedCount(); + + return Optional.of(deleteCount == idList.size()); + } + + @Override + public List similaritySearch(String query) { + return similaritySearch(SearchRequest.query(query)); + } + + @Override + public List similaritySearch(SearchRequest request) { + + String nativeFilterExpressions = (request.getFilterExpression() != null) + ? this.filterExpressionConverter.convertExpression(request.getFilterExpression()) : ""; + + List queryEmbedding = this.embeddingClient.embed(request.getQuery()); + var vectorSearch = new VectorSearchAggregation(queryEmbedding, this.config.pathName, this.config.numCandidates, + this.config.vectorIndexName, request.getTopK(), nativeFilterExpressions); + + Aggregation aggregation = Aggregation.newAggregation(vectorSearch, + Aggregation.addFields() + .addField(SCORE_FIELD_NAME) + .withValueOfExpression("{\"$meta\":\"vectorSearchScore\"}") + .build(), + Aggregation.match(new Criteria(SCORE_FIELD_NAME).gte(request.getSimilarityThreshold()))); + + return this.mongoTemplate.aggregate(aggregation, this.config.collectionName, BasicDBObject.class) + .getMappedResults() + .stream() + .map(this::mapBasicDbObject) + .toList(); + } + + public static class MongoDBVectorStoreConfig { + + private final String collectionName; + + private final String vectorIndexName; + + private final String pathName; + + private final List metadataFieldsToFilter; + + private final int numCandidates; + + private MongoDBVectorStoreConfig(Builder builder) { + this.collectionName = builder.collectionName; + this.vectorIndexName = builder.vectorIndexName; + this.pathName = builder.pathName; + this.numCandidates = builder.numCandidates; + this.metadataFieldsToFilter = builder.metadataFieldsToFilter; + } + + public static Builder builder() { + return new Builder(); + } + + public static MongoDBVectorStoreConfig defaultConfig() { + return builder().build(); + } + + public static class Builder { + + private String collectionName = DEFAULT_VECTOR_COLLECTION_NAME; + + private String vectorIndexName = DEFAULT_VECTOR_INDEX_NAME; + + private String pathName = DEFAULT_PATH_NAME; + + private int numCandidates = DEFAULT_NUM_CANDIDATES; + + private List metadataFieldsToFilter = Collections.emptyList(); + + private Builder() { + } + + /** + * Configures the collection to use This must match the name of the collection + * for the Vector Search Index in Atlas + * @param collectionName + * @return this builder + */ + public Builder withCollectionName(String collectionName) { + Assert.notNull(collectionName, "Collection Name must not be null"); + Assert.notNull(collectionName, "Collection Name must not be empty"); + this.collectionName = collectionName; + return this; + } + + /** + * Configures the vector index name. This must match the name of the Vector + * Search Index Name in Atlas + * @param vectorIndexName + * @return this builder + */ + public Builder withVectorIndexName(String vectorIndexName) { + Assert.notNull(vectorIndexName, "Vector Index Name must not be null"); + Assert.notNull(vectorIndexName, "Vector Index Name must not be empty"); + this.vectorIndexName = vectorIndexName; + return this; + } + + /** + * Configures the path name. This must match the name of the field indexed for + * the Vector Search Index in Atlas + * @param pathName + * @return this builder + */ + public Builder withPathName(String pathName) { + Assert.notNull(pathName, "Path Name must not be null"); + Assert.notNull(pathName, "Path Name must not be empty"); + this.pathName = pathName; + return this; + } + + public Builder withMetadataFieldsToFilter(List metadataFieldsToFilter) { + Assert.notEmpty(metadataFieldsToFilter, "Fields list must not be empty"); + this.metadataFieldsToFilter = metadataFieldsToFilter; + return this; + } + + public MongoDBVectorStoreConfig build() { + return new MongoDBVectorStoreConfig(this); + } + + } + + } + +} \ No newline at end of file diff --git a/vector-stores/spring-ai-mongodb-atlas-store/src/main/java/org/springframework/ai/vectorstore/VectorSearchAggregation.java b/vector-stores/spring-ai-mongodb-atlas-store/src/main/java/org/springframework/ai/vectorstore/VectorSearchAggregation.java new file mode 100644 index 000000000..572749af4 --- /dev/null +++ b/vector-stores/spring-ai-mongodb-atlas-store/src/main/java/org/springframework/ai/vectorstore/VectorSearchAggregation.java @@ -0,0 +1,42 @@ +/* + * Copyright 2023 - 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.ai.vectorstore; + +import org.bson.Document; +import org.springframework.data.mongodb.core.aggregation.AggregationOperation; +import org.springframework.data.mongodb.core.aggregation.AggregationOperationContext; +import org.springframework.lang.NonNull; + +import java.util.List; + +record VectorSearchAggregation(List embeddings, String path, int numCandidates, String index, int count, + String filter) implements AggregationOperation { + + @SuppressWarnings("null") + @Override + public org.bson.Document toDocument(@NonNull AggregationOperationContext context) { + var vectorSearch = new Document("queryVector", embeddings).append("path", path) + .append("numCandidates", numCandidates) + .append("index", index) + .append("limit", count); + if (!filter.isEmpty()) { + vectorSearch.append("filter", Document.parse(filter)); + } + var doc = new Document("$vectorSearch", vectorSearch); + + return context.getMappedObject(doc); + } +} \ No newline at end of file diff --git a/vector-stores/spring-ai-mongodb-atlas-store/src/test/java/org/springframework/ai/vectorstore/MongoDBAtlasContainer.java b/vector-stores/spring-ai-mongodb-atlas-store/src/test/java/org/springframework/ai/vectorstore/MongoDBAtlasContainer.java new file mode 100644 index 000000000..2d2dd6aa5 --- /dev/null +++ b/vector-stores/spring-ai-mongodb-atlas-store/src/test/java/org/springframework/ai/vectorstore/MongoDBAtlasContainer.java @@ -0,0 +1,39 @@ +/* + * Copyright 2023 - 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.ai.vectorstore; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; + +import java.time.Duration; + +public class MongoDBAtlasContainer extends GenericContainer { + + public MongoDBAtlasContainer() { + super("mongodb/atlas:v1.15.1"); + withPrivilegedMode(true); + withCommand("/bin/bash", "-c", + "atlas deployments setup local-test --type local --port 27778 --bindIpAll --username root --password root --force && tail -f /dev/null"); + withExposedPorts(27778); + waitingFor(Wait.forLogMessage(".*Deployment created!.*\\n", 1)); + withStartupTimeout(Duration.ofMinutes(5)).withReuse(true); + } + + public String getConnectionString() { + return String.format("mongodb://root:root@%s:%s/?directConnection=true", getHost(), getMappedPort(27778)); + } + +} \ No newline at end of file diff --git a/vector-stores/spring-ai-mongodb-atlas-store/src/test/java/org/springframework/ai/vectorstore/MongoDBAtlasFilterConverterTest.java b/vector-stores/spring-ai-mongodb-atlas-store/src/test/java/org/springframework/ai/vectorstore/MongoDBAtlasFilterConverterTest.java new file mode 100644 index 000000000..6ab38c55c --- /dev/null +++ b/vector-stores/spring-ai-mongodb-atlas-store/src/test/java/org/springframework/ai/vectorstore/MongoDBAtlasFilterConverterTest.java @@ -0,0 +1,125 @@ +/* + * Copyright 2023 - 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.ai.vectorstore; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.ai.vectorstore.filter.Filter.Expression; +import org.springframework.ai.vectorstore.filter.Filter.Group; +import org.springframework.ai.vectorstore.filter.Filter.Key; +import org.springframework.ai.vectorstore.filter.Filter.Value; +import org.springframework.ai.vectorstore.filter.FilterExpressionConverter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.ai.vectorstore.filter.Filter.ExpressionType.AND; +import static org.springframework.ai.vectorstore.filter.Filter.ExpressionType.EQ; +import static org.springframework.ai.vectorstore.filter.Filter.ExpressionType.GTE; +import static org.springframework.ai.vectorstore.filter.Filter.ExpressionType.IN; +import static org.springframework.ai.vectorstore.filter.Filter.ExpressionType.LTE; +import static org.springframework.ai.vectorstore.filter.Filter.ExpressionType.NE; +import static org.springframework.ai.vectorstore.filter.Filter.ExpressionType.NIN; +import static org.springframework.ai.vectorstore.filter.Filter.ExpressionType.OR; + +/** + * @author Christopher Smith + */ +public class MongoDBAtlasFilterConverterTest { + + FilterExpressionConverter converter = new MongoDBAtlasFilterExpressionConverter(); + + @Test + public void testEQ() { + // country == "BG" + String vectorExpr = converter.convertExpression(new Expression(EQ, new Key("country"), new Value("BG"))); + assertThat(vectorExpr).isEqualTo("{\"metadata.country\":{$eq:\"BG\"}}"); + } + + @Test + public void tesEqAndGte() { + // genre == "drama" AND year >= 2020 + String vectorExpr = converter + .convertExpression(new Expression(AND, new Expression(EQ, new Key("genre"), new Value("drama")), + new Expression(GTE, new Key("year"), new Value(2020)))); + assertThat(vectorExpr) + .isEqualTo("{$and:[{\"metadata.genre\":{$eq:\"drama\"}},{\"metadata.year\":{$gte:2020}}]}"); + } + + @Test + public void tesIn() { + // genre in ["comedy", "documentary", "drama"] + String vectorExpr = converter.convertExpression( + new Expression(IN, new Key("genre"), new Value(List.of("comedy", "documentary", "drama")))); + assertThat(vectorExpr).isEqualTo("{\"metadata.genre\":{$in:[\"comedy\",\"documentary\",\"drama\"]}}"); + } + + @Test + public void testNe() { + // year >= 2020 OR country == "BG" AND city != "Sofia" + String vectorExpr = converter + .convertExpression(new Expression(OR, new Expression(GTE, new Key("year"), new Value(2020)), + new Expression(AND, new Expression(EQ, new Key("country"), new Value("BG")), + new Expression(NE, new Key("city"), new Value("Sofia"))))); + assertThat(vectorExpr).isEqualTo( + "{$or:[{\"metadata.year\":{$gte:2020}},{$and:[{\"metadata.country\":{$eq:\"BG\"}},{\"metadata.city\":{$ne:\"Sofia\"}}]}]}"); + } + + @Test + public void testGroup() { + // (year >= 2020 OR country == "BG") AND city NIN ["Sofia", "Plovdiv"] + String vectorExpr = converter.convertExpression(new Expression(AND, + new Group(new Expression(OR, new Expression(GTE, new Key("year"), new Value(2020)), + new Expression(EQ, new Key("country"), new Value("BG")))), + new Expression(NIN, new Key("city"), new Value(List.of("Sofia", "Plovdiv"))))); + assertThat(vectorExpr).isEqualTo( + "{$and:[{$or:[{\"metadata.year\":{$gte:2020}},{\"metadata.country\":{$eq:\"BG\"}}]},{\"metadata.city\":{$nin:[\"Sofia\",\"Plovdiv\"]}}]}"); + } + + @Test + public void testBoolean() { + // isOpen == true AND year >= 2020 AND country IN ["BG", "NL", "US"] + String vectorExpr = converter.convertExpression(new Expression(AND, + new Expression(AND, new Expression(EQ, new Key("isOpen"), new Value(true)), + new Expression(GTE, new Key("year"), new Value(2020))), + new Expression(IN, new Key("country"), new Value(List.of("BG", "NL", "US"))))); + + assertThat(vectorExpr).isEqualTo( + "{$and:[{$and:[{\"metadata.isOpen\":{$eq:true}},{\"metadata.year\":{$gte:2020}}]},{\"metadata.country\":{$in:[\"BG\",\"NL\",\"US\"]}}]}"); + } + + @Test + public void testDecimal() { + // temperature >= -15.6 && temperature <= +20.13 + String vectorExpr = converter + .convertExpression(new Expression(AND, new Expression(GTE, new Key("temperature"), new Value(-15.6)), + new Expression(LTE, new Key("temperature"), new Value(20.13)))); + + assertThat(vectorExpr) + .isEqualTo("{$and:[{\"metadata.temperature\":{$gte:-15.6}},{\"metadata.temperature\":{$lte:20.13}}]}"); + } + + @Test + public void testComplexIdentifiers() { + String vectorExpr = converter + .convertExpression(new Expression(EQ, new Key("\"country 1 2 3\""), new Value("BG"))); + assertThat(vectorExpr).isEqualTo("{\"metadata.country 1 2 3\":{$eq:\"BG\"}}"); + + vectorExpr = converter.convertExpression(new Expression(EQ, new Key("'country 1 2 3'"), new Value("BG"))); + assertThat(vectorExpr).isEqualTo("{\"metadata.country 1 2 3\":{$eq:\"BG\"}}"); + } + +} diff --git a/vector-stores/spring-ai-mongodb-atlas-store/src/test/java/org/springframework/ai/vectorstore/MongoDBAtlasVectorStoreIT.java b/vector-stores/spring-ai-mongodb-atlas-store/src/test/java/org/springframework/ai/vectorstore/MongoDBAtlasVectorStoreIT.java new file mode 100644 index 000000000..afc2dcbd7 --- /dev/null +++ b/vector-stores/spring-ai-mongodb-atlas-store/src/test/java/org/springframework/ai/vectorstore/MongoDBAtlasVectorStoreIT.java @@ -0,0 +1,216 @@ +/* + * Copyright 2023 - 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.ai.vectorstore; + +import com.mongodb.client.MongoClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.ai.autoconfigure.openai.OpenAiAutoConfiguration; +import org.springframework.ai.document.Document; +import org.springframework.ai.embedding.EmbeddingClient; +import org.springframework.ai.openai.OpenAiEmbeddingClient; +import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Chris Smith + */ + +@Testcontainers +class MongoDBAtlasVectorStoreIT { + + @Container + private static MongoDBAtlasContainer container = new MongoDBAtlasContainer(); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withUserConfiguration(TestApplication.class) + .withPropertyValues("spring.ai.openai.apiKey=" + System.getenv("OPENAI_API_KEY"), + String.format("spring.data.mongodb.database=" + "springaisample"), + String.format("spring.data.mongodb.uri=" + container.getConnectionString())); + + @BeforeEach + public void beforeEach() { + contextRunner.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)).run(context -> { + MongoTemplate mongoTemplate = context.getBean(MongoTemplate.class); + mongoTemplate.getCollection("vector_store").deleteMany(new org.bson.Document()); + }); + } + + @Test + void vectorStoreTest() { + contextRunner.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)).run(context -> { + VectorStore vectorStore = context.getBean(VectorStore.class); + + List documents = List.of( + new Document( + "Spring AI rocks!! Spring AI rocks!! Spring AI rocks!! Spring AI rocks!! Spring AI rocks!!", + Collections.singletonMap("meta1", "meta1")), + new Document("Hello World Hello World Hello World Hello World Hello World Hello World Hello World"), + new Document( + "Great Depression Great Depression Great Depression Great Depression Great Depression Great Depression", + Collections.singletonMap("meta2", "meta2"))); + + vectorStore.add(documents); + Thread.sleep(5000); // Await a second for the document to be indexed + + List results = vectorStore.similaritySearch(SearchRequest.query("Great").withTopK(1)); + + assertThat(results).hasSize(1); + Document resultDoc = results.get(0); + assertThat(resultDoc.getId()).isEqualTo(documents.get(2).getId()); + assertThat(resultDoc.getContent()).isEqualTo( + "Great Depression Great Depression Great Depression Great Depression Great Depression Great Depression"); + assertThat(resultDoc.getMetadata()).containsEntry("meta2", "meta2"); + + // Remove all documents from the store + vectorStore.delete(documents.stream().map(Document::getId).collect(Collectors.toList())); + + List results2 = vectorStore.similaritySearch(SearchRequest.query("Great").withTopK(1)); + assertThat(results2).isEmpty(); + + }); + } + + @Test + void documentUpdateTest() { + contextRunner.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)).run(context -> { + VectorStore vectorStore = context.getBean(VectorStore.class); + + Document document = new Document(UUID.randomUUID().toString(), "Spring AI rocks!!", + Collections.singletonMap("meta1", "meta1")); + + vectorStore.add(List.of(document)); + Thread.sleep(5000); // Await a second for the document to be indexed + + List results = vectorStore.similaritySearch(SearchRequest.query("Spring").withTopK(5)); + + assertThat(results).hasSize(1); + Document resultDoc = results.get(0); + assertThat(resultDoc.getId()).isEqualTo(document.getId()); + assertThat(resultDoc.getContent()).isEqualTo("Spring AI rocks!!"); + assertThat(resultDoc.getMetadata()).containsEntry("meta1", "meta1"); + + Document sameIdDocument = new Document(document.getId(), + "The World is Big and Salvation Lurks Around the Corner", + Collections.singletonMap("meta2", "meta2")); + + vectorStore.add(List.of(sameIdDocument)); + + results = vectorStore.similaritySearch(SearchRequest.query("FooBar").withTopK(5)); + + assertThat(results).hasSize(1); + resultDoc = results.get(0); + assertThat(resultDoc.getId()).isEqualTo(document.getId()); + assertThat(resultDoc.getContent()).isEqualTo("The World is Big and Salvation Lurks Around the Corner"); + assertThat(resultDoc.getMetadata()).containsEntry("meta2", "meta2"); + }); + } + + @Test + void searchWithFilters() { + contextRunner.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)).run(context -> { + VectorStore vectorStore = context.getBean(VectorStore.class); + + var bgDocument = new Document("The World is Big and Salvation Lurks Around the Corner", + Map.of("country", "BG", "year", 2020)); + var nlDocument = new Document("The World is Big and Salvation Lurks Around the Corner", + Map.of("country", "NL")); + var bgDocument2 = new Document("The World is Big and Salvation Lurks Around the Corner", + Map.of("country", "BG", "year", 2023)); + + vectorStore.add(List.of(bgDocument, nlDocument, bgDocument2)); + Thread.sleep(5000); // Await a second for the document to be indexed + + List results = vectorStore.similaritySearch(SearchRequest.query("The World").withTopK(5)); + assertThat(results).hasSize(3); + + results = vectorStore.similaritySearch(SearchRequest.query("The World") + .withTopK(5) + .withSimilarityThresholdAll() + .withFilterExpression("country == 'NL'")); + assertThat(results).hasSize(1); + assertThat(results.get(0).getId()).isEqualTo(nlDocument.getId()); + + results = vectorStore.similaritySearch(SearchRequest.query("The World") + .withTopK(5) + .withSimilarityThresholdAll() + .withFilterExpression("country == 'BG'")); + + assertThat(results).hasSize(2); + assertThat(results.get(0).getId()).isIn(bgDocument.getId(), bgDocument2.getId()); + assertThat(results.get(1).getId()).isIn(bgDocument.getId(), bgDocument2.getId()); + + results = vectorStore.similaritySearch(SearchRequest.query("The World") + .withTopK(5) + .withSimilarityThresholdAll() + .withFilterExpression("country == 'BG' && year == 2020")); + + assertThat(results).hasSize(1); + assertThat(results.get(0).getId()).isEqualTo(bgDocument.getId()); + + results = vectorStore.similaritySearch(SearchRequest.query("The World") + .withTopK(5) + .withSimilarityThresholdAll() + .withFilterExpression("NOT(country == 'BG' && year == 2020)")); + + assertThat(results).hasSize(2); + assertThat(results.get(0).getId()).isIn(nlDocument.getId(), bgDocument2.getId()); + assertThat(results.get(1).getId()).isIn(nlDocument.getId(), bgDocument2.getId()); + + }); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + public static class TestApplication { + + @Bean + public VectorStore vectorStore(MongoTemplate mongoTemplate, EmbeddingClient embeddingClient) { + return new MongoDBAtlasVectorStore(mongoTemplate, embeddingClient, + MongoDBAtlasVectorStore.MongoDBVectorStoreConfig.builder() + .withMetadataFieldsToFilter(List.of("country", "year")) + .build()); + } + + @Bean + public MongoTemplate mongoTemplate(MongoClient mongoClient) { + return new MongoTemplate(mongoClient, "springaisample"); + } + + @Bean + public EmbeddingClient embeddingClient() { + return new OpenAiEmbeddingClient(new OpenAiApi(System.getenv("OPENAI_API_KEY"))); + } + + } + +} \ No newline at end of file diff --git a/vector-stores/spring-ai-mongodb-atlas-store/src/test/java/org/springframework/ai/vectorstore/VectorSearchAggregationTest.java b/vector-stores/spring-ai-mongodb-atlas-store/src/test/java/org/springframework/ai/vectorstore/VectorSearchAggregationTest.java new file mode 100644 index 000000000..f489df3da --- /dev/null +++ b/vector-stores/spring-ai-mongodb-atlas-store/src/test/java/org/springframework/ai/vectorstore/VectorSearchAggregationTest.java @@ -0,0 +1,63 @@ +/* + * Copyright 2023 - 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.ai.vectorstore; + +import org.bson.Document; +import org.junit.jupiter.api.Test; +import org.springframework.data.mongodb.core.aggregation.Aggregation; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class VectorSearchAggregationTest { + + @Test + void toDocumentNoFilter() { + var vectorSearchAggregation = new VectorSearchAggregation(List.of(1.0, 2.0, 3.0), "embedding", 10, + "vector_store", 10, ""); + var aggregation = Aggregation.newAggregation(vectorSearchAggregation); + var document = aggregation.toDocument("vector_store", Aggregation.DEFAULT_CONTEXT); + + var vectorSearchDocument = new Document("$vectorSearch", + new Document("queryVector", List.of(1.0, 2.0, 3.0)).append("path", "embedding") + .append("numCandidates", 10) + .append("index", "vector_store") + .append("limit", 10)); + var expected = new Document().append("aggregate", "vector_store") + .append("pipeline", List.of(vectorSearchDocument)); + assertEquals(expected, document); + } + + @Test + void toDocumentWithFilter() { + var vectorSearchAggregation = new VectorSearchAggregation(List.of(1.0, 2.0, 3.0), "embedding", 10, + "vector_store", 10, "{\"metadata.country\":{$eq:\"BG\"}}"); + var aggregation = Aggregation.newAggregation(vectorSearchAggregation); + var document = aggregation.toDocument("vector_store", Aggregation.DEFAULT_CONTEXT); + + var vectorSearchDocument = new Document("$vectorSearch", + new Document("queryVector", List.of(1.0, 2.0, 3.0)).append("path", "embedding") + .append("numCandidates", 10) + .append("index", "vector_store") + .append("filter", new Document("metadata.country", new Document().append("$eq", "BG"))) + .append("limit", 10)); + var expected = new Document().append("aggregate", "vector_store") + .append("pipeline", List.of(vectorSearchDocument)); + assertEquals(expected, document); + } + +} \ No newline at end of file