From 9334d7d540f8d0ee7cb09e44b272c95c2c159627 Mon Sep 17 00:00:00 2001 From: Jemin Huh Date: Tue, 9 Jan 2024 22:42:01 +0900 Subject: [PATCH] Add Elasticsearch vector store integration - Implement ElasticsearchVectoSotore and IT. - Add ElasticsearchAiSearchFilterExpressionConverter. - Add dependency to BOM and module to parent pom. - Fix ElasticsearchVectorStoreIT FilterExpression with Date type requires the use of epoch milliseconds. - Add license formatting. --- pom.xml | 2 +- spring-ai-bom/pom.xml | 8 +- .../filter/FilterExpressionConverter.java | 2 - .../spring-ai-elasticsearch-store/pom.xml | 78 ++++ ...archAiSearchFilterExpressionConverter.java | 150 +++++++ .../vectorstore/ElasticsearchVectorStore.java | 215 ++++++++++ ...AiSearchFilterExpressionConverterTest.java | 117 ++++++ .../ElasticsearchVectorStoreIT.java | 376 ++++++++++++++++++ 8 files changed, 944 insertions(+), 4 deletions(-) create mode 100644 vector-stores/spring-ai-elasticsearch-store/pom.xml create mode 100644 vector-stores/spring-ai-elasticsearch-store/src/main/java/org/springframework/ai/vectorstore/ElasticsearchAiSearchFilterExpressionConverter.java create mode 100644 vector-stores/spring-ai-elasticsearch-store/src/main/java/org/springframework/ai/vectorstore/ElasticsearchVectorStore.java create mode 100644 vector-stores/spring-ai-elasticsearch-store/src/test/java/org/springframework/ai/vectorstore/ElasticsearchAiSearchFilterExpressionConverterTest.java create mode 100644 vector-stores/spring-ai-elasticsearch-store/src/test/java/org/springframework/ai/vectorstore/ElasticsearchVectorStoreIT.java diff --git a/pom.xml b/pom.xml index 8fc852901..a16eaee71 100644 --- a/pom.xml +++ b/pom.xml @@ -27,7 +27,6 @@ models/spring-ai-vertex-ai-palm2 models/spring-ai-vertex-ai-gemini models/spring-ai-anthropic - spring-ai-test spring-ai-spring-boot-autoconfigure spring-ai-spring-boot-starters/spring-ai-starter-openai @@ -67,6 +66,7 @@ spring-ai-spring-boot-starters/spring-ai-starter-mongodb-atlas-store spring-ai-spring-boot-testcontainers spring-ai-spring-boot-starters/spring-ai-starter-anthropic + vector-stores/spring-ai-elasticsearch-store diff --git a/spring-ai-bom/pom.xml b/spring-ai-bom/pom.xml index 7457c2798..6d72deb07 100644 --- a/spring-ai-bom/pom.xml +++ b/spring-ai-bom/pom.xml @@ -182,7 +182,13 @@ org.springframework.ai - spring-ai-mongodb-atlas-store + spring-ai-mongodb-atlas-store + ${project.version} + + + + org.springframework.ai + spring-ai-elasticsearch-store ${project.version} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/vectorstore/filter/FilterExpressionConverter.java b/spring-ai-core/src/main/java/org/springframework/ai/vectorstore/filter/FilterExpressionConverter.java index 31a9f6232..127f2dc92 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/vectorstore/filter/FilterExpressionConverter.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/vectorstore/filter/FilterExpressionConverter.java @@ -15,8 +15,6 @@ */ package org.springframework.ai.vectorstore.filter; -import org.springframework.ai.vectorstore.filter.Filter; - /** * Converters a generic, portable {@link Filter.Expression} into a * {@link org.springframework.ai.vectorstore.VectorStore} specific expression language diff --git a/vector-stores/spring-ai-elasticsearch-store/pom.xml b/vector-stores/spring-ai-elasticsearch-store/pom.xml new file mode 100644 index 000000000..11aac09f9 --- /dev/null +++ b/vector-stores/spring-ai-elasticsearch-store/pom.xml @@ -0,0 +1,78 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../pom.xml + + spring-ai-elasticsearch-store + jar + Spring AI Vector Store - Elasticsearch + Spring AI Elasticsearch Vector Store + https://github.com/spring-projects/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 + + + + + 4.0.3 + + + + + org.springframework.ai + spring-ai-core + ${parent.version} + + + + co.elastic.clients + elasticsearch-java + 8.12.2 + + + + + org.springframework.ai + spring-ai-openai + ${parent.version} + test + + + + + org.springframework.ai + spring-ai-test + ${parent.version} + test + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.testcontainers + elasticsearch + test + + + + org.testcontainers + junit-jupiter + ${testcontainers.version} + test + + + + + diff --git a/vector-stores/spring-ai-elasticsearch-store/src/main/java/org/springframework/ai/vectorstore/ElasticsearchAiSearchFilterExpressionConverter.java b/vector-stores/spring-ai-elasticsearch-store/src/main/java/org/springframework/ai/vectorstore/ElasticsearchAiSearchFilterExpressionConverter.java new file mode 100644 index 000000000..e0ce7d620 --- /dev/null +++ b/vector-stores/spring-ai-elasticsearch-store/src/main/java/org/springframework/ai/vectorstore/ElasticsearchAiSearchFilterExpressionConverter.java @@ -0,0 +1,150 @@ +/* + * 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.Filter.Expression; +import org.springframework.ai.vectorstore.filter.Filter.Key; +import org.springframework.ai.vectorstore.filter.converter.AbstractFilterExpressionConverter; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; +import java.util.TimeZone; +import java.util.regex.Pattern; + +/** + * @author Jemin Huh + * @since 1.0.0 + */ +public class ElasticsearchAiSearchFilterExpressionConverter extends AbstractFilterExpressionConverter { + + private static final Pattern DATE_FORMAT_PATTERN = Pattern.compile("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z"); + + private final SimpleDateFormat dateFormat; + + public ElasticsearchAiSearchFilterExpressionConverter() { + this.dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"); + this.dateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); + } + + @Override + protected void doExpression(Expression expression, StringBuilder context) { + if (expression.type() == Filter.ExpressionType.IN || expression.type() == Filter.ExpressionType.NIN) { + context.append(getOperationSymbol(expression)); + context.append("("); + this.convertOperand(expression.left(), context); + this.convertOperand(expression.right(), context); + context.append(")"); + } + else { + this.convertOperand(expression.left(), context); + context.append(getOperationSymbol(expression)); + this.convertOperand(expression.right(), context); + } + } + + @Override + protected void doStartValueRange(Filter.Value listValue, StringBuilder context) { + } + + @Override + protected void doEndValueRange(Filter.Value listValue, StringBuilder context) { + } + + @Override + protected void doAddValueRangeSpitter(Filter.Value listValue, StringBuilder context) { + context.append(" OR "); + } + + private String getOperationSymbol(Expression exp) { + return switch (exp.type()) { + case AND -> " AND "; + case OR -> " OR "; + case EQ, IN -> ""; + case NE -> " NOT "; + case LT -> "<"; + case LTE -> "<="; + case GT -> ">"; + case GTE -> ">="; + case NIN -> "NOT "; + default -> throw new RuntimeException("Not supported expression type: " + exp.type()); + }; + } + + @Override + public void doKey(Key key, StringBuilder context) { + var identifier = hasOuterQuotes(key.key()) ? removeOuterQuotes(key.key()) : key.key(); + var prefixedIdentifier = withMetaPrefix(identifier); + context.append(prefixedIdentifier.trim()).append(":"); + } + + public String withMetaPrefix(String identifier) { + return "metadata." + identifier; + } + + @Override + protected void doValue(Filter.Value filterValue, StringBuilder context) { + if (filterValue.value() instanceof List list) { + int c = 0; + for (Object v : list) { + context.append(v); + if (c++ < list.size() - 1) { + this.doAddValueRangeSpitter(filterValue, context); + } + } + } + else { + this.doSingleValue(filterValue.value(), context); + } + } + + @Override + protected void doSingleValue(Object value, StringBuilder context) { + if (value instanceof Date date) { + context.append(this.dateFormat.format(date)); + } + else if (value instanceof String text) { + if (DATE_FORMAT_PATTERN.matcher(text).matches()) { + try { + Date date = this.dateFormat.parse(text); + context.append(this.dateFormat.format(date)); + } + catch (ParseException e) { + throw new IllegalArgumentException("Invalid date type:" + text, e); + } + } + else { + context.append(text); + } + } + else { + context.append(value); + } + } + + @Override + public void doStartGroup(Filter.Group group, StringBuilder context) { + context.append("("); + } + + @Override + public void doEndGroup(Filter.Group group, StringBuilder context) { + context.append(")"); + } + +} \ No newline at end of file diff --git a/vector-stores/spring-ai-elasticsearch-store/src/main/java/org/springframework/ai/vectorstore/ElasticsearchVectorStore.java b/vector-stores/spring-ai-elasticsearch-store/src/main/java/org/springframework/ai/vectorstore/ElasticsearchVectorStore.java new file mode 100644 index 000000000..75a41a580 --- /dev/null +++ b/vector-stores/spring-ai-elasticsearch-store/src/main/java/org/springframework/ai/vectorstore/ElasticsearchVectorStore.java @@ -0,0 +1,215 @@ +/* + * 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 co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch._types.query_dsl.Query; +import co.elastic.clients.elasticsearch.core.BulkRequest; +import co.elastic.clients.elasticsearch.core.BulkResponse; +import co.elastic.clients.elasticsearch.core.search.Hit; +import co.elastic.clients.elasticsearch.indices.CreateIndexResponse; +import co.elastic.clients.json.JsonData; +import co.elastic.clients.json.jackson.JacksonJsonpMapper; +import co.elastic.clients.transport.endpoints.BooleanResponse; +import co.elastic.clients.transport.rest_client.RestClientTransport; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.elasticsearch.client.RestClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.document.Document; +import org.springframework.ai.embedding.EmbeddingClient; +import org.springframework.ai.vectorstore.filter.Filter; +import org.springframework.ai.vectorstore.filter.FilterExpressionConverter; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.util.Assert; + +import java.io.IOException; +import java.io.StringReader; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * @author Jemin Huh + * @since 1.0.0 + */ +public class ElasticsearchVectorStore implements VectorStore, InitializingBean { + + // divided by 2 to get score in the range [0, 1] + public static final String COSINE_SIMILARITY_FUNCTION = "(cosineSimilarity(params.query_vector, 'embedding') + 1.0) / 2"; + + private static final Logger logger = LoggerFactory.getLogger(ElasticsearchVectorStore.class); + + private static final String INDEX_NAME = "spring-ai-document-index"; + + private final EmbeddingClient embeddingClient; + + private final ElasticsearchClient elasticsearchClient; + + private final String index; + + private final FilterExpressionConverter filterExpressionConverter; + + private String similarityFunction; + + public ElasticsearchVectorStore(RestClient restClient, EmbeddingClient embeddingClient) { + this(INDEX_NAME, restClient, embeddingClient); + } + + public ElasticsearchVectorStore(String index, RestClient restClient, EmbeddingClient embeddingClient) { + Objects.requireNonNull(embeddingClient, "RestClient must not be null"); + Objects.requireNonNull(embeddingClient, "EmbeddingClient must not be null"); + this.elasticsearchClient = new ElasticsearchClient(new RestClientTransport(restClient, new JacksonJsonpMapper( + new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)))); + this.embeddingClient = embeddingClient; + this.index = index; + this.filterExpressionConverter = new ElasticsearchAiSearchFilterExpressionConverter(); + // the potential functions for vector fields at + // https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-script-score-query.html#vector-functions + this.similarityFunction = COSINE_SIMILARITY_FUNCTION; + } + + public ElasticsearchVectorStore withSimilarityFunction(String similarityFunction) { + this.similarityFunction = similarityFunction; + return this; + } + + @Override + public void add(List documents) { + BulkRequest.Builder builkRequestBuilder = new BulkRequest.Builder(); + for (Document document : documents) { + if (Objects.isNull(document.getEmbedding()) || document.getEmbedding().isEmpty()) { + logger.debug("Calling EmbeddingClient for document id = " + document.getId()); + document.setEmbedding(this.embeddingClient.embed(document)); + } + builkRequestBuilder + .operations(op -> op.index(idx -> idx.index(this.index).id(document.getId()).document(document))); + } + bulkRequest(builkRequestBuilder.build()); + } + + @Override + public Optional delete(List idList) { + BulkRequest.Builder builkRequestBuilder = new BulkRequest.Builder(); + for (String id : idList) + builkRequestBuilder.operations(op -> op.delete(idx -> idx.index(this.index).id(id))); + return Optional.of(bulkRequest(builkRequestBuilder.build()).errors()); + } + + private BulkResponse bulkRequest(BulkRequest bulkRequest) { + try { + return this.elasticsearchClient.bulk(bulkRequest); + } + catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public List similaritySearch(SearchRequest searchRequest) { + Assert.notNull(searchRequest, "The search request must not be null."); + return similaritySearch(this.embeddingClient.embed(searchRequest.getQuery()), searchRequest.getTopK(), + Double.valueOf(searchRequest.getSimilarityThreshold()).floatValue(), + searchRequest.getFilterExpression()); + } + + public List similaritySearch(List embedding, int topK, double similarityThreshold, + Filter.Expression filterExpression) { + return similaritySearch(new co.elastic.clients.elasticsearch.core.SearchRequest.Builder() + .query(getElasticsearchSimilarityQuery(embedding, filterExpression)) + .size(topK) + .minScore(similarityThreshold) + .build()); + } + + private Query getElasticsearchSimilarityQuery(List embedding, Filter.Expression filterExpression) { + return Query.of(queryBuilder -> queryBuilder.scriptScore(scriptScoreQueryBuilder -> scriptScoreQueryBuilder + .query(queryBuilder2 -> queryBuilder2.queryString(queryStringQuerybuilder -> queryStringQuerybuilder + .query(getElasticsearchQueryString(filterExpression)))) + .script(scriptBuilder -> scriptBuilder + .inline(inlineScriptBuilder -> inlineScriptBuilder.source(this.similarityFunction) + .params("query_vector", JsonData.of(embedding)))))); + } + + private String getElasticsearchQueryString(Filter.Expression filterExpression) { + return Objects.isNull(filterExpression) ? "*" + : this.filterExpressionConverter.convertExpression(filterExpression); + + } + + private List similaritySearch(co.elastic.clients.elasticsearch.core.SearchRequest searchRequest) { + try { + return this.elasticsearchClient.search(searchRequest, Document.class) + .hits() + .hits() + .stream() + .map(this::toDocument) + .collect(Collectors.toList()); + } + catch (IOException e) { + throw new RuntimeException(e); + } + } + + private Document toDocument(Hit hit) { + Document document = hit.source(); + document.getMetadata().put("distance", 1 - hit.score().floatValue()); + return document; + } + + public boolean exists(String targetIndex) { + try { + BooleanResponse response = this.elasticsearchClient.indices() + .exists(existRequestBuilder -> existRequestBuilder.index(targetIndex)); + return response.value(); + } + catch (IOException e) { + throw new RuntimeException(e); + } + } + + public CreateIndexResponse createIndexMapping(String index, String mappingJson) { + try { + return this.elasticsearchClient.indices() + .create(createIndexBuilder -> createIndexBuilder.index(index) + .mappings(typeMappingBuilder -> typeMappingBuilder.withJson(new StringReader(mappingJson)))); + } + catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public void afterPropertiesSet() { + if (!exists(this.index)) { + createIndexMapping(this.index, """ + { + "properties": { + "embedding": { + "type": "dense_vector", + "dims": 1536, + "index": true, + "similarity": "cosine" + } + } + } + """); + } + } + +} diff --git a/vector-stores/spring-ai-elasticsearch-store/src/test/java/org/springframework/ai/vectorstore/ElasticsearchAiSearchFilterExpressionConverterTest.java b/vector-stores/spring-ai-elasticsearch-store/src/test/java/org/springframework/ai/vectorstore/ElasticsearchAiSearchFilterExpressionConverterTest.java new file mode 100644 index 000000000..636761cf6 --- /dev/null +++ b/vector-stores/spring-ai-elasticsearch-store/src/test/java/org/springframework/ai/vectorstore/ElasticsearchAiSearchFilterExpressionConverterTest.java @@ -0,0 +1,117 @@ +/* + * 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.junit.jupiter.api.Test; +import org.springframework.ai.vectorstore.filter.Filter; +import org.springframework.ai.vectorstore.filter.FilterExpressionConverter; + +import java.util.Date; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.ai.vectorstore.filter.Filter.ExpressionType.*; + +class ElasticsearchAiSearchFilterExpressionConverterTest { + + final FilterExpressionConverter converter = new ElasticsearchAiSearchFilterExpressionConverter(); + + @Test + public void testDate() { + String vectorExpr = converter.convertExpression(new Filter.Expression(EQ, new Filter.Key("activationDate"), + new Filter.Value(new Date(1704637752148L)))); + assertThat(vectorExpr).isEqualTo("metadata.activationDate:2024-01-07T14:29:12Z"); + + vectorExpr = converter.convertExpression( + new Filter.Expression(EQ, new Filter.Key("activationDate"), new Filter.Value("1970-01-01T00:00:02Z"))); + assertThat(vectorExpr).isEqualTo("metadata.activationDate:1970-01-01T00:00:02Z"); + } + + @Test + public void testEQ() { + String vectorExpr = converter + .convertExpression(new Filter.Expression(EQ, new Filter.Key("country"), new Filter.Value("BG"))); + assertThat(vectorExpr).isEqualTo("metadata.country:BG"); + } + + @Test + public void tesEqAndGte() { + String vectorExpr = converter.convertExpression(new Filter.Expression(AND, + new Filter.Expression(EQ, new Filter.Key("genre"), new Filter.Value("drama")), + new Filter.Expression(GTE, new Filter.Key("year"), new Filter.Value(2020)))); + assertThat(vectorExpr).isEqualTo("metadata.genre:drama AND metadata.year:>=2020"); + } + + @Test + public void tesIn() { + String vectorExpr = converter.convertExpression(new Filter.Expression(IN, new Filter.Key("genre"), + new Filter.Value(List.of("comedy", "documentary", "drama")))); + assertThat(vectorExpr).isEqualTo("(metadata.genre:comedy OR documentary OR drama)"); + } + + @Test + public void testNe() { + String vectorExpr = converter.convertExpression( + new Filter.Expression(OR, new Filter.Expression(GTE, new Filter.Key("year"), new Filter.Value(2020)), + new Filter.Expression(AND, + new Filter.Expression(EQ, new Filter.Key("country"), new Filter.Value("BG")), + new Filter.Expression(NE, new Filter.Key("city"), new Filter.Value("Sofia"))))); + assertThat(vectorExpr).isEqualTo("metadata.year:>=2020 OR metadata.country:BG AND metadata.city: NOT Sofia"); + } + + @Test + public void testGroup() { + String vectorExpr = converter.convertExpression(new Filter.Expression(AND, + new Filter.Group(new Filter.Expression(OR, + new Filter.Expression(GTE, new Filter.Key("year"), new Filter.Value(2020)), + new Filter.Expression(EQ, new Filter.Key("country"), new Filter.Value("BG")))), + new Filter.Expression(NIN, new Filter.Key("city"), new Filter.Value(List.of("Sofia", "Plovdiv"))))); + assertThat(vectorExpr) + .isEqualTo("(metadata.year:>=2020 OR metadata.country:BG) AND NOT (metadata.city:Sofia OR Plovdiv)"); + } + + @Test + public void tesBoolean() { + String vectorExpr = converter.convertExpression(new Filter.Expression(AND, + new Filter.Expression(AND, new Filter.Expression(EQ, new Filter.Key("isOpen"), new Filter.Value(true)), + new Filter.Expression(GTE, new Filter.Key("year"), new Filter.Value(2020))), + new Filter.Expression(IN, new Filter.Key("country"), new Filter.Value(List.of("BG", "NL", "US"))))); + + assertThat(vectorExpr) + .isEqualTo("metadata.isOpen:true AND metadata.year:>=2020 AND (metadata.country:BG OR NL OR US)"); + } + + @Test + public void testDecimal() { + String vectorExpr = converter.convertExpression(new Filter.Expression(AND, + new Filter.Expression(GTE, new Filter.Key("temperature"), new Filter.Value(-15.6)), + new Filter.Expression(LTE, new Filter.Key("temperature"), new Filter.Value(20.13)))); + + assertThat(vectorExpr).isEqualTo("metadata.temperature:>=-15.6 AND metadata.temperature:<=20.13"); + } + + @Test + public void testComplexIdentifiers() { + String vectorExpr = converter + .convertExpression(new Filter.Expression(EQ, new Filter.Key("\"country 1 2 3\""), new Filter.Value("BG"))); + assertThat(vectorExpr).isEqualTo("metadata.country 1 2 3:BG"); + + vectorExpr = converter + .convertExpression(new Filter.Expression(EQ, new Filter.Key("'country 1 2 3'"), new Filter.Value("BG"))); + assertThat(vectorExpr).isEqualTo("metadata.country 1 2 3:BG"); + } + +} diff --git a/vector-stores/spring-ai-elasticsearch-store/src/test/java/org/springframework/ai/vectorstore/ElasticsearchVectorStoreIT.java b/vector-stores/spring-ai-elasticsearch-store/src/test/java/org/springframework/ai/vectorstore/ElasticsearchVectorStoreIT.java new file mode 100644 index 000000000..c277ff1cb --- /dev/null +++ b/vector-stores/spring-ai-elasticsearch-store/src/test/java/org/springframework/ai/vectorstore/ElasticsearchVectorStoreIT.java @@ -0,0 +1,376 @@ +/* + * 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.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.ZonedDateTime; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import org.apache.http.HttpHost; +import org.awaitility.Awaitility; +import org.elasticsearch.client.RestClient; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.testcontainers.elasticsearch.ElasticsearchContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.shaded.com.fasterxml.jackson.databind.ObjectMapper; + +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.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.core.io.DefaultResourceLoader; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; + +@Testcontainers +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") +class ElasticsearchVectorStoreIT { + + @Container + private static final ElasticsearchContainer elasticsearchContainer = new ElasticsearchContainer( + "docker.elastic.co/elasticsearch/elasticsearch:8.12.2") + .withEnv("xpack.security.enabled", "false"); + + private static final String DEFAULT = "default cosine similarity"; + + protected final ObjectMapper objectMapper = new ObjectMapper(); + + private List documents = List.of( + new Document("1", getText("classpath:/test/data/spring.ai.txt"), Map.of("meta1", "meta1")), + new Document("2", getText("classpath:/test/data/time.shelter.txt"), Map.of()), + new Document("3", getText("classpath:/test/data/great.depression.txt"), Map.of("meta2", "meta2"))); + + @BeforeAll + public static void beforeAll() { + Awaitility.setDefaultPollInterval(2, TimeUnit.SECONDS); + Awaitility.setDefaultPollDelay(Duration.ZERO); + Awaitility.setDefaultTimeout(Duration.ofMinutes(1)); + } + + private String getText(String uri) { + var resource = new DefaultResourceLoader().getResource(uri); + try { + return resource.getContentAsString(StandardCharsets.UTF_8); + } + catch (IOException e) { + throw new RuntimeException(e); + } + } + + private ApplicationContextRunner getContextRunner() { + return new ApplicationContextRunner().withUserConfiguration(TestApplication.class); + } + + @BeforeEach + void cleanDatabase() { + getContextRunner().run(context -> { + VectorStore vectorStore = context.getBean(VectorStore.class); + vectorStore.delete(List.of("_all")); + }); + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { DEFAULT, """ + double value = dotProduct(params.query_vector, 'embedding'); + return sigmoid(1, Math.E, -value); + """, "1 / (1 + l1norm(params.query_vector, 'embedding'))", + "1 / (1 + l2norm(params.query_vector, 'embedding'))" }) + public void addAndSearchTest(String similarityFunction) { + + getContextRunner().run(context -> { + ElasticsearchVectorStore vectorStore = context.getBean(ElasticsearchVectorStore.class); + + if (!DEFAULT.equals(similarityFunction)) { + vectorStore.withSimilarityFunction(similarityFunction); + } + + vectorStore.add(documents); + + Awaitility.await() + .until(() -> vectorStore + .similaritySearch(SearchRequest.query("Great Depression").withTopK(1).withSimilarityThreshold(0)), + hasSize(1)); + + List results = vectorStore + .similaritySearch(SearchRequest.query("Great Depression").withTopK(1).withSimilarityThreshold(0)); + + assertThat(results).hasSize(1); + Document resultDoc = results.get(0); + assertThat(resultDoc.getId()).isEqualTo(documents.get(2).getId()); + assertThat(resultDoc.getContent()).contains("The Great Depression (1929–1939) was an economic shock"); + assertThat(resultDoc.getMetadata()).hasSize(2); + assertThat(resultDoc.getMetadata()).containsKey("meta2"); + assertThat(resultDoc.getMetadata()).containsKey("distance"); + + // Remove all documents from the store + vectorStore.delete(documents.stream().map(Document::getId).toList()); + + Awaitility.await() + .until(() -> vectorStore + .similaritySearch(SearchRequest.query("Great Depression").withTopK(1).withSimilarityThreshold(0)), + hasSize(0)); + }); + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { DEFAULT, """ + double value = dotProduct(params.query_vector, 'embedding'); + return sigmoid(1, Math.E, -value); + """, "1 / (1 + l1norm(params.query_vector, 'embedding'))", + "1 / (1 + l2norm(params.query_vector, 'embedding'))" }) + public void searchWithFilters(String similarityFunction) { + + getContextRunner().run(context -> { + ElasticsearchVectorStore vectorStore = context.getBean(ElasticsearchVectorStore.class); + + if (!DEFAULT.equals(similarityFunction)) { + vectorStore.withSimilarityFunction(similarityFunction); + } + + var bgDocument = new Document("1", "The World is Big and Salvation Lurks Around the Corner", + Map.of("country", "BG", "year", 2020, "activationDate", new Date(1000))); + var nlDocument = new Document("2", "The World is Big and Salvation Lurks Around the Corner", + Map.of("country", "NL", "activationDate", new Date(2000))); + var bgDocument2 = new Document("3", "The World is Big and Salvation Lurks Around the Corner", + Map.of("country", "BG", "year", 2023, "activationDate", new Date(3000))); + + vectorStore.add(List.of(bgDocument, nlDocument, bgDocument2)); + + Awaitility.await() + .until(() -> vectorStore.similaritySearch(SearchRequest.query("The World").withTopK(5)), hasSize(3)); + + List 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("country in ['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 in ['BG','NL']")); + + assertThat(results).hasSize(3); + + results = vectorStore.similaritySearch(SearchRequest.query("The World") + .withTopK(5) + .withSimilarityThresholdAll() + .withFilterExpression("country not in ['BG']")); + + assertThat(results).hasSize(1); + assertThat(results.get(0).getId()).isEqualTo(nlDocument.getId()); + + results = vectorStore.similaritySearch(SearchRequest.query("The World") + .withTopK(5) + .withSimilarityThresholdAll() + .withFilterExpression("NOT(country not in ['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( + "activationDate > " + ZonedDateTime.parse("1970-01-01T00:00:02Z").toInstant().toEpochMilli())); + + assertThat(results).hasSize(1); + assertThat(results.get(0).getId()).isEqualTo(bgDocument2.getId()); + + // Remove all documents from the store + vectorStore.delete(documents.stream().map(Document::getId).toList()); + + Awaitility.await() + .until(() -> vectorStore.similaritySearch(SearchRequest.query("The World").withTopK(1)), hasSize(0)); + }); + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { DEFAULT, """ + double value = dotProduct(params.query_vector, 'embedding'); + return sigmoid(1, Math.E, -value); + """, "1 / (1 + l1norm(params.query_vector, 'embedding'))", + "1 / (1 + l2norm(params.query_vector, 'embedding'))" }) + public void documentUpdateTest(String similarityFunction) { + + getContextRunner().run(context -> { + ElasticsearchVectorStore vectorStore = context.getBean(ElasticsearchVectorStore.class); + if (!DEFAULT.equals(similarityFunction)) { + vectorStore.withSimilarityFunction(similarityFunction); + } + + Document document = new Document(UUID.randomUUID().toString(), "Spring AI rocks!!", + Map.of("meta1", "meta1")); + vectorStore.add(List.of(document)); + + Awaitility.await() + .until(() -> vectorStore + .similaritySearch(SearchRequest.query("Spring").withSimilarityThreshold(0).withTopK(5)), + hasSize(1)); + + List results = vectorStore + .similaritySearch(SearchRequest.query("Spring").withSimilarityThreshold(0).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()).containsKey("meta1"); + assertThat(resultDoc.getMetadata()).containsKey("distance"); + + Document sameIdDocument = new Document(document.getId(), + "The World is Big and Salvation Lurks Around the Corner", Map.of("meta2", "meta2")); + + vectorStore.add(List.of(sameIdDocument)); + SearchRequest fooBarSearchRequest = SearchRequest.query("FooBar").withTopK(5); + + Awaitility.await() + .until(() -> vectorStore.similaritySearch(fooBarSearchRequest).get(0).getContent(), + equalTo("The World is Big and Salvation Lurks Around the Corner")); + + results = vectorStore.similaritySearch(fooBarSearchRequest); + + 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()).containsKey("meta2"); + assertThat(resultDoc.getMetadata()).containsKey("distance"); + + // Remove all documents from the store + vectorStore.delete(List.of(document.getId())); + + Awaitility.await().until(() -> vectorStore.similaritySearch(fooBarSearchRequest), hasSize(0)); + + }); + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { DEFAULT, """ + double value = dotProduct(params.query_vector, 'embedding'); + return sigmoid(1, Math.E, -value); + """, "1 / (1 + l1norm(params.query_vector, 'embedding'))", + "1 / (1 + l2norm(params.query_vector, 'embedding'))" }) + public void searchThresholdTest(String similarityFunction) { + + getContextRunner().run(context -> { + ElasticsearchVectorStore vectorStore = context.getBean(ElasticsearchVectorStore.class); + if (!DEFAULT.equals(similarityFunction)) { + vectorStore.withSimilarityFunction(similarityFunction); + } + + vectorStore.add(documents); + + SearchRequest query = SearchRequest.query("Great Depression") + .withTopK(50) + .withSimilarityThreshold(SearchRequest.SIMILARITY_THRESHOLD_ACCEPT_ALL); + + Awaitility.await().until(() -> vectorStore.similaritySearch(query), hasSize(3)); + + List fullResult = vectorStore.similaritySearch(query); + + List distances = fullResult.stream().map(doc -> (Float) doc.getMetadata().get("distance")).toList(); + + assertThat(distances).hasSize(3); + + float threshold = (distances.get(0) + distances.get(1)) / 2; + + List results = vectorStore.similaritySearch( + SearchRequest.query("Great Depression").withTopK(50).withSimilarityThreshold(1 - threshold)); + + assertThat(results).hasSize(1); + Document resultDoc = results.get(0); + assertThat(resultDoc.getId()).isEqualTo(documents.get(2).getId()); + assertThat(resultDoc.getContent()).contains("The Great Depression (1929–1939) was an economic shock"); + assertThat(resultDoc.getMetadata()).containsKey("meta2"); + assertThat(resultDoc.getMetadata()).containsKey("distance"); + + // Remove all documents from the store + vectorStore.delete(documents.stream().map(Document::getId).toList()); + + Awaitility.await() + .until(() -> vectorStore + .similaritySearch(SearchRequest.query("Great Depression").withTopK(50).withSimilarityThreshold(0)), + hasSize(0)); + }); + } + + @SpringBootConfiguration + @EnableAutoConfiguration(exclude = { DataSourceAutoConfiguration.class }) + public static class TestApplication { + + @Bean + public ElasticsearchVectorStore vectorStore(EmbeddingClient embeddingClient) { + return new ElasticsearchVectorStore( + RestClient.builder(HttpHost.create(elasticsearchContainer.getHttpHostAddress())).build(), + embeddingClient); + } + + @Bean + public EmbeddingClient embeddingClient() { + return new OpenAiEmbeddingClient(new OpenAiApi(System.getenv("OPENAI_API_KEY"))); + } + + } + +}