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
This commit is contained in:
Chris Smith
2023-11-04 11:47:34 -04:00
committed by Christian Tzolov
parent 9c19dc1665
commit 5f0123cc92
10 changed files with 919 additions and 0 deletions

View File

@@ -60,6 +60,7 @@
<module>spring-ai-spring-boot-starters/spring-ai-starter-bedrock-ai</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-mistral-ai</module>
<module>spring-ai-retry</module>
<module>vector-stores/spring-ai-mongodb-atlas-store</module>
</modules>
<organization>

View File

@@ -174,6 +174,12 @@
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-mongodb-atlas-store</artifactId>
<version>${project.version}</version>
</dependency>
<!-- Utilities -->
<dependency>
<groupId>org.springframework.ai</groupId>

View File

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<artifactId>spring-ai-mongodb-atlas-store</artifactId>
<packaging>jar</packaging>
<name>Spring AI MongoDB Atlas Vector Store</name>
<description>Spring AI MongoDB Atlas Vector Store</description>
<url>https://github.com/spring-projects-experimental/spring-ai</url>
<scm>
<url>https://github.com/spring-projects/spring-ai</url>
<connection>git://github.com/spring-projects/spring-ai.git</connection>
<developerConnection>git@github.com:spring-projects/spring-ai.git</developerConnection>
</scm>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-core</artifactId>
<version>${parent.version}</version>
</dependency>
<!-- MongoDB -->
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-mongodb</artifactId>
</dependency>
<dependency>
<groupId>org.mongodb</groupId>
<artifactId>mongodb-driver-sync</artifactId>
</dependency>
<!-- TESTING -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>${parent.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -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 + "\"");
}
}

View File

@@ -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<org.bson.Document> 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<String, Object> metadata = (Map<String, Object>) basicDBObject.get(METADATA_FIELD_NAME);
List<Double> embedding = (List<Double>) basicDBObject.get(this.config.pathName);
Document document = new Document(id, content, metadata);
document.setEmbedding(embedding);
return document;
}
@Override
public void add(List<Document> documents) {
for (Document document : documents) {
List<Double> embedding = this.embeddingClient.embed(document);
document.setEmbedding(embedding);
this.mongoTemplate.save(document, this.config.collectionName);
}
}
@Override
public Optional<Boolean> delete(List<String> 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<Document> similaritySearch(String query) {
return similaritySearch(SearchRequest.query(query));
}
@Override
public List<Document> similaritySearch(SearchRequest request) {
String nativeFilterExpressions = (request.getFilterExpression() != null)
? this.filterExpressionConverter.convertExpression(request.getFilterExpression()) : "";
List<Double> 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<String> 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<String> 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<String> metadataFieldsToFilter) {
Assert.notEmpty(metadataFieldsToFilter, "Fields list must not be empty");
this.metadataFieldsToFilter = metadataFieldsToFilter;
return this;
}
public MongoDBVectorStoreConfig build() {
return new MongoDBVectorStoreConfig(this);
}
}
}
}

View File

@@ -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<Double> 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);
}
}

View File

@@ -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<MongoDBAtlasContainer> {
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));
}
}

View File

@@ -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\"}}");
}
}

View File

@@ -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<Document> 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<Document> 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<Document> 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<Document> 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<Document> 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")));
}
}
}

View File

@@ -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);
}
}