From e655a366f9d1ff173344fdf9ce1632c0cce12cf5 Mon Sep 17 00:00:00 2001 From: Jemin Huh Date: Mon, 25 Mar 2024 20:58:15 +0900 Subject: [PATCH 01/46] add support for OpenSearch vector store --- .../spring-ai-opensearch-store/pom.xml | 85 ++++ ...archAiSearchFilterExpressionConverter.java | 150 +++++++ .../ai/vectorstore/OpenSearchVectorStore.java | 202 ++++++++++ ...AiSearchFilterExpressionConverterTest.java | 117 ++++++ .../vectorstore/OpenSearchVectorStoreIT.java | 367 ++++++++++++++++++ 5 files changed, 921 insertions(+) create mode 100644 vector-stores/spring-ai-opensearch-store/pom.xml create mode 100644 vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchAiSearchFilterExpressionConverter.java create mode 100644 vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchVectorStore.java create mode 100644 vector-stores/spring-ai-opensearch-store/src/test/java/org/springframework/ai/vectorstore/OpenSearchAiSearchFilterExpressionConverterTest.java create mode 100644 vector-stores/spring-ai-opensearch-store/src/test/java/org/springframework/ai/vectorstore/OpenSearchVectorStoreIT.java diff --git a/vector-stores/spring-ai-opensearch-store/pom.xml b/vector-stores/spring-ai-opensearch-store/pom.xml new file mode 100644 index 00000000000..3681050f526 --- /dev/null +++ b/vector-stores/spring-ai-opensearch-store/pom.xml @@ -0,0 +1,85 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../pom.xml + + spring-ai-opensearch-store + jar + Spring AI Vector Store - OpenSearch + Spring AI OpenSearch 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} + + + + org.opensearch.client + opensearch-java + 2.9.1 + + + + org.apache.httpcomponents.client5 + httpclient5 + 5.3.1 + + + + + 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.opensearch + opensearch-testcontainers + 2.0.1 + test + + + + org.testcontainers + junit-jupiter + ${testcontainers.version} + test + + + + + diff --git a/vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchAiSearchFilterExpressionConverter.java b/vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchAiSearchFilterExpressionConverter.java new file mode 100644 index 00000000000..9035a86d299 --- /dev/null +++ b/vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchAiSearchFilterExpressionConverter.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 OpenSearchAiSearchFilterExpressionConverter 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 OpenSearchAiSearchFilterExpressionConverter() { + 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-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchVectorStore.java b/vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchVectorStore.java new file mode 100644 index 00000000000..af2ee7055d2 --- /dev/null +++ b/vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchVectorStore.java @@ -0,0 +1,202 @@ +/* + * 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.opensearch.client.json.JsonData; +import org.opensearch.client.opensearch.OpenSearchClient; +import org.opensearch.client.opensearch._types.mapping.Property; +import org.opensearch.client.opensearch._types.query_dsl.Query; +import org.opensearch.client.opensearch.core.BulkRequest; +import org.opensearch.client.opensearch.core.BulkResponse; +import org.opensearch.client.opensearch.core.search.Hit; +import org.opensearch.client.opensearch.indices.CreateIndexRequest; +import org.opensearch.client.opensearch.indices.CreateIndexResponse; +import org.opensearch.client.transport.endpoints.BooleanResponse; +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.util.*; +import java.util.stream.Collectors; + +/** + * @author Jemin Huh + * @since 1.0.0 + */ +public class OpenSearchVectorStore implements VectorStore, InitializingBean { + + public static final String COSINE_SIMILARITY_FUNCTION = "cosinesimil"; + + private static final Logger logger = LoggerFactory.getLogger(OpenSearchVectorStore.class); + + private static final String INDEX_NAME = "spring-ai-document-index"; + + private final EmbeddingClient embeddingClient; + + private final OpenSearchClient openSearchClient; + + private final String index; + + private final FilterExpressionConverter filterExpressionConverter; + + private String similarityFunction; + + public OpenSearchVectorStore(OpenSearchClient openSearchClient, EmbeddingClient embeddingClient) { + this(INDEX_NAME, openSearchClient, embeddingClient); + } + + public OpenSearchVectorStore(String index, OpenSearchClient openSearchClient, + EmbeddingClient embeddingClient) { + Objects.requireNonNull(embeddingClient, "RestClient must not be null"); + Objects.requireNonNull(embeddingClient, "EmbeddingClient must not be null"); + this.openSearchClient = openSearchClient; + this.embeddingClient = embeddingClient; + this.index = index; + this.filterExpressionConverter = new OpenSearchAiSearchFilterExpressionConverter(); + // the potential functions for vector fields at + // https://opensearch.org/docs/latest/search-plugins/knn/approximate-knn/#spaces + this.similarityFunction = COSINE_SIMILARITY_FUNCTION; + } + + public OpenSearchVectorStore 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.openSearchClient.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(), + searchRequest.getSimilarityThreshold(), searchRequest.getFilterExpression()); + } + + public List similaritySearch(List embedding, int topK, double similarityThreshold, + Filter.Expression filterExpression) { + return similaritySearch(new org.opensearch.client.opensearch.core.SearchRequest.Builder() + .query(getOpenSearchSimilarityQuery(embedding, filterExpression)) + .size(topK) + .minScore(similarityThreshold) + .build()); + } + + private Query getOpenSearchSimilarityQuery(List embedding, Filter.Expression filterExpression) { + return Query.of(queryBuilder -> queryBuilder.scriptScore(scriptScoreQueryBuilder -> { + scriptScoreQueryBuilder.query( + queryBuilder2 -> queryBuilder2.queryString(queryStringQuerybuilder -> queryStringQuerybuilder + .query(getOpenSearchQueryString(filterExpression)))) + .script(scriptBuilder -> scriptBuilder + .inline(inlineScriptBuilder -> inlineScriptBuilder.source("knn_score") + .lang("knn") + .params("field", JsonData.of("embedding")) + .params("query_value", JsonData.of(embedding)) + .params("space_type", JsonData.of(this.similarityFunction)))); + // https://opensearch.org/docs/latest/search-plugins/knn/knn-score-script + // k-NN ensures non-negative scores by adding 1 to cosine similarity, extending OpenSearch scores to 0-2. + // A 0.5 boost normalizes to 0-1. + return this.similarityFunction.equals(COSINE_SIMILARITY_FUNCTION) ? scriptScoreQueryBuilder.boost( + 0.5f) : scriptScoreQueryBuilder; + })); + } + + private String getOpenSearchQueryString(Filter.Expression filterExpression) { + return Objects.isNull(filterExpression) ? "*" + : this.filterExpressionConverter.convertExpression(filterExpression); + + } + + private List similaritySearch(org.opensearch.client.opensearch.core.SearchRequest searchRequest) { + try { + return this.openSearchClient.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.openSearchClient.indices() + .exists(existRequestBuilder -> existRequestBuilder.index(targetIndex)); + return response.value(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public CreateIndexResponse createIndexMapping(String index, Map properties) { + try { + return this.openSearchClient.indices() + .create(new CreateIndexRequest.Builder().index(index).settings(setting -> setting.knn(true)) + .mappings(propertiesBuilder -> propertiesBuilder.properties(properties)).build()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public void afterPropertiesSet() { + if (!exists(this.index)) { + createIndexMapping(this.index, Map.of("embedding", Property.of(p -> p.knnVector(k -> k.dimension(1536))))); + } + } +} \ No newline at end of file diff --git a/vector-stores/spring-ai-opensearch-store/src/test/java/org/springframework/ai/vectorstore/OpenSearchAiSearchFilterExpressionConverterTest.java b/vector-stores/spring-ai-opensearch-store/src/test/java/org/springframework/ai/vectorstore/OpenSearchAiSearchFilterExpressionConverterTest.java new file mode 100644 index 00000000000..274f132730e --- /dev/null +++ b/vector-stores/spring-ai-opensearch-store/src/test/java/org/springframework/ai/vectorstore/OpenSearchAiSearchFilterExpressionConverterTest.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 OpenSearchAiSearchFilterExpressionConverterTest { + + final FilterExpressionConverter converter = new OpenSearchAiSearchFilterExpressionConverter(); + + @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-opensearch-store/src/test/java/org/springframework/ai/vectorstore/OpenSearchVectorStoreIT.java b/vector-stores/spring-ai-opensearch-store/src/test/java/org/springframework/ai/vectorstore/OpenSearchVectorStoreIT.java new file mode 100644 index 00000000000..3d8d97f53f3 --- /dev/null +++ b/vector-stores/spring-ai-opensearch-store/src/test/java/org/springframework/ai/vectorstore/OpenSearchVectorStoreIT.java @@ -0,0 +1,367 @@ +/* + * 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.apache.hc.core5.http.HttpHost; +import org.awaitility.Awaitility; +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.opensearch.client.opensearch.OpenSearchClient; +import org.opensearch.client.transport.OpenSearchTransport; +import org.opensearch.client.transport.httpclient5.ApacheHttpClient5Transport; +import org.opensearch.client.transport.httpclient5.ApacheHttpClient5TransportBuilder; +import org.opensearch.testcontainers.OpensearchContainer; +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 org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import javax.net.ssl.SSLEngine; +import java.io.IOException; +import java.net.URISyntaxException; +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 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 OpenSearchVectorStoreIT { + + @Container + private static final OpensearchContainer opensearchContainer = + new OpensearchContainer<>(DockerImageName.parse("opensearchproject/opensearch:2.12.0")); + + private static final String DEFAULT = "cosinesimil"; + + 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, "l1", "l2", "linf"}) + public void addAndSearchTest(String similarityFunction) { + + getContextRunner().run(context -> { + OpenSearchVectorStore vectorStore = context.getBean(OpenSearchVectorStore.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, "l1", "l2", "linf"}) + public void searchWithFilters(String similarityFunction) { + + getContextRunner().run(context -> { + OpenSearchVectorStore vectorStore = context.getBean(OpenSearchVectorStore.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, "l1", "l2", "linf"}) + public void documentUpdateTest(String similarityFunction) { + + getContextRunner().run(context -> { + OpenSearchVectorStore vectorStore = context.getBean(OpenSearchVectorStore.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, "l1", "l2", "linf"}) + public void searchThresholdTest(String similarityFunction) { + + getContextRunner().run(context -> { + OpenSearchVectorStore vectorStore = context.getBean(OpenSearchVectorStore.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 OpenSearchVectorStore vectorStore(EmbeddingClient embeddingClient) { + try { + return new OpenSearchVectorStore(new OpenSearchClient(ApacheHttpClient5TransportBuilder.builder( + HttpHost.create(opensearchContainer.getHttpHostAddress())).build()), embeddingClient); + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } + } + + @Bean + public EmbeddingClient embeddingClient() { + return new OpenAiEmbeddingClient(new OpenAiApi(System.getenv("OPENAI_API_KEY"))); + } + + } + +} From b15b30887e2cca0c719a980b5f00f8e89f0c67bd Mon Sep 17 00:00:00 2001 From: Jemin Huh Date: Thu, 4 Apr 2024 22:34:12 +0900 Subject: [PATCH 02/46] moving versions for OpenSearch as a property in the parent POM --- pom.xml | 2 ++ vector-stores/spring-ai-opensearch-store/pom.xml | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 33e48a02c0e..2e058cf3787 100644 --- a/pom.xml +++ b/pom.xml @@ -142,6 +142,8 @@ 11.6.1 4.5.1 1.7.1 + 2.9.1 + 5.3.1 1.19.7 diff --git a/vector-stores/spring-ai-opensearch-store/pom.xml b/vector-stores/spring-ai-opensearch-store/pom.xml index 3681050f526..4c11603369d 100644 --- a/vector-stores/spring-ai-opensearch-store/pom.xml +++ b/vector-stores/spring-ai-opensearch-store/pom.xml @@ -35,13 +35,13 @@ org.opensearch.client opensearch-java - 2.9.1 + ${opensearch-client.version} org.apache.httpcomponents.client5 httpclient5 - 5.3.1 + ${httpclient5.version} From 7b77b193c0bc4cc54abf49041b932725ca268568 Mon Sep 17 00:00:00 2001 From: Jemin Huh Date: Mon, 15 Apr 2024 00:22:08 +0900 Subject: [PATCH 03/46] Add mappingJson parameter to OpenSearchVectorStore --- .../ai/vectorstore/OpenSearchVectorStore.java | 41 +++++++++++++++---- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchVectorStore.java b/vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchVectorStore.java index af2ee7055d2..608ce87cf24 100644 --- a/vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchVectorStore.java +++ b/vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchVectorStore.java @@ -16,9 +16,11 @@ package org.springframework.ai.vectorstore; +import jakarta.json.stream.JsonParser; import org.opensearch.client.json.JsonData; +import org.opensearch.client.json.JsonpMapper; import org.opensearch.client.opensearch.OpenSearchClient; -import org.opensearch.client.opensearch._types.mapping.Property; +import org.opensearch.client.opensearch._types.mapping.TypeMapping; import org.opensearch.client.opensearch._types.query_dsl.Query; import org.opensearch.client.opensearch.core.BulkRequest; import org.opensearch.client.opensearch.core.BulkResponse; @@ -36,6 +38,7 @@ import org.springframework.util.Assert; import java.io.IOException; +import java.io.StringReader; import java.util.*; import java.util.stream.Collectors; @@ -49,7 +52,17 @@ public class OpenSearchVectorStore implements VectorStore, InitializingBean { private static final Logger logger = LoggerFactory.getLogger(OpenSearchVectorStore.class); - private static final String INDEX_NAME = "spring-ai-document-index"; + public static final String DEFAULT_INDEX_NAME = "spring-ai-document-index"; + public static final String DEFAULT_MAPPING_EMBEDDING_TYPE_KNN_VECTOR_DIMENSION_1536 = """ + { + "properties":{ + "embedding":{ + "type":"knn_vector", + "dimension":1536 + } + } + } + """; private final EmbeddingClient embeddingClient; @@ -59,19 +72,27 @@ public class OpenSearchVectorStore implements VectorStore, InitializingBean { private final FilterExpressionConverter filterExpressionConverter; + private final String mappingJson; + private String similarityFunction; public OpenSearchVectorStore(OpenSearchClient openSearchClient, EmbeddingClient embeddingClient) { - this(INDEX_NAME, openSearchClient, embeddingClient); + this(openSearchClient, embeddingClient, DEFAULT_MAPPING_EMBEDDING_TYPE_KNN_VECTOR_DIMENSION_1536); + } + + public OpenSearchVectorStore(OpenSearchClient openSearchClient, EmbeddingClient embeddingClient, + String mappingJson) { + this(DEFAULT_INDEX_NAME, openSearchClient, embeddingClient, mappingJson); } public OpenSearchVectorStore(String index, OpenSearchClient openSearchClient, - EmbeddingClient embeddingClient) { + EmbeddingClient embeddingClient, String mappingJson) { Objects.requireNonNull(embeddingClient, "RestClient must not be null"); Objects.requireNonNull(embeddingClient, "EmbeddingClient must not be null"); this.openSearchClient = openSearchClient; this.embeddingClient = embeddingClient; this.index = index; + this.mappingJson = mappingJson; this.filterExpressionConverter = new OpenSearchAiSearchFilterExpressionConverter(); // the potential functions for vector fields at // https://opensearch.org/docs/latest/search-plugins/knn/approximate-knn/#spaces @@ -183,11 +204,13 @@ public boolean exists(String targetIndex) { } } - public CreateIndexResponse createIndexMapping(String index, Map properties) { + private CreateIndexResponse createIndexMapping(String index, String mappingJson) { + JsonpMapper mapper = openSearchClient._transport().jsonpMapper(); + JsonParser parser = mapper.jsonProvider().createParser(new StringReader(mappingJson)); try { - return this.openSearchClient.indices() - .create(new CreateIndexRequest.Builder().index(index).settings(setting -> setting.knn(true)) - .mappings(propertiesBuilder -> propertiesBuilder.properties(properties)).build()); + return this.openSearchClient.indices().create(new CreateIndexRequest.Builder().index(index) + .settings(settingsBuilder -> settingsBuilder.knn(true)) + .mappings(TypeMapping._DESERIALIZER.deserialize(parser, mapper)).build()); } catch (IOException e) { throw new RuntimeException(e); } @@ -196,7 +219,7 @@ public CreateIndexResponse createIndexMapping(String index, Map p.knnVector(k -> k.dimension(1536))))); + createIndexMapping(this.index, mappingJson); } } } \ No newline at end of file From 9c252bcfe12e5b26fae60f21c550e39586f7b82d Mon Sep 17 00:00:00 2001 From: Jemin Huh Date: Mon, 15 Apr 2024 00:27:52 +0900 Subject: [PATCH 04/46] Add opensearch auto-configuration and boot starter --- pom.xml | 6 + spring-ai-bom/pom.xml | 6 + spring-ai-spring-boot-autoconfigure/pom.xml | 14 ++ ...penSearchVectorStoreAutoConfiguration.java | 62 +++++++++ .../OpenSearchVectorStoreProperties.java | 60 +++++++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + ...nSearchVectorStoreAutoConfigurationIT.java | 125 ++++++++++++++++++ .../pom.xml | 42 ++++++ 8 files changed, 316 insertions(+) create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreAutoConfiguration.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreProperties.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreAutoConfigurationIT.java create mode 100644 spring-ai-spring-boot-starters/spring-ai-starter-opensearch-store/pom.xml diff --git a/pom.xml b/pom.xml index 38e984a8366..96b1f4f3371 100644 --- a/pom.xml +++ b/pom.xml @@ -74,6 +74,8 @@ vector-stores/spring-ai-elasticsearch-store spring-ai-spring-boot-starters/spring-ai-starter-watsonx-ai spring-ai-spring-boot-starters/spring-ai-starter-elasticsearch-store + vector-stores/spring-ai-opensearch-store + spring-ai-spring-boot-starters/spring-ai-starter-opensearch-store @@ -154,6 +156,10 @@ 2.9.1 5.3.1 + + 1.19.7 + 2.0.1 + 0.0.4 1.6.2 diff --git a/spring-ai-bom/pom.xml b/spring-ai-bom/pom.xml index d1c857f9da9..43a481c157d 100644 --- a/spring-ai-bom/pom.xml +++ b/spring-ai-bom/pom.xml @@ -210,6 +210,12 @@ ${project.version} + + org.springframework.ai + spring-ai-opensearch-store + ${project.version} + + org.springframework.ai diff --git a/spring-ai-spring-boot-autoconfigure/pom.xml b/spring-ai-spring-boot-autoconfigure/pom.xml index d48275f3d83..a01b11affc9 100644 --- a/spring-ai-spring-boot-autoconfigure/pom.xml +++ b/spring-ai-spring-boot-autoconfigure/pom.xml @@ -267,6 +267,13 @@ true + + org.springframework.ai + spring-ai-opensearch-store + ${project.parent.version} + true + + @@ -354,6 +361,13 @@ test + + org.opensearch + opensearch-testcontainers + ${testcontainers.opensearch.version} + test + + org.skyscreamer jsonassert diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreAutoConfiguration.java new file mode 100644 index 00000000000..538571dd758 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreAutoConfiguration.java @@ -0,0 +1,62 @@ +/* + * 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.autoconfigure.vectorstore.opensearch; + +import org.apache.hc.core5.http.HttpHost; +import org.opensearch.client.opensearch.OpenSearchClient; +import org.opensearch.client.transport.httpclient5.ApacheHttpClient5TransportBuilder; +import org.springframework.ai.embedding.EmbeddingClient; +import org.springframework.ai.vectorstore.OpenSearchVectorStore; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +import java.net.URISyntaxException; +import java.util.Optional; + +@AutoConfiguration +@ConditionalOnClass({OpenSearchVectorStore.class, EmbeddingClient.class, OpenSearchClient.class}) +@EnableConfigurationProperties(OpenSearchVectorStoreProperties.class) +class OpenSearchVectorStoreAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + OpenSearchVectorStore vectorStore(OpenSearchVectorStoreProperties properties, OpenSearchClient openSearchClient, + EmbeddingClient embeddingClient) { + return new OpenSearchVectorStore( + Optional.ofNullable(properties.getIndexName()).orElse(OpenSearchVectorStore.DEFAULT_INDEX_NAME), + openSearchClient, embeddingClient, Optional.ofNullable(properties.getMappingJson()) + .orElse(OpenSearchVectorStore.DEFAULT_MAPPING_EMBEDDING_TYPE_KNN_VECTOR_DIMENSION_1536)); + } + + @Bean + @ConditionalOnMissingBean + OpenSearchClient openSearchClient(OpenSearchVectorStoreProperties properties) { + return new OpenSearchClient(ApacheHttpClient5TransportBuilder.builder( + properties.getUris().stream().map(s -> creatHttpHost(s)).toArray(HttpHost[]::new)).build()); + } + + private HttpHost creatHttpHost(String s) { + try { + return HttpHost.create(s); + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreProperties.java new file mode 100644 index 00000000000..4e45b4da9ee --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreProperties.java @@ -0,0 +1,60 @@ +/* + * 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.autoconfigure.vectorstore.opensearch; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.List; + +@ConfigurationProperties(prefix = OpenSearchVectorStoreProperties.CONFIG_PREFIX) +public class OpenSearchVectorStoreProperties { + + public static final String CONFIG_PREFIX = "spring.ai.vectorstore.opensearch"; + + /** + * Comma-separated list of the OpenSearch instances to use. + */ + private List uris; + + private String indexName; + + private String mappingJson; + + public String getMappingJson() { + return mappingJson; + } + + public void setMappingJson(String mappingJson) { + this.mappingJson = mappingJson; + } + + public List getUris() { + return uris; + } + + public void setUris(List uris) { + this.uris = uris; + } + + public String getIndexName() { + return this.indexName; + } + + public void setIndexName(String indexName) { + this.indexName = indexName; + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 59f104b6825..7c5ca75a113 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -32,3 +32,4 @@ org.springframework.ai.autoconfigure.anthropic.AnthropicAutoConfiguration org.springframework.ai.autoconfigure.watsonxai.WatsonxAiAutoConfiguration org.springframework.ai.autoconfigure.vectorstore.elasticsearch.ElasticsearchVectorStoreAutoConfiguration org.springframework.ai.autoconfigure.vectorstore.cassandra.CassandraVectorStoreAutoConfiguration +org.springframework.ai.autoconfigure.vectorstore.opensearch.OpenSearchVectorStoreAutoConfiguration diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreAutoConfigurationIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreAutoConfigurationIT.java new file mode 100644 index 00000000000..bfa3dab48f2 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreAutoConfigurationIT.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.autoconfigure.vectorstore.opensearch; + +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Test; +import org.opensearch.testcontainers.OpensearchContainer; +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.document.Document; +import org.springframework.ai.embedding.EmbeddingClient; +import org.springframework.ai.transformers.TransformersEmbeddingClient; +import org.springframework.ai.vectorstore.OpenSearchVectorStore; +import org.springframework.ai.vectorstore.SearchRequest; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.DefaultResourceLoader; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.hasSize; + +@Testcontainers +class OpenSearchVectorStoreAutoConfigurationIT { + + @Container + private static final OpensearchContainer opensearchContainer = + new OpensearchContainer<>(DockerImageName.parse("opensearchproject/opensearch:2.12.0")); + + private static final String DOCUMENT_INDEX = "auto-spring-ai-document-index"; + + 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"))); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner().withConfiguration( + AutoConfigurations.of(OpenSearchVectorStoreAutoConfiguration.class, SpringAiRetryAutoConfiguration.class)) + .withUserConfiguration(Config.class) + .withPropertyValues( + OpenSearchVectorStoreProperties.CONFIG_PREFIX + ".uris=" + opensearchContainer.getHttpHostAddress(), + OpenSearchVectorStoreProperties.CONFIG_PREFIX + ".indexName=" + DOCUMENT_INDEX, + OpenSearchVectorStoreProperties.CONFIG_PREFIX + ".mappingJson=" + """ + { + "properties":{ + "embedding":{ + "type":"knn_vector", + "dimension":384 + } + } + } + """); + + @Test + public void addAndSearchTest() { + + this.contextRunner.run(context -> { + OpenSearchVectorStore vectorStore = context.getBean(OpenSearchVectorStore.class); + + 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)); + }); + } + + 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); + } + } + + @Configuration(proxyBeanMethods = false) + static class Config { + + @Bean + public EmbeddingClient embeddingClient() { + return new TransformersEmbeddingClient(); + } + + } + +} diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-opensearch-store/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-opensearch-store/pom.xml new file mode 100644 index 00000000000..c97eb81ad68 --- /dev/null +++ b/spring-ai-spring-boot-starters/spring-ai-starter-opensearch-store/pom.xml @@ -0,0 +1,42 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../pom.xml + + spring-ai-opensearch-store-spring-boot-starter + jar + Spring AI Starter - OpenSearch Store + Spring AI OpenSearch Store Auto Configuration + 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 + + + + + + org.springframework.boot + spring-boot-starter + + + + org.springframework.ai + spring-ai-spring-boot-autoconfigure + ${project.parent.version} + + + + org.springframework.ai + spring-ai-opensearch-store + ${project.parent.version} + + + + From 4230038b28d854bcec27d8c0a454a6c96840ea50 Mon Sep 17 00:00:00 2001 From: Mark Pollack Date: Wed, 17 Apr 2024 10:54:29 -0400 Subject: [PATCH 05/46] Remove unused ChatMessage class --- .../ai/aot/SpringAiCoreRuntimeHints.java | 4 +- .../ai/chat/messages/ChatMessage.java | 42 ------------------- 2 files changed, 2 insertions(+), 44 deletions(-) delete mode 100644 spring-ai-core/src/main/java/org/springframework/ai/chat/messages/ChatMessage.java diff --git a/spring-ai-core/src/main/java/org/springframework/ai/aot/SpringAiCoreRuntimeHints.java b/spring-ai-core/src/main/java/org/springframework/ai/aot/SpringAiCoreRuntimeHints.java index ecf2a3fd94e..2d00f1ea1d3 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/aot/SpringAiCoreRuntimeHints.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/aot/SpringAiCoreRuntimeHints.java @@ -35,8 +35,8 @@ public class SpringAiCoreRuntimeHints implements RuntimeHintsRegistrar { @Override public void registerHints(@NonNull RuntimeHints hints, @Nullable ClassLoader classLoader) { - var chatTypes = Set.of(AbstractMessage.class, AssistantMessage.class, ChatMessage.class, FunctionMessage.class, - Message.class, MessageType.class, UserMessage.class, SystemMessage.class, FunctionCallbackContext.class, + var chatTypes = Set.of(AbstractMessage.class, AssistantMessage.class, FunctionMessage.class, Message.class, + MessageType.class, UserMessage.class, SystemMessage.class, FunctionCallbackContext.class, FunctionCallback.class, FunctionCallbackWrapper.class); for (var c : chatTypes) { hints.reflection().registerType(c); diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/messages/ChatMessage.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/messages/ChatMessage.java deleted file mode 100644 index ea4803a5943..00000000000 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/messages/ChatMessage.java +++ /dev/null @@ -1,42 +0,0 @@ -/* - * 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.chat.messages; - -import java.util.Map; - -/** - * Represents a chat message in a chat application. - * - */ -public class ChatMessage extends AbstractMessage { - - public ChatMessage(String role, String content) { - super(MessageType.valueOf(role), content); - } - - public ChatMessage(String role, String content, Map properties) { - super(MessageType.valueOf(role), content, properties); - } - - public ChatMessage(MessageType messageType, String content) { - super(messageType, content); - } - - public ChatMessage(MessageType messageType, String content, Map properties) { - super(messageType, content, properties); - } - -} From f698902d38a90c6e1741ab7d354c956413b4e464 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Fri, 19 Apr 2024 11:11:47 +0200 Subject: [PATCH 06/46] doc: Add top page about API multimodality suppot Resolves #573 --- .../ROOT/images/orbis-sensualium-pictus2.jpg | Bin 0 -> 57433 bytes .../ROOT/images/spring-ai-message-api.jpg | Bin 0 -> 404658 bytes .../src/main/antora/modules/ROOT/nav.adoc | 1 + .../modules/ROOT/pages/api/multimodality.adoc | 64 ++++++++++++++++++ 4 files changed, 65 insertions(+) create mode 100644 spring-ai-docs/src/main/antora/modules/ROOT/images/orbis-sensualium-pictus2.jpg create mode 100644 spring-ai-docs/src/main/antora/modules/ROOT/images/spring-ai-message-api.jpg create mode 100644 spring-ai-docs/src/main/antora/modules/ROOT/pages/api/multimodality.adoc diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/images/orbis-sensualium-pictus2.jpg b/spring-ai-docs/src/main/antora/modules/ROOT/images/orbis-sensualium-pictus2.jpg new file mode 100644 index 0000000000000000000000000000000000000000..688c39d41b9af2b4560ebd8d9f778eeec883c618 GIT binary patch literal 57433 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 MKLoE;-#9`pw1z)fyyEGryEA2#nf-!&1Rs_q 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%0Aq(>_m2dj(nLJL%A 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

    Z%W*_-Wnp!(?S?UTMa+VlM+il8`mr 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 "All things that are naturally connected ought to be taught in combination" + +Contrary to those principles, in the past, our approach to Machine Learning was often focused on specialized models tailored to process a single modality. +For instance, we developed audio models for tasks like text-to-speech or speech-to-text, and computer vision models for tasks such as object detection and classification. + +However, a new wave of multimodal large language models starts to emerge. +Examples include OpenAI's GPT-4 Vision, Google's Vertex AI Gemini Pro Vision, Anthropic's Claude3, and open source offerings LLaVA and balklava are able to accept multiple inputs, including text images, audio and video and generate text responses by integrating these inputs. + +The multimodal large language model (LLM) features enable the models to process and generate text in conjunction with other modalities such as images, audio, or video. + +== Spring AI Multimodality + +Multimodality refers to a model’s ability to simultaneously understand and process information from various sources, including text, images, audio, and other data formats. + +The Spring AI Message API provides all necessary abstractions to support multimodal LLMs. + +image::spring-ai-message-api.jpg[Spring AI Message API, width=600, align="center"] + +The Message’s `content` field is used as primarily text inputs, while the, optional, `media` field allows adding one or more additional content of different modalities such as images, audio and video. +The `MimeType` specifies the modality type. +Depending on the used LLMs the Media's data field can be either encoded raw media content or an URI to the content. + +NOTE: The media field is currently applicable only for user input messages (e.g., `UserMessage`). It does not hold significance for system messages. The `AssistantMessage`, which includes the LLM response, provides text content only. To generate non-text media outputs, you should utilize one of dedicated, single modality models.* + + +For example we can take the following picture (*multimodal.test.png*) as an input and ask the LLM to explain what it sees in the picture. + +image::multimodal.test.png[Multimodal Test Image, 200, 200, align="left"] + +From most of the multimodal LLMs, the Spring AI code would look something like this: + +[source,java] +---- +byte[] imageData = new ClassPathResource("/multimodal.test.png").getContentAsByteArray(); + +var userMessage = new UserMessage( + "Explain what do you see in this picture?", // content + List.of(new Media(MimeTypeUtils.IMAGE_PNG, imageData))); // media + +ChatResponse response = chatClient.call(new Prompt(List.of(userMessage))); +---- + +and produce a response like: + +> This is an image of a fruit bowl with a simple design. The bowl is made of metal with curved wire edges that create an open structure, allowing the fruit to be visible from all angles. Inside the bowl, there are two yellow bananas resting on top of what appears to be a red apple. The bananas are slightly overripe, as indicated by the brown spots on their peels. The bowl has a metal ring at the top, likely to serve as a handle for carrying. The bowl is placed on a flat surface with a neutral-colored background that provides a clear view of the fruit inside. + +Latest version of Spring AI provides multimodal support for the following Chat Clients: + +* xref:api/chat/openai-chat.adoc#_multimodal[Open AI - (GPT-4-Vision model)] +* xref:api/chat/openai-chat.adoc#_multimodal[Ollama - (LlaVa and Baklava models)] +* xref:api/chat/vertexai-gemini-chat.adoc#_multimodal[Vertex AI Gemini - (gemini-pro-vision model)] +* xref:api/chat/anthropic-chat.adoc#_multimodal[Anthropic Claude 3] +* xref:api/chat/bedrock/bedrock-anthropic3.adoc#_multimodal[AWS Bedrock Anthropic Claude 3] \ No newline at end of file From 0eaf7d05c9bc509f8ae2d9e16594ec2db4fd2b4a Mon Sep 17 00:00:00 2001 From: mck Date: Wed, 10 Apr 2024 12:46:57 +0200 Subject: [PATCH 07/46] Cassandra Vector Store initial impl follow up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add concurrency to store.add(..) (bc embeddingClient is slow) - CassandraVectorStoreAutoConfiguration uses CassandraAutoConfiguration - driver profiles for production stability+performance, - small cleanups and naming fixes, - main doc tidy-up - astradb compatibility (protocol V4) – don't create embeddings again for documents that already have them similar to https://github.com/spring-projects/spring-ai/pull/413 --- .../pages/api/vectordbs/apache-cassandra.adoc | 54 ++++----- .../cassandra/CassandraConnectionDetails.java | 37 ------ ...CassandraVectorStoreAutoConfiguration.java | 92 +++++---------- .../CassandraVectorStoreProperties.java | 54 +++------ ...ssandraVectorStoreAutoConfigurationIT.java | 15 +-- .../CassandraVectorStorePropertiesTests.java | 25 ++-- .../CassandraFilterExpressionConverter.java | 2 +- .../ai/vectorstore/CassandraVectorStore.java | 109 ++++++++++++------ .../CassandraVectorStoreConfig.java | 48 ++++++-- .../src/main/resources/application.conf | 24 ++++ .../CassandraRichSchemaVectorStoreIT.java | 96 +++++++++++---- .../vectorstore/CassandraVectorStoreIT.java | 7 +- .../vectorstore/WikiVectorStoreExample.java | 23 ++-- 13 files changed, 315 insertions(+), 271 deletions(-) delete mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraConnectionDetails.java create mode 100644 vector-stores/spring-ai-cassandra/src/main/resources/application.conf diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/apache-cassandra.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/apache-cassandra.adoc index a264c08c1a9..51c6110cb0f 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/apache-cassandra.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/apache-cassandra.adoc @@ -4,9 +4,9 @@ This section walks you through setting up `CassandraVectorStore` to store docume == What is Apache Cassandra ? -link:https://cassandra.apache.org[Apache Cassandra] is a true open source distributed database reknown for scalability and high availability without compromising performance. +link:https://cassandra.apache.org[Apache Cassandra®] is a true open source distributed database reknown for linear scalability, proven fault-tolerance and low latency, making it the perfect platform for mission-critical transactional data. -Linear scalability, proven fault-tolerance and low latency on commodity hardware makes it the perfect platform for mission-critical data. Its Vector Similarity Search (VSS) is based on the JVector library that ensures best-in-class performance and relevancy. +Its Vector Similarity Search (VSS) is based on the JVector library that ensures best-in-class performance and relevancy. A vector search in Apache Cassandra is done as simply as: ``` @@ -15,9 +15,13 @@ SELECT content FROM table ORDER BY content_vector ANN OF query_embedding ; More docs on this can be read https://cassandra.apache.org/doc/latest/cassandra/getting-started/vector-search-quickstart.html[here]. -The Spring AI Cassandra Vector Store is designed to work for both brand new RAG applications as well as being able to be retrofitted on top of existing data and tables. This vector store may also equally be used for non-RAG non_AI use-cases, e.g. semantic searcing in an existing database. The Vector Store will automatically create, or enhance, the schema as needed according to its configuration. If you don't want the schema modifications, configure the store with `disallowSchemaChanges`. +This Spring AI Vector Store is designed to work for both brand new RAG applications as well as being able to be retrofitted on top of existing data and tables. -== What is JVector Vector Search ? +The store can also be used for non-RAG use-cases in an existing database, e.g. semantic searches, geo-proximity searches, etc. + +The store will automatically create, or enhance, the schema as needed according to its configuration. If you don't want the schema modifications, configure the store with `disallowSchemaChanges`. + +== What is JVector ? link:https://github.com/jbellis/jvector[JVector] is a pure Java embedded vector search engine. @@ -70,13 +74,6 @@ Add these dependencies to your project: TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file. -* If for example you want to use the OpenAI modules, remember to provide your OpenAI API Key. Set it as an environment variable like so: - -[source,bash] ----- -export SPRING_AI_OPENAI_API_KEY='Your_OpenAI_API_Key' ----- - == Usage @@ -93,21 +90,14 @@ public VectorStore vectorStore(EmbeddingClient embeddingClient) { } ---- -NOTE: It is more convenient and preferred to create the `CassandraVectorStore` as a Bean. -But if you decide you can create it manually. - [NOTE] ==== -The default configuration connects to Cassandra at localhost:9042 and will automatically create the default schema at `springframework_ai_vector.springframework_ai_vector_store`. - -Please see `CassandraVectorStoreConfig.Builder` for all the configuration options. +The default configuration connects to Cassandra at `localhost:9042` and will automatically create a default schema in keyspace `springframework`, table `ai_vector_store`. ==== [NOTE] ==== -The Cassandra Java Driver is easiest configured via the `application.conf` file on the classpath. - -More info can be found link: https://github.com/apache/cassandra-java-driver/tree/4.x/manual/core/configuration[here]. +The Cassandra Java Driver is easiest configured via an `application.conf` file on the classpath. More info https://github.com/apache/cassandra-java-driver/tree/4.x/manual/core/configuration[here]. ==== Then in your main code, create some documents: @@ -148,7 +138,7 @@ List results = vectorStore.similaritySearch( === Metadata filtering -You can leverage the generic, portable link:https://docs.spring.io/spring-ai/reference/api/vectordbs.html#_metadata_filters[metadata filters] with the CassandraVectorStore as well. Metadata fields must be configured in `CassandraVectorStoreConfig`. +You can leverage the generic, portable link:https://docs.spring.io/spring-ai/reference/api/vectordbs.html#_metadata_filters[metadata filters] with the CassandraVectorStore as well. Metadata columns must be configured in `CassandraVectorStoreConfig`. For example, you can use either the text expression language: @@ -173,7 +163,9 @@ vectorStore.similaritySearch( The portable filter expressions get automatically converted into link:https://cassandra.apache.org/doc/latest/cassandra/developing/cql/index.html[CQL queries]. -Metadata fields to be searchable need to be either primary key columns or SAI indexed. To do this configure the metadata field with the `SchemaColumnTags.INDEXED`. +For metadata columns to be searchable they must be either primary keys or SAI indexed. To make non-primary-key columns indexed configure the metadata column with the `SchemaColumnTags.INDEXED`. + + == Advanced Example: Vector Store ontop full Wikipedia dataset @@ -187,7 +179,8 @@ Create the schema in the Cassandra database first: [source,bash] ---- -wget https://raw.githubusercontent.com/datastax-labs/colbert-wikipedia-data/main/schema.cql -O colbert-wikipedia-schema.cql +wget https://s.apache.org/colbert-wikipedia-schema-cql -O colbert-wikipedia-schema.cql + cqlsh -f colbert-wikipedia-schema.cql ---- @@ -212,14 +205,14 @@ public CassandraVectorStore store(EmbeddingClient embeddingClient) { .withTableName("articles") .withPartitionKeys(partitionColumns) .withClusteringKeys(clusteringColumns) - .withContentFieldName("body") - .withEmbeddingFieldName("all_minilm_l6_v2_embedding") + .withContentColumnName("body") + .withEmbeddingColumndName("all_minilm_l6_v2_embedding") .withIndexName("all_minilm_l6_v2_ann") .disallowSchemaChanges() - .addMetadataFields(extraColumns) + .addMetadataColumns(extraColumns) .withPrimaryKeyTranslator((List primaryKeys) -> { - // the deliminator used to join fields together into the document's id - // is arbitary, here "§¶" is used + // the deliminator used to join fields together into the document's id is arbitary + // here "§¶" is used if (primaryKeys.isEmpty()) { return "test§¶0"; } @@ -243,8 +236,11 @@ public EmbeddingClient embeddingClient() { } ---- + +== Complete wikipedia dataset + And, if you would like to load the full wikipedia dataset. -First download the `simplewiki-sstable.tar` from this link https://drive.google.com/file/d/1CcMMsj8jTKRVGep4A7hmOSvaPepsaKYP/view?usp=share_link . This will take a while, the file is tens of GBs. +First download the `simplewiki-sstable.tar` from this link https://s.apache.org/simplewiki-sstable-tar . This will take a while, the file is tens of GBs. [source,bash] ---- diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraConnectionDetails.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraConnectionDetails.java deleted file mode 100644 index b67f90f6ac9..00000000000 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraConnectionDetails.java +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright 2024 - 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.autoconfigure.vectorstore.cassandra; - -import java.net.InetSocketAddress; -import java.util.List; - -import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails; - -/** - * @author Mick Semb Wever - * @since 1.0.0 - */ -public interface CassandraConnectionDetails extends ConnectionDetails { - - boolean hasCassandraContactPoints(); - - List getCassandraContactPoints(); - - boolean hasCassandraLocalDatacenter(); - - String getCassandraLocalDatacenter(); - -} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraVectorStoreAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraVectorStoreAutoConfiguration.java index 1bb93f410a3..f53a6ce35e3 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraVectorStoreAutoConfiguration.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraVectorStoreAutoConfiguration.java @@ -15,16 +15,17 @@ */ package org.springframework.ai.autoconfigure.vectorstore.cassandra; -import java.net.InetSocketAddress; -import java.util.Arrays; -import java.util.List; +import java.time.Duration; -import com.google.common.base.Preconditions; +import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.config.DefaultDriverOption; import org.springframework.ai.embedding.EmbeddingClient; import org.springframework.ai.vectorstore.CassandraVectorStore; import org.springframework.ai.vectorstore.CassandraVectorStoreConfig; import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.cassandra.CassandraAutoConfiguration; +import org.springframework.boot.autoconfigure.cassandra.DriverConfigLoaderBuilderCustomizer; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -34,37 +35,24 @@ * @author Mick Semb Wever * @since 1.0.0 */ -@AutoConfiguration -@ConditionalOnClass({ CassandraVectorStore.class, EmbeddingClient.class }) +@AutoConfiguration(after = CassandraAutoConfiguration.class) +@ConditionalOnClass({ CassandraVectorStore.class, EmbeddingClient.class, CqlSession.class }) @EnableConfigurationProperties(CassandraVectorStoreProperties.class) public class CassandraVectorStoreAutoConfiguration { - @Bean - @ConditionalOnMissingBean(CassandraConnectionDetails.class) - public PropertiesCassandraConnectionDetails cassandraConnectionDetails(CassandraVectorStoreProperties properties) { - return new PropertiesCassandraConnectionDetails(properties); - } - @Bean @ConditionalOnMissingBean public CassandraVectorStore vectorStore(EmbeddingClient embeddingClient, CassandraVectorStoreProperties properties, - CassandraConnectionDetails cassandraConnectionDetails) { + CqlSession cqlSession) { - var builder = CassandraVectorStoreConfig.builder(); - if (cassandraConnectionDetails.hasCassandraContactPoints()) { - for (InetSocketAddress contactPoint : cassandraConnectionDetails.getCassandraContactPoints()) { - builder = builder.addContactPoint(contactPoint); - } - } - if (cassandraConnectionDetails.hasCassandraLocalDatacenter()) { - builder = builder.withLocalDatacenter(cassandraConnectionDetails.getCassandraLocalDatacenter()); - } + var builder = CassandraVectorStoreConfig.builder().withCqlSession(cqlSession); builder = builder.withKeyspaceName(properties.getKeyspace()) .withTableName(properties.getTable()) - .withContentColumnName(properties.getContentFieldName()) - .withEmbeddingColumnName(properties.getEmbeddingFieldName()) - .withIndexName(properties.getIndexName()); + .withContentColumnName(properties.getContentColumnName()) + .withEmbeddingColumnName(properties.getEmbeddingColumnName()) + .withIndexName(properties.getIndexName()) + .withFixedThreadPoolExecutorSize(properties.getFixedThreadPoolExecutorSize()); if (properties.getDisallowSchemaCreation()) { builder = builder.disallowSchemaChanges(); @@ -73,46 +61,20 @@ public CassandraVectorStore vectorStore(EmbeddingClient embeddingClient, Cassand return new CassandraVectorStore(builder.build(), embeddingClient); } - private static class PropertiesCassandraConnectionDetails implements CassandraConnectionDetails { - - private final CassandraVectorStoreProperties properties; - - public PropertiesCassandraConnectionDetails(CassandraVectorStoreProperties properties) { - this.properties = properties; - } - - private String[] getCassandraContactPointHosts() { - return this.properties.getCassandraContactPointHosts().split("(,| )"); - } - - @Override - public List getCassandraContactPoints() { - - Preconditions.checkState(hasCassandraContactPoints(), "cassandraContactPointHosts has not been set"); - final int port = this.properties.getCassandraContactPointPort(); - - return Arrays.asList(getCassandraContactPointHosts()) - .stream() - .map((host) -> InetSocketAddress.createUnresolved(host, port)) - .toList(); - } - - @Override - public String getCassandraLocalDatacenter() { - Preconditions.checkState(hasCassandraLocalDatacenter(), "cassandraLocalDatacenter has not been set"); - return this.properties.getCassandraLocalDatacenter(); - } - - @Override - public boolean hasCassandraContactPoints() { - return null != this.properties.getCassandraContactPointHosts(); - } - - @Override - public boolean hasCassandraLocalDatacenter() { - return null != this.properties.getCassandraLocalDatacenter(); - } - + @Bean + public DriverConfigLoaderBuilderCustomizer driverConfigLoaderBuilderCustomizer() { + // this replaces spring-ai-cassandra-*.jar!application.conf + // as spring-boot autoconfigure will not resolve the default driver configs + return (builder) -> builder.startProfile(CassandraVectorStore.DRIVER_PROFILE_UPDATES) + .withString(DefaultDriverOption.REQUEST_CONSISTENCY, "LOCAL_QUORUM") + .withDuration(DefaultDriverOption.REQUEST_TIMEOUT, Duration.ofSeconds(1)) + .withBoolean(DefaultDriverOption.REQUEST_DEFAULT_IDEMPOTENCE, true) + .endProfile() + .startProfile(CassandraVectorStore.DRIVER_PROFILE_SEARCH) + .withString(DefaultDriverOption.REQUEST_CONSISTENCY, "LOCAL_ONE") + .withDuration(DefaultDriverOption.REQUEST_TIMEOUT, Duration.ofSeconds(10)) + .withBoolean(DefaultDriverOption.REQUEST_DEFAULT_IDEMPOTENCE, true) + .endProfile(); } } diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraVectorStoreProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraVectorStoreProperties.java index 73b014ab6b0..1f243310079 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraVectorStoreProperties.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraVectorStoreProperties.java @@ -15,6 +15,8 @@ */ package org.springframework.ai.autoconfigure.vectorstore.cassandra; +import com.google.api.client.util.Preconditions; + import org.springframework.ai.vectorstore.CassandraVectorStoreConfig; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -27,12 +29,6 @@ public class CassandraVectorStoreProperties { public static final String CONFIG_PREFIX = "spring.ai.vectorstore.cassandra"; - private String cassandraContactPointHosts = null; - - private int cassandraContactPointPort = 9042; - - private String cassandraLocalDatacenter = null; - private String keyspace = CassandraVectorStoreConfig.DEFAULT_KEYSPACE_NAME; private String table = CassandraVectorStoreConfig.DEFAULT_TABLE_NAME; @@ -45,30 +41,7 @@ public class CassandraVectorStoreProperties { private boolean disallowSchemaChanges = false; - public String getCassandraContactPointHosts() { - return this.cassandraContactPointHosts; - } - - /** comma or space separated */ - public void setCassandraContactPointHosts(String cassandraContactPointHosts) { - this.cassandraContactPointHosts = cassandraContactPointHosts; - } - - public int getCassandraContactPointPort() { - return this.cassandraContactPointPort; - } - - public void setCassandraContactPointPort(int cassandraContactPointPort) { - this.cassandraContactPointPort = cassandraContactPointPort; - } - - public String getCassandraLocalDatacenter() { - return this.cassandraLocalDatacenter; - } - - public void setCassandraLocalDatacenter(String cassandraLocalDatacenter) { - this.cassandraLocalDatacenter = cassandraLocalDatacenter; - } + private int fixedThreadPoolExecutorSize = CassandraVectorStoreConfig.DEFAULT_ADD_CONCURRENCY; public String getKeyspace() { return this.keyspace; @@ -94,20 +67,20 @@ public void setIndexName(String indexName) { this.indexName = indexName; } - public String getContentFieldName() { + public String getContentColumnName() { return this.contentColumnName; } - public void setContentFieldName(String contentFieldName) { - this.contentColumnName = contentFieldName; + public void setContentColumnName(String contentColumnName) { + this.contentColumnName = contentColumnName; } - public String getEmbeddingFieldName() { + public String getEmbeddingColumnName() { return this.embeddingColumnName; } - public void setEmbeddingFieldName(String embeddingFieldName) { - this.embeddingColumnName = embeddingFieldName; + public void setEmbeddingColumnName(String embeddingColumnName) { + this.embeddingColumnName = embeddingColumnName; } public Boolean getDisallowSchemaCreation() { @@ -118,4 +91,13 @@ public void setDisallowSchemaCreation(boolean disallowSchemaCreation) { this.disallowSchemaChanges = disallowSchemaCreation; } + public int getFixedThreadPoolExecutorSize() { + return this.fixedThreadPoolExecutorSize; + } + + public void setFixedThreadPoolExecutorSize(int fixedThreadPoolExecutorSize) { + Preconditions.checkArgument(0 < fixedThreadPoolExecutorSize); + this.fixedThreadPoolExecutorSize = fixedThreadPoolExecutorSize; + } + } diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraVectorStoreAutoConfigurationIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraVectorStoreAutoConfigurationIT.java index 15530052f96..119bde94478 100644 --- a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraVectorStoreAutoConfigurationIT.java +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraVectorStoreAutoConfigurationIT.java @@ -31,6 +31,7 @@ import org.springframework.ai.vectorstore.SearchRequest; import org.springframework.ai.vectorstore.VectorStore; import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.cassandra.CassandraAutoConfiguration; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -55,18 +56,18 @@ class CassandraVectorStoreAutoConfigurationIT { ResourceUtils.getText("classpath:/test/data/great.depression.txt"), Map.of("depression", "bad"))); private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(CassandraVectorStoreAutoConfiguration.class)) + .withConfiguration( + AutoConfigurations.of(CassandraVectorStoreAutoConfiguration.class, CassandraAutoConfiguration.class)) .withUserConfiguration(Config.class) .withPropertyValues("spring.ai.vectorstore.cassandra.keyspace=test_autoconfigure") - .withPropertyValues("spring.ai.vectorstore.cassandra.contentFieldName=doc_chunk"); + .withPropertyValues("spring.ai.vectorstore.cassandra.contentColumnName=doc_chunk"); @Test void addAndSearch() { - contextRunner - .withPropertyValues("spring.ai.vectorstore.cassandra.cassandraContactPointHosts=" + getContactPointHost()) - .withPropertyValues("spring.ai.vectorstore.cassandra.cassandraContactPointPort=" + getContactPointPort()) - .withPropertyValues("spring.ai.vectorstore.cassandra.cassandraLocalDatacenter=" - + cassandraContainer.getLocalDatacenter()) + contextRunner.withPropertyValues("spring.cassandra.contactPoints=" + getContactPointHost()) + .withPropertyValues("spring.cassandra.port=" + getContactPointPort()) + .withPropertyValues("spring.cassandra.localDatacenter=" + cassandraContainer.getLocalDatacenter()) + .withPropertyValues("spring.ai.vectorstore.cassandra.fixedThreadPoolExecutorSize=8") .run(context -> { VectorStore vectorStore = context.getBean(VectorStore.class); diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraVectorStorePropertiesTests.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraVectorStorePropertiesTests.java index c5a3e4d0f7c..ca5c678d722 100644 --- a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraVectorStorePropertiesTests.java +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraVectorStorePropertiesTests.java @@ -30,39 +30,34 @@ class CassandraVectorStorePropertiesTests { @Test void defaultValues() { var props = new CassandraVectorStoreProperties(); - assertThat(props.getCassandraContactPointHosts()).isNull(); - assertThat(props.getCassandraContactPointPort()).isEqualTo(9042); - assertThat(props.getCassandraLocalDatacenter()).isNull(); assertThat(props.getKeyspace()).isEqualTo(CassandraVectorStoreConfig.DEFAULT_KEYSPACE_NAME); assertThat(props.getTable()).isEqualTo(CassandraVectorStoreConfig.DEFAULT_TABLE_NAME); - assertThat(props.getContentFieldName()).isEqualTo(CassandraVectorStoreConfig.DEFAULT_CONTENT_COLUMN_NAME); - assertThat(props.getEmbeddingFieldName()).isEqualTo(CassandraVectorStoreConfig.DEFAULT_EMBEDDING_COLUMN_NAME); + assertThat(props.getContentColumnName()).isEqualTo(CassandraVectorStoreConfig.DEFAULT_CONTENT_COLUMN_NAME); + assertThat(props.getEmbeddingColumnName()).isEqualTo(CassandraVectorStoreConfig.DEFAULT_EMBEDDING_COLUMN_NAME); assertThat(props.getIndexName()).isEqualTo(CassandraVectorStoreConfig.DEFAULT_INDEX_NAME); assertThat(props.getDisallowSchemaCreation()).isFalse(); + assertThat(props.getFixedThreadPoolExecutorSize()) + .isEqualTo(CassandraVectorStoreConfig.DEFAULT_ADD_CONCURRENCY); } @Test void customValues() { var props = new CassandraVectorStoreProperties(); - props.setCassandraContactPointHosts("127.0.0.1,127.0.0.2"); - props.setCassandraContactPointPort(9043); - props.setCassandraLocalDatacenter("dc1"); props.setKeyspace("my_keyspace"); props.setTable("my_table"); - props.setContentFieldName("my_content"); - props.setEmbeddingFieldName("my_vector"); + props.setContentColumnName("my_content"); + props.setEmbeddingColumnName("my_vector"); props.setIndexName("my_sai"); props.setDisallowSchemaCreation(true); + props.setFixedThreadPoolExecutorSize(10); - assertThat(props.getCassandraContactPointHosts()).isEqualTo("127.0.0.1,127.0.0.2"); - assertThat(props.getCassandraContactPointPort()).isEqualTo(9043); - assertThat(props.getCassandraLocalDatacenter()).isEqualTo("dc1"); assertThat(props.getKeyspace()).isEqualTo("my_keyspace"); assertThat(props.getTable()).isEqualTo("my_table"); - assertThat(props.getContentFieldName()).isEqualTo("my_content"); - assertThat(props.getEmbeddingFieldName()).isEqualTo("my_vector"); + assertThat(props.getContentColumnName()).isEqualTo("my_content"); + assertThat(props.getEmbeddingColumnName()).isEqualTo("my_vector"); assertThat(props.getIndexName()).isEqualTo("my_sai"); assertThat(props.getDisallowSchemaCreation()).isTrue(); + assertThat(props.getFixedThreadPoolExecutorSize()).isEqualTo(10); } } diff --git a/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraFilterExpressionConverter.java b/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraFilterExpressionConverter.java index 4a8b681a5f0..3efd440341b 100644 --- a/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraFilterExpressionConverter.java +++ b/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraFilterExpressionConverter.java @@ -79,7 +79,7 @@ private static void doOperand(ExpressionType type, StringBuilder context) { // TODO SAI supports collections // reach out to mck@apache.org if you'd like these implemented // case CONTAINS -> context.append(" CONTAINS "); - // case CONTAINS_KEY -> context.append(" CONTAINS KEY "); + // case CONTAINS_KEY -> context.append(" CONTAINS_KEY "); default -> throw new UnsupportedOperationException( String.format("Expression type %s not yet implemented. Patches welcome.", type)); } diff --git a/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStore.java b/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStore.java index 6f651c944df..4e2732d58b7 100644 --- a/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStore.java +++ b/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStore.java @@ -17,16 +17,20 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import com.datastax.oss.driver.api.core.cql.BoundStatement; import com.datastax.oss.driver.api.core.cql.BoundStatementBuilder; import com.datastax.oss.driver.api.core.cql.PreparedStatement; import com.datastax.oss.driver.api.core.cql.Row; +import com.datastax.oss.driver.api.core.cql.SimpleStatement; import com.datastax.oss.driver.api.core.data.CqlVector; import com.datastax.oss.driver.api.core.metadata.schema.TableMetadata; import com.datastax.oss.driver.api.querybuilder.QueryBuilder; @@ -40,6 +44,7 @@ import org.springframework.ai.document.Document; import org.springframework.ai.embedding.EmbeddingClient; +import org.springframework.ai.vectorstore.CassandraVectorStoreConfig; import org.springframework.ai.vectorstore.CassandraVectorStoreConfig.SchemaColumn; import org.springframework.ai.vectorstore.filter.FilterExpressionConverter; import org.springframework.beans.factory.InitializingBean; @@ -53,13 +58,13 @@ * fields in the documents to be stored alongside the vector and content data. * * This class requires a CassandraVectorStoreConfig configuration object for - * initialization, which includes settings like connection details, index name, field + * initialization, which includes settings like connection details, index name, column * names, etc. It also requires an EmbeddingClient to convert documents into embeddings * before storing them. * * A schema matching the configuration is automatically created if it doesn't exist. * Missing columns and indexes in existing tables will also be automatically created. - * Disable this with the disallowSchemaCreation. + * Disable this with the CassandraVectorStoreConfig#disallowSchemaChanges(). * * This class is designed to work with brand new tables that it creates for you, or on top * of existing Cassandra tables. The latter is appropriate when wanting to keep data in @@ -69,9 +74,20 @@ * Instances of this class are not dynamic against server-side schema changes. If you * change the schema server-side you need a new CassandraVectorStore instance. * + * When adding documents with the method {@link #add(List)} it first calls + * embeddingClient to create the embeddings. This is slow. Configure + * {@link CassandraVectorStoreConfig.Builder#withFixedThreadPoolExecutorSize(int)} + * accordingly to improve performance so embeddings are created and the documents are + * added concurrently. The default concurrency is 16 + * ({@link CassandraVectorStoreConfig#DEFAULT_ADD_CONCURRENCY}). Remote transformers + * probably want higher concurrency, and local transformers may need lower concurrency. + * This concurrency limit does not need to be higher than the max parallel calls made to + * the {@link #add(List)} method multiplied by the list size. This setting can + * also serve as a protecting throttle against your embedding model. + * * @author Mick Semb Wever * @see VectorStore - * @see CassandraVectorStoreConfig + * @see org.springframework.ai.vectorstore.CassandraVectorStoreConfig * @see EmbeddingClient * @since 1.0.0 */ @@ -87,10 +103,14 @@ public enum Similarity { } - private static final String QUERY_FORMAT = "select %s,%s,%s%s from %s.%s ? order by %s ann of ? limit ?"; - public static final String SIMILARITY_FIELD_NAME = "similarity_score"; + public static final String DRIVER_PROFILE_UPDATES = "spring-ai-updates"; + + public static final String DRIVER_PROFILE_SEARCH = "spring-ai-search"; + + private static final String QUERY_FORMAT = "select %s,%s,%s%s from %s.%s ? order by %s ann of ? limit ?"; + private static final Logger logger = LoggerFactory.getLogger(CassandraVectorStore.class); private final CassandraVectorStoreConfig conf; @@ -99,7 +119,7 @@ public enum Similarity { private final FilterExpressionConverter filterExpressionConverter; - private final Map, PreparedStatement> addStmts = new HashMap<>(); + private final ConcurrentMap, PreparedStatement> addStmts = new ConcurrentHashMap<>(); private final PreparedStatement deleteStmt; @@ -133,30 +153,39 @@ public CassandraVectorStore(CassandraVectorStoreConfig conf, EmbeddingClient emb @Override public void add(List documents) { - CompletableFuture[] futures = new CompletableFuture[documents.size()]; - short i = 0; - for (Document d : documents) { - List primaryKeyValues = this.conf.documentIdTranslator.apply(d.getId()); - var embedding = this.embeddingClient.embed(d).stream().map(Double::floatValue).toList(); + var futures = new CompletableFuture[documents.size()]; - BoundStatementBuilder builder = prepareAddStatement(d.getMetadata().keySet()).boundStatementBuilder(); - for (int k = 0; k < primaryKeyValues.size(); ++k) { - SchemaColumn keyColumn = this.conf.getPrimaryKeyColumn(k); - builder = builder.set(keyColumn.name(), primaryKeyValues.get(k), keyColumn.javaType()); - } + int i = 0; + for (Document d : documents) { + futures[i++] = CompletableFuture.runAsync(() -> { + List primaryKeyValues = this.conf.documentIdTranslator.apply(d.getId()); + + var embedding = (null != d.getEmbedding() && !d.getEmbedding().isEmpty() ? d.getEmbedding() + : this.embeddingClient.embed(d)) + .stream() + .map(Double::floatValue) + .toList(); + + BoundStatementBuilder builder = prepareAddStatement(d.getMetadata().keySet()).boundStatementBuilder(); + for (int k = 0; k < primaryKeyValues.size(); ++k) { + SchemaColumn keyColumn = this.conf.getPrimaryKeyColumn(k); + builder = builder.set(keyColumn.name(), primaryKeyValues.get(k), keyColumn.javaType()); + } - builder = builder.setString(this.conf.schema.content(), d.getContent()) - .setVector(this.conf.schema.embedding(), CqlVector.newInstance(embedding), Float.class); + builder = builder.setString(this.conf.schema.content(), d.getContent()) + .setVector(this.conf.schema.embedding(), CqlVector.newInstance(embedding), Float.class); - for (var metadataColumn : this.conf.schema.metadataColumns() - .stream() - .filter((mc) -> d.getMetadata().containsKey(mc.name())) - .toList()) { + for (var metadataColumn : this.conf.schema.metadataColumns() + .stream() + .filter((mc) -> d.getMetadata().containsKey(mc.name())) + .toList()) { - builder = builder.set(metadataColumn.name(), d.getMetadata().get(metadataColumn.name()), - metadataColumn.javaType()); - } - futures[i++] = this.conf.session.executeAsync(builder.build()).toCompletableFuture(); + builder = builder.set(metadataColumn.name(), d.getMetadata().get(metadataColumn.name()), + metadataColumn.javaType()); + } + BoundStatement s = builder.build().setExecutionProfileName(DRIVER_PROFILE_UPDATES); + this.conf.session.execute(s); + }, this.conf.executor); } CompletableFuture.allOf(futures).join(); } @@ -164,7 +193,7 @@ public void add(List documents) { @Override public Optional delete(List idList) { CompletableFuture[] futures = new CompletableFuture[idList.size()]; - short i = 0; + int i = 0; for (String id : idList) { List primaryKeyValues = this.conf.documentIdTranslator.apply(id); BoundStatement s = this.deleteStmt.bind(primaryKeyValues.toArray()); @@ -191,8 +220,9 @@ public List similaritySearch(SearchRequest request) { String query = String.format(this.similarityStmt, cqlVector, whereClause, cqlVector, request.getTopK()); List documents = new ArrayList<>(); logger.trace("Executing {}", query); + SimpleStatement s = SimpleStatement.newInstance(query).setExecutionProfileName(DRIVER_PROFILE_SEARCH); - for (Row row : this.conf.session.execute(query)) { + for (Row row : this.conf.session.execute(s)) { float score = row.getFloat(0); if (score < request.getSimilarityThreshold()) { break; @@ -248,7 +278,16 @@ private PreparedStatement prepareDeleteStatement() { } private PreparedStatement prepareAddStatement(Set metadataFields) { - if (!this.addStmts.containsKey(metadataFields)) { + + // metadata fields that are not configured as metadata columns are not added + Set fieldsThatAreColumns = new HashSet<>(this.conf.schema.metadataColumns() + .stream() + .map((mc) -> mc.name()) + .filter((mc) -> metadataFields.contains(mc)) + .toList()); + + return this.addStmts.computeIfAbsent(fieldsThatAreColumns, (fields) -> { + RegularInsert stmt = null; InsertInto stmtStart = QueryBuilder.insertInto(this.conf.schema.keyspace(), this.conf.schema.table()); @@ -262,17 +301,11 @@ private PreparedStatement prepareAddStatement(Set metadataFields) { stmt = stmt.value(this.conf.schema.content(), QueryBuilder.bindMarker(this.conf.schema.content())) .value(this.conf.schema.embedding(), QueryBuilder.bindMarker(this.conf.schema.embedding())); - for (String metadataField : this.conf.schema.metadataColumns() - .stream() - .map((mc) -> mc.name()) - .filter((mc) -> metadataFields.contains(mc)) - .toList()) { - + for (String metadataField : fields) { stmt = stmt.value(metadataField, QueryBuilder.bindMarker(metadataField)); } - this.addStmts.putIfAbsent(metadataFields, this.conf.session.prepare(stmt.build())); - } - return this.addStmts.get(metadataFields); + return this.conf.session.prepare(stmt.build()); + }); } private String similaritySearchStatement() { diff --git a/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStoreConfig.java b/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStoreConfig.java index d85d1ee4bc3..38b39e16fe9 100644 --- a/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStoreConfig.java +++ b/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStoreConfig.java @@ -22,6 +22,8 @@ import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; import java.util.function.Function; import java.util.stream.Stream; @@ -48,15 +50,17 @@ /** * Configuration for the Cassandra vector store. * - * All metadata fields configured to the store will be fetched and added to all queried + * All metadata columns configured to the store will be fetched and added to all queried * documents. * - * If you wish to metadata search against a field its 'searchable' argument must be true. + * To filter expression search against a metadata column configure it with + * SchemaColumnTags.INDEXED * * The Cassandra Java Driver is configured via the application.conf resource found in the * classpath. See * https://github.com/apache/cassandra-java-driver/tree/4.x/manual/core/configuration * + * @author Mick Semb Wever * @since 1.0.0 */ public final class CassandraVectorStoreConfig implements AutoCloseable { @@ -73,6 +77,8 @@ public final class CassandraVectorStoreConfig implements AutoCloseable { public static final String DEFAULT_EMBEDDING_COLUMN_NAME = "embedding"; + public static final int DEFAULT_ADD_CONCURRENCY = 16; + private static final Logger logger = LoggerFactory.getLogger(CassandraVectorStore.class); record Schema(String keyspace, String table, List partitionKeys, List clusteringKeys, @@ -127,6 +133,8 @@ public interface PrimaryKeyTranslator extends Function, String> { final PrimaryKeyTranslator primaryKeyTranslator; + final Executor executor; + private final boolean closeSessionOnClose; private CassandraVectorStoreConfig(Builder builder) { @@ -139,6 +147,7 @@ private CassandraVectorStoreConfig(Builder builder) { this.disallowSchemaChanges = builder.disallowSchemaCreation; this.documentIdTranslator = builder.documentIdTranslator; this.primaryKeyTranslator = builder.primaryKeyTranslator; + this.executor = Executors.newFixedThreadPool(builder.fixedThreadPoolExecutorSize); } public static Builder builder() { @@ -187,6 +196,8 @@ public static class Builder { private boolean disallowSchemaCreation = false; + private int fixedThreadPoolExecutorSize = DEFAULT_ADD_CONCURRENCY; + private DocumentIdTranslator documentIdTranslator = (String id) -> List.of(id); private PrimaryKeyTranslator primaryKeyTranslator = (List primaryKeyColumns) -> { @@ -261,20 +272,27 @@ public Builder withEmbeddingColumnName(String name) { return this; } - public Builder addMetadataColumn(SchemaColumn... fields) { + public Builder addMetadataColumns(SchemaColumn... columns) { Builder builder = this; - for (SchemaColumn f : fields) { + for (SchemaColumn f : columns) { builder = builder.addMetadataColumn(f); } return builder; } - public Builder addMetadataColumn(SchemaColumn field) { + public Builder addMetadataColumns(List columns) { + Builder builder = this; + this.metadataColumns.addAll(columns); + return builder; + } + + public Builder addMetadataColumn(SchemaColumn column) { - Preconditions.checkArgument(this.metadataColumns.stream().noneMatch((sc) -> sc.name().equals(field.name())), - "A metadata field with name %s has already been added", field.name()); + Preconditions.checkArgument( + this.metadataColumns.stream().noneMatch((sc) -> sc.name().equals(column.name())), + "A metadata column with name %s has already been added", column.name()); - this.metadataColumns.add(field); + this.metadataColumns.add(column); return this; } @@ -283,6 +301,18 @@ public Builder disallowSchemaChanges() { return this; } + /** + * Executor to use when adding documents. The hotspot is the call to the + * embeddingClient. For remote transformers you probably want a higher value to + * utilize network. For local transformers you probably want a lower value to + * avoid saturation. + **/ + public Builder withFixedThreadPoolExecutorSize(int threads) { + Preconditions.checkArgument(0 < threads); + this.fixedThreadPoolExecutorSize = threads; + return this; + } + public Builder withDocumentIdTranslator(DocumentIdTranslator documentIdTranslator) { this.documentIdTranslator = documentIdTranslator; return this; @@ -480,7 +510,7 @@ private void ensureTableColumnsExist(int vectorDimension) { if (column.isPresent()) { Preconditions.checkArgument(column.get().getType().equals(metadata.type()), - "Cannot change type on metadata field %s from %s to %s", metadata.name(), + "Cannot change type on metadata column %s from %s to %s", metadata.name(), column.get().getType(), metadata.type()); } else { diff --git a/vector-stores/spring-ai-cassandra/src/main/resources/application.conf b/vector-stores/spring-ai-cassandra/src/main/resources/application.conf new file mode 100644 index 00000000000..91b1c800e7d --- /dev/null +++ b/vector-stores/spring-ai-cassandra/src/main/resources/application.conf @@ -0,0 +1,24 @@ +# Reference configuration for the DataStax Java driver for Apache Cassandra® +# see https://github.com/apache/cassandra-java-driver/tree/4.x/manual/core/configuration +# +# +# when using spring-boot autoconfigure this will not be used +# instead CassandraVectorStoreAutoConfiguration.driverConfigLoaderBuilderCustomizer() is used +datastax-java-driver { + profiles { + spring-ai-updates { + basic.request { + consistency = LOCAL_QUORUM + timeout = 1 seconds + default-idempotence = true + } + } + spring-ai-search { + basic.request { + consistency = LOCAL_ONE + timeout = 10 seconds + default-idempotence = true + } + } + } +} \ No newline at end of file diff --git a/vector-stores/spring-ai-cassandra/src/test/java/org/springframework/ai/vectorstore/CassandraRichSchemaVectorStoreIT.java b/vector-stores/spring-ai-cassandra/src/test/java/org/springframework/ai/vectorstore/CassandraRichSchemaVectorStoreIT.java index 91ec53e503e..2dc59afae34 100644 --- a/vector-stores/spring-ai-cassandra/src/test/java/org/springframework/ai/vectorstore/CassandraRichSchemaVectorStoreIT.java +++ b/vector-stores/spring-ai-cassandra/src/test/java/org/springframework/ai/vectorstore/CassandraRichSchemaVectorStoreIT.java @@ -17,23 +17,29 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; -import org.junit.jupiter.api.Assertions; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadLocalRandom; import com.datastax.oss.driver.api.core.CqlSession; import com.datastax.oss.driver.api.core.CqlSessionBuilder; import com.datastax.oss.driver.api.core.servererrors.InvalidQueryException; import com.datastax.oss.driver.api.core.servererrors.SyntaxError; import com.datastax.oss.driver.api.core.type.DataTypes; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.testcontainers.containers.CassandraContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils; import org.testcontainers.utility.DockerImageName; import org.springframework.ai.document.Document; @@ -174,6 +180,51 @@ void addAndSearch() { }); } + @Test + void addAndSearchPoormansBench() { + // todo – replace with JMH (parameters: nThreads, rounds, runs, docsPerAdd) + int nThreads = CassandraVectorStoreConfig.DEFAULT_ADD_CONCURRENCY; + int runs = 10; // 100; + int docsPerAdd = 12; // 128; + int rounds = 3; + + contextRunner.run(context -> { + + try (CassandraVectorStore store = new CassandraVectorStore( + storeBuilder(context, List.of()).withFixedThreadPoolExecutorSize(nThreads).build(), + context.getBean(EmbeddingClient.class))) { + + var executor = Executors.newFixedThreadPool((int) (nThreads * 1.2)); + for (int k = 0; k < rounds; ++k) { + long start = System.nanoTime(); + var futures = new CompletableFuture[runs]; + for (int j = 0; j < runs; ++j) { + futures[j] = CompletableFuture.runAsync(() -> { + List documents = new ArrayList<>(); + for (int i = docsPerAdd; i >= 0; --i) { + + documents.add(new Document( + RandomStringUtils.randomAlphanumeric(4) + "§¶" + + ThreadLocalRandom.current().nextInt(1, 10), + RandomStringUtils.randomAlphanumeric(1024), Map.of("revision", + ThreadLocalRandom.current().nextInt(1, 100000), "id", 1000))); + } + store.add(documents); + + var results = store.similaritySearch( + SearchRequest.query(RandomStringUtils.randomAlphanumeric(20)).withTopK(10)); + + assertThat(results).hasSize(10); + }, executor); + } + CompletableFuture.allOf(futures).join(); + long time = System.nanoTime() - start; + logger.info("add+search took an average of {} ms", Duration.ofNanos(time / runs).toMillis()); + } + } + }); + } + @Test void searchWithPartitionFilter() throws InterruptedException { contextRunner.run(context -> { @@ -456,22 +507,37 @@ private StoreWrapper createSto } private StoreWrapper createStore(ApplicationContext context, - List extraMetadataFields, boolean disallowSchemaCreation, boolean dropKeyspaceFirst) + List columnOverrides, boolean disallowSchemaCreation, boolean dropKeyspaceFirst) throws IOException { - Optional wikiOverride = extraMetadataFields.stream() + CassandraVectorStoreConfig.Builder builder = storeBuilder(context, columnOverrides); + if (disallowSchemaCreation) { + builder = builder.disallowSchemaChanges(); + } + + CassandraVectorStoreConfig conf = builder.build(); + if (dropKeyspaceFirst) { + conf.dropKeyspace(); + } + return new StoreWrapper(new CassandraVectorStore(conf, context.getBean(EmbeddingClient.class)), conf); + } + + static CassandraVectorStoreConfig.Builder storeBuilder(ApplicationContext context, + List columnOverrides) throws IOException { + + Optional wikiOverride = columnOverrides.stream() .filter((f) -> "wiki".equals(f.name())) .findFirst(); - Optional langOverride = extraMetadataFields.stream() + Optional langOverride = columnOverrides.stream() .filter((f) -> "language".equals(f.name())) .findFirst(); - Optional titleOverride = extraMetadataFields.stream() + Optional titleOverride = columnOverrides.stream() .filter((f) -> "title".equals(f.name())) .findFirst(); - Optional chunkNoOverride = extraMetadataFields.stream() + Optional chunkNoOverride = columnOverrides.stream() .filter((f) -> "chunk_no".equals(f.name())) .findFirst(); @@ -493,7 +559,7 @@ private StoreWrapper createSto .withEmbeddingColumnName("all_minilm_l6_v2_embedding") .withIndexName("all_minilm_l6_v2_ann") - .addMetadataColumn(new SchemaColumn("revision", DataTypes.INT), + .addMetadataColumns(new SchemaColumn("revision", DataTypes.INT), new SchemaColumn("id", DataTypes.INT, CassandraVectorStoreConfig.SchemaColumnTags.INDEXED)) // this store uses '§¶' as a deliminator in the document id between db columns @@ -511,21 +577,7 @@ private StoreWrapper createSto return List.of("simplewiki", "en", title, chunk_no); }); - for (SchemaColumn cf : extraMetadataFields) { - if (!partitionKeys.contains(cf) && !clusteringKeys.contains(cf)) { - builder = builder.addMetadataColumn(cf); - } - } - - if (disallowSchemaCreation) { - builder = builder.disallowSchemaChanges(); - } - - CassandraVectorStoreConfig conf = builder.build(); - if (dropKeyspaceFirst) { - conf.dropKeyspace(); - } - return new StoreWrapper(new CassandraVectorStore(conf, context.getBean(EmbeddingClient.class)), conf); + return builder; } private void executeCqlFile(ApplicationContext context, String filename) throws IOException { diff --git a/vector-stores/spring-ai-cassandra/src/test/java/org/springframework/ai/vectorstore/CassandraVectorStoreIT.java b/vector-stores/spring-ai-cassandra/src/test/java/org/springframework/ai/vectorstore/CassandraVectorStoreIT.java index 0984a067ff0..d1fac3901b3 100644 --- a/vector-stores/spring-ai-cassandra/src/test/java/org/springframework/ai/vectorstore/CassandraVectorStoreIT.java +++ b/vector-stores/spring-ai-cassandra/src/test/java/org/springframework/ai/vectorstore/CassandraVectorStoreIT.java @@ -345,8 +345,9 @@ public static class TestApplication { public CassandraVectorStore store(CqlSession cqlSession, EmbeddingClient embeddingClient) { CassandraVectorStoreConfig conf = storeBuilder(cqlSession) - .addMetadataColumn(new SchemaColumn("meta1", DataTypes.TEXT), new SchemaColumn("meta2", DataTypes.TEXT), - new SchemaColumn("country", DataTypes.TEXT), new SchemaColumn("year", DataTypes.SMALLINT)) + .addMetadataColumns(new SchemaColumn("meta1", DataTypes.TEXT), + new SchemaColumn("meta2", DataTypes.TEXT), new SchemaColumn("country", DataTypes.TEXT), + new SchemaColumn("year", DataTypes.SMALLINT)) .build(); conf.dropKeyspace(); @@ -378,7 +379,7 @@ static CassandraVectorStoreConfig.Builder storeBuilder(CqlSession cqlSession) { private CassandraVectorStore createTestStore(ApplicationContext context, SchemaColumn... metadataFields) { CassandraVectorStoreConfig.Builder builder = storeBuilder(context.getBean(CqlSession.class)) - .addMetadataColumn(metadataFields); + .addMetadataColumns(metadataFields); CassandraVectorStoreConfig conf = builder.build(); conf.dropKeyspace(); diff --git a/vector-stores/spring-ai-cassandra/src/test/java/org/springframework/ai/vectorstore/WikiVectorStoreExample.java b/vector-stores/spring-ai-cassandra/src/test/java/org/springframework/ai/vectorstore/WikiVectorStoreExample.java index 76028e6502c..910dac85a4f 100644 --- a/vector-stores/spring-ai-cassandra/src/test/java/org/springframework/ai/vectorstore/WikiVectorStoreExample.java +++ b/vector-stores/spring-ai-cassandra/src/test/java/org/springframework/ai/vectorstore/WikiVectorStoreExample.java @@ -81,25 +81,30 @@ public static class TestApplication { @Bean public CassandraVectorStore store(CqlSession cqlSession, EmbeddingClient embeddingClient) { + List partitionColumns = List.of(new SchemaColumn("wiki", DataTypes.TEXT), + new SchemaColumn("language", DataTypes.TEXT), new SchemaColumn("title", DataTypes.TEXT)); + + List clusteringColumns = List.of(new SchemaColumn("chunk_no", DataTypes.INT), + new SchemaColumn("bert_embedding_no", DataTypes.INT)); + + List extraColumns = List.of(new SchemaColumn("revision", DataTypes.INT), + new SchemaColumn("id", DataTypes.INT)); + CassandraVectorStoreConfig conf = CassandraVectorStoreConfig.builder() .withCqlSession(cqlSession) .withKeyspaceName("wikidata") .withTableName("articles") - - .withPartitionKeys(List.of(new SchemaColumn("wiki", DataTypes.TEXT), - new SchemaColumn("language", DataTypes.TEXT), new SchemaColumn("title", DataTypes.TEXT))) - - .withClusteringKeys(List.of(new SchemaColumn("chunk_no", DataTypes.INT), - new SchemaColumn("bert_embedding_no", DataTypes.INT))) - + .withPartitionKeys(partitionColumns) + .withClusteringKeys(clusteringColumns) .withContentColumnName("body") .withEmbeddingColumnName("all_minilm_l6_v2_embedding") .withIndexName("all_minilm_l6_v2_ann") .disallowSchemaChanges() - - .addMetadataColumn(new SchemaColumn("revision", DataTypes.INT), new SchemaColumn("id", DataTypes.INT)) + .addMetadataColumns(extraColumns) .withPrimaryKeyTranslator((List primaryKeys) -> { + // the deliminator used to join fields together into the document's id + // is arbitary, here "§¶" is used if (primaryKeys.isEmpty()) { return "test§¶0"; } From df92dff36a1927ce5f4b11db4095266eceb07c9a Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Fri, 19 Apr 2024 18:43:42 +0200 Subject: [PATCH 08/46] Fix OpenAI audio options merging order Resolves #601 --- .../org/springframework/ai/openai/OpenAiAudioSpeechClient.java | 2 +- .../ai/openai/OpenAiAudioTranscriptionClient.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiAudioSpeechClient.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiAudioSpeechClient.java index 86465687588..fc4575440d0 100644 --- a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiAudioSpeechClient.java +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiAudioSpeechClient.java @@ -133,7 +133,7 @@ private OpenAiAudioApi.SpeechRequest createRequestBody(SpeechPrompt request) { if (request.getOptions() != null) { if (request.getOptions() instanceof OpenAiAudioSpeechOptions runtimeOptions) { - options = this.merge(options, runtimeOptions); + options = this.merge(runtimeOptions, options); } else { throw new IllegalArgumentException("Prompt options are not of type SpeechOptions: " diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiAudioTranscriptionClient.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiAudioTranscriptionClient.java index b99c7cca6de..e021571e0ea 100644 --- a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiAudioTranscriptionClient.java +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiAudioTranscriptionClient.java @@ -178,7 +178,7 @@ OpenAiAudioApi.TranscriptionRequest createRequestBody(AudioTranscriptionPrompt r if (request.getOptions() != null) { if (request.getOptions() instanceof OpenAiAudioTranscriptionOptions runtimeOptions) { - options = this.merge(options, runtimeOptions); + options = this.merge(runtimeOptions, options); } else { throw new IllegalArgumentException("Prompt options are not of type TranscriptionOptions: " From 2cdd9b513817ceec1276e35db89c2c34551be21f Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Sat, 20 Apr 2024 07:05:07 +0200 Subject: [PATCH 09/46] Add (Streaming)ChatClient convinience defaults Facilitates the creation of multimodal message queries. --- .../java/org/springframework/ai/chat/ChatClient.java | 10 ++++++++++ .../springframework/ai/chat/StreamingChatClient.java | 10 ++++++++++ .../springframework/ai/chat/messages/UserMessage.java | 5 +++++ 3 files changed, 25 insertions(+) diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatClient.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatClient.java index 6925c16eedb..cff4f8674b8 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatClient.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatClient.java @@ -16,6 +16,10 @@ package org.springframework.ai.chat; import org.springframework.ai.chat.prompt.Prompt; + +import java.util.Arrays; + +import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.model.ModelClient; @@ -28,6 +32,12 @@ default String call(String message) { return (generation != null) ? generation.getOutput().getContent() : ""; } + default String call(Message... messages) { + Prompt prompt = new Prompt(Arrays.asList(messages)); + Generation generation = call(prompt).getResult(); + return (generation != null) ? generation.getOutput().getContent() : ""; + } + @Override ChatResponse call(Prompt prompt); diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/StreamingChatClient.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/StreamingChatClient.java index a6e4a0b7629..642d72022d2 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/StreamingChatClient.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/StreamingChatClient.java @@ -15,8 +15,11 @@ */ package org.springframework.ai.chat; +import java.util.Arrays; + import reactor.core.publisher.Flux; +import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.model.StreamingModelClient; @@ -30,6 +33,13 @@ default Flux stream(String message) { : response.getResult().getOutput().getContent()); } + default Flux call(Message... messages) { + Prompt prompt = new Prompt(Arrays.asList(messages)); + return stream(prompt).map(response -> (response.getResult() == null || response.getResult().getOutput() == null + || response.getResult().getOutput().getContent() == null) ? "" + : response.getResult().getOutput().getContent()); + } + @Override Flux stream(Prompt prompt); diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/messages/UserMessage.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/messages/UserMessage.java index d981a67edec..4c9229516b6 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/messages/UserMessage.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/messages/UserMessage.java @@ -15,6 +15,7 @@ */ package org.springframework.ai.chat.messages; +import java.util.Arrays; import java.util.List; import org.springframework.core.io.Resource; @@ -38,6 +39,10 @@ public UserMessage(String textContent, List mediaList) { super(MessageType.USER, textContent, mediaList); } + public UserMessage(String textContent, Media... media) { + this(textContent, Arrays.asList(media)); + } + @Override public String toString() { return "UserMessage{" + "content='" + getContent() + '\'' + ", properties=" + properties + ", messageType=" From be6081f08ee5f1cdd7dba7e0eaab70a96662bd4e Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Sat, 20 Apr 2024 07:23:00 +0200 Subject: [PATCH 10/46] fix: rename call to stream --- .../java/org/springframework/ai/chat/StreamingChatClient.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/StreamingChatClient.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/StreamingChatClient.java index 642d72022d2..69634b1920d 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/StreamingChatClient.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/StreamingChatClient.java @@ -33,7 +33,7 @@ default Flux stream(String message) { : response.getResult().getOutput().getContent()); } - default Flux call(Message... messages) { + default Flux stream(Message... messages) { Prompt prompt = new Prompt(Arrays.asList(messages)); return stream(prompt).map(response -> (response.getResult() == null || response.getResult().getOutput() == null || response.getResult().getOutput().getContent() == null) ? "" From 6178b3dcc4db427253671a6cbe624bcddfdccbd3 Mon Sep 17 00:00:00 2001 From: "omar.mahamid" Date: Thu, 11 Apr 2024 23:56:12 +0300 Subject: [PATCH 11/46] Fix CassandraVectorStoreConfig logger name aligne logger name with the className --- .../ai/vectorstore/CassandraVectorStoreConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStoreConfig.java b/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStoreConfig.java index 38b39e16fe9..91356e5e074 100644 --- a/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStoreConfig.java +++ b/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStoreConfig.java @@ -79,7 +79,7 @@ public final class CassandraVectorStoreConfig implements AutoCloseable { public static final int DEFAULT_ADD_CONCURRENCY = 16; - private static final Logger logger = LoggerFactory.getLogger(CassandraVectorStore.class); + private static final Logger logger = LoggerFactory.getLogger(CassandraVectorStoreConfig.class); record Schema(String keyspace, String table, List partitionKeys, List clusteringKeys, String content, String embedding, String index, Set metadataColumns) { From 8c04b056315540838a155911387fd2d71f626064 Mon Sep 17 00:00:00 2001 From: wmz7year Date: Wed, 17 Apr 2024 04:41:54 +0800 Subject: [PATCH 12/46] Add support for AWS bedrock claude3 opus model support --- .../ai/bedrock/anthropic3/api/Anthropic3ChatBedrockApi.java | 6 +++++- .../ROOT/pages/api/chat/bedrock/bedrock-anthropic3.adoc | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/anthropic3/api/Anthropic3ChatBedrockApi.java b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/anthropic3/api/Anthropic3ChatBedrockApi.java index e76bfcbeff3..983894267ff 100644 --- a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/anthropic3/api/Anthropic3ChatBedrockApi.java +++ b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/anthropic3/api/Anthropic3ChatBedrockApi.java @@ -441,7 +441,11 @@ public enum AnthropicChatModel { /** * anthropic.claude-3-haiku-20240307-v1:0 */ - CLAUDE_V3_HAIKU("anthropic.claude-3-haiku-20240307-v1:0"); + CLAUDE_V3_HAIKU("anthropic.claude-3-haiku-20240307-v1:0"), + /** + * anthropic.claude-3-opus-20240229-v1:0 + */ + CLAUDE_V3_OPUS("anthropic.claude-3-opus-20240229-v1:0"); private final String id; diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/bedrock/bedrock-anthropic3.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/bedrock/bedrock-anthropic3.adoc index c0d5f516d82..af1ac613ae2 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/bedrock/bedrock-anthropic3.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/bedrock/bedrock-anthropic3.adoc @@ -264,7 +264,7 @@ Flux response = chatClient.stream( The https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/anthropic3/api/Anthropic3ChatBedrockApi.java[Anthropic3ChatBedrockApi] provides is lightweight Java client on top of AWS Bedrock link:https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-claude.html[Anthropic Claude models]. -Client supports the `anthropic.claude-3-sonnet-20240229-v1:0`,`anthropic.claude-3-haiku-20240307-v1:0` and the legacy `anthropic.claude-v2`, `anthropic.claude-v2:1` and `anthropic.claude-instant-v1` models for both synchronous (e.g. `chatCompletion()`) and streaming (e.g. `chatCompletionStream()`) responses. +Client supports the `anthropic.claude-3-opus-20240229-v1:0`,`anthropic.claude-3-sonnet-20240229-v1:0`,`anthropic.claude-3-haiku-20240307-v1:0` and the legacy `anthropic.claude-v2`, `anthropic.claude-v2:1` and `anthropic.claude-instant-v1` models for both synchronous (e.g. `chatCompletion()`) and streaming (e.g. `chatCompletionStream()`) responses. Here is a simple snippet how to use the api programmatically: From d3f2a8fb15bf3138ee7b13f4b9aed0ff5f780d3c Mon Sep 17 00:00:00 2001 From: wmz7year Date: Fri, 19 Apr 2024 08:51:45 +0800 Subject: [PATCH 13/46] Fix bean name conflicts when using BedrockAnthropic and BedrockAnthropic3 at the same time. --- .../anthropic3/BedrockAnthropic3ChatAutoConfiguration.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/anthropic3/BedrockAnthropic3ChatAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/anthropic3/BedrockAnthropic3ChatAutoConfiguration.java index 31f18597ca7..7d04798d033 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/anthropic3/BedrockAnthropic3ChatAutoConfiguration.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/anthropic3/BedrockAnthropic3ChatAutoConfiguration.java @@ -35,6 +35,7 @@ * Leverages the Spring Cloud AWS to resolve the {@link AwsCredentialsProvider}. * * @author Christian Tzolov + * @author Wei Jiang * @since 0.8.0 */ @AutoConfiguration @@ -46,14 +47,14 @@ public class BedrockAnthropic3ChatAutoConfiguration { @Bean @ConditionalOnMissingBean - public Anthropic3ChatBedrockApi anthropicApi(AwsCredentialsProvider credentialsProvider, + public Anthropic3ChatBedrockApi anthropic3Api(AwsCredentialsProvider credentialsProvider, BedrockAnthropic3ChatProperties properties, BedrockAwsConnectionProperties awsProperties) { return new Anthropic3ChatBedrockApi(properties.getModel(), credentialsProvider, awsProperties.getRegion(), new ObjectMapper(), awsProperties.getTimeout()); } @Bean - public BedrockAnthropic3ChatClient anthropicChatClient(Anthropic3ChatBedrockApi anthropicApi, + public BedrockAnthropic3ChatClient anthropic3ChatClient(Anthropic3ChatBedrockApi anthropicApi, BedrockAnthropic3ChatProperties properties) { return new BedrockAnthropic3ChatClient(anthropicApi, properties.getOptions()); } From 8fa688fef6eb124d3e152d859b539fa9c3547249 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20H=C3=BCgelmeyer?= Date: Fri, 12 Apr 2024 10:56:57 +0200 Subject: [PATCH 14/46] Update qdrant.adoc Fixed java code. --- .../main/antora/modules/ROOT/pages/api/vectordbs/qdrant.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/qdrant.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/qdrant.adoc index 3b8caba0ab8..a5d4b871a16 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/qdrant.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/qdrant.adoc @@ -90,7 +90,7 @@ List documents = List.of( new Document("You walk forward facing the past and you turn back toward the future.", Map.of("meta2", "meta2"))); // Add the documents to Qdrant -vectorStore.add(List.of(document)); +vectorStore.add(documents); // Retrieve documents similar to a query List results = vectorStore.similaritySearch(SearchRequest.query("Spring").withTopK(5)); From 3b79a1a5c979e74284b857d454cd64cd28692fe3 Mon Sep 17 00:00:00 2001 From: Vincent Le Date: Tue, 26 Mar 2024 22:12:16 -0400 Subject: [PATCH 15/46] Add documentation for Elasticsearch VectorStore. --- .../pages/api/vectordbs/elasticsearch.adoc | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/elasticsearch.adoc diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/elasticsearch.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/elasticsearch.adoc new file mode 100644 index 00000000000..4a7b731379a --- /dev/null +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/elasticsearch.adoc @@ -0,0 +1,144 @@ += Elasticsearch + +This section walks you through setting up the Elasticsearch `VectorStore` to store document embeddings and perform similarity searches. + +link:https://www.elastic.co/elasticsearch[Elasticsearch] is an open source search and analytics engine based on the Apache Lucene library. + +== Prerequisites + +First you need access to an Elasticsearch instance. + +On startup, the `ElasticsearchVectorStore` will attempt to create the index mapping. + +Optionally, you can do this manually like so: + +[source,text] +---- +PUT spring-ai-document-index +{ + "mappings": { + "properties": { + "embedding": { + "type": "dense_vector", + "dims": 1536, + "index": true, + "similarity": cosine + } + } + } +} +---- + +TIP: replace the `1536` with the actual embedding dimension if you are using a different dimension. + +Next if required, an API key for the xref:api/embeddings.adoc#available-implementations[EmbeddingClient] to generate the embeddings stored by the `ElasticsearchVectorStore`. + +== Dependencies + +Add the ElasticsearchVectorStore boot starter dependency to your project: + +[source,xml] +---- + + org.springframework.ai + spring-ai-elasticsearch-store-spring-boot-starter + +---- + +or to your Gradle `build.gradle` build file. + +[source,groovy] +---- +dependencies { + implementation 'org.springframework.ai:spring-ai-elasticsearch-store-spring-boot-starter' +} +---- + +The Vector Store, also requires an `EmbeddingClient` instance to calculate embeddings for the documents. +You can pick one of the available xref:api/embeddings.adoc#available-implementations[EmbeddingClient Implementations]. + +For example to use the xref:api/embeddings/openai-embeddings.adoc[OpenAI EmbeddingClient] add the following dependency to your project: + +[source,xml] +---- + + org.springframework.ai + spring-ai-openai-spring-boot-starter + +---- + +or to your Gradle `build.gradle` build file. + +[source,groovy] +---- +dependencies { + implementation 'org.springframework.ai:spring-ai-openai-spring-boot-starter' +} +---- + +TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file. +Refer to the xref:getting-started.adoc#repositories[Repositories] section to add Milestone and/or Snapshot Repositories to your build file. + +To connect to Elasticsearch, create a `RestClient` bean and provide the access details for your instance. + +[source,java] +---- +@Bean +public RestClient restClient() { + return RestClient.builder(HttpHost.create("http://localhost:9200")) + .build(); +} +---- + +Next, you can provide the `ElasticsearchVectorStore` configuration via Spring Boot's `application.yml`: + +[source,yaml] +---- +spring: + ai: + vectorstore: + elasticsearch: + index-name: spring-ai-index +---- + +TIP: Check the list of <> to learn about the default values and configuration options. + +In your main code, create some documents: + +[source,java] +---- +List documents = List.of( + new Document("Spring AI rocks!! Spring AI rocks!! Spring AI rocks!! Spring AI rocks!! Spring AI rocks!!", Map.of("meta1", "meta1")), + new Document("The World is Big and Salvation Lurks Around the Corner"), + new Document("You walk forward facing the past and you turn back toward the future.", Map.of("meta2", "meta2"))); +---- + +Add the documents to Elasticsearch: + +[source,java] +---- +vectorStore.add(List.of(document)); +---- + +And finally, retrieve documents similar to a query: + +[source,java] +---- +List results = vectorStore.similaritySearch(SearchRequest.query("Spring").withTopK(5)); +---- + +If all goes well, you should retrieve the document containing the text "Spring AI rocks!!". + +[[elasticsearchvector-properties]] +== ElasticsearchVectorStore Properties + +You can use the following properties in your Spring Boot configuration to customize the Elasticsearch Vector Store. + + +|=== +|Property |Default Value + +|`spring.ai.vectorstore.elasticsearch.index-name` +|spring-ai-document-index +|=== + From 9fbc09a5a47793f3536945d9c98c506f4805c05e Mon Sep 17 00:00:00 2001 From: Vincent Le Date: Sat, 30 Mar 2024 11:31:43 -0400 Subject: [PATCH 16/46] Replace manual bean configuration with application.yml configuration. --- .../pages/api/vectordbs/elasticsearch.adoc | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/elasticsearch.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/elasticsearch.adoc index 4a7b731379a..e24df050fb7 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/elasticsearch.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/elasticsearch.adoc @@ -79,22 +79,16 @@ dependencies { TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file. Refer to the xref:getting-started.adoc#repositories[Repositories] section to add Milestone and/or Snapshot Repositories to your build file. -To connect to Elasticsearch, create a `RestClient` bean and provide the access details for your instance. - -[source,java] ----- -@Bean -public RestClient restClient() { - return RestClient.builder(HttpHost.create("http://localhost:9200")) - .build(); -} ----- - -Next, you can provide the `ElasticsearchVectorStore` configuration via Spring Boot's `application.yml`: +To connect to and configure Elasticsearch, you need to provide access details for your instance. +A simple configuration can either be provided via Spring Boot's `application.yml` [source,yaml] ---- spring: + elasticsearch: + uris: + username: + password: ai: vectorstore: elasticsearch: From e9d7398811f082e6b8d8725b17636796931a3051 Mon Sep 17 00:00:00 2001 From: Vincent Le Date: Mon, 15 Apr 2024 21:32:57 -0400 Subject: [PATCH 17/46] Rewrite Elasticsearch documentation to follow Neo4J's format. --- .../pages/api/vectordbs/elasticsearch.adoc | 171 +++++++++++------- 1 file changed, 109 insertions(+), 62 deletions(-) diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/elasticsearch.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/elasticsearch.adoc index e24df050fb7..9bea3eb060e 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/elasticsearch.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/elasticsearch.adoc @@ -6,42 +6,20 @@ link:https://www.elastic.co/elasticsearch[Elasticsearch] is an open source searc == Prerequisites -First you need access to an Elasticsearch instance. - -On startup, the `ElasticsearchVectorStore` will attempt to create the index mapping. - -Optionally, you can do this manually like so: - -[source,text] ----- -PUT spring-ai-document-index -{ - "mappings": { - "properties": { - "embedding": { - "type": "dense_vector", - "dims": 1536, - "index": true, - "similarity": cosine - } - } - } -} ----- - -TIP: replace the `1536` with the actual embedding dimension if you are using a different dimension. - -Next if required, an API key for the xref:api/embeddings.adoc#available-implementations[EmbeddingClient] to generate the embeddings stored by the `ElasticsearchVectorStore`. +* A running Elasticsearch instance. The following options are available: +** link:https://hub.docker.com/_/elasticsearch/[Docker] +** link:https://www.elastic.co/guide/en/elasticsearch/reference/current/install-elasticsearch.html#elasticsearch-install-packages[Self-Managed Elasticsearch] +** link:https://www.elastic.co/cloud/elasticsearch-service/signup?page=docs&placement=docs-body[Elastic Cloud] == Dependencies -Add the ElasticsearchVectorStore boot starter dependency to your project: +Add the Elasticsearch Vector Store dependency to your project: [source,xml] ---- org.springframework.ai - spring-ai-elasticsearch-store-spring-boot-starter + spring-ai-elasticsearch-store ---- @@ -50,20 +28,59 @@ or to your Gradle `build.gradle` build file. [source,groovy] ---- dependencies { - implementation 'org.springframework.ai:spring-ai-elasticsearch-store-spring-boot-starter' + implementation 'org.springframework.ai:spring-ai-elasticsearch-store' } ---- -The Vector Store, also requires an `EmbeddingClient` instance to calculate embeddings for the documents. -You can pick one of the available xref:api/embeddings.adoc#available-implementations[EmbeddingClient Implementations]. +TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file. + +== Configuration + +To connect to Elasticsearch and use the `ElasticsearchVectorStore`, you need to provide access details for your instance. +A simple configuration can either be provided via Spring Boot's `application.yml`, + +[source,yaml] +---- +spring: + elasticsearch: + uris: + username: + password: +# API key if needed, e.g. OpenAI + ai: + openai: + api: + key: +---- + +environment variables, + +[source,bash] +---- +export SPRING_ELASTICSEARCH_URIS= +export SPRING_ELASTICSEARCH_USERNAME= +export SPRING_ELASTICSEARCH_PASSWORD= +# API key if needed, e.g. OpenAI +export SPRING_AI_OPENAI_API_KEY= +---- + +or can be a mix of those. +For example, if you want to store your password as an environment variable but keep the rest in the plain `application.yml` file. + +NOTE: If you choose to create a shell script for ease in future work, be sure to run it prior to starting your application by "sourcing" the file, i.e. `source .sh`. + +Spring Boot's auto-configuration feature for the Elasticsearch RestClient will create a bean instance that will be used by the `ElasticsearchVectorStore`. -For example to use the xref:api/embeddings/openai-embeddings.adoc[OpenAI EmbeddingClient] add the following dependency to your project: +== Auto-configuration + +Spring AI provides Spring Boot auto-configuration for the Elasticsearch Vector Store. +To enable it, add the following dependency to your project's Maven `pom.xml` file: [source,xml] ---- - org.springframework.ai - spring-ai-openai-spring-boot-starter + org.springframework.ai + spring-ai-elasticsearch-store-spring-boot-starter ---- @@ -72,62 +89,92 @@ or to your Gradle `build.gradle` build file. [source,groovy] ---- dependencies { - implementation 'org.springframework.ai:spring-ai-openai-spring-boot-starter' + implementation 'org.springframework.ai:spring-ai-elasticsearch-store-spring-boot-starter' } ---- TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file. -Refer to the xref:getting-started.adoc#repositories[Repositories] section to add Milestone and/or Snapshot Repositories to your build file. -To connect to and configure Elasticsearch, you need to provide access details for your instance. -A simple configuration can either be provided via Spring Boot's `application.yml` +Please have a look at the list of <> for the vector store to learn about the default values and configuration options. -[source,yaml] +Here is an example of the needed bean: + +[source,java] ---- -spring: - elasticsearch: - uris: - username: - password: - ai: - vectorstore: - elasticsearch: - index-name: spring-ai-index +@Bean +public EmbeddingClient embeddingCLient() { + // Can be any other EmbeddingClient implementation + return new OpenAiEmbeddingClient(new OpenAiApi(System.getenv("SPRING_AI_OPENAI_API_KEY"))); +} ---- -TIP: Check the list of <> to learn about the default values and configuration options. - -In your main code, create some documents: +In cases where the Spring Boot auto-configured Elasticsearch `RestClient` bean is not what you want or need, you can still define your own bean. +Please read the link:https://www.elastic.co/guide/en/elasticsearch/client/java-api-client/current/java-rest-low-usage-initialization.html[Elasticsearch Documentation] +for more in-depth information about the configuration of a custom RestClient. [source,java] ---- -List documents = List.of( - new Document("Spring AI rocks!! Spring AI rocks!! Spring AI rocks!! Spring AI rocks!! Spring AI rocks!!", Map.of("meta1", "meta1")), - new Document("The World is Big and Salvation Lurks Around the Corner"), - new Document("You walk forward facing the past and you turn back toward the future.", Map.of("meta2", "meta2"))); +@Bean +public RestClient restClienbt() { + RestClientBuilder builder = RestClient.builder(new HttpHost("", 9200, "http")); + Header[] defaultHeaders = new Header[] { new BasicHeader("Authorization", "Basic ") }; + builder.setDefaultHeaders(defaultHeaders); + return builder.build(); +} ---- -Add the documents to Elasticsearch: +Now you can auto-wire the `ElasticsearchVectorStore` as a vector store in your application. + +== Metadata Filtering + +You can leverage the generic, portable xref:api/vectordbs.adoc#metadata-filters[metadata filters] with Elasticsearcg as well. + +For example, you can use either the text expression language: [source,java] ---- -vectorStore.add(List.of(document)); +vectorStore.similaritySearch(SearchRequest.defaults() + .withQuery("The World") + .withTopK(TOP_K) + .withSimilarityThreshold(SIMILARITY_THRESHOLD) + .withFilterExpression("author in ['john', 'jill'] && 'article_type' == 'blog'")); ---- -And finally, retrieve documents similar to a query: +or programmatically using the `Filter.Expression` DSL: [source,java] ---- -List results = vectorStore.similaritySearch(SearchRequest.query("Spring").withTopK(5)); +FilterExpressionBuilder b = new FilterExpressionBuilder(); + +vectorStore.similaritySearch(SearchRequest.defaults() + .withQuery("The World") + .withTopK(TOP_K) + .withSimilarityThreshold(SIMILARITY_THRESHOLD) + .withFilterExpression(b.and( + b.in("john", "jill"), + b.eq("article_type", "blog")).build())); ---- -If all goes well, you should retrieve the document containing the text "Spring AI rocks!!". +NOTE: Those (portable) filter expressions get automatically converted into the proprietary Elasticsearch `WHERE` link:https://www.elastic.co/guide/en/elasticsearch/reference/current/sql-syntax-select.html#sql-syntax-where[filter expressions]. + +For example, this portable filter expression: + +[source,sql] +---- +author in ['john', 'jill'] && 'article_type' == 'blog' +---- + +is converted into the proprietary Elasticsearch filter format: + +[source,text] +---- +(metadata.author:john OR jill) AND metadata.article_type:blog +---- [[elasticsearchvector-properties]] == ElasticsearchVectorStore Properties -You can use the following properties in your Spring Boot configuration to customize the Elasticsearch Vector Store. - +You can use the following properties in your Spring Boot configuration to customize the Elasticsearch vector store. |=== |Property |Default Value From 65e6880fa0f8db22ebcf780c3c840dc5e063b37f Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Sat, 20 Apr 2024 08:36:45 +0200 Subject: [PATCH 18/46] doc: add elasticsearch to nav catalog --- spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc | 1 + 1 file changed, 1 insertion(+) diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc index b4a492c74ed..7d8e11bb98f 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc @@ -48,6 +48,7 @@ *** xref:api/vectordbs/azure.adoc[] *** xref:api/vectordbs/apache-cassandra.adoc[] *** xref:api/vectordbs/chroma.adoc[] +*** xref:api/vectordbs/elasticsearch.adoc[] *** xref:api/vectordbs/gemfire.adoc[GemFire] *** xref:api/vectordbs/milvus.adoc[] *** xref:api/vectordbs/mongodb.adoc[] From 563f9b48af60a176b32ee4fa03877123f076b9a7 Mon Sep 17 00:00:00 2001 From: wmz7year Date: Tue, 16 Apr 2024 14:29:58 +0800 Subject: [PATCH 19/46] Bedrock client autoconfiguration support external AwsCredentialsProvider and AwsRegionProvider --- .../api/AnthropicChatBedrockApi.java | 16 ++ .../api/Anthropic3ChatBedrockApi.java | 16 ++ .../ai/bedrock/api/AbstractBedrockApi.java | 30 +++- .../cohere/api/CohereChatBedrockApi.java | 16 ++ .../cohere/api/CohereEmbeddingBedrockApi.java | 17 +++ .../api/Ai21Jurassic2ChatBedrockApi.java | 16 ++ .../llama2/api/Llama2ChatBedrockApi.java | 16 ++ .../titan/api/TitanChatBedrockApi.java | 16 ++ .../titan/api/TitanEmbeddingBedrockApi.java | 16 ++ .../modules/ROOT/pages/api/bedrock.adoc | 12 ++ .../BedrockAwsConnectionConfiguration.java | 38 +++++ ...BedrockAnthropicChatAutoConfiguration.java | 11 +- ...edrockAnthropic3ChatAutoConfiguration.java | 9 +- .../BedrockCohereChatAutoConfiguration.java | 10 +- ...drockCohereEmbeddingAutoConfiguration.java | 10 +- ...ockAi21Jurassic2ChatAutoConfiguration.java | 10 +- .../BedrockLlama2ChatAutoConfiguration.java | 9 +- .../BedrockTitanChatAutoConfiguration.java | 11 +- ...edrockTitanEmbeddingAutoConfiguration.java | 10 +- .../BedrockAwsConnectionConfigurationIT.java | 139 ++++++++++++++++++ 20 files changed, 404 insertions(+), 24 deletions(-) create mode 100644 spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/BedrockAwsConnectionConfigurationIT.java diff --git a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/anthropic/api/AnthropicChatBedrockApi.java b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/anthropic/api/AnthropicChatBedrockApi.java index 2437b35eebb..55a2d80af50 100644 --- a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/anthropic/api/AnthropicChatBedrockApi.java +++ b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/anthropic/api/AnthropicChatBedrockApi.java @@ -24,6 +24,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import reactor.core.publisher.Flux; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.regions.Region; import org.springframework.ai.bedrock.anthropic.api.AnthropicChatBedrockApi.AnthropicChatRequest; import org.springframework.ai.bedrock.anthropic.api.AnthropicChatBedrockApi.AnthropicChatResponse; @@ -32,6 +33,7 @@ /** * @author Christian Tzolov + * @author Wei Jiang * @since 0.8.0 */ // @formatter:off @@ -92,6 +94,20 @@ public AnthropicChatBedrockApi(String modelId, AwsCredentialsProvider credential super(modelId, credentialsProvider, region, objectMapper, timeout); } + /** + * Create a new AnthropicChatBedrockApi instance using the provided credentials provider, region and object mapper. + * + * @param modelId The model id to use. See the {@link AnthropicChatModel} for the supported models. + * @param credentialsProvider The credentials provider to connect to AWS. + * @param region The AWS region to use. + * @param objectMapper The object mapper to use for JSON serialization and deserialization. + * @param timeout The timeout to use. + */ + public AnthropicChatBedrockApi(String modelId, AwsCredentialsProvider credentialsProvider, Region region, + ObjectMapper objectMapper, Duration timeout) { + super(modelId, credentialsProvider, region, objectMapper, timeout); + } + // https://github.com/build-on-aws/amazon-bedrock-java-examples/blob/main/example_code/bedrock-runtime/src/main/java/aws/community/examples/InvokeBedrockStreamingAsync.java // https://docs.anthropic.com/claude/reference/complete_post diff --git a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/anthropic3/api/Anthropic3ChatBedrockApi.java b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/anthropic3/api/Anthropic3ChatBedrockApi.java index 983894267ff..0148b498373 100644 --- a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/anthropic3/api/Anthropic3ChatBedrockApi.java +++ b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/anthropic3/api/Anthropic3ChatBedrockApi.java @@ -26,6 +26,7 @@ import org.springframework.util.Assert; import reactor.core.publisher.Flux; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.regions.Region; import java.time.Duration; import java.util.List; @@ -39,6 +40,7 @@ * * @author Ben Middleton * @author Christian Tzolov + * @author Wei Jiang * @since 1.0.0 */ // @formatter:off @@ -96,6 +98,20 @@ public Anthropic3ChatBedrockApi(String modelId, AwsCredentialsProvider credentia super(modelId, credentialsProvider, region, objectMapper, timeout); } + /** + * Create a new AnthropicChatBedrockApi instance using the provided credentials provider, region and object mapper. + * + * @param modelId The model id to use. See the {@link AnthropicChatModel} for the supported models. + * @param credentialsProvider The credentials provider to connect to AWS. + * @param region The AWS region to use. + * @param objectMapper The object mapper to use for JSON serialization and deserialization. + * @param timeout The timeout to use. + */ + public Anthropic3ChatBedrockApi(String modelId, AwsCredentialsProvider credentialsProvider, Region region, + ObjectMapper objectMapper, Duration timeout) { + super(modelId, credentialsProvider, region, objectMapper, timeout); + } + // https://github.com/build-on-aws/amazon-bedrock-java-examples/blob/main/example_code/bedrock-runtime/src/main/java/aws/community/examples/InvokeBedrockStreamingAsync.java // https://docs.anthropic.com/claude/reference/complete_post diff --git a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/api/AbstractBedrockApi.java b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/api/AbstractBedrockApi.java index 74f4249f04f..17e0eed79b7 100644 --- a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/api/AbstractBedrockApi.java +++ b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/api/AbstractBedrockApi.java @@ -61,6 +61,7 @@ * @see Model Parameters * @author Christian Tzolov + * @author Wei Jiang * @since 0.8.0 */ public abstract class AbstractBedrockApi { @@ -69,7 +70,7 @@ public abstract class AbstractBedrockApi { private final String modelId; private final ObjectMapper objectMapper; - private final String region; + private final Region region; private final BedrockRuntimeClient client; private final BedrockRuntimeAsyncClient clientStreaming; @@ -93,7 +94,7 @@ public AbstractBedrockApi(String modelId, String region, Duration timeout) { this(modelId, ProfileCredentialsProvider.builder().build(), region, ModelOptionsUtils.OBJECT_MAPPER, timeout); } - /** + /** * Create a new AbstractBedrockApi instance using the provided credentials provider, region and object mapper. * * @param modelId The model id to use. @@ -105,6 +106,7 @@ public AbstractBedrockApi(String modelId, AwsCredentialsProvider credentialsProv ObjectMapper objectMapper) { this(modelId, credentialsProvider, region, objectMapper, Duration.ofMinutes(5)); } + /** * Create a new AbstractBedrockApi instance using the provided credentials provider, region and object mapper. * @@ -118,10 +120,26 @@ public AbstractBedrockApi(String modelId, AwsCredentialsProvider credentialsProv */ public AbstractBedrockApi(String modelId, AwsCredentialsProvider credentialsProvider, String region, ObjectMapper objectMapper, Duration timeout) { + this(modelId, credentialsProvider, Region.of(region), objectMapper, timeout); + } + + /** + * Create a new AbstractBedrockApi instance using the provided credentials provider, region and object mapper. + * + * @param modelId The model id to use. + * @param credentialsProvider The credentials provider to connect to AWS. + * @param region The AWS region to use. + * @param objectMapper The object mapper to use for JSON serialization and deserialization. + * @param timeout Configure the amount of time to allow the client to complete the execution of an API call. + * This timeout covers the entire client execution except for marshalling. This includes request handler execution, + * all HTTP requests including retries, unmarshalling, etc. This value should always be positive, if present. + */ + public AbstractBedrockApi(String modelId, AwsCredentialsProvider credentialsProvider, Region region, + ObjectMapper objectMapper, Duration timeout) { Assert.hasText(modelId, "Model id must not be empty"); Assert.notNull(credentialsProvider, "Credentials provider must not be null"); - Assert.hasText(region, "Region must not be empty"); + Assert.notNull(region, "Region must not be empty"); Assert.notNull(objectMapper, "Object mapper must not be null"); Assert.notNull(timeout, "Timeout must not be null"); @@ -131,13 +149,13 @@ public AbstractBedrockApi(String modelId, AwsCredentialsProvider credentialsProv this.client = BedrockRuntimeClient.builder() - .region(Region.of(this.region)) + .region(this.region) .credentialsProvider(credentialsProvider) .overrideConfiguration(c -> c.apiCallTimeout(timeout)) .build(); this.clientStreaming = BedrockRuntimeAsyncClient.builder() - .region(Region.of(this.region)) + .region(this.region) .credentialsProvider(credentialsProvider) .overrideConfiguration(c -> c.apiCallTimeout(timeout)) .build(); @@ -153,7 +171,7 @@ public String getModelId() { /** * @return The AWS region. */ - public String getRegion() { + public Region getRegion() { return this.region; } diff --git a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/cohere/api/CohereChatBedrockApi.java b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/cohere/api/CohereChatBedrockApi.java index b3b02b6993f..5b133a9976c 100644 --- a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/cohere/api/CohereChatBedrockApi.java +++ b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/cohere/api/CohereChatBedrockApi.java @@ -25,6 +25,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import reactor.core.publisher.Flux; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.regions.Region; import org.springframework.ai.bedrock.api.AbstractBedrockApi; import org.springframework.ai.bedrock.cohere.api.CohereChatBedrockApi.CohereChatRequest; @@ -36,6 +37,7 @@ * https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-cohere.html * * @author Christian Tzolov + * @author Wei Jiang * @since 0.8.0 */ public class CohereChatBedrockApi extends @@ -91,6 +93,20 @@ public CohereChatBedrockApi(String modelId, AwsCredentialsProvider credentialsPr super(modelId, credentialsProvider, region, objectMapper, timeout); } + /** + * Create a new CohereChatBedrockApi instance using the provided credentials provider, region and object mapper. + * + * @param modelId The model id to use. See the {@link CohereChatModel} for the supported models. + * @param credentialsProvider The credentials provider to connect to AWS. + * @param region The AWS region to use. + * @param objectMapper The object mapper to use for JSON serialization and deserialization. + * @param timeout The timeout to use. + */ + public CohereChatBedrockApi(String modelId, AwsCredentialsProvider credentialsProvider, Region region, + ObjectMapper objectMapper, Duration timeout) { + super(modelId, credentialsProvider, region, objectMapper, timeout); + } + /** * CohereChatRequest encapsulates the request parameters for the Cohere command model. * diff --git a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/cohere/api/CohereEmbeddingBedrockApi.java b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/cohere/api/CohereEmbeddingBedrockApi.java index 7d0fa442cde..13752cc4486 100644 --- a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/cohere/api/CohereEmbeddingBedrockApi.java +++ b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/cohere/api/CohereEmbeddingBedrockApi.java @@ -24,6 +24,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.ObjectMapper; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.regions.Region; import org.springframework.ai.bedrock.api.AbstractBedrockApi; import org.springframework.ai.bedrock.cohere.api.CohereEmbeddingBedrockApi.CohereEmbeddingRequest; @@ -34,6 +35,7 @@ * https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-cohere.html#model-parameters-embed * * @author Christian Tzolov + * @author Wei Jiang * @since 0.8.0 */ public class CohereEmbeddingBedrockApi extends @@ -91,6 +93,21 @@ public CohereEmbeddingBedrockApi(String modelId, AwsCredentialsProvider credenti super(modelId, credentialsProvider, region, objectMapper, timeout); } + /** + * Create a new CohereEmbeddingBedrockApi instance using the provided credentials provider, region and object + * mapper. + * + * @param modelId The model id to use. See the {@link CohereEmbeddingModel} for the supported models. + * @param credentialsProvider The credentials provider to connect to AWS. + * @param region The AWS region to use. + * @param objectMapper The object mapper to use for JSON serialization and deserialization. + * @param timeout The timeout to use. + */ + public CohereEmbeddingBedrockApi(String modelId, AwsCredentialsProvider credentialsProvider, Region region, + ObjectMapper objectMapper, Duration timeout) { + super(modelId, credentialsProvider, region, objectMapper, timeout); + } + /** * The Cohere Embed model request. * diff --git a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/jurassic2/api/Ai21Jurassic2ChatBedrockApi.java b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/jurassic2/api/Ai21Jurassic2ChatBedrockApi.java index fa505176350..0ec58c8bd2c 100644 --- a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/jurassic2/api/Ai21Jurassic2ChatBedrockApi.java +++ b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/jurassic2/api/Ai21Jurassic2ChatBedrockApi.java @@ -29,12 +29,14 @@ import org.springframework.ai.bedrock.jurassic2.api.Ai21Jurassic2ChatBedrockApi.Ai21Jurassic2ChatResponse; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.regions.Region; /** * Java client for the Bedrock Jurassic2 chat model. * https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-jurassic2.html * * @author Christian Tzolov + * @author Wei Jiang * @since 0.8.0 */ public class Ai21Jurassic2ChatBedrockApi extends @@ -92,6 +94,20 @@ public Ai21Jurassic2ChatBedrockApi(String modelId, AwsCredentialsProvider creden super(modelId, credentialsProvider, region, objectMapper, timeout); } + /** + * Create a new Ai21Jurassic2ChatBedrockApi instance. + * + * @param modelId The model id to use. See the {@link Ai21Jurassic2ChatBedrockApi.Ai21Jurassic2ChatModel} for the supported models. + * @param credentialsProvider The credentials provider to connect to AWS. + * @param region The AWS region to use. + * @param objectMapper The object mapper to use for JSON serialization and deserialization. + * @param timeout The timeout to use. + */ + public Ai21Jurassic2ChatBedrockApi(String modelId, AwsCredentialsProvider credentialsProvider, Region region, + ObjectMapper objectMapper, Duration timeout) { + super(modelId, credentialsProvider, region, objectMapper, timeout); + } + /** * AI21 Jurassic2 chat request parameters. * diff --git a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama2/api/Llama2ChatBedrockApi.java b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama2/api/Llama2ChatBedrockApi.java index af10d69bdfb..390ad28fffe 100644 --- a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama2/api/Llama2ChatBedrockApi.java +++ b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama2/api/Llama2ChatBedrockApi.java @@ -21,6 +21,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import reactor.core.publisher.Flux; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.regions.Region; import org.springframework.ai.bedrock.api.AbstractBedrockApi; import org.springframework.ai.bedrock.llama2.api.Llama2ChatBedrockApi.Llama2ChatRequest; @@ -34,6 +35,7 @@ * https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-meta.html * * @author Christian Tzolov + * @author Wei Jiang * @since 0.8.0 */ public class Llama2ChatBedrockApi extends @@ -89,6 +91,20 @@ public Llama2ChatBedrockApi(String modelId, AwsCredentialsProvider credentialsPr super(modelId, credentialsProvider, region, objectMapper, timeout); } + /** + * Create a new Llama2ChatBedrockApi instance using the provided credentials provider, region and object mapper. + * + * @param modelId The model id to use. See the {@link Llama2ChatModel} for the supported models. + * @param credentialsProvider The credentials provider to connect to AWS. + * @param region The AWS region to use. + * @param objectMapper The object mapper to use for JSON serialization and deserialization. + * @param timeout The timeout to use. + */ + public Llama2ChatBedrockApi(String modelId, AwsCredentialsProvider credentialsProvider, Region region, + ObjectMapper objectMapper, Duration timeout) { + super(modelId, credentialsProvider, region, objectMapper, timeout); + } + /** * Llama2ChatRequest encapsulates the request parameters for the Meta Llama2 chat model. * diff --git a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/titan/api/TitanChatBedrockApi.java b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/titan/api/TitanChatBedrockApi.java index 498b34bf3d8..f4a219a8d99 100644 --- a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/titan/api/TitanChatBedrockApi.java +++ b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/titan/api/TitanChatBedrockApi.java @@ -24,6 +24,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import reactor.core.publisher.Flux; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.regions.Region; import org.springframework.ai.bedrock.api.AbstractBedrockApi; import org.springframework.ai.bedrock.titan.api.TitanChatBedrockApi.TitanChatRequest; @@ -38,6 +39,7 @@ * https://docs.aws.amazon.com/bedrock/latest/userguide/titan-text-models.html * * @author Christian Tzolov + * @author Wei Jiang * @since 0.8.0 */ // @formatter:off @@ -92,6 +94,20 @@ public TitanChatBedrockApi(String modelId, AwsCredentialsProvider credentialsPro super(modelId, credentialsProvider, region, objectMapper, timeout); } + /** + * Create a new TitanChatBedrockApi instance using the provided credentials provider, region and object mapper. + * + * @param modelId The model id to use. See the {@link TitanChatModel} for the supported models. + * @param credentialsProvider The credentials provider to connect to AWS. + * @param region The AWS region to use. + * @param objectMapper The object mapper to use for JSON serialization and deserialization. + * @param timeout The timeout to use. + */ + public TitanChatBedrockApi(String modelId, AwsCredentialsProvider credentialsProvider, Region region, + ObjectMapper objectMapper, Duration timeout) { + super(modelId, credentialsProvider, region, objectMapper, timeout); + } + /** * TitanChatRequest encapsulates the request parameters for the Titan chat model. * diff --git a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/titan/api/TitanEmbeddingBedrockApi.java b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/titan/api/TitanEmbeddingBedrockApi.java index 9c1dcb3b267..5901799c32f 100644 --- a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/titan/api/TitanEmbeddingBedrockApi.java +++ b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/titan/api/TitanEmbeddingBedrockApi.java @@ -23,6 +23,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.ObjectMapper; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.regions.Region; import org.springframework.ai.bedrock.api.AbstractBedrockApi; import org.springframework.ai.bedrock.titan.api.TitanEmbeddingBedrockApi.TitanEmbeddingRequest; @@ -34,6 +35,7 @@ * https://docs.aws.amazon.com/bedrock/latest/userguide/titan-multiemb-models.html * * @author Christian Tzolov + * @author Wei Jiang * @since 0.8.0 */ // @formatter:off @@ -65,6 +67,20 @@ public TitanEmbeddingBedrockApi(String modelId, AwsCredentialsProvider credentia super(modelId, credentialsProvider, region, objectMapper, timeout); } + /** + * Create a new TitanEmbeddingBedrockApi instance. + * + * @param modelId The model id to use. See the {@link TitanEmbeddingModel} for the supported models. + * @param credentialsProvider The credentials provider to connect to AWS. + * @param region The AWS region to use. + * @param objectMapper The object mapper to use for JSON serialization and deserialization. + * @param timeout The timeout to use. + */ + public TitanEmbeddingBedrockApi(String modelId, AwsCredentialsProvider credentialsProvider, Region region, + ObjectMapper objectMapper, Duration timeout) { + super(modelId, credentialsProvider, region, objectMapper, timeout); + } + /** * Titan Embedding request parameters. * diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/bedrock.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/bedrock.adoc index 4da2b06f7d0..89e3190b091 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/bedrock.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/bedrock.adoc @@ -63,6 +63,18 @@ AWS credentials are resolved in the following order: 6. Credentials delivered through the Amazon EC2 container service if the `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI` environment variable is set and the security manager has permission to access the variable. 7. Instance profile credentials delivered through the Amazon EC2 metadata service or set the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. +AWS region is resolved in the following order: + +1. Spring-AI Bedrock `spring.ai.bedrock.aws.region` property. +2. Java System Properties - `aws.region`. +3. Environment Variables - `AWS_REGION`. +4. Credential profiles file at the default location (`~/.aws/credentials`) shared by all AWS SDKs and the AWS CLI. +5. Instance profile region delivered through the Amazon EC2 metadata service. + +In addition to the standard Spring-AI Bedrock credentials and region properties configuration, Spring-AI provides support for custom `AwsCredentialsProvider` and `AwsRegionProvider` beans. + +NOTE: For example, using Spring-AI and https://spring.io/projects/spring-cloud-aws[Spring Cloud for Amazon Web Services] at the same time. Spring-AI is compatible with Spring Cloud for Amazon Web Services credential configuration. + === Enable selected Bedrock model NOTE: By default, all models are disabled. You have to enable the chosen Bedrock models explicitly using the `spring.ai.bedrock...enabled=true` property. diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/BedrockAwsConnectionConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/BedrockAwsConnectionConfiguration.java index 9cfd634d13b..46ee804056d 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/BedrockAwsConnectionConfiguration.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/BedrockAwsConnectionConfiguration.java @@ -19,6 +19,9 @@ import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.regions.providers.AwsRegionProvider; +import software.amazon.awssdk.regions.providers.DefaultAwsRegionProviderChain; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -28,6 +31,7 @@ /** * @author Christian Tzolov + * @author Wei Jiang */ @Configuration @EnableConfigurationProperties({ BedrockAwsConnectionProperties.class }) @@ -45,4 +49,38 @@ public AwsCredentialsProvider credentialsProvider(BedrockAwsConnectionProperties return DefaultCredentialsProvider.create(); } + @Bean + @ConditionalOnMissingBean + public AwsRegionProvider regionProvider(BedrockAwsConnectionProperties properties) { + + if (StringUtils.hasText(properties.getRegion())) { + return new StaticRegionProvider(properties.getRegion()); + } + + return DefaultAwsRegionProviderChain.builder().build(); + } + + /** + * @author Wei Jiang + */ + static class StaticRegionProvider implements AwsRegionProvider { + + private final Region region; + + public StaticRegionProvider(String region) { + try { + this.region = Region.of(region); + } + catch (IllegalArgumentException e) { + throw new IllegalArgumentException("The region '" + region + "' is not a valid region!", e); + } + } + + @Override + public Region getRegion() { + return this.region; + } + + } + } diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/anthropic/BedrockAnthropicChatAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/anthropic/BedrockAnthropicChatAutoConfiguration.java index 9a74161b050..7b3d4d2d545 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/anthropic/BedrockAnthropicChatAutoConfiguration.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/anthropic/BedrockAnthropicChatAutoConfiguration.java @@ -21,6 +21,7 @@ import org.springframework.ai.bedrock.anthropic.BedrockAnthropicChatClient; import org.springframework.ai.bedrock.anthropic.api.AnthropicChatBedrockApi; import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -28,6 +29,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.regions.providers.AwsRegionProvider; /** * {@link AutoConfiguration Auto-configuration} for Bedrock Anthropic Chat Client. @@ -35,6 +37,7 @@ * Leverages the Spring Cloud AWS to resolve the {@link AwsCredentialsProvider}. * * @author Christian Tzolov + * @author Wei Jiang * @since 0.8.0 */ @AutoConfiguration @@ -46,16 +49,18 @@ public class BedrockAnthropicChatAutoConfiguration { @Bean @ConditionalOnMissingBean + @ConditionalOnBean({ AwsCredentialsProvider.class, AwsRegionProvider.class }) public AnthropicChatBedrockApi anthropicApi(AwsCredentialsProvider credentialsProvider, - BedrockAnthropicChatProperties properties, BedrockAwsConnectionProperties awsProperties) { - return new AnthropicChatBedrockApi(properties.getModel(), credentialsProvider, awsProperties.getRegion(), + AwsRegionProvider regionProvider, BedrockAnthropicChatProperties properties, + BedrockAwsConnectionProperties awsProperties) { + return new AnthropicChatBedrockApi(properties.getModel(), credentialsProvider, regionProvider.getRegion(), new ObjectMapper(), awsProperties.getTimeout()); } @Bean + @ConditionalOnBean(AnthropicChatBedrockApi.class) public BedrockAnthropicChatClient anthropicChatClient(AnthropicChatBedrockApi anthropicApi, BedrockAnthropicChatProperties properties) { - return new BedrockAnthropicChatClient(anthropicApi, properties.getOptions()); } diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/anthropic3/BedrockAnthropic3ChatAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/anthropic3/BedrockAnthropic3ChatAutoConfiguration.java index 7d04798d033..60e5cdce69c 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/anthropic3/BedrockAnthropic3ChatAutoConfiguration.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/anthropic3/BedrockAnthropic3ChatAutoConfiguration.java @@ -21,6 +21,7 @@ import org.springframework.ai.bedrock.anthropic3.BedrockAnthropic3ChatClient; import org.springframework.ai.bedrock.anthropic3.api.Anthropic3ChatBedrockApi; import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -28,6 +29,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.regions.providers.AwsRegionProvider; /** * {@link AutoConfiguration Auto-configuration} for Bedrock Anthropic Chat Client. @@ -47,13 +49,16 @@ public class BedrockAnthropic3ChatAutoConfiguration { @Bean @ConditionalOnMissingBean + @ConditionalOnBean({ AwsCredentialsProvider.class, AwsRegionProvider.class }) public Anthropic3ChatBedrockApi anthropic3Api(AwsCredentialsProvider credentialsProvider, - BedrockAnthropic3ChatProperties properties, BedrockAwsConnectionProperties awsProperties) { - return new Anthropic3ChatBedrockApi(properties.getModel(), credentialsProvider, awsProperties.getRegion(), + AwsRegionProvider regionProvider, BedrockAnthropic3ChatProperties properties, + BedrockAwsConnectionProperties awsProperties) { + return new Anthropic3ChatBedrockApi(properties.getModel(), credentialsProvider, regionProvider.getRegion(), new ObjectMapper(), awsProperties.getTimeout()); } @Bean + @ConditionalOnBean(Anthropic3ChatBedrockApi.class) public BedrockAnthropic3ChatClient anthropic3ChatClient(Anthropic3ChatBedrockApi anthropicApi, BedrockAnthropic3ChatProperties properties) { return new BedrockAnthropic3ChatClient(anthropicApi, properties.getOptions()); diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/cohere/BedrockCohereChatAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/cohere/BedrockCohereChatAutoConfiguration.java index 3ee34f90fa1..66b6d5e5e77 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/cohere/BedrockCohereChatAutoConfiguration.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/cohere/BedrockCohereChatAutoConfiguration.java @@ -21,6 +21,7 @@ import org.springframework.ai.bedrock.cohere.BedrockCohereChatClient; import org.springframework.ai.bedrock.cohere.api.CohereChatBedrockApi; import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -28,11 +29,13 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.regions.providers.AwsRegionProvider; /** * {@link AutoConfiguration Auto-configuration} for Bedrock Cohere Chat Client. * * @author Christian Tzolov + * @author Wei Jiang * @since 0.8.0 */ @AutoConfiguration @@ -44,13 +47,16 @@ public class BedrockCohereChatAutoConfiguration { @Bean @ConditionalOnMissingBean + @ConditionalOnBean({ AwsCredentialsProvider.class, AwsRegionProvider.class }) public CohereChatBedrockApi cohereChatApi(AwsCredentialsProvider credentialsProvider, - BedrockCohereChatProperties properties, BedrockAwsConnectionProperties awsProperties) { - return new CohereChatBedrockApi(properties.getModel(), credentialsProvider, awsProperties.getRegion(), + AwsRegionProvider regionProvider, BedrockCohereChatProperties properties, + BedrockAwsConnectionProperties awsProperties) { + return new CohereChatBedrockApi(properties.getModel(), credentialsProvider, regionProvider.getRegion(), new ObjectMapper(), awsProperties.getTimeout()); } @Bean + @ConditionalOnBean(CohereChatBedrockApi.class) public BedrockCohereChatClient cohereChatClient(CohereChatBedrockApi cohereChatApi, BedrockCohereChatProperties properties) { diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/cohere/BedrockCohereEmbeddingAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/cohere/BedrockCohereEmbeddingAutoConfiguration.java index 95ecba88858..76e3ea6033d 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/cohere/BedrockCohereEmbeddingAutoConfiguration.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/cohere/BedrockCohereEmbeddingAutoConfiguration.java @@ -17,12 +17,14 @@ import com.fasterxml.jackson.databind.ObjectMapper; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.regions.providers.AwsRegionProvider; import org.springframework.ai.autoconfigure.bedrock.BedrockAwsConnectionConfiguration; import org.springframework.ai.autoconfigure.bedrock.BedrockAwsConnectionProperties; import org.springframework.ai.bedrock.cohere.BedrockCohereEmbeddingClient; import org.springframework.ai.bedrock.cohere.api.CohereEmbeddingBedrockApi; import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -34,6 +36,7 @@ * {@link AutoConfiguration Auto-configuration} for Bedrock Cohere Embedding Client. * * @author Christian Tzolov + * @author Wei Jiang * @since 0.8.0 */ @AutoConfiguration @@ -45,14 +48,17 @@ public class BedrockCohereEmbeddingAutoConfiguration { @Bean @ConditionalOnMissingBean + @ConditionalOnBean({ AwsCredentialsProvider.class, AwsRegionProvider.class }) public CohereEmbeddingBedrockApi cohereEmbeddingApi(AwsCredentialsProvider credentialsProvider, - BedrockCohereEmbeddingProperties properties, BedrockAwsConnectionProperties awsProperties) { - return new CohereEmbeddingBedrockApi(properties.getModel(), credentialsProvider, awsProperties.getRegion(), + AwsRegionProvider regionProvider, BedrockCohereEmbeddingProperties properties, + BedrockAwsConnectionProperties awsProperties) { + return new CohereEmbeddingBedrockApi(properties.getModel(), credentialsProvider, regionProvider.getRegion(), new ObjectMapper(), awsProperties.getTimeout()); } @Bean @ConditionalOnMissingBean + @ConditionalOnBean(CohereEmbeddingBedrockApi.class) public BedrockCohereEmbeddingClient cohereEmbeddingClient(CohereEmbeddingBedrockApi cohereEmbeddingApi, BedrockCohereEmbeddingProperties properties) { diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/jurrasic2/BedrockAi21Jurassic2ChatAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/jurrasic2/BedrockAi21Jurassic2ChatAutoConfiguration.java index f7c50657238..e8266a0e417 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/jurrasic2/BedrockAi21Jurassic2ChatAutoConfiguration.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/jurrasic2/BedrockAi21Jurassic2ChatAutoConfiguration.java @@ -22,6 +22,7 @@ import org.springframework.ai.bedrock.jurassic2.BedrockAi21Jurassic2ChatClient; import org.springframework.ai.bedrock.jurassic2.api.Ai21Jurassic2ChatBedrockApi; import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -29,11 +30,13 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.regions.providers.AwsRegionProvider; /** * {@link AutoConfiguration Auto-configuration} for Bedrock Jurassic2 Chat Client. * * @author Ahmed Yousri + * @author Wei Jiang * @since 1.0.0 */ @AutoConfiguration @@ -46,13 +49,16 @@ public class BedrockAi21Jurassic2ChatAutoConfiguration { @Bean @ConditionalOnMissingBean + @ConditionalOnBean({ AwsCredentialsProvider.class, AwsRegionProvider.class }) public Ai21Jurassic2ChatBedrockApi ai21Jurassic2ChatBedrockApi(AwsCredentialsProvider credentialsProvider, - BedrockAi21Jurassic2ChatProperties properties, BedrockAwsConnectionProperties awsProperties) { - return new Ai21Jurassic2ChatBedrockApi(properties.getModel(), credentialsProvider, awsProperties.getRegion(), + AwsRegionProvider regionProvider, BedrockAi21Jurassic2ChatProperties properties, + BedrockAwsConnectionProperties awsProperties) { + return new Ai21Jurassic2ChatBedrockApi(properties.getModel(), credentialsProvider, regionProvider.getRegion(), new ObjectMapper(), awsProperties.getTimeout()); } @Bean + @ConditionalOnBean(Ai21Jurassic2ChatBedrockApi.class) public BedrockAi21Jurassic2ChatClient jurassic2ChatClient(Ai21Jurassic2ChatBedrockApi ai21Jurassic2ChatBedrockApi, BedrockAi21Jurassic2ChatProperties properties) { diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/llama2/BedrockLlama2ChatAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/llama2/BedrockLlama2ChatAutoConfiguration.java index 314e3671be2..ef3d53e4faa 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/llama2/BedrockLlama2ChatAutoConfiguration.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/llama2/BedrockLlama2ChatAutoConfiguration.java @@ -17,12 +17,14 @@ import com.fasterxml.jackson.databind.ObjectMapper; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.regions.providers.AwsRegionProvider; import org.springframework.ai.autoconfigure.bedrock.BedrockAwsConnectionConfiguration; import org.springframework.ai.autoconfigure.bedrock.BedrockAwsConnectionProperties; import org.springframework.ai.bedrock.llama2.BedrockLlama2ChatClient; import org.springframework.ai.bedrock.llama2.api.Llama2ChatBedrockApi; import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -36,6 +38,7 @@ * Leverages the Spring Cloud AWS to resolve the {@link AwsCredentialsProvider}. * * @author Christian Tzolov + * @author Wei Jiang * @since 0.8.0 */ @AutoConfiguration @@ -47,13 +50,15 @@ public class BedrockLlama2ChatAutoConfiguration { @Bean @ConditionalOnMissingBean - public Llama2ChatBedrockApi llama2Api(AwsCredentialsProvider credentialsProvider, + @ConditionalOnBean({ AwsCredentialsProvider.class, AwsRegionProvider.class }) + public Llama2ChatBedrockApi llama2Api(AwsCredentialsProvider credentialsProvider, AwsRegionProvider regionProvider, BedrockLlama2ChatProperties properties, BedrockAwsConnectionProperties awsProperties) { - return new Llama2ChatBedrockApi(properties.getModel(), credentialsProvider, awsProperties.getRegion(), + return new Llama2ChatBedrockApi(properties.getModel(), credentialsProvider, regionProvider.getRegion(), new ObjectMapper(), awsProperties.getTimeout()); } @Bean + @ConditionalOnBean(Llama2ChatBedrockApi.class) public BedrockLlama2ChatClient llama2ChatClient(Llama2ChatBedrockApi llama2Api, BedrockLlama2ChatProperties properties) { diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/titan/BedrockTitanChatAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/titan/BedrockTitanChatAutoConfiguration.java index e24ec3696ab..67995b9e39c 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/titan/BedrockTitanChatAutoConfiguration.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/titan/BedrockTitanChatAutoConfiguration.java @@ -21,6 +21,7 @@ import org.springframework.ai.bedrock.titan.BedrockTitanChatClient; import org.springframework.ai.bedrock.titan.api.TitanChatBedrockApi; import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -28,11 +29,13 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.regions.providers.AwsRegionProvider; /** * {@link AutoConfiguration Auto-configuration} for Bedrock Titan Chat Client. * * @author Christian Tzolov + * @author Wei Jiang * @since 0.8.0 */ @AutoConfiguration @@ -44,14 +47,16 @@ public class BedrockTitanChatAutoConfiguration { @Bean @ConditionalOnMissingBean + @ConditionalOnBean({ AwsCredentialsProvider.class, AwsRegionProvider.class }) public TitanChatBedrockApi titanChatBedrockApi(AwsCredentialsProvider credentialsProvider, - BedrockTitanChatProperties properties, BedrockAwsConnectionProperties awsProperties) { - - return new TitanChatBedrockApi(properties.getModel(), credentialsProvider, awsProperties.getRegion(), + AwsRegionProvider regionProvider, BedrockTitanChatProperties properties, + BedrockAwsConnectionProperties awsProperties) { + return new TitanChatBedrockApi(properties.getModel(), credentialsProvider, regionProvider.getRegion(), new ObjectMapper(), awsProperties.getTimeout()); } @Bean + @ConditionalOnBean(TitanChatBedrockApi.class) public BedrockTitanChatClient titanChatClient(TitanChatBedrockApi titanChatApi, BedrockTitanChatProperties properties) { diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/titan/BedrockTitanEmbeddingAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/titan/BedrockTitanEmbeddingAutoConfiguration.java index f36c6e427fd..5ea79d4513a 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/titan/BedrockTitanEmbeddingAutoConfiguration.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/titan/BedrockTitanEmbeddingAutoConfiguration.java @@ -17,12 +17,14 @@ import com.fasterxml.jackson.databind.ObjectMapper; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.regions.providers.AwsRegionProvider; import org.springframework.ai.autoconfigure.bedrock.BedrockAwsConnectionConfiguration; import org.springframework.ai.autoconfigure.bedrock.BedrockAwsConnectionProperties; import org.springframework.ai.bedrock.titan.BedrockTitanEmbeddingClient; import org.springframework.ai.bedrock.titan.api.TitanEmbeddingBedrockApi; import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -34,6 +36,7 @@ * {@link AutoConfiguration Auto-configuration} for Bedrock Titan Embedding Client. * * @author Christian Tzolov + * @author Wei Jiang * @since 0.8.0 */ @AutoConfiguration @@ -45,14 +48,17 @@ public class BedrockTitanEmbeddingAutoConfiguration { @Bean @ConditionalOnMissingBean + @ConditionalOnBean({ AwsCredentialsProvider.class, AwsRegionProvider.class }) public TitanEmbeddingBedrockApi titanEmbeddingBedrockApi(AwsCredentialsProvider credentialsProvider, - BedrockTitanEmbeddingProperties properties, BedrockAwsConnectionProperties awsProperties) { - return new TitanEmbeddingBedrockApi(properties.getModel(), credentialsProvider, awsProperties.getRegion(), + AwsRegionProvider regionProvider, BedrockTitanEmbeddingProperties properties, + BedrockAwsConnectionProperties awsProperties) { + return new TitanEmbeddingBedrockApi(properties.getModel(), credentialsProvider, regionProvider.getRegion(), new ObjectMapper(), awsProperties.getTimeout()); } @Bean @ConditionalOnMissingBean + @ConditionalOnBean(TitanEmbeddingBedrockApi.class) public BedrockTitanEmbeddingClient titanEmbeddingClient(TitanEmbeddingBedrockApi titanEmbeddingApi, BedrockTitanEmbeddingProperties properties) { diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/BedrockAwsConnectionConfigurationIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/BedrockAwsConnectionConfigurationIT.java new file mode 100644 index 00000000000..bea58ce80e3 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/BedrockAwsConnectionConfigurationIT.java @@ -0,0 +1,139 @@ +/* + * 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.autoconfigure.bedrock; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; + +import software.amazon.awssdk.auth.credentials.AwsCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.regions.providers.AwsRegionProvider; + +/** + * @author Wei Jiang + * @since 0.8.1 + */ +@EnabledIfEnvironmentVariable(named = "AWS_ACCESS_KEY_ID", matches = ".*") +@EnabledIfEnvironmentVariable(named = "AWS_SECRET_ACCESS_KEY", matches = ".*") +public class BedrockAwsConnectionConfigurationIT { + + @Test + public void autoConfigureAWSCredentialAndRegionProvider() { + new ApplicationContextRunner() + .withPropertyValues("spring.ai.bedrock.aws.access-key=" + System.getenv("AWS_ACCESS_KEY_ID"), + "spring.ai.bedrock.aws.secret-key=" + System.getenv("AWS_SECRET_ACCESS_KEY"), + "spring.ai.bedrock.aws.region=" + Region.US_EAST_1.id()) + .withConfiguration(AutoConfigurations.of(TestAutoConfiguration.class)) + .run((context) -> { + var awsCredentialsProvider = context.getBean(AwsCredentialsProvider.class); + var awsRegionProvider = context.getBean(AwsRegionProvider.class); + + assertThat(awsCredentialsProvider).isNotNull(); + assertThat(awsRegionProvider).isNotNull(); + + var credentials = awsCredentialsProvider.resolveCredentials(); + assertThat(credentials).isNotNull(); + assertThat(credentials.accessKeyId()).isEqualTo(System.getenv("AWS_ACCESS_KEY_ID")); + assertThat(credentials.secretAccessKey()).isEqualTo(System.getenv("AWS_SECRET_ACCESS_KEY")); + + assertThat(awsRegionProvider.getRegion()).isEqualTo(Region.US_EAST_1); + }); + } + + @Test + public void autoConfigureWithCustomAWSCredentialAndRegionProvider() { + new ApplicationContextRunner() + .withPropertyValues("spring.ai.bedrock.aws.access-key=" + System.getenv("AWS_ACCESS_KEY_ID"), + "spring.ai.bedrock.aws.secret-key=" + System.getenv("AWS_SECRET_ACCESS_KEY"), + "spring.ai.bedrock.aws.region=" + Region.US_EAST_1.id()) + .withConfiguration(AutoConfigurations.of(TestAutoConfiguration.class, + CustomAwsCredentialsProviderAndAwsRegionProviderAutoConfiguration.class)) + .run((context) -> { + var awsCredentialsProvider = context.getBean(AwsCredentialsProvider.class); + var awsRegionProvider = context.getBean(AwsRegionProvider.class); + + assertThat(awsCredentialsProvider).isNotNull(); + assertThat(awsRegionProvider).isNotNull(); + + var credentials = awsCredentialsProvider.resolveCredentials(); + assertThat(credentials).isNotNull(); + assertThat(credentials.accessKeyId()).isEqualTo("CUSTOM_ACCESS_KEY"); + assertThat(credentials.secretAccessKey()).isEqualTo("CUSTOM_SECRET_ACCESS_KEY"); + + assertThat(awsRegionProvider.getRegion()).isEqualTo(Region.AWS_GLOBAL); + }); + } + + @EnableConfigurationProperties({ BedrockAwsConnectionProperties.class }) + @Import(BedrockAwsConnectionConfiguration.class) + static class TestAutoConfiguration { + + } + + @AutoConfiguration + static class CustomAwsCredentialsProviderAndAwsRegionProviderAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public AwsCredentialsProvider credentialsProvider() { + return new AwsCredentialsProvider() { + + @Override + public AwsCredentials resolveCredentials() { + return new AwsCredentials() { + + @Override + public String accessKeyId() { + return "CUSTOM_ACCESS_KEY"; + } + + @Override + public String secretAccessKey() { + return "CUSTOM_SECRET_ACCESS_KEY"; + } + + }; + } + + }; + } + + @Bean + @ConditionalOnMissingBean + public AwsRegionProvider regionProvider() { + return new AwsRegionProvider() { + + @Override + public Region getRegion() { + return Region.AWS_GLOBAL; + } + + }; + } + + } + +} From b21427c3c67852d19cc0509b005272084a3d17cb Mon Sep 17 00:00:00 2001 From: Shane Witbeck <30923+thesurlydev@users.noreply.github.com> Date: Mon, 22 Apr 2024 11:31:12 -0700 Subject: [PATCH 20/46] Fix erroneous references to OpenAI --- .../main/antora/modules/ROOT/pages/api/chat/ollama-chat.adoc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/ollama-chat.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/ollama-chat.adoc index e1e36a4597d..4bb9acf2566 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/ollama-chat.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/ollama-chat.adoc @@ -176,9 +176,9 @@ where fruits are being displayed, possibly for convenience or aesthetic purposes == Sample Controller -https://start.spring.io/[Create] a new Spring Boot project and add the `spring-ai-openai-spring-boot-starter` to your pom (or gradle) dependencies. +https://start.spring.io/[Create] a new Spring Boot project and add the `spring-ai-ollama-spring-boot-starter` to your pom (or gradle) dependencies. -Add a `application.properties` file, under the `src/main/resources` directory, to enable and configure the OpenAi Chat client: +Add a `application.properties` file, under the `src/main/resources` directory, to enable and configure the Ollama Chat client: [source,application.properties] ---- From 9aa97b51a0e68563f18d51af8e78640a15d428c6 Mon Sep 17 00:00:00 2001 From: jiacheo Date: Mon, 22 Apr 2024 18:13:22 +0800 Subject: [PATCH 21/46] fix wrong document code examples --- .../main/antora/modules/ROOT/pages/api/etl-pipeline.adoc | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/etl-pipeline.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/etl-pipeline.adoc index 87b237a4cec..d0656f16084 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/etl-pipeline.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/etl-pipeline.adoc @@ -170,10 +170,10 @@ Example: public class MyTikaDocumentReader { @Value("classpath:/word-sample.docx") // This is the word document to load - private Resource resource; + private Resource resource; - List loadText() { - TikaDocumentReader tikaDocumentReader = new TikaDocumentReader(resourceUri); + List loadText() { + TikaDocumentReader tikaDocumentReader = new TikaDocumentReader(resource); return tikaDocumentReader.get(); } } @@ -229,4 +229,4 @@ See xref:api/vectordbs.adoc[Vector DB Documentation] for a full listing. The following class diagram illustrates the ETL interfaces and implementations. // image::etl-class-diagram.jpg[align="center", width="800px"] -image::etl-class-diagram.jpg[align="center"] \ No newline at end of file +image::etl-class-diagram.jpg[align="center"] From b0add718a9a2d2e8c84a4e6d9f67e4ca611b14da Mon Sep 17 00:00:00 2001 From: mck Date: Mon, 22 Apr 2024 14:07:45 +0200 Subject: [PATCH 22/46] Fix CassandraVectorStoreAutoConfiguration's @ConditionalOnClass to not include EmbeddingClient Any autoconfigure on an embeddingClient was unnecessarily creating the CassandraVectorStore bean, and possible prematurely. --- .../cassandra/CassandraVectorStoreAutoConfiguration.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraVectorStoreAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraVectorStoreAutoConfiguration.java index f53a6ce35e3..23a077ee340 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraVectorStoreAutoConfiguration.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraVectorStoreAutoConfiguration.java @@ -36,7 +36,7 @@ * @since 1.0.0 */ @AutoConfiguration(after = CassandraAutoConfiguration.class) -@ConditionalOnClass({ CassandraVectorStore.class, EmbeddingClient.class, CqlSession.class }) +@ConditionalOnClass({ CassandraVectorStore.class, CqlSession.class }) @EnableConfigurationProperties(CassandraVectorStoreProperties.class) public class CassandraVectorStoreAutoConfiguration { From 9e865f0b0f9aa03360d59f2974d0e1620d9fba77 Mon Sep 17 00:00:00 2001 From: wmz7year Date: Wed, 24 Apr 2024 07:36:41 +0800 Subject: [PATCH 23/46] Add Bedrock Meta LLama3 AI model support. - re-enable llama structured output tests --- README.md | 2 +- models/spring-ai-bedrock/README.md | 2 +- .../ai/bedrock/aot/BedrockRuntimeHints.java | 8 +- .../BedrockLlamaChatClient.java} | 45 +++++----- .../BedrockLlamaChatOptions.java} | 8 +- .../api/LlamaChatBedrockApi.java} | 84 ++++++++++-------- .../BedrockAnthropic3ChatClientIT.java | 2 +- .../bedrock/aot/BedrockRuntimeHintsTests.java | 4 +- .../BedrockLlamaChatClientIT.java} | 41 ++++----- .../BedrockLlamaCreateRequestTests.java} | 21 +++-- .../api/LlamaChatBedrockApiIT.java} | 34 +++---- ...hat-api.jpg => bedrock-llama-chat-api.jpg} | Bin .../src/main/antora/modules/ROOT/nav.adoc | 2 +- .../modules/ROOT/pages/api/bedrock.adoc | 6 +- ...bedrock-llama2.adoc => bedrock-llama.adoc} | 84 +++++++++--------- .../modules/ROOT/pages/api/chatclient.adoc | 2 +- .../modules/ROOT/pages/getting-started.adoc | 2 +- .../BedrockLlamaChatAutoConfiguration.java} | 29 +++--- .../BedrockLlamaChatProperties.java} | 26 +++--- ...ot.autoconfigure.AutoConfiguration.imports | 2 +- .../BedrockLlamaChatAutoConfigurationIT.java} | 70 +++++++-------- 21 files changed, 244 insertions(+), 230 deletions(-) rename models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/{llama2/BedrockLlama2ChatClient.java => llama/BedrockLlamaChatClient.java} (67%) rename models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/{llama2/BedrockLlama2ChatOptions.java => llama/BedrockLlamaChatOptions.java} (91%) rename models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/{llama2/api/Llama2ChatBedrockApi.java => llama/api/LlamaChatBedrockApi.java} (66%) rename models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/{llama2/BedrockLlama2ChatClientIT.java => llama/BedrockLlamaChatClientIT.java} (88%) rename models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/{llama2/BedrockLlama2CreateRequestTests.java => llama/BedrockLlamaCreateRequestTests.java} (67%) rename models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/{llama2/api/Llama2ChatBedrockApiIT.java => llama/api/LlamaChatBedrockApiIT.java} (61%) rename spring-ai-docs/src/main/antora/modules/ROOT/images/bedrock/{bedrock-llama2-chat-api.jpg => bedrock-llama-chat-api.jpg} (100%) rename spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/bedrock/{bedrock-llama2.adoc => bedrock-llama.adoc} (55%) rename spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/{llama2/BedrockLlama2ChatAutoConfiguration.java => llama/BedrockLlamaChatAutoConfiguration.java} (64%) rename spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/{llama2/BedrockLlama2ChatProperties.java => llama/BedrockLlamaChatProperties.java} (63%) rename spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/{llama2/BedrockLlama2ChatAutoConfigurationIT.java => llama/BedrockLlamaChatAutoConfigurationIT.java} (61%) diff --git a/README.md b/README.md index 50e4facc713..1c53af2bdc9 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ You can find more details in the [Reference Documentation](https://docs.spring.i Spring AI supports many AI models. For an overview see here. Specific models currently supported are * OpenAI * Azure OpenAI -* Amazon Bedrock (Anthropic, Llama2, Cohere, Titan, Jurassic2) +* Amazon Bedrock (Anthropic, Llama, Cohere, Titan, Jurassic2) * HuggingFace * Google VertexAI (PaLM2, Gemini) * Mistral AI diff --git a/models/spring-ai-bedrock/README.md b/models/spring-ai-bedrock/README.md index 94311055fba..19e48518a60 100644 --- a/models/spring-ai-bedrock/README.md +++ b/models/spring-ai-bedrock/README.md @@ -4,7 +4,7 @@ - [Anthropic2 Chat Documentation](https://docs.spring.io/spring-ai/reference/1.0-SNAPSHOT/api/chat/bedrock/bedrock-anthropic.html) - [Cohere Chat Documentation](https://docs.spring.io/spring-ai/reference/1.0-SNAPSHOT/api/chat/bedrock/bedrock-cohere.html) - [Cohere Embedding Documentation](https://docs.spring.io/spring-ai/reference/1.0-SNAPSHOT/api/embeddings/bedrock-cohere-embedding.html) -- [Llama2 Chat Documentation](https://docs.spring.io/spring-ai/reference/1.0-SNAPSHOT/api/chat/bedrock/bedrock-llama2.html) +- [Llama Chat Documentation](https://docs.spring.io/spring-ai/reference/1.0-SNAPSHOT/api/chat/bedrock/bedrock-llama.html) - [Titan Chat Documentation](https://docs.spring.io/spring-ai/reference/1.0-SNAPSHOT/api/chat/bedrock/bedrock-titan.html) - [Titan Embedding Documentation](https://docs.spring.io/spring-ai/reference/1.0-SNAPSHOT/api/embeddings/bedrock-titan-embedding.html) - [Jurassic2 Chat Documentation](https://docs.spring.io/spring-ai/reference/1.0-SNAPSHOT/api/chat/bedrock/bedrock-jurassic2.html) diff --git a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/aot/BedrockRuntimeHints.java b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/aot/BedrockRuntimeHints.java index 8ed33139b7d..edb3c4b1337 100644 --- a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/aot/BedrockRuntimeHints.java +++ b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/aot/BedrockRuntimeHints.java @@ -25,8 +25,8 @@ import org.springframework.ai.bedrock.cohere.api.CohereChatBedrockApi; import org.springframework.ai.bedrock.cohere.api.CohereEmbeddingBedrockApi; import org.springframework.ai.bedrock.jurassic2.api.Ai21Jurassic2ChatBedrockApi; -import org.springframework.ai.bedrock.llama2.BedrockLlama2ChatOptions; -import org.springframework.ai.bedrock.llama2.api.Llama2ChatBedrockApi; +import org.springframework.ai.bedrock.llama.BedrockLlamaChatOptions; +import org.springframework.ai.bedrock.llama.api.LlamaChatBedrockApi; import org.springframework.ai.bedrock.titan.BedrockTitanChatOptions; import org.springframework.ai.bedrock.titan.api.TitanChatBedrockApi; import org.springframework.ai.bedrock.titan.api.TitanEmbeddingBedrockApi; @@ -63,9 +63,9 @@ public void registerHints(RuntimeHints hints, ClassLoader classLoader) { for (var tr : findJsonAnnotatedClassesInPackage(BedrockCohereEmbeddingOptions.class)) hints.reflection().registerType(tr, mcs); - for (var tr : findJsonAnnotatedClassesInPackage(Llama2ChatBedrockApi.class)) + for (var tr : findJsonAnnotatedClassesInPackage(LlamaChatBedrockApi.class)) hints.reflection().registerType(tr, mcs); - for (var tr : findJsonAnnotatedClassesInPackage(BedrockLlama2ChatOptions.class)) + for (var tr : findJsonAnnotatedClassesInPackage(BedrockLlamaChatOptions.class)) hints.reflection().registerType(tr, mcs); for (var tr : findJsonAnnotatedClassesInPackage(TitanChatBedrockApi.class)) diff --git a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama2/BedrockLlama2ChatClient.java b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama/BedrockLlamaChatClient.java similarity index 67% rename from models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama2/BedrockLlama2ChatClient.java rename to models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama/BedrockLlamaChatClient.java index a12fe0b6eb9..c1be58e5a1d 100644 --- a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama2/BedrockLlama2ChatClient.java +++ b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama/BedrockLlamaChatClient.java @@ -13,16 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.ai.bedrock.llama2; +package org.springframework.ai.bedrock.llama; import java.util.List; import reactor.core.publisher.Flux; import org.springframework.ai.bedrock.MessageToPromptConverter; -import org.springframework.ai.bedrock.llama2.api.Llama2ChatBedrockApi; -import org.springframework.ai.bedrock.llama2.api.Llama2ChatBedrockApi.Llama2ChatRequest; -import org.springframework.ai.bedrock.llama2.api.Llama2ChatBedrockApi.Llama2ChatResponse; +import org.springframework.ai.bedrock.llama.api.LlamaChatBedrockApi; +import org.springframework.ai.bedrock.llama.api.LlamaChatBedrockApi.LlamaChatRequest; +import org.springframework.ai.bedrock.llama.api.LlamaChatBedrockApi.LlamaChatResponse; import org.springframework.ai.chat.ChatClient; import org.springframework.ai.chat.prompt.ChatOptions; import org.springframework.ai.chat.ChatResponse; @@ -35,26 +35,27 @@ import org.springframework.util.Assert; /** - * Java {@link ChatClient} and {@link StreamingChatClient} for the Bedrock Llama2 chat + * Java {@link ChatClient} and {@link StreamingChatClient} for the Bedrock Llama chat * generative. * * @author Christian Tzolov + * @author Wei Jiang * @since 0.8.0 */ -public class BedrockLlama2ChatClient implements ChatClient, StreamingChatClient { +public class BedrockLlamaChatClient implements ChatClient, StreamingChatClient { - private final Llama2ChatBedrockApi chatApi; + private final LlamaChatBedrockApi chatApi; - private final BedrockLlama2ChatOptions defaultOptions; + private final BedrockLlamaChatOptions defaultOptions; - public BedrockLlama2ChatClient(Llama2ChatBedrockApi chatApi) { + public BedrockLlamaChatClient(LlamaChatBedrockApi chatApi) { this(chatApi, - BedrockLlama2ChatOptions.builder().withTemperature(0.8f).withTopP(0.9f).withMaxGenLen(100).build()); + BedrockLlamaChatOptions.builder().withTemperature(0.8f).withTopP(0.9f).withMaxGenLen(100).build()); } - public BedrockLlama2ChatClient(Llama2ChatBedrockApi chatApi, BedrockLlama2ChatOptions options) { - Assert.notNull(chatApi, "Llama2ChatBedrockApi must not be null"); - Assert.notNull(options, "BedrockLlama2ChatOptions must not be null"); + public BedrockLlamaChatClient(LlamaChatBedrockApi chatApi, BedrockLlamaChatOptions options) { + Assert.notNull(chatApi, "LlamaChatBedrockApi must not be null"); + Assert.notNull(options, "BedrockLlamaChatOptions must not be null"); this.chatApi = chatApi; this.defaultOptions = options; @@ -65,7 +66,7 @@ public ChatResponse call(Prompt prompt) { var request = createRequest(prompt); - Llama2ChatResponse response = this.chatApi.chatCompletion(request); + LlamaChatResponse response = this.chatApi.chatCompletion(request); return new ChatResponse(List.of(new Generation(response.generation()).withGenerationMetadata( ChatGenerationMetadata.from(response.stopReason().name(), extractUsage(response))))); @@ -76,7 +77,7 @@ public Flux stream(Prompt prompt) { var request = createRequest(prompt); - Flux fluxResponse = this.chatApi.chatCompletionStream(request); + Flux fluxResponse = this.chatApi.chatCompletionStream(request); return fluxResponse.map(response -> { String stopReason = response.stopReason() != null ? response.stopReason().name() : null; @@ -85,7 +86,7 @@ public Flux stream(Prompt prompt) { }); } - private Usage extractUsage(Llama2ChatResponse response) { + private Usage extractUsage(LlamaChatResponse response) { return new Usage() { @Override @@ -103,22 +104,22 @@ public Long getGenerationTokens() { /** * Accessible for testing. */ - Llama2ChatRequest createRequest(Prompt prompt) { + LlamaChatRequest createRequest(Prompt prompt) { final String promptValue = MessageToPromptConverter.create().toPrompt(prompt.getInstructions()); - Llama2ChatRequest request = Llama2ChatRequest.builder(promptValue).build(); + LlamaChatRequest request = LlamaChatRequest.builder(promptValue).build(); if (this.defaultOptions != null) { - request = ModelOptionsUtils.merge(request, this.defaultOptions, Llama2ChatRequest.class); + request = ModelOptionsUtils.merge(request, this.defaultOptions, LlamaChatRequest.class); } if (prompt.getOptions() != null) { if (prompt.getOptions() instanceof ChatOptions runtimeOptions) { - BedrockLlama2ChatOptions updatedRuntimeOptions = ModelOptionsUtils.copyToTarget(runtimeOptions, - ChatOptions.class, BedrockLlama2ChatOptions.class); + BedrockLlamaChatOptions updatedRuntimeOptions = ModelOptionsUtils.copyToTarget(runtimeOptions, + ChatOptions.class, BedrockLlamaChatOptions.class); - request = ModelOptionsUtils.merge(updatedRuntimeOptions, request, Llama2ChatRequest.class); + request = ModelOptionsUtils.merge(updatedRuntimeOptions, request, LlamaChatRequest.class); } else { throw new IllegalArgumentException("Prompt options are not of type ChatOptions: " diff --git a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama2/BedrockLlama2ChatOptions.java b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama/BedrockLlamaChatOptions.java similarity index 91% rename from models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama2/BedrockLlama2ChatOptions.java rename to models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama/BedrockLlamaChatOptions.java index a944b09904a..3502fd4c441 100644 --- a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama2/BedrockLlama2ChatOptions.java +++ b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama/BedrockLlamaChatOptions.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.ai.bedrock.llama2; +package org.springframework.ai.bedrock.llama; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; @@ -26,7 +26,7 @@ * @author Christian Tzolov */ @JsonInclude(Include.NON_NULL) -public class BedrockLlama2ChatOptions implements ChatOptions { +public class BedrockLlamaChatOptions implements ChatOptions { /** * The temperature value controls the randomness of the generated text. Use a lower @@ -51,7 +51,7 @@ public static Builder builder() { public static class Builder { - private BedrockLlama2ChatOptions options = new BedrockLlama2ChatOptions(); + private BedrockLlamaChatOptions options = new BedrockLlamaChatOptions(); public Builder withTemperature(Float temperature) { this.options.setTemperature(temperature); @@ -68,7 +68,7 @@ public Builder withMaxGenLen(Integer maxGenLen) { return this; } - public BedrockLlama2ChatOptions build() { + public BedrockLlamaChatOptions build() { return this.options; } diff --git a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama2/api/Llama2ChatBedrockApi.java b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama/api/LlamaChatBedrockApi.java similarity index 66% rename from models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama2/api/Llama2ChatBedrockApi.java rename to models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama/api/LlamaChatBedrockApi.java index 390ad28fffe..25d71aedeea 100644 --- a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama2/api/Llama2ChatBedrockApi.java +++ b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama/api/LlamaChatBedrockApi.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.ai.bedrock.llama2.api; +package org.springframework.ai.bedrock.llama.api; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; @@ -24,89 +24,89 @@ import software.amazon.awssdk.regions.Region; import org.springframework.ai.bedrock.api.AbstractBedrockApi; -import org.springframework.ai.bedrock.llama2.api.Llama2ChatBedrockApi.Llama2ChatRequest; -import org.springframework.ai.bedrock.llama2.api.Llama2ChatBedrockApi.Llama2ChatResponse; +import org.springframework.ai.bedrock.llama.api.LlamaChatBedrockApi.LlamaChatRequest; +import org.springframework.ai.bedrock.llama.api.LlamaChatBedrockApi.LlamaChatResponse; import java.time.Duration; // @formatter:off /** - * Java client for the Bedrock Llama2 chat model. + * Java client for the Bedrock Llama chat model. * https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-meta.html * * @author Christian Tzolov * @author Wei Jiang * @since 0.8.0 */ -public class Llama2ChatBedrockApi extends - AbstractBedrockApi { +public class LlamaChatBedrockApi extends + AbstractBedrockApi { /** - * Create a new Llama2ChatBedrockApi instance using the default credentials provider chain, the default object + * Create a new LlamaChatBedrockApi instance using the default credentials provider chain, the default object * mapper, default temperature and topP values. * - * @param modelId The model id to use. See the {@link Llama2ChatModel} for the supported models. + * @param modelId The model id to use. See the {@link LlamaChatModel} for the supported models. * @param region The AWS region to use. */ - public Llama2ChatBedrockApi(String modelId, String region) { + public LlamaChatBedrockApi(String modelId, String region) { super(modelId, region); } /** - * Create a new Llama2ChatBedrockApi instance using the provided credentials provider, region and object mapper. + * Create a new LlamaChatBedrockApi instance using the provided credentials provider, region and object mapper. * - * @param modelId The model id to use. See the {@link Llama2ChatModel} for the supported models. + * @param modelId The model id to use. See the {@link LlamaChatModel} for the supported models. * @param credentialsProvider The credentials provider to connect to AWS. * @param region The AWS region to use. * @param objectMapper The object mapper to use for JSON serialization and deserialization. */ - public Llama2ChatBedrockApi(String modelId, AwsCredentialsProvider credentialsProvider, String region, + public LlamaChatBedrockApi(String modelId, AwsCredentialsProvider credentialsProvider, String region, ObjectMapper objectMapper) { super(modelId, credentialsProvider, region, objectMapper); } /** - * Create a new Llama2ChatBedrockApi instance using the default credentials provider chain, the default object + * Create a new LlamaChatBedrockApi instance using the default credentials provider chain, the default object * mapper, default temperature and topP values. * - * @param modelId The model id to use. See the {@link Llama2ChatModel} for the supported models. + * @param modelId The model id to use. See the {@link LlamaChatModel} for the supported models. * @param region The AWS region to use. * @param timeout The timeout to use. */ - public Llama2ChatBedrockApi(String modelId, String region, Duration timeout) { + public LlamaChatBedrockApi(String modelId, String region, Duration timeout) { super(modelId, region, timeout); } /** - * Create a new Llama2ChatBedrockApi instance using the provided credentials provider, region and object mapper. + * Create a new LlamaChatBedrockApi instance using the provided credentials provider, region and object mapper. * - * @param modelId The model id to use. See the {@link Llama2ChatModel} for the supported models. + * @param modelId The model id to use. See the {@link LlamaChatModel} for the supported models. * @param credentialsProvider The credentials provider to connect to AWS. * @param region The AWS region to use. * @param objectMapper The object mapper to use for JSON serialization and deserialization. * @param timeout The timeout to use. */ - public Llama2ChatBedrockApi(String modelId, AwsCredentialsProvider credentialsProvider, String region, + public LlamaChatBedrockApi(String modelId, AwsCredentialsProvider credentialsProvider, String region, ObjectMapper objectMapper, Duration timeout) { super(modelId, credentialsProvider, region, objectMapper, timeout); } /** - * Create a new Llama2ChatBedrockApi instance using the provided credentials provider, region and object mapper. + * Create a new LlamaChatBedrockApi instance using the provided credentials provider, region and object mapper. * - * @param modelId The model id to use. See the {@link Llama2ChatModel} for the supported models. + * @param modelId The model id to use. See the {@link LlamaChatModel} for the supported models. * @param credentialsProvider The credentials provider to connect to AWS. * @param region The AWS region to use. * @param objectMapper The object mapper to use for JSON serialization and deserialization. * @param timeout The timeout to use. */ - public Llama2ChatBedrockApi(String modelId, AwsCredentialsProvider credentialsProvider, Region region, + public LlamaChatBedrockApi(String modelId, AwsCredentialsProvider credentialsProvider, Region region, ObjectMapper objectMapper, Duration timeout) { super(modelId, credentialsProvider, region, objectMapper, timeout); } /** - * Llama2ChatRequest encapsulates the request parameters for the Meta Llama2 chat model. + * LlamaChatRequest encapsulates the request parameters for the Meta Llama chat model. * * @param prompt The prompt to use for the chat. * @param temperature The temperature value controls the randomness of the generated text. Use a lower value to @@ -116,16 +116,16 @@ public Llama2ChatBedrockApi(String modelId, AwsCredentialsProvider credentialsPr * @param maxGenLen The maximum length of the generated text. */ @JsonInclude(Include.NON_NULL) - public record Llama2ChatRequest( + public record LlamaChatRequest( @JsonProperty("prompt") String prompt, @JsonProperty("temperature") Float temperature, @JsonProperty("top_p") Float topP, @JsonProperty("max_gen_len") Integer maxGenLen) { /** - * Create a new Llama2ChatRequest builder. + * Create a new LlamaChatRequest builder. * @param prompt compulsory prompt parameter. - * @return a new Llama2ChatRequest builder. + * @return a new LlamaChatRequest builder. */ public static Builder builder(String prompt) { return new Builder(prompt); @@ -156,8 +156,8 @@ public Builder withMaxGenLen(Integer maxGenLen) { return this; } - public Llama2ChatRequest build() { - return new Llama2ChatRequest( + public LlamaChatRequest build() { + return new LlamaChatRequest( prompt, temperature, topP, @@ -168,7 +168,7 @@ public Llama2ChatRequest build() { } /** - * Llama2ChatResponse encapsulates the response parameters for the Meta Llama2 chat model. + * LlamaChatResponse encapsulates the response parameters for the Meta Llama chat model. * * @param generation The generated text. * @param promptTokenCount The number of tokens in the prompt. @@ -179,7 +179,7 @@ public Llama2ChatRequest build() { * increasing the value of max_gen_len and trying again. */ @JsonInclude(Include.NON_NULL) - public record Llama2ChatResponse( + public record LlamaChatResponse( @JsonProperty("generation") String generation, @JsonProperty("prompt_token_count") Integer promptTokenCount, @JsonProperty("generation_token_count") Integer generationTokenCount, @@ -202,9 +202,9 @@ public enum StopReason { } /** - * Llama2 models version. + * Llama models version. */ - public enum Llama2ChatModel { + public enum LlamaChatModel { /** * meta.llama2-13b-chat-v1 @@ -214,7 +214,17 @@ public enum Llama2ChatModel { /** * meta.llama2-70b-chat-v1 */ - LLAMA2_70B_CHAT_V1("meta.llama2-70b-chat-v1"); + LLAMA2_70B_CHAT_V1("meta.llama2-70b-chat-v1"), + + /** + * meta.llama3-8b-instruct-v1:0 + */ + LLAMA3_8B_INSTRUCT_V1("meta.llama3-8b-instruct-v1:0"), + + /** + * meta.llama3-70b-instruct-v1:0 + */ + LLAMA3_70B_INSTRUCT_V1("meta.llama3-70b-instruct-v1:0"); private final String id; @@ -225,19 +235,19 @@ public String id() { return id; } - Llama2ChatModel(String value) { + LlamaChatModel(String value) { this.id = value; } } @Override - public Llama2ChatResponse chatCompletion(Llama2ChatRequest request) { - return this.internalInvocation(request, Llama2ChatResponse.class); + public LlamaChatResponse chatCompletion(LlamaChatRequest request) { + return this.internalInvocation(request, LlamaChatResponse.class); } @Override - public Flux chatCompletionStream(Llama2ChatRequest request) { - return this.internalInvocationStream(request, Llama2ChatResponse.class); + public Flux chatCompletionStream(LlamaChatRequest request) { + return this.internalInvocationStream(request, LlamaChatResponse.class); } } // @formatter:on \ No newline at end of file diff --git a/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/anthropic3/BedrockAnthropic3ChatClientIT.java b/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/anthropic3/BedrockAnthropic3ChatClientIT.java index c2050f18ca0..4fa59402054 100644 --- a/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/anthropic3/BedrockAnthropic3ChatClientIT.java +++ b/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/anthropic3/BedrockAnthropic3ChatClientIT.java @@ -161,9 +161,9 @@ void beanOutputParserRecords() { String format = outputParser.getFormat(); String template = """ Generate the filmography of 5 movies for Tom Hanks. - Remove non JSON tex blocks from the output. {format} Provide your answer in the JSON format with the feature names as the keys. + Remove Markdown code blocks from the output. """; PromptTemplate promptTemplate = new PromptTemplate(template, Map.of("format", format)); Prompt prompt = new Prompt(promptTemplate.createMessage()); diff --git a/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/aot/BedrockRuntimeHintsTests.java b/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/aot/BedrockRuntimeHintsTests.java index a4b51a70750..92060ef5bf0 100644 --- a/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/aot/BedrockRuntimeHintsTests.java +++ b/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/aot/BedrockRuntimeHintsTests.java @@ -20,7 +20,7 @@ import org.springframework.ai.bedrock.cohere.api.CohereChatBedrockApi; import org.springframework.ai.bedrock.cohere.api.CohereEmbeddingBedrockApi; import org.springframework.ai.bedrock.jurassic2.api.Ai21Jurassic2ChatBedrockApi; -import org.springframework.ai.bedrock.llama2.api.Llama2ChatBedrockApi; +import org.springframework.ai.bedrock.llama.api.LlamaChatBedrockApi; import org.springframework.ai.bedrock.titan.api.TitanChatBedrockApi; import org.springframework.ai.bedrock.titan.api.TitanEmbeddingBedrockApi; import org.springframework.aot.hint.RuntimeHints; @@ -43,7 +43,7 @@ void registerHints() { bedrockRuntimeHints.registerHints(runtimeHints, null); List classList = Arrays.asList(Ai21Jurassic2ChatBedrockApi.class, CohereChatBedrockApi.class, - CohereEmbeddingBedrockApi.class, Llama2ChatBedrockApi.class, TitanChatBedrockApi.class, + CohereEmbeddingBedrockApi.class, LlamaChatBedrockApi.class, TitanChatBedrockApi.class, TitanEmbeddingBedrockApi.class, AnthropicChatBedrockApi.class); for (Class aClass : classList) { diff --git a/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/llama2/BedrockLlama2ChatClientIT.java b/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/llama/BedrockLlamaChatClientIT.java similarity index 88% rename from models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/llama2/BedrockLlama2ChatClientIT.java rename to models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/llama/BedrockLlamaChatClientIT.java index 7cbf3f23566..554afcf9fa0 100644 --- a/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/llama2/BedrockLlama2ChatClientIT.java +++ b/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/llama/BedrockLlamaChatClientIT.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.ai.bedrock.llama2; +package org.springframework.ai.bedrock.llama; import java.time.Duration; import java.util.Arrays; @@ -22,27 +22,25 @@ import java.util.stream.Collectors; import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import reactor.core.publisher.Flux; - -import org.springframework.ai.chat.ChatResponse; -import org.springframework.ai.chat.messages.AssistantMessage; import software.amazon.awssdk.auth.credentials.EnvironmentVariableCredentialsProvider; import software.amazon.awssdk.regions.Region; -import org.springframework.ai.bedrock.llama2.api.Llama2ChatBedrockApi; -import org.springframework.ai.bedrock.llama2.api.Llama2ChatBedrockApi.Llama2ChatModel; +import org.springframework.ai.bedrock.llama.api.LlamaChatBedrockApi; +import org.springframework.ai.bedrock.llama.api.LlamaChatBedrockApi.LlamaChatModel; +import org.springframework.ai.chat.ChatResponse; import org.springframework.ai.chat.Generation; -import org.springframework.ai.parser.BeanOutputParser; -import org.springframework.ai.parser.ListOutputParser; -import org.springframework.ai.parser.MapOutputParser; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.chat.prompt.PromptTemplate; import org.springframework.ai.chat.prompt.SystemPromptTemplate; -import org.springframework.ai.chat.messages.Message; -import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.parser.BeanOutputParser; +import org.springframework.ai.parser.ListOutputParser; +import org.springframework.ai.parser.MapOutputParser; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.SpringBootConfiguration; @@ -56,10 +54,10 @@ @SpringBootTest @EnabledIfEnvironmentVariable(named = "AWS_ACCESS_KEY_ID", matches = ".*") @EnabledIfEnvironmentVariable(named = "AWS_SECRET_ACCESS_KEY", matches = ".*") -class BedrockLlama2ChatClientIT { +class BedrockLlamaChatClientIT { @Autowired - private BedrockLlama2ChatClient client; + private BedrockLlamaChatClient client; @Value("classpath:/prompts/system-message.st") private Resource systemResource; @@ -67,8 +65,8 @@ class BedrockLlama2ChatClientIT { @Test void multipleStreamAttempts() { + Flux joke2Stream = client.stream(new Prompt(new UserMessage("Tell me a Toy joke?"))); Flux joke1Stream = client.stream(new Prompt(new UserMessage("Tell me a joke?"))); - Flux joke2Stream = client.stream(new Prompt(new UserMessage("Tell me a toy joke?"))); String joke1 = joke1Stream.collectList() .block() @@ -105,7 +103,6 @@ void roleTest() { assertThat(response.getResult().getOutput().getContent()).contains("Blackbeard"); } - @Disabled("TODO: Fix the parser instructions to return the correct format") @Test void outputParser() { DefaultConversionService conversionService = new DefaultConversionService(); @@ -147,7 +144,6 @@ void mapOutputParser() { record ActorsFilmsRecord(String actor, List movies) { } - @Disabled("TODO: Fix the parser instructions to return the correct format") @Test void beanOutputParserRecords() { @@ -169,7 +165,6 @@ void beanOutputParserRecords() { assertThat(actorsFilms.movies()).hasSize(5); } - @Disabled("TODO: Fix the parser instructions to return the correct format") @Test void beanStreamOutputParserRecords() { @@ -204,16 +199,16 @@ void beanStreamOutputParserRecords() { public static class TestConfiguration { @Bean - public Llama2ChatBedrockApi llama2Api() { - return new Llama2ChatBedrockApi(Llama2ChatModel.LLAMA2_70B_CHAT_V1.id(), + public LlamaChatBedrockApi llamaApi() { + return new LlamaChatBedrockApi(LlamaChatModel.LLAMA3_70B_INSTRUCT_V1.id(), EnvironmentVariableCredentialsProvider.create(), Region.US_EAST_1.id(), new ObjectMapper(), Duration.ofMinutes(2)); } @Bean - public BedrockLlama2ChatClient llama2ChatClient(Llama2ChatBedrockApi llama2Api) { - return new BedrockLlama2ChatClient(llama2Api, - BedrockLlama2ChatOptions.builder().withTemperature(0.5f).withMaxGenLen(100).withTopP(0.9f).build()); + public BedrockLlamaChatClient llamaChatClient(LlamaChatBedrockApi llamaApi) { + return new BedrockLlamaChatClient(llamaApi, + BedrockLlamaChatOptions.builder().withTemperature(0.5f).withMaxGenLen(100).withTopP(0.9f).build()); } } diff --git a/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/llama2/BedrockLlama2CreateRequestTests.java b/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/llama/BedrockLlamaCreateRequestTests.java similarity index 67% rename from models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/llama2/BedrockLlama2CreateRequestTests.java rename to models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/llama/BedrockLlamaCreateRequestTests.java index 1a3329016f3..77018af9e6e 100644 --- a/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/llama2/BedrockLlama2CreateRequestTests.java +++ b/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/llama/BedrockLlamaCreateRequestTests.java @@ -13,15 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.ai.bedrock.llama2; +package org.springframework.ai.bedrock.llama; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + import software.amazon.awssdk.auth.credentials.EnvironmentVariableCredentialsProvider; import software.amazon.awssdk.regions.Region; -import org.springframework.ai.bedrock.llama2.api.Llama2ChatBedrockApi; -import org.springframework.ai.bedrock.llama2.api.Llama2ChatBedrockApi.Llama2ChatModel; +import org.springframework.ai.bedrock.llama.api.LlamaChatBedrockApi; +import org.springframework.ai.bedrock.llama.api.LlamaChatBedrockApi.LlamaChatModel; import org.springframework.ai.chat.prompt.Prompt; import java.time.Duration; @@ -30,18 +32,21 @@ /** * @author Christian Tzolov + * @author Wei Jiang */ -public class BedrockLlama2CreateRequestTests { +@EnabledIfEnvironmentVariable(named = "AWS_ACCESS_KEY_ID", matches = ".*") +@EnabledIfEnvironmentVariable(named = "AWS_SECRET_ACCESS_KEY", matches = ".*") +public class BedrockLlamaCreateRequestTests { - private Llama2ChatBedrockApi api = new Llama2ChatBedrockApi(Llama2ChatModel.LLAMA2_70B_CHAT_V1.id(), + private LlamaChatBedrockApi api = new LlamaChatBedrockApi(LlamaChatModel.LLAMA3_70B_INSTRUCT_V1.id(), EnvironmentVariableCredentialsProvider.create(), Region.US_EAST_1.id(), new ObjectMapper(), Duration.ofMinutes(2)); @Test public void createRequestWithChatOptions() { - var client = new BedrockLlama2ChatClient(api, - BedrockLlama2ChatOptions.builder().withTemperature(66.6f).withMaxGenLen(666).withTopP(0.66f).build()); + var client = new BedrockLlamaChatClient(api, + BedrockLlamaChatOptions.builder().withTemperature(66.6f).withMaxGenLen(666).withTopP(0.66f).build()); var request = client.createRequest(new Prompt("Test message content")); @@ -51,7 +56,7 @@ public void createRequestWithChatOptions() { assertThat(request.maxGenLen()).isEqualTo(666); request = client.createRequest(new Prompt("Test message content", - BedrockLlama2ChatOptions.builder().withTemperature(99.9f).withMaxGenLen(999).withTopP(0.99f).build())); + BedrockLlamaChatOptions.builder().withTemperature(99.9f).withMaxGenLen(999).withTopP(0.99f).build())); assertThat(request.prompt()).isNotEmpty(); assertThat(request.temperature()).isEqualTo(99.9f); diff --git a/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/llama2/api/Llama2ChatBedrockApiIT.java b/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/llama/api/LlamaChatBedrockApiIT.java similarity index 61% rename from models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/llama2/api/Llama2ChatBedrockApiIT.java rename to models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/llama/api/LlamaChatBedrockApiIT.java index dc97d8e7b8f..5b4587358fb 100644 --- a/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/llama2/api/Llama2ChatBedrockApiIT.java +++ b/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/llama/api/LlamaChatBedrockApiIT.java @@ -13,47 +13,51 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.ai.bedrock.llama2.api; +package org.springframework.ai.bedrock.llama.api; import java.time.Duration; import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.ai.bedrock.llama.api.LlamaChatBedrockApi.LlamaChatModel; +import org.springframework.ai.bedrock.llama.api.LlamaChatBedrockApi.LlamaChatRequest; +import org.springframework.ai.bedrock.llama.api.LlamaChatBedrockApi.LlamaChatResponse; + +import com.fasterxml.jackson.databind.ObjectMapper; + import reactor.core.publisher.Flux; +import software.amazon.awssdk.auth.credentials.EnvironmentVariableCredentialsProvider; import software.amazon.awssdk.regions.Region; -import org.springframework.ai.bedrock.llama2.api.Llama2ChatBedrockApi.Llama2ChatModel; -import org.springframework.ai.bedrock.llama2.api.Llama2ChatBedrockApi.Llama2ChatRequest; -import org.springframework.ai.bedrock.llama2.api.Llama2ChatBedrockApi.Llama2ChatResponse; - import static org.assertj.core.api.Assertions.assertThat; /** * @author Christian Tzolov + * @author Wei Jiang */ @EnabledIfEnvironmentVariable(named = "AWS_ACCESS_KEY_ID", matches = ".*") @EnabledIfEnvironmentVariable(named = "AWS_SECRET_ACCESS_KEY", matches = ".*") -public class Llama2ChatBedrockApiIT { +public class LlamaChatBedrockApiIT { - private Llama2ChatBedrockApi llama2ChatApi = new Llama2ChatBedrockApi(Llama2ChatModel.LLAMA2_70B_CHAT_V1.id(), - Region.US_EAST_1.id(), Duration.ofMinutes(2)); + private LlamaChatBedrockApi llamaChatApi = new LlamaChatBedrockApi(LlamaChatModel.LLAMA3_70B_INSTRUCT_V1.id(), + EnvironmentVariableCredentialsProvider.create(), Region.US_EAST_1.id(), new ObjectMapper(), + Duration.ofMinutes(2)); @Test public void chatCompletion() { - Llama2ChatRequest request = Llama2ChatRequest.builder("Hello, my name is") + LlamaChatRequest request = LlamaChatRequest.builder("Hello, my name is") .withTemperature(0.9f) .withTopP(0.9f) .withMaxGenLen(20) .build(); - Llama2ChatResponse response = llama2ChatApi.chatCompletion(request); + LlamaChatResponse response = llamaChatApi.chatCompletion(request); System.out.println(response.generation()); assertThat(response).isNotNull(); assertThat(response.generation()).isNotEmpty(); - assertThat(response.promptTokenCount()).isEqualTo(6); assertThat(response.generationTokenCount()).isGreaterThan(10); assertThat(response.generationTokenCount()).isLessThanOrEqualTo(20); assertThat(response.stopReason()).isNotNull(); @@ -63,15 +67,15 @@ public void chatCompletion() { @Test public void chatCompletionStream() { - Llama2ChatRequest request = new Llama2ChatRequest("Hello, my name is", 0.9f, 0.9f, 20); - Flux responseStream = llama2ChatApi.chatCompletionStream(request); - List responses = responseStream.collectList().block(); + LlamaChatRequest request = new LlamaChatRequest("Hello, my name is", 0.9f, 0.9f, 20); + Flux responseStream = llamaChatApi.chatCompletionStream(request); + List responses = responseStream.collectList().block(); assertThat(responses).isNotNull(); assertThat(responses).hasSizeGreaterThan(10); assertThat(responses.get(0).generation()).isNotEmpty(); - Llama2ChatResponse lastResponse = responses.get(responses.size() - 1); + LlamaChatResponse lastResponse = responses.get(responses.size() - 1); assertThat(lastResponse.stopReason()).isNotNull(); assertThat(lastResponse.amazonBedrockInvocationMetrics()).isNotNull(); } diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/images/bedrock/bedrock-llama2-chat-api.jpg b/spring-ai-docs/src/main/antora/modules/ROOT/images/bedrock/bedrock-llama-chat-api.jpg similarity index 100% rename from spring-ai-docs/src/main/antora/modules/ROOT/images/bedrock/bedrock-llama2-chat-api.jpg rename to spring-ai-docs/src/main/antora/modules/ROOT/images/bedrock/bedrock-llama-chat-api.jpg diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc index 7d8e11bb98f..5c05e9d6277 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc @@ -11,7 +11,7 @@ *** xref:api/bedrock-chat.adoc[Amazon Bedrock] **** xref:api/chat/bedrock/bedrock-anthropic3.adoc[Anthropic3] **** xref:api/chat/bedrock/bedrock-anthropic.adoc[Anthropic2] -**** xref:api/chat/bedrock/bedrock-llama2.adoc[Llama2] +**** xref:api/chat/bedrock/bedrock-llama.adoc[Llama] **** xref:api/chat/bedrock/bedrock-cohere.adoc[Cohere] **** xref:api/chat/bedrock/bedrock-titan.adoc[Titan] **** xref:api/chat/bedrock/bedrock-jurassic2.adoc[Jurassic2] diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/bedrock.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/bedrock.adoc index 89e3190b091..3bdb84e102f 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/bedrock.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/bedrock.adoc @@ -85,7 +85,7 @@ Here are the supported `` and `` combinations: |==== | Model | Chat | Chat Streaming | Embedding -| llama2 | Yes | Yes | No +| llama | Yes | Yes | No | jurassic2 | Yes | No | No | cohere | Yes | Yes | Yes | anthropic 2 | Yes | Yes | No @@ -94,7 +94,7 @@ Here are the supported `` and `` combinations: | titan | Yes | Yes | Yes (however, no batch support) |==== -For example, to enable the Bedrock Llama2 Chat client, you need to set `spring.ai.bedrock.llama2.chat.enabled=true`. +For example, to enable the Bedrock Llama Chat client, you need to set `spring.ai.bedrock.llama.chat.enabled=true`. Next, you can use the `spring.ai.bedrock...*` properties to configure each model as provided. @@ -102,7 +102,7 @@ For more information, refer to the documentation below for each supported model. * xref:api/chat/bedrock/bedrock-anthropic.adoc[Spring AI Bedrock Anthropic 2 Chat]: `spring.ai.bedrock.anthropic.chat.enabled=true` * xref:api/chat/bedrock/bedrock-anthropic3.adoc[Spring AI Bedrock Anthropic 3 Chat]: `spring.ai.bedrock.anthropic.chat.enabled=true` -* xref:api/chat/bedrock/bedrock-llama2.adoc[Spring AI Bedrock Llama2 Chat]: `spring.ai.bedrock.llama2.chat.enabled=true` +* xref:api/chat/bedrock/bedrock-llama.adoc[Spring AI Bedrock Llama Chat]: `spring.ai.bedrock.llama.chat.enabled=true` * xref:api/chat/bedrock/bedrock-cohere.adoc[Spring AI Bedrock Cohere Chat]: `spring.ai.bedrock.cohere.chat.enabled=true` * xref:api/embeddings/bedrock-cohere-embedding.adoc[Spring AI Bedrock Cohere Embeddings]: `spring.ai.bedrock.cohere.embedding.enabled=true` * xref:api/chat/bedrock/bedrock-titan.adoc[Spring AI Bedrock Titan Chat]: `spring.ai.bedrock.titan.chat.enabled=true` diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/bedrock/bedrock-llama2.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/bedrock/bedrock-llama.adoc similarity index 55% rename from spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/bedrock/bedrock-llama2.adoc rename to spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/bedrock/bedrock-llama.adoc index 7f891e39432..1d04b2b8ffb 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/bedrock/bedrock-llama2.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/bedrock/bedrock-llama.adoc @@ -1,13 +1,13 @@ -= Llama2 Chat += Llama Chat -https://ai.meta.com/llama/[Meta's Llama 2 Chat] is part of the Llama 2 collection of large language models. +https://ai.meta.com/llama/[Meta's Llama Chat] is part of the Llama collection of large language models. It excels in dialogue-based applications with a parameter scale ranging from 7 billion to 70 billion. Leveraging public datasets and over 1 million human annotations, Llama Chat offers context-aware dialogues. -Trained on 2 trillion tokens from public data sources, Llama-2-Chat provides extensive knowledge for insightful conversations. +Trained on 2 trillion tokens from public data sources, Llama-Chat provides extensive knowledge for insightful conversations. Rigorous testing, including over 1,000 hours of red-teaming and annotation, ensures both performance and safety, making it a reliable choice for AI-driven dialogues. -The https://aws.amazon.com/bedrock/llama-2/[AWS Llama 2 Model Page] and https://docs.aws.amazon.com/bedrock/latest/userguide/what-is-bedrock.html[Amazon Bedrock User Guide] contains detailed information on how to use the AWS hosted model. +The https://aws.amazon.com/bedrock/llama/[AWS Llama Model Page] and https://docs.aws.amazon.com/bedrock/latest/userguide/what-is-bedrock.html[Amazon Bedrock User Guide] contains detailed information on how to use the AWS hosted model. == Prerequisites @@ -43,15 +43,15 @@ dependencies { TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file. -=== Enable Llama2 Chat Support +=== Enable Llama Chat Support -By default the Bedrock Llama2 model is disabled. -To enable it set the `spring.ai.bedrock.llama2.chat.enabled` property to `true`. +By default the Bedrock Llama model is disabled. +To enable it set the `spring.ai.bedrock.llama.chat.enabled` property to `true`. Exporting environment variable is one way to set this configuration property: [source,shell] ---- -export SPRING_AI_BEDROCK_LLAMA2_CHAT_ENABLED=true +export SPRING_AI_BEDROCK_LLAMA_CHAT_ENABLED=true ---- === Chat Properties @@ -69,29 +69,29 @@ The prefix `spring.ai.bedrock.aws` is the property prefix to configure the conne |==== -The prefix `spring.ai.bedrock.llama2.chat` is the property prefix that configures the chat client implementation for Llama2. +The prefix `spring.ai.bedrock.llama.chat` is the property prefix that configures the chat client implementation for Llama. [cols="2,5,1"] |==== | Property | Description | Default -| spring.ai.bedrock.llama2.chat.enabled | Enable or disable support for Llama2 | false -| spring.ai.bedrock.llama2.chat.model | The model id to use (See Below) | meta.llama2-70b-chat-v1 -| spring.ai.bedrock.llama2.chat.options.temperature | Controls the randomness of the output. Values can range over [0.0,1.0], inclusive. A value closer to 1.0 will produce responses that are more varied, while a value closer to 0.0 will typically result in less surprising responses from the model. This value specifies default to be used by the backend while making the call to the model. | 0.7 -| spring.ai.bedrock.llama2.chat.options.top-p | The maximum cumulative probability of tokens to consider when sampling. The model uses combined Top-k and nucleus sampling. Nucleus sampling considers the smallest set of tokens whose probability sum is at least topP. | AWS Bedrock default -| spring.ai.bedrock.llama2.chat.options.max-gen-len | Specify the maximum number of tokens to use in the generated response. The model truncates the response once the generated text exceeds maxGenLen. | 300 +| spring.ai.bedrock.llama.chat.enabled | Enable or disable support for Llama | false +| spring.ai.bedrock.llama.chat.model | The model id to use (See Below) | meta.llama3-70b-instruct-v1:0 +| spring.ai.bedrock.llama.chat.options.temperature | Controls the randomness of the output. Values can range over [0.0,1.0], inclusive. A value closer to 1.0 will produce responses that are more varied, while a value closer to 0.0 will typically result in less surprising responses from the model. This value specifies default to be used by the backend while making the call to the model. | 0.7 +| spring.ai.bedrock.llama.chat.options.top-p | The maximum cumulative probability of tokens to consider when sampling. The model uses combined Top-k and nucleus sampling. Nucleus sampling considers the smallest set of tokens whose probability sum is at least topP. | AWS Bedrock default +| spring.ai.bedrock.llama.chat.options.max-gen-len | Specify the maximum number of tokens to use in the generated response. The model truncates the response once the generated text exceeds maxGenLen. | 300 |==== -Look at https://github.com/spring-projects/spring-ai/blob/4ba9a3cd689b9fd3a3805f540debe398a079c6ef/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama2/api/Llama2ChatBedrockApi.java#L164[Llama2ChatBedrockApi#Llama2ChatModel] for other model IDs. The other value supported is `meta.llama2-13b-chat-v1`. +Look at https://github.com/spring-projects/spring-ai/blob/4ba9a3cd689b9fd3a3805f540debe398a079c6ef/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama/api/LlamaChatBedrockApi.java#L164[LlamaChatBedrockApi#LlamaChatModel] for other model IDs. The other value supported is `meta.llama2-13b-chat-v1`. Model ID values can also be found in the https://docs.aws.amazon.com/bedrock/latest/userguide/model-ids-arns.html[AWS Bedrock documentation for base model IDs]. -TIP: All properties prefixed with `spring.ai.bedrock.llama2.chat.options` can be overridden at runtime by adding a request specific <> to the `Prompt` call. +TIP: All properties prefixed with `spring.ai.bedrock.llama.chat.options` can be overridden at runtime by adding a request specific <> to the `Prompt` call. == Runtime Options [[chat-options]] -The https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama2/BedrockLlama2ChatOptions.java[BedrockLlama2ChatOptions.java] provides model configurations, such as temperature, topK, topP, etc. +The https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama/BedrockLlamaChatOptions.java[BedrockLlChatOptions.java] provides model configurations, such as temperature, topK, topP, etc. -On start-up, the default options can be configured with the `BedrockLlama2ChatClient(api, options)` constructor or the `spring.ai.bedrock.llama2.chat.options.*` properties. +On start-up, the default options can be configured with the `BedrockLlamaChatClient(api, options)` constructor or the `spring.ai.bedrock.llama.chat.options.*` properties. At run-time you can override the default options by adding new, request specific, options to the `Prompt` call. For example to override the default temperature for a specific request: @@ -101,13 +101,13 @@ For example to override the default temperature for a specific request: ChatResponse response = chatClient.call( new Prompt( "Generate the names of 5 famous pirates.", - BedrockLlama2ChatOptions.builder() + BedrockLlamaChatOptions.builder() .withTemperature(0.4) .build() )); ---- -TIP: In addition to the model specific https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama2/BedrockLlama2ChatOptions.java[BedrockLlama2ChatOptions] you can use a portable https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptions.java[ChatOptions] instance, created with the https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptionsBuilder.java[ChatOptionsBuilder#builder()]. +TIP: In addition to the model specific https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama/BedrockLlamaChatOptions.java[BedrockLlamaChatOptions] you can use a portable https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptions.java[ChatOptions] instance, created with the https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptionsBuilder.java[ChatOptionsBuilder#builder()]. == Sample Controller @@ -122,13 +122,13 @@ spring.ai.bedrock.aws.timeout=1000ms spring.ai.bedrock.aws.access-key=${AWS_ACCESS_KEY_ID} spring.ai.bedrock.aws.secret-key=${AWS_SECRET_ACCESS_KEY} -spring.ai.bedrock.llama2.chat.enabled=true -spring.ai.bedrock.llama2.chat.options.temperature=0.8 +spring.ai.bedrock.llama.chat.enabled=true +spring.ai.bedrock.llama.chat.options.temperature=0.8 ---- TIP: replace the `regions`, `access-key` and `secret-key` with your AWS credentials. -This will create a `BedrockLlama2ChatClient` implementation that you can inject into your class. +This will create a `BedrockLlamaChatClient` implementation that you can inject into your class. Here is an example of a simple `@Controller` class that uses the chat client for text generations. [source,java] @@ -136,10 +136,10 @@ Here is an example of a simple `@Controller` class that uses the chat client for @RestController public class ChatController { - private final BedrockLlama2ChatClient chatClient; + private final BedrockLlamaChatClient chatClient; @Autowired - public ChatController(BedrockLlama2ChatClient chatClient) { + public ChatController(BedrockLlamaChatClient chatClient) { this.chatClient = chatClient; } @@ -158,7 +158,7 @@ public class ChatController { == Manual Configuration -The https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama2/BedrockLlama2ChatClient.java[BedrockLlama2ChatClient] implements the `ChatClient` and `StreamingChatClient` and uses the <> to connect to the Bedrock Anthropic service. +The https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama/BedrockLlamaChatClient.java[BedrockLlamaChatClient] implements the `ChatClient` and `StreamingChatClient` and uses the <> to connect to the Bedrock Anthropic service. Add the `spring-ai-bedrock` dependency to your project's Maven `pom.xml` file: @@ -181,18 +181,18 @@ dependencies { TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file. -Next, create an https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama2/BedrockLlama2ChatClient.java[BedrockLlama2ChatClient] and use it for text generations: +Next, create an https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama/BedrockLlamaChatClient.java[BedrockLlamaChatClient] and use it for text generations: [source,java] ---- -Llama2ChatBedrockApi api = new Llama2ChatBedrockApi(Llama2ChatModel.LLAMA2_70B_CHAT_V1.id(), +LlamaChatBedrockApi api = new LlamaChatBedrockApi(LlamaChatModel.LLAMA2_70B_CHAT_V1.id(), EnvironmentVariableCredentialsProvider.create(), Region.US_EAST_1.id(), new ObjectMapper(), Duration.ofMillis(1000L)); -BedrockLlama2ChatClient chatClient = new BedrockLlama2ChatClient(api, - BedrockLlama2ChatOptions.builder() +BedrockLlamaChatClient chatClient = new BedrockLlamaChatClient(api, + BedrockLlamaChatOptions.builder() .withTemperature(0.5f) .withMaxGenLen(100) .withTopP(0.9f).build()); @@ -205,38 +205,38 @@ Flux response = chatClient.stream( new Prompt("Generate the names of 5 famous pirates.")); ---- -== Low-level Llama2ChatBedrockApi Client [[low-level-api]] +== Low-level LlamaChatBedrockApi Client [[low-level-api]] -https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama2/api/Llama2ChatBedrockApi.java[Llama2ChatBedrockApi] provides is lightweight Java client on top of AWS Bedrock https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-meta.html[Meta Llama 2 and Llama 2 Chat models]. +https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama/api/LlamaChatBedrockApi.java[LlamaChatBedrockApi] provides is lightweight Java client on top of AWS Bedrock https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-meta.html[Meta Llama 2 and Llama 2 Chat models]. -Following class diagram illustrates the Llama2ChatBedrockApi interface and building blocks: +Following class diagram illustrates the LlamaChatBedrockApi interface and building blocks: -image::bedrock/bedrock-llama2-chat-api.jpg[Llama2ChatBedrockApi Class Diagram] +image::bedrock/bedrock-llama-chat-api.jpg[LlamaChatBedrockApi Class Diagram] -The Llama2ChatBedrockApi supports the `meta.llama2-13b-chat-v1` and `meta.llama2-70b-chat-v1` models for both synchronous (e.g. `chatCompletion()`) and streaming (e.g. `chatCompletionStream()`) responses. +The LlamaChatBedrockApi supports the `meta.llama3-8b-instruct-v1:0`,`meta.llama3-70b-instruct-v1:0`,`meta.llama2-13b-chat-v1` and `meta.llama2-70b-chat-v1` models for both synchronous (e.g. `chatCompletion()`) and streaming (e.g. `chatCompletionStream()`) responses. Here is a simple snippet how to use the api programmatically: [source,java] ---- -Llama2ChatBedrockApi llama2ChatApi = new Llama2ChatBedrockApi( - Llama2ChatModel.LLAMA2_70B_CHAT_V1.id(), +LlamaChatBedrockApi llamaChatApi = new LlamaChatBedrockApi( + LlamaChatModel.LLAMA3_70B_INSTRUCT_V1.id(), Region.US_EAST_1.id(), Duration.ofMillis(1000L)); -Llama2ChatRequest request = Llama2ChatRequest.builder("Hello, my name is") +LlamaChatRequest request = LlamaChatRequest.builder("Hello, my name is") .withTemperature(0.9f) .withTopP(0.9f) .withMaxGenLen(20) .build(); -Llama2ChatResponse response = llama2ChatApi.chatCompletion(request); +LlamaChatResponse response = llamaChatApi.chatCompletion(request); // Streaming response -Flux responseStream = llama2ChatApi.chatCompletionStream(request); -List responses = responseStream.collectList().block(); +Flux responseStream = llamaChatApi.chatCompletionStream(request); +List responses = responseStream.collectList().block(); ---- -Follow the https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama2/api/Llama2ChatBedrockApi.java[Llama2ChatBedrockApi.java]'s JavaDoc for further information. +Follow the https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama/api/LlamaChatBedrockApi.java[LlamaChatBedrockApi.java]'s JavaDoc for further information. diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chatclient.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chatclient.adoc index 7bd008b400f..902d0d59b78 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chatclient.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chatclient.adoc @@ -183,7 +183,7 @@ image::spring-ai-chat-completions-clients.jpg[align="center", width="800px"] * xref:api/chat/vertexai-gemini-chat.adoc[Google Vertex AI Gemini Chat Completion] (streaming, multi-modality & function-calling support) * xref:api/bedrock.adoc[Amazon Bedrock] ** xref:api/chat/bedrock/bedrock-cohere.adoc[Cohere Chat Completion] -** xref:api/chat/bedrock/bedrock-llama2.adoc[Llama2 Chat Completion] +** xref:api/chat/bedrock/bedrock-llama.adoc[Llama Chat Completion] ** xref:api/chat/bedrock/bedrock-titan.adoc[Titan Chat Completion] ** xref:api/chat/bedrock/bedrock-anthropic.adoc[Anthropic Chat Completion] ** xref:api/chat/bedrock/bedrock-jurassic2.adoc[Jurassic2 Chat Completion] diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/getting-started.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/getting-started.adoc index 486e6e1e4ec..e2f04a0796f 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/getting-started.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/getting-started.adoc @@ -147,7 +147,7 @@ Each of the following sections in the documentation shows which dependencies you ** xref:api/chat/vertexai-gemini-chat.adoc[Google Vertex AI Gemini Chat Completion] (streaming, multi-modality & function-calling support) ** xref:api/bedrock.adoc[Amazon Bedrock] *** xref:api/chat/bedrock/bedrock-cohere.adoc[Cohere Chat Completion] -*** xref:api/chat/bedrock/bedrock-llama2.adoc[Llama2 Chat Completion] +*** xref:api/chat/bedrock/bedrock-llama.adoc[Llama Chat Completion] *** xref:api/chat/bedrock/bedrock-titan.adoc[Titan Chat Completion] *** xref:api/chat/bedrock/bedrock-anthropic.adoc[Anthropic Chat Completion] *** xref:api/chat/bedrock/bedrock-jurassic2.adoc[Jurassic2 Chat Completion] diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/llama2/BedrockLlama2ChatAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/llama/BedrockLlamaChatAutoConfiguration.java similarity index 64% rename from spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/llama2/BedrockLlama2ChatAutoConfiguration.java rename to spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/llama/BedrockLlamaChatAutoConfiguration.java index ef3d53e4faa..9293acc84f2 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/llama2/BedrockLlama2ChatAutoConfiguration.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/llama/BedrockLlamaChatAutoConfiguration.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.ai.autoconfigure.bedrock.llama2; +package org.springframework.ai.autoconfigure.bedrock.llama; import com.fasterxml.jackson.databind.ObjectMapper; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; @@ -21,8 +21,8 @@ import org.springframework.ai.autoconfigure.bedrock.BedrockAwsConnectionConfiguration; import org.springframework.ai.autoconfigure.bedrock.BedrockAwsConnectionProperties; -import org.springframework.ai.bedrock.llama2.BedrockLlama2ChatClient; -import org.springframework.ai.bedrock.llama2.api.Llama2ChatBedrockApi; +import org.springframework.ai.bedrock.llama.BedrockLlamaChatClient; +import org.springframework.ai.bedrock.llama.api.LlamaChatBedrockApi; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; @@ -33,7 +33,7 @@ import org.springframework.context.annotation.Import; /** - * {@link AutoConfiguration Auto-configuration} for Bedrock Llama2 Chat Client. + * {@link AutoConfiguration Auto-configuration} for Bedrock Llama Chat Client. * * Leverages the Spring Cloud AWS to resolve the {@link AwsCredentialsProvider}. * @@ -42,27 +42,26 @@ * @since 0.8.0 */ @AutoConfiguration -@ConditionalOnClass(Llama2ChatBedrockApi.class) -@EnableConfigurationProperties({ BedrockLlama2ChatProperties.class, BedrockAwsConnectionProperties.class }) -@ConditionalOnProperty(prefix = BedrockLlama2ChatProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true") +@ConditionalOnClass(LlamaChatBedrockApi.class) +@EnableConfigurationProperties({ BedrockLlamaChatProperties.class, BedrockAwsConnectionProperties.class }) +@ConditionalOnProperty(prefix = BedrockLlamaChatProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true") @Import(BedrockAwsConnectionConfiguration.class) -public class BedrockLlama2ChatAutoConfiguration { +public class BedrockLlamaChatAutoConfiguration { @Bean @ConditionalOnMissingBean @ConditionalOnBean({ AwsCredentialsProvider.class, AwsRegionProvider.class }) - public Llama2ChatBedrockApi llama2Api(AwsCredentialsProvider credentialsProvider, AwsRegionProvider regionProvider, - BedrockLlama2ChatProperties properties, BedrockAwsConnectionProperties awsProperties) { - return new Llama2ChatBedrockApi(properties.getModel(), credentialsProvider, regionProvider.getRegion(), + public LlamaChatBedrockApi llamaApi(AwsCredentialsProvider credentialsProvider, AwsRegionProvider regionProvider, + BedrockLlamaChatProperties properties, BedrockAwsConnectionProperties awsProperties) { + return new LlamaChatBedrockApi(properties.getModel(), credentialsProvider, regionProvider.getRegion(), new ObjectMapper(), awsProperties.getTimeout()); } @Bean - @ConditionalOnBean(Llama2ChatBedrockApi.class) - public BedrockLlama2ChatClient llama2ChatClient(Llama2ChatBedrockApi llama2Api, - BedrockLlama2ChatProperties properties) { + @ConditionalOnBean(LlamaChatBedrockApi.class) + public BedrockLlamaChatClient llamaChatClient(LlamaChatBedrockApi llamaApi, BedrockLlamaChatProperties properties) { - return new BedrockLlama2ChatClient(llama2Api, properties.getOptions()); + return new BedrockLlamaChatClient(llamaApi, properties.getOptions()); } } diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/llama2/BedrockLlama2ChatProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/llama/BedrockLlamaChatProperties.java similarity index 63% rename from spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/llama2/BedrockLlama2ChatProperties.java rename to spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/llama/BedrockLlamaChatProperties.java index a81f3a4af0c..f93b65aca32 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/llama2/BedrockLlama2ChatProperties.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/llama/BedrockLlamaChatProperties.java @@ -13,36 +13,36 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.ai.autoconfigure.bedrock.llama2; +package org.springframework.ai.autoconfigure.bedrock.llama; -import org.springframework.ai.bedrock.llama2.BedrockLlama2ChatOptions; -import org.springframework.ai.bedrock.llama2.api.Llama2ChatBedrockApi.Llama2ChatModel; +import org.springframework.ai.bedrock.llama.BedrockLlamaChatOptions; +import org.springframework.ai.bedrock.llama.api.LlamaChatBedrockApi.LlamaChatModel; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.NestedConfigurationProperty; /** - * Configuration properties for Bedrock Llama2. + * Configuration properties for Bedrock Llama. * * @author Christian Tzolov * @since 0.8.0 */ -@ConfigurationProperties(BedrockLlama2ChatProperties.CONFIG_PREFIX) -public class BedrockLlama2ChatProperties { +@ConfigurationProperties(BedrockLlamaChatProperties.CONFIG_PREFIX) +public class BedrockLlamaChatProperties { - public static final String CONFIG_PREFIX = "spring.ai.bedrock.llama2.chat"; + public static final String CONFIG_PREFIX = "spring.ai.bedrock.llama.chat"; /** - * Enable Bedrock Llama2 chat client. Disabled by default. + * Enable Bedrock Llama chat client. Disabled by default. */ private boolean enabled = false; /** - * The generative id to use. See the {@link Llama2ChatModel} for the supported models. + * The generative id to use. See the {@link LlamaChatModel} for the supported models. */ - private String model = Llama2ChatModel.LLAMA2_70B_CHAT_V1.id(); + private String model = LlamaChatModel.LLAMA3_70B_INSTRUCT_V1.id(); @NestedConfigurationProperty - private BedrockLlama2ChatOptions options = BedrockLlama2ChatOptions.builder() + private BedrockLlamaChatOptions options = BedrockLlamaChatOptions.builder() .withTemperature(0.7f) .withMaxGenLen(300) .build(); @@ -63,11 +63,11 @@ public void setModel(String model) { this.model = model; } - public BedrockLlama2ChatOptions getOptions() { + public BedrockLlamaChatOptions getOptions() { return this.options; } - public void setOptions(BedrockLlama2ChatOptions options) { + public void setOptions(BedrockLlamaChatOptions options) { this.options = options; } diff --git a/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 59f104b6825..c2816002bcd 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -6,7 +6,7 @@ org.springframework.ai.autoconfigure.huggingface.HuggingfaceChatAutoConfiguratio org.springframework.ai.autoconfigure.vertexai.palm2.VertexAiPalm2AutoConfiguration org.springframework.ai.autoconfigure.vertexai.gemini.VertexAiGeminiAutoConfiguration org.springframework.ai.autoconfigure.bedrock.jurrasic2.BedrockAi21Jurassic2ChatAutoConfiguration -org.springframework.ai.autoconfigure.bedrock.llama2.BedrockLlama2ChatAutoConfiguration +org.springframework.ai.autoconfigure.bedrock.llama.BedrockLlamaChatAutoConfiguration org.springframework.ai.autoconfigure.bedrock.cohere.BedrockCohereChatAutoConfiguration org.springframework.ai.autoconfigure.bedrock.cohere.BedrockCohereEmbeddingAutoConfiguration org.springframework.ai.autoconfigure.bedrock.anthropic.BedrockAnthropicChatAutoConfiguration diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/llama2/BedrockLlama2ChatAutoConfigurationIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/llama/BedrockLlamaChatAutoConfigurationIT.java similarity index 61% rename from spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/llama2/BedrockLlama2ChatAutoConfigurationIT.java rename to spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/llama/BedrockLlamaChatAutoConfigurationIT.java index 3c55877bcaf..6ca69b65599 100644 --- a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/llama2/BedrockLlama2ChatAutoConfigurationIT.java +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/llama/BedrockLlamaChatAutoConfigurationIT.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.ai.autoconfigure.bedrock.llama2; +package org.springframework.ai.autoconfigure.bedrock.llama; import java.util.List; import java.util.Map; @@ -27,8 +27,8 @@ import software.amazon.awssdk.regions.Region; import org.springframework.ai.autoconfigure.bedrock.BedrockAwsConnectionProperties; -import org.springframework.ai.bedrock.llama2.BedrockLlama2ChatClient; -import org.springframework.ai.bedrock.llama2.api.Llama2ChatBedrockApi.Llama2ChatModel; +import org.springframework.ai.bedrock.llama.BedrockLlamaChatClient; +import org.springframework.ai.bedrock.llama.api.LlamaChatBedrockApi.LlamaChatModel; import org.springframework.ai.chat.Generation; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.chat.prompt.SystemPromptTemplate; @@ -41,21 +41,22 @@ /** * @author Christian Tzolov + * @author Wei Jiang * @since 0.8.0 */ @EnabledIfEnvironmentVariable(named = "AWS_ACCESS_KEY_ID", matches = ".*") @EnabledIfEnvironmentVariable(named = "AWS_SECRET_ACCESS_KEY", matches = ".*") -public class BedrockLlama2ChatAutoConfigurationIT { +public class BedrockLlamaChatAutoConfigurationIT { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withPropertyValues("spring.ai.bedrock.llama2.chat.enabled=true", + .withPropertyValues("spring.ai.bedrock.llama.chat.enabled=true", "spring.ai.bedrock.aws.access-key=" + System.getenv("AWS_ACCESS_KEY_ID"), "spring.ai.bedrock.aws.secret-key=" + System.getenv("AWS_SECRET_ACCESS_KEY"), "spring.ai.bedrock.aws.region=" + Region.US_EAST_1.id(), - "spring.ai.bedrock.llama2.chat.model=" + Llama2ChatModel.LLAMA2_70B_CHAT_V1.id(), - "spring.ai.bedrock.llama2.chat.options.temperature=0.5", - "spring.ai.bedrock.llama2.chat.options.maxGenLen=500") - .withConfiguration(AutoConfigurations.of(BedrockLlama2ChatAutoConfiguration.class)); + "spring.ai.bedrock.llama.chat.model=" + LlamaChatModel.LLAMA3_70B_INSTRUCT_V1.id(), + "spring.ai.bedrock.llama.chat.options.temperature=0.5", + "spring.ai.bedrock.llama.chat.options.maxGenLen=500") + .withConfiguration(AutoConfigurations.of(BedrockLlamaChatAutoConfiguration.class)); private final Message systemMessage = new SystemPromptTemplate(""" You are a helpful AI assistant. Your name is {name}. @@ -70,8 +71,8 @@ public class BedrockLlama2ChatAutoConfigurationIT { @Test public void chatCompletion() { contextRunner.run(context -> { - BedrockLlama2ChatClient llama2ChatClient = context.getBean(BedrockLlama2ChatClient.class); - ChatResponse response = llama2ChatClient.call(new Prompt(List.of(userMessage, systemMessage))); + BedrockLlamaChatClient llamaChatClient = context.getBean(BedrockLlamaChatClient.class); + ChatResponse response = llamaChatClient.call(new Prompt(List.of(userMessage, systemMessage))); assertThat(response.getResult().getOutput().getContent()).contains("Blackbeard"); }); } @@ -80,9 +81,9 @@ public void chatCompletion() { public void chatCompletionStreaming() { contextRunner.run(context -> { - BedrockLlama2ChatClient llama2ChatClient = context.getBean(BedrockLlama2ChatClient.class); + BedrockLlamaChatClient llamaChatClient = context.getBean(BedrockLlamaChatClient.class); - Flux response = llama2ChatClient.stream(new Prompt(List.of(userMessage, systemMessage))); + Flux response = llamaChatClient.stream(new Prompt(List.of(userMessage, systemMessage))); List responses = response.collectList().block(); assertThat(responses.size()).isGreaterThan(2); @@ -102,23 +103,23 @@ public void chatCompletionStreaming() { public void propertiesTest() { new ApplicationContextRunner() - .withPropertyValues("spring.ai.bedrock.llama2.chat.enabled=true", + .withPropertyValues("spring.ai.bedrock.llama.chat.enabled=true", "spring.ai.bedrock.aws.access-key=ACCESS_KEY", "spring.ai.bedrock.aws.secret-key=SECRET_KEY", - "spring.ai.bedrock.llama2.chat.model=MODEL_XYZ", + "spring.ai.bedrock.llama.chat.model=MODEL_XYZ", "spring.ai.bedrock.aws.region=" + Region.EU_CENTRAL_1.id(), - "spring.ai.bedrock.llama2.chat.options.temperature=0.55", - "spring.ai.bedrock.llama2.chat.options.maxGenLen=123") - .withConfiguration(AutoConfigurations.of(BedrockLlama2ChatAutoConfiguration.class)) + "spring.ai.bedrock.llama.chat.options.temperature=0.55", + "spring.ai.bedrock.llama.chat.options.maxGenLen=123") + .withConfiguration(AutoConfigurations.of(BedrockLlamaChatAutoConfiguration.class)) .run(context -> { - var llama2ChatProperties = context.getBean(BedrockLlama2ChatProperties.class); + var llamaChatProperties = context.getBean(BedrockLlamaChatProperties.class); var awsProperties = context.getBean(BedrockAwsConnectionProperties.class); - assertThat(llama2ChatProperties.isEnabled()).isTrue(); + assertThat(llamaChatProperties.isEnabled()).isTrue(); assertThat(awsProperties.getRegion()).isEqualTo(Region.EU_CENTRAL_1.id()); - assertThat(llama2ChatProperties.getOptions().getTemperature()).isEqualTo(0.55f); - assertThat(llama2ChatProperties.getOptions().getMaxGenLen()).isEqualTo(123); - assertThat(llama2ChatProperties.getModel()).isEqualTo("MODEL_XYZ"); + assertThat(llamaChatProperties.getOptions().getTemperature()).isEqualTo(0.55f); + assertThat(llamaChatProperties.getOptions().getMaxGenLen()).isEqualTo(123); + assertThat(llamaChatProperties.getModel()).isEqualTo("MODEL_XYZ"); assertThat(awsProperties.getAccessKey()).isEqualTo("ACCESS_KEY"); assertThat(awsProperties.getSecretKey()).isEqualTo("SECRET_KEY"); @@ -129,27 +130,26 @@ public void propertiesTest() { public void chatCompletionDisabled() { // It is disabled by default - new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(BedrockLlama2ChatAutoConfiguration.class)) + new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(BedrockLlamaChatAutoConfiguration.class)) .run(context -> { - assertThat(context.getBeansOfType(BedrockLlama2ChatProperties.class)).isEmpty(); - assertThat(context.getBeansOfType(BedrockLlama2ChatClient.class)).isEmpty(); + assertThat(context.getBeansOfType(BedrockLlamaChatProperties.class)).isEmpty(); + assertThat(context.getBeansOfType(BedrockLlamaChatClient.class)).isEmpty(); }); // Explicitly enable the chat auto-configuration. - new ApplicationContextRunner().withPropertyValues("spring.ai.bedrock.llama2.chat.enabled=true") - .withConfiguration(AutoConfigurations.of(BedrockLlama2ChatAutoConfiguration.class)) + new ApplicationContextRunner().withPropertyValues("spring.ai.bedrock.llama.chat.enabled=true") + .withConfiguration(AutoConfigurations.of(BedrockLlamaChatAutoConfiguration.class)) .run(context -> { - assertThat(context.getBeansOfType(BedrockLlama2ChatProperties.class)).isNotEmpty(); - assertThat(context.getBeansOfType(BedrockLlama2ChatClient.class)).isNotEmpty(); + assertThat(context.getBeansOfType(BedrockLlamaChatProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(BedrockLlamaChatClient.class)).isNotEmpty(); }); // Explicitly disable the chat auto-configuration. - new ApplicationContextRunner().withPropertyValues("spring.ai.bedrock.llama2.chat.enabled=false") - .withConfiguration(AutoConfigurations.of(BedrockLlama2ChatAutoConfiguration.class)) + new ApplicationContextRunner().withPropertyValues("spring.ai.bedrock.llama.chat.enabled=false") + .withConfiguration(AutoConfigurations.of(BedrockLlamaChatAutoConfiguration.class)) .run(context -> { - assertThat(context.getBeansOfType(BedrockLlama2ChatProperties.class)).isEmpty(); - assertThat(context.getBeansOfType(BedrockLlama2ChatClient.class)).isEmpty(); + assertThat(context.getBeansOfType(BedrockLlamaChatProperties.class)).isEmpty(); + assertThat(context.getBeansOfType(BedrockLlamaChatClient.class)).isEmpty(); }); } From 6b9f8d3ac98e5dbd4c2262f6001b17a2b8533971 Mon Sep 17 00:00:00 2001 From: Dan Vega Date: Thu, 25 Apr 2024 10:58:52 -0400 Subject: [PATCH 24/46] Implement a function, not functions --- .../ROOT/pages/api/chat/functions/openai-chat-functions.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/functions/openai-chat-functions.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/functions/openai-chat-functions.adoc index d3cf341bf1f..7f74dc4a320 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/functions/openai-chat-functions.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/functions/openai-chat-functions.adoc @@ -9,7 +9,7 @@ The OpenAI API does not call the function directly; instead, the model generates Spring AI provides flexible and user-friendly ways to register and call custom functions. In general, the custom functions need to provide a function `name`, `description`, and the function call `signature` (as JSON schema) to let the model know what arguments the function expects. The `description` helps the model to understand when to call the function. -As a developer, you need to implement a functions that takes the function call arguments sent from the AI model, and respond with the result back to the model. Your function can in turn invoke other 3rd party services to provide the results. +As a developer, you need to implement a function that takes the function call arguments sent from the AI model, and respond with the result back to the model. Your function can in turn invoke other 3rd party services to provide the results. Spring AI makes this as easy as defining a `@Bean` definition that returns a `java.util.Function` and supplying the bean name as an option when invoking the `ChatClient`. From a5923f5a7967c63f145c47fdb42aebd6a32b6fd6 Mon Sep 17 00:00:00 2001 From: Johnny Lim Date: Thu, 25 Apr 2024 23:58:55 +0900 Subject: [PATCH 25/46] Fix sample in "Manual Configuration" section of OpenAI Embeddings API --- .../pages/api/embeddings/openai-embeddings.adoc | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/openai-embeddings.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/openai-embeddings.adoc index f1d9eb04a83..75a0d45f3b5 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/openai-embeddings.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/openai-embeddings.adoc @@ -187,14 +187,17 @@ Next, create an `OpenAiEmbeddingClient` instance and use it to compute the simil ---- var openAiApi = new OpenAiApi(System.getenv("OPENAI_API_KEY")); -var embeddingClient = new OpenAiEmbeddingClient(openAiApi) - .withDefaultOptions(OpenAiChatOptions.build() - .withModel("text-embedding-ada-002") - .withUser("user-6") - .build()); +var embeddingClient = new OpenAiEmbeddingClient( + openAiApi, + MetadataMode.EMBED, + OpenAiEmbeddingOptions.builder() + .withModel("text-embedding-ada-002") + .withUser("user-6") + .build(), + RetryUtils.DEFAULT_RETRY_TEMPLATE); EmbeddingResponse embeddingResponse = embeddingClient - .embedForResponse(List.of("Hello World", "World is big and salvation is near")); + .embedForResponse(List.of("Hello World", "World is big and salvation is near")); ---- The `OpenAiEmbeddingOptions` provides the configuration information for the embedding requests. From db6f7cda3e9a3455d2f6aa702624f597edf6023b Mon Sep 17 00:00:00 2001 From: Ikko Eltociear Ashimine Date: Mon, 15 Apr 2024 16:29:37 +0900 Subject: [PATCH 26/46] Update AzureVectorStore.java minor fix --- .../springframework/ai/vectorstore/azure/AzureVectorStore.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vector-stores/spring-ai-azure/src/main/java/org/springframework/ai/vectorstore/azure/AzureVectorStore.java b/vector-stores/spring-ai-azure/src/main/java/org/springframework/ai/vectorstore/azure/AzureVectorStore.java index ec29ce0bfcf..165b190b06d 100644 --- a/vector-stores/spring-ai-azure/src/main/java/org/springframework/ai/vectorstore/azure/AzureVectorStore.java +++ b/vector-stores/spring-ai-azure/src/main/java/org/springframework/ai/vectorstore/azure/AzureVectorStore.java @@ -320,7 +320,7 @@ private List toFloatList(List doubleList) { } /** - * Internal data structure for retrieving and and storing documents. + * Internal data structure for retrieving and storing documents. */ private record AzureSearchDocument(String id, String content, List embedding, String metadata) { } From 773b7bd3cacb3c644b2fcb67bf6355a463c47bf7 Mon Sep 17 00:00:00 2001 From: Sagar Bhat Date: Mon, 22 Apr 2024 18:43:45 +1000 Subject: [PATCH 27/46] PgVectorStore: creating an index only if missing - Resolve an issue where a new index keeps getting created during application start up. - Solution is is to create an index only if an index on the embedding column does not exist. - Add missing index name. --- .../org/springframework/ai/vectorstore/PgVectorStore.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/vector-stores/spring-ai-pgvector-store/src/main/java/org/springframework/ai/vectorstore/PgVectorStore.java b/vector-stores/spring-ai-pgvector-store/src/main/java/org/springframework/ai/vectorstore/PgVectorStore.java index 1d85ee361df..c2b7954d900 100644 --- a/vector-stores/spring-ai-pgvector-store/src/main/java/org/springframework/ai/vectorstore/PgVectorStore.java +++ b/vector-stores/spring-ai-pgvector-store/src/main/java/org/springframework/ai/vectorstore/PgVectorStore.java @@ -60,6 +60,8 @@ public class PgVectorStore implements VectorStore, InitializingBean { public static final String VECTOR_TABLE_NAME = "vector_store"; + public static final String VECTOR_INDEX_NAME = "spring_ai_vector_index"; + public final FilterExpressionConverter filterExpressionConverter = new PgVectorFilterExpressionConverter(); private final JdbcTemplate jdbcTemplate; @@ -352,8 +354,8 @@ embedding vector(%d) if (this.createIndexMethod != PgIndexType.NONE) { this.jdbcTemplate.execute(String.format(""" - CREATE INDEX ON %s USING %s (embedding %s) - """, VECTOR_TABLE_NAME, this.createIndexMethod, this.getDistanceType().index)); + CREATE INDEX IF NOT EXISTS %s ON %s USING %s (embedding %s) + """, VECTOR_INDEX_NAME, VECTOR_TABLE_NAME, this.createIndexMethod, this.getDistanceType().index)); } } From 7e03a15cf5ce4fbdfcf219fbfed84514201d4d67 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Fri, 26 Apr 2024 21:04:48 +0200 Subject: [PATCH 28/46] Mistral AI streaming function API change fix --- .../ai/mistralai/MistralAiChatClient.java | 8 +++++++- .../springframework/ai/mistralai/api/MistralAiApi.java | 10 ++++------ .../api/MistralAiStreamFunctionCallingHelper.java | 3 +-- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/MistralAiChatClient.java b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/MistralAiChatClient.java index 91aad8f5490..ad7c347b054 100644 --- a/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/MistralAiChatClient.java +++ b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/MistralAiChatClient.java @@ -285,7 +285,13 @@ protected List doGetUserMessages(ChatCompletionRequest re @SuppressWarnings("null") @Override protected ChatCompletionMessage doGetToolResponseMessage(ResponseEntity chatCompletion) { - return chatCompletion.getBody().choices().iterator().next().message(); + ChatCompletionMessage msg = chatCompletion.getBody().choices().iterator().next().message(); + if (msg.role() == null) { + // add missing role + msg = new ChatCompletionMessage(msg.content(), ChatCompletionMessage.Role.ASSISTANT, msg.name(), + msg.toolCalls()); + } + return msg; } @Override diff --git a/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/api/MistralAiApi.java b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/api/MistralAiApi.java index 19ba2ac521f..5373fd01d30 100644 --- a/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/api/MistralAiApi.java +++ b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/api/MistralAiApi.java @@ -535,14 +535,12 @@ public enum ChatCompletionFinishReason { */ @JsonProperty("model_length") MODEL_LENGTH, /** - * The model called a tool. + * */ - @JsonProperty("tool_call") TOOL_CALL, - - // anticipation of future changes. Based on: - // https://github.com/mistralai/client-python/blob/main/src/mistralai/models/chat_completion.py @JsonProperty("error") ERROR, - + /** + * The model requested a tool call. + */ @JsonProperty("tool_calls") TOOL_CALLS // @formatter:on diff --git a/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/api/MistralAiStreamFunctionCallingHelper.java b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/api/MistralAiStreamFunctionCallingHelper.java index 50cd223536c..ed730ec488c 100644 --- a/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/api/MistralAiStreamFunctionCallingHelper.java +++ b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/api/MistralAiStreamFunctionCallingHelper.java @@ -190,8 +190,7 @@ public boolean isStreamingToolFunctionCallFinish(ChatCompletionChunk chatComplet } var choice = choices.get(0); - return choice.finishReason() == ChatCompletionFinishReason.TOOL_CALL - || choice.finishReason() == ChatCompletionFinishReason.TOOL_CALLS; + return choice.finishReason() == ChatCompletionFinishReason.TOOL_CALLS; } } From 5f9ecdd899761974e77114f7584610aa803a3e97 Mon Sep 17 00:00:00 2001 From: Ricken Bazolo Date: Tue, 9 Apr 2024 22:15:12 +0200 Subject: [PATCH 29/46] Fixing Log probability information --- .../ai/mistralai/MistralAiChatClient.java | 2 +- .../ai/mistralai/api/MistralAiApi.java | 54 ++++++++++++++++++- .../MistralAiStreamFunctionCallingHelper.java | 10 ++-- .../ai/mistralai/MistralAiRetryTests.java | 4 +- 4 files changed, 62 insertions(+), 8 deletions(-) diff --git a/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/MistralAiChatClient.java b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/MistralAiChatClient.java index ad7c347b054..f5c1f4fd5bf 100644 --- a/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/MistralAiChatClient.java +++ b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/MistralAiChatClient.java @@ -177,7 +177,7 @@ public Flux stream(Prompt prompt) { private ChatCompletion toChatCompletion(ChatCompletionChunk chunk) { List choices = chunk.choices() .stream() - .map(cc -> new Choice(cc.index(), cc.delta(), cc.finishReason())) + .map(cc -> new Choice(cc.index(), cc.delta(), cc.finishReason(), cc.logprobs())) .toList(); return new ChatCompletion(chunk.id(), "chat.completion", chunk.created(), chunk.model(), choices, null); diff --git a/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/api/MistralAiApi.java b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/api/MistralAiApi.java index 5373fd01d30..5732c871a66 100644 --- a/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/api/MistralAiApi.java +++ b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/api/MistralAiApi.java @@ -575,17 +575,65 @@ public record ChatCompletion( * @param index The index of the choice in the list of choices. * @param message A chat completion message generated by the model. * @param finishReason The reason the model stopped generating tokens. + * @param logprobs Log probability information for the choice. */ @JsonInclude(Include.NON_NULL) public record Choice( // @formatter:off @JsonProperty("index") Integer index, @JsonProperty("message") ChatCompletionMessage message, - @JsonProperty("finish_reason") ChatCompletionFinishReason finishReason) { + @JsonProperty("finish_reason") ChatCompletionFinishReason finishReason, + @JsonProperty("logprobs") LogProbs logprobs) { // @formatter:on } } + /** + * + * Log probability information for the choice. anticipation of future changes. + * + * @param content A list of message content tokens with log probability information. + */ + @JsonInclude(Include.NON_NULL) + public record LogProbs(@JsonProperty("content") List content) { + + /** + * Message content tokens with log probability information. + * + * @param token The token. + * @param logprob The log probability of the token. + * @param probBytes A list of integers representing the UTF-8 bytes representation + * of the token. Useful in instances where characters are represented by multiple + * tokens and their byte representations must be combined to generate the correct + * text representation. Can be null if there is no bytes representation for the + * token. + * @param topLogprobs List of the most likely tokens and their log probability, at + * this token position. In rare cases, there may be fewer than the number of + * requested top_logprobs returned. + */ + @JsonInclude(Include.NON_NULL) + public record Content(@JsonProperty("token") String token, @JsonProperty("logprob") Float logprob, + @JsonProperty("bytes") List probBytes, + @JsonProperty("top_logprobs") List topLogprobs) { + + /** + * The most likely tokens and their log probability, at this token position. + * + * @param token The token. + * @param logprob The log probability of the token. + * @param probBytes A list of integers representing the UTF-8 bytes + * representation of the token. Useful in instances where characters are + * represented by multiple tokens and their byte representations must be + * combined to generate the correct text representation. Can be null if there + * is no bytes representation for the token. + */ + @JsonInclude(Include.NON_NULL) + public record TopLogProbs(@JsonProperty("token") String token, @JsonProperty("logprob") Float logprob, + @JsonProperty("bytes") List probBytes) { + } + } + } + /** * Represents a streamed chunk of a chat completion response returned by model, based * on the provided input. @@ -614,13 +662,15 @@ public record ChatCompletionChunk( * @param index The index of the choice in the list of choices. * @param delta A chat completion delta generated by streamed model responses. * @param finishReason The reason the model stopped generating tokens. + * @param logprobs Log probability information for the choice. */ @JsonInclude(Include.NON_NULL) public record ChunkChoice( // @formatter:off @JsonProperty("index") Integer index, @JsonProperty("delta") ChatCompletionMessage delta, - @JsonProperty("finish_reason") ChatCompletionFinishReason finishReason) { + @JsonProperty("finish_reason") ChatCompletionFinishReason finishReason, + @JsonProperty("logprobs") LogProbs logprobs) { // @formatter:on } } diff --git a/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/api/MistralAiStreamFunctionCallingHelper.java b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/api/MistralAiStreamFunctionCallingHelper.java index ed730ec488c..774bd072934 100644 --- a/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/api/MistralAiStreamFunctionCallingHelper.java +++ b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/api/MistralAiStreamFunctionCallingHelper.java @@ -27,6 +27,7 @@ import org.springframework.ai.mistralai.api.MistralAiApi.ChatCompletionMessage.ChatCompletionFunction; import org.springframework.ai.mistralai.api.MistralAiApi.ChatCompletionMessage.Role; import org.springframework.ai.mistralai.api.MistralAiApi.ChatCompletionMessage.ToolCall; +import org.springframework.ai.mistralai.api.MistralAiApi.LogProbs; import org.springframework.util.CollectionUtils; /** @@ -83,8 +84,10 @@ private ChunkChoice merge(ChunkChoice previous, ChunkChoice current) { .toList(); var role = current.delta().role() != null ? current.delta().role() : Role.ASSISTANT; - current = new ChunkChoice(current.index(), new ChatCompletionMessage(current.delta().content(), - role, current.delta().name(), toolCallsWithID), current.finishReason()); + current = new ChunkChoice( + current.index(), new ChatCompletionMessage(current.delta().content(), role, + current.delta().name(), toolCallsWithID), + current.finishReason(), current.logprobs()); } } return current; @@ -95,8 +98,9 @@ private ChunkChoice merge(ChunkChoice previous, ChunkChoice current) { Integer index = (current.index() != null ? current.index() : previous.index()); ChatCompletionMessage message = merge(previous.delta(), current.delta()); + LogProbs logprobs = (current.logprobs() != null ? current.logprobs() : previous.logprobs()); - return new ChunkChoice(index, message, finishReason); + return new ChunkChoice(index, message, finishReason, logprobs); } private ChatCompletionMessage merge(ChatCompletionMessage previous, ChatCompletionMessage current) { diff --git a/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/MistralAiRetryTests.java b/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/MistralAiRetryTests.java index 549d788eade..1ca349d21a3 100644 --- a/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/MistralAiRetryTests.java +++ b/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/MistralAiRetryTests.java @@ -109,7 +109,7 @@ public void beforeEach() { public void mistralAiChatTransientError() { var choice = new ChatCompletion.Choice(0, new ChatCompletionMessage("Response", Role.ASSISTANT), - ChatCompletionFinishReason.STOP); + ChatCompletionFinishReason.STOP, null); ChatCompletion expectedChatCompletion = new ChatCompletion("id", "chat.completion", 789l, "model", List.of(choice), new MistralAiApi.Usage(10, 10, 10)); @@ -137,7 +137,7 @@ public void mistralAiChatNonTransientError() { public void mistralAiChatStreamTransientError() { var choice = new ChatCompletionChunk.ChunkChoice(0, new ChatCompletionMessage("Response", Role.ASSISTANT), - ChatCompletionFinishReason.STOP); + ChatCompletionFinishReason.STOP, null); ChatCompletionChunk expectedChatCompletion = new ChatCompletionChunk("id", "chat.completion.chunk", 789l, "model", List.of(choice)); From a50969ec8b029676e26d9753eccfe83420f3a696 Mon Sep 17 00:00:00 2001 From: wmz7year Date: Mon, 22 Apr 2024 12:31:25 +0800 Subject: [PATCH 30/46] Bedrock Titan embedding client adds BedrockTitanEmbeddingOptions to support dynamic embedding request types. --- .../ai/bedrock/aot/BedrockRuntimeHints.java | 4 ++ .../titan/BedrockTitanEmbeddingClient.java | 18 ++++- .../titan/BedrockTitanEmbeddingOptions.java | 65 +++++++++++++++++++ .../titan/BedrockTitanEmbeddingClientIT.java | 15 ++++- .../embeddings/bedrock-titan-embedding.adoc | 18 ++++- 5 files changed, 113 insertions(+), 7 deletions(-) create mode 100644 models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/titan/BedrockTitanEmbeddingOptions.java diff --git a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/aot/BedrockRuntimeHints.java b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/aot/BedrockRuntimeHints.java index edb3c4b1337..7db24b3b8c6 100644 --- a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/aot/BedrockRuntimeHints.java +++ b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/aot/BedrockRuntimeHints.java @@ -28,6 +28,7 @@ import org.springframework.ai.bedrock.llama.BedrockLlamaChatOptions; import org.springframework.ai.bedrock.llama.api.LlamaChatBedrockApi; import org.springframework.ai.bedrock.titan.BedrockTitanChatOptions; +import org.springframework.ai.bedrock.titan.BedrockTitanEmbeddingOptions; import org.springframework.ai.bedrock.titan.api.TitanChatBedrockApi; import org.springframework.ai.bedrock.titan.api.TitanEmbeddingBedrockApi; import org.springframework.aot.hint.MemberCategory; @@ -43,6 +44,7 @@ * @author Josh Long * @author Christian Tzolov * @author Mark Pollack + * @author Wei Jiang */ public class BedrockRuntimeHints implements RuntimeHintsRegistrar { @@ -72,6 +74,8 @@ public void registerHints(RuntimeHints hints, ClassLoader classLoader) { hints.reflection().registerType(tr, mcs); for (var tr : findJsonAnnotatedClassesInPackage(BedrockTitanChatOptions.class)) hints.reflection().registerType(tr, mcs); + for (var tr : findJsonAnnotatedClassesInPackage(BedrockTitanEmbeddingOptions.class)) + hints.reflection().registerType(tr, mcs); for (var tr : findJsonAnnotatedClassesInPackage(TitanEmbeddingBedrockApi.class)) hints.reflection().registerType(tr, mcs); diff --git a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/titan/BedrockTitanEmbeddingClient.java b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/titan/BedrockTitanEmbeddingClient.java index d48135f80ec..1d64f92eff8 100644 --- a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/titan/BedrockTitanEmbeddingClient.java +++ b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/titan/BedrockTitanEmbeddingClient.java @@ -28,6 +28,7 @@ import org.springframework.ai.document.Document; import org.springframework.ai.embedding.AbstractEmbeddingClient; import org.springframework.ai.embedding.Embedding; +import org.springframework.ai.embedding.EmbeddingOptions; import org.springframework.ai.embedding.EmbeddingRequest; import org.springframework.ai.embedding.EmbeddingResponse; import org.springframework.util.Assert; @@ -40,6 +41,7 @@ * Note: Titan Embedding does not support batch embedding. * * @author Christian Tzolov + * @author Wei Jiang * @since 0.8.0 */ public class BedrockTitanEmbeddingClient extends AbstractEmbeddingClient { @@ -87,9 +89,7 @@ public EmbeddingResponse call(EmbeddingRequest request) { List> embeddingList = new ArrayList<>(); for (String inputContent : request.getInstructions()) { - var apiRequest = (this.inputType == InputType.IMAGE) - ? new TitanEmbeddingRequest.Builder().withInputImage(inputContent).build() - : new TitanEmbeddingRequest.Builder().withInputText(inputContent).build(); + var apiRequest = createTitanEmbeddingRequest(inputContent, request.getOptions()); TitanEmbeddingResponse response = this.embeddingApi.embedding(apiRequest); embeddingList.add(response.embedding()); } @@ -100,6 +100,18 @@ public EmbeddingResponse call(EmbeddingRequest request) { return new EmbeddingResponse(embeddings); } + private TitanEmbeddingRequest createTitanEmbeddingRequest(String inputContent, EmbeddingOptions requestOptions) { + InputType inputType = this.inputType; + + if (requestOptions != null + && requestOptions instanceof BedrockTitanEmbeddingOptions bedrockTitanEmbeddingOptions) { + inputType = bedrockTitanEmbeddingOptions.getInputType(); + } + + return (inputType == InputType.IMAGE) ? new TitanEmbeddingRequest.Builder().withInputImage(inputContent).build() + : new TitanEmbeddingRequest.Builder().withInputText(inputContent).build(); + } + @Override public int dimensions() { if (this.inputType == InputType.IMAGE) { diff --git a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/titan/BedrockTitanEmbeddingOptions.java b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/titan/BedrockTitanEmbeddingOptions.java new file mode 100644 index 00000000000..fd1c609bf91 --- /dev/null +++ b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/titan/BedrockTitanEmbeddingOptions.java @@ -0,0 +1,65 @@ +/* + * 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.bedrock.titan; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; + +import org.springframework.ai.bedrock.titan.BedrockTitanEmbeddingClient.InputType; +import org.springframework.ai.embedding.EmbeddingOptions; +import org.springframework.util.Assert; + +/** + * @author Wei Jiang + */ +@JsonInclude(Include.NON_NULL) +public class BedrockTitanEmbeddingOptions implements EmbeddingOptions { + + /** + * Titan Embedding API input types. Could be either text or image (encoded in base64). + */ + private InputType inputType; + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private BedrockTitanEmbeddingOptions options = new BedrockTitanEmbeddingOptions(); + + public Builder withInputType(InputType inputType) { + Assert.notNull(inputType, "input type can not be null."); + + this.options.setInputType(inputType); + return this; + } + + public BedrockTitanEmbeddingOptions build() { + return this.options; + } + + } + + public InputType getInputType() { + return this.inputType; + } + + public void setInputType(InputType inputType) { + this.inputType = inputType; + } + +} diff --git a/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/titan/BedrockTitanEmbeddingClientIT.java b/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/titan/BedrockTitanEmbeddingClientIT.java index dead759015c..6400a15f010 100644 --- a/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/titan/BedrockTitanEmbeddingClientIT.java +++ b/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/titan/BedrockTitanEmbeddingClientIT.java @@ -22,10 +22,14 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import software.amazon.awssdk.auth.credentials.EnvironmentVariableCredentialsProvider; import software.amazon.awssdk.regions.Region; +import org.springframework.ai.bedrock.titan.BedrockTitanEmbeddingClient.InputType; import org.springframework.ai.bedrock.titan.api.TitanEmbeddingBedrockApi; import org.springframework.ai.bedrock.titan.api.TitanEmbeddingBedrockApi.TitanEmbeddingModel; +import org.springframework.ai.embedding.EmbeddingRequest; import org.springframework.ai.embedding.EmbeddingResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.SpringBootConfiguration; @@ -33,6 +37,8 @@ import org.springframework.context.annotation.Bean; import org.springframework.core.io.DefaultResourceLoader; +import com.fasterxml.jackson.databind.ObjectMapper; + import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest @@ -46,7 +52,8 @@ class BedrockTitanEmbeddingClientIT { @Test void singleEmbedding() { assertThat(embeddingClient).isNotNull(); - EmbeddingResponse embeddingResponse = embeddingClient.embedForResponse(List.of("Hello World")); + EmbeddingResponse embeddingResponse = embeddingClient.call(new EmbeddingRequest(List.of("Hello World"), + BedrockTitanEmbeddingOptions.builder().withInputType(InputType.TEXT).build())); assertThat(embeddingResponse.getResults()).hasSize(1); assertThat(embeddingResponse.getResults().get(0).getOutput()).isNotEmpty(); assertThat(embeddingClient.dimensions()).isEqualTo(1024); @@ -59,7 +66,8 @@ void imageEmbedding() throws IOException { .getContentAsByteArray(); EmbeddingResponse embeddingResponse = embeddingClient - .embedForResponse(List.of(Base64.getEncoder().encodeToString(image))); + .call(new EmbeddingRequest(List.of(Base64.getEncoder().encodeToString(image)), + BedrockTitanEmbeddingOptions.builder().withInputType(InputType.IMAGE).build())); assertThat(embeddingResponse.getResults()).hasSize(1); assertThat(embeddingResponse.getResults().get(0).getOutput()).isNotEmpty(); assertThat(embeddingClient.dimensions()).isEqualTo(1024); @@ -70,7 +78,8 @@ public static class TestConfiguration { @Bean public TitanEmbeddingBedrockApi titanEmbeddingApi() { - return new TitanEmbeddingBedrockApi(TitanEmbeddingModel.TITAN_EMBED_IMAGE_V1.id(), Region.US_EAST_1.id(), + return new TitanEmbeddingBedrockApi(TitanEmbeddingModel.TITAN_EMBED_IMAGE_V1.id(), + EnvironmentVariableCredentialsProvider.create(), Region.US_EAST_1.id(), new ObjectMapper(), Duration.ofMinutes(2)); } diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/bedrock-titan-embedding.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/bedrock-titan-embedding.adoc index 6d5d675a06c..b7bc8a74eb7 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/bedrock-titan-embedding.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/bedrock-titan-embedding.adoc @@ -81,6 +81,22 @@ The prefix `spring.ai.bedrock.titan.embedding` (defined in `BedrockTitanEmbeddin Supported values are: `amazon.titan-embed-image-v1` and `amazon.titan-embed-text-v1`. Model ID values can also be found in the https://docs.aws.amazon.com/bedrock/latest/userguide/model-ids-arns.html[AWS Bedrock documentation for base model IDs]. +== Runtime Options [[embedding-options]] + +The https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/titan/BedrockTitanEmbeddingOptions.java[BedrockTitanEmbeddingOptions.java] provides model configurations, such as `input-type`. +On start-up, the default options can be configured with the `BedrockTitanEmbeddingClient(api).withInputType(type)` method or the `spring.ai.bedrock.titan.embedding.input-type` properties. + +At run-time you can override the default options by adding new, request specific, options to the `EmbeddingRequest` call. +For example to override the default temperature for a specific request: + +[source,java] +---- +EmbeddingResponse embeddingResponse = embeddingClient.call( + new EmbeddingRequest(List.of("Hello World", "World is big and salvation is near"), + BedrockTitanEmbeddingOptions.builder() + .withInputType(InputType.TEXT) + .build())); +---- == Sample Controller @@ -154,7 +170,7 @@ Next, create an https://github.com/spring-projects/spring-ai/blob/main/models/sp var titanEmbeddingApi = new TitanEmbeddingBedrockApi( TitanEmbeddingModel.TITAN_EMBED_IMAGE_V1.id(), Region.US_EAST_1.id()); -var embeddingClient new BedrockTitanEmbeddingClient(titanEmbeddingApi); +var embeddingClient = new BedrockTitanEmbeddingClient(titanEmbeddingApi); EmbeddingResponse embeddingResponse = embeddingClient .embedForResponse(List.of("Hello World")); // NOTE titan does not support batch embedding. From daf131be2cc668fe8f1b536b3025dff14e1d231f Mon Sep 17 00:00:00 2001 From: mck Date: Wed, 24 Apr 2024 14:12:49 +0200 Subject: [PATCH 31/46] Fix column creation, when adding additional normal and embedding colums), and make index name unique (for when there are multiple vector indexes in the same keyspace) And change stream to for-loop when converting List to Float[] for performance --- .../CassandraVectorStoreProperties.java | 2 +- .../CassandraVectorStorePropertiesTests.java | 2 +- .../ai/vectorstore/CassandraVectorStore.java | 11 ++++++++++- .../vectorstore/CassandraVectorStoreConfig.java | 16 ++++++++++++---- .../CassandraRichSchemaVectorStoreIT.java | 7 ++++++- .../resources/test_wiki_partial_4_schema.cql | 10 ++++++++++ 6 files changed, 40 insertions(+), 8 deletions(-) create mode 100644 vector-stores/spring-ai-cassandra/src/test/resources/test_wiki_partial_4_schema.cql diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraVectorStoreProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraVectorStoreProperties.java index 1f243310079..27af7605e38 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraVectorStoreProperties.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraVectorStoreProperties.java @@ -33,7 +33,7 @@ public class CassandraVectorStoreProperties { private String table = CassandraVectorStoreConfig.DEFAULT_TABLE_NAME; - private String indexName = CassandraVectorStoreConfig.DEFAULT_INDEX_NAME; + private String indexName = null; private String contentColumnName = CassandraVectorStoreConfig.DEFAULT_CONTENT_COLUMN_NAME; diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraVectorStorePropertiesTests.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraVectorStorePropertiesTests.java index ca5c678d722..c0d5ad04aeb 100644 --- a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraVectorStorePropertiesTests.java +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraVectorStorePropertiesTests.java @@ -34,7 +34,7 @@ void defaultValues() { assertThat(props.getTable()).isEqualTo(CassandraVectorStoreConfig.DEFAULT_TABLE_NAME); assertThat(props.getContentColumnName()).isEqualTo(CassandraVectorStoreConfig.DEFAULT_CONTENT_COLUMN_NAME); assertThat(props.getEmbeddingColumnName()).isEqualTo(CassandraVectorStoreConfig.DEFAULT_EMBEDDING_COLUMN_NAME); - assertThat(props.getIndexName()).isEqualTo(CassandraVectorStoreConfig.DEFAULT_INDEX_NAME); + assertThat(props.getIndexName()).isNull(); assertThat(props.getDisallowSchemaCreation()).isFalse(); assertThat(props.getFixedThreadPoolExecutorSize()) .isEqualTo(CassandraVectorStoreConfig.DEFAULT_ADD_CONCURRENCY); diff --git a/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStore.java b/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStore.java index 4e2732d58b7..c7422cc5b7e 100644 --- a/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStore.java +++ b/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStore.java @@ -206,7 +206,7 @@ public Optional delete(List idList) { @Override public List similaritySearch(SearchRequest request) { Preconditions.checkArgument(request.getTopK() <= 1000); - var embedding = this.embeddingClient.embed(request.getQuery()).stream().map(Double::floatValue).toList(); + var embedding = toFloatArray(this.embeddingClient.embed(request.getQuery())); CqlVector cqlVector = CqlVector.newInstance(embedding); String whereClause = ""; @@ -350,4 +350,13 @@ private String getDocumentId(Row row) { return this.conf.primaryKeyTranslator.apply(primaryKeyValues); } + private static Float[] toFloatArray(List embeddingDouble) { + Float[] embeddingFloat = new Float[embeddingDouble.size()]; + int i = 0; + for (Double d : embeddingDouble) { + embeddingFloat[i++] = d.floatValue(); + } + return embeddingFloat; + } + } diff --git a/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStoreConfig.java b/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStoreConfig.java index 91356e5e074..32a76d5087e 100644 --- a/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStoreConfig.java +++ b/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStoreConfig.java @@ -44,9 +44,12 @@ import com.datastax.oss.driver.api.querybuilder.schema.CreateTableStart; import com.datastax.oss.driver.shaded.guava.common.annotations.VisibleForTesting; import com.datastax.oss.driver.shaded.guava.common.base.Preconditions; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.lang.Nullable; + /** * Configuration for the Cassandra vector store. * @@ -71,7 +74,7 @@ public final class CassandraVectorStoreConfig implements AutoCloseable { public static final String DEFAULT_ID_NAME = "id"; - public static final String DEFAULT_INDEX_NAME = "embedding_index"; + public static final String DEFAULT_INDEX_SUFFIX = "idx"; public static final String DEFAULT_CONTENT_COLUMN_NAME = "content"; @@ -186,7 +189,7 @@ public static class Builder { private List clusteringKeys = List.of(); - private String indexName = DEFAULT_INDEX_NAME; + private String indexName = null; private String contentColumnName = DEFAULT_CONTENT_COLUMN_NAME; @@ -257,6 +260,8 @@ public Builder withClusteringKeys(List clusteringKeys) { return this; } + /** defaults (if null) to '__idx' **/ + @Nullable public Builder withIndexName(String name) { this.indexName = name; return this; @@ -324,6 +329,9 @@ public Builder withPrimaryKeyTranslator(PrimaryKeyTranslator primaryKeyTranslato } public CassandraVectorStoreConfig build() { + if (null == this.indexName) { + this.indexName = String.format("%s_%s_%s", this.table, this.embeddingColumnName, DEFAULT_INDEX_SUFFIX); + } for (SchemaColumn metadata : this.metadataColumns) { Preconditions.checkArgument( @@ -530,7 +538,7 @@ private void ensureTableColumnsExist(int vectorDimension) { // special case for embedding column, bc JAVA-3118, as above StringBuilder alterTableStmt = new StringBuilder(((BuildableQuery) alterTable).asCql()); if (newColumns.isEmpty() && !addContent) { - alterTableStmt.append(" ADD "); + alterTableStmt.append(" ADD ("); } else { alterTableStmt.setLength(alterTableStmt.length() - 1); @@ -539,7 +547,7 @@ private void ensureTableColumnsExist(int vectorDimension) { alterTableStmt.append(this.schema.embedding) .append(" vector"); + .append(">)"); logger.debug("Executing {}", alterTableStmt.toString()); this.session.execute(alterTableStmt.toString()); diff --git a/vector-stores/spring-ai-cassandra/src/test/java/org/springframework/ai/vectorstore/CassandraRichSchemaVectorStoreIT.java b/vector-stores/spring-ai-cassandra/src/test/java/org/springframework/ai/vectorstore/CassandraRichSchemaVectorStoreIT.java index 2dc59afae34..868c79fbb3f 100644 --- a/vector-stores/spring-ai-cassandra/src/test/java/org/springframework/ai/vectorstore/CassandraRichSchemaVectorStoreIT.java +++ b/vector-stores/spring-ai-cassandra/src/test/java/org/springframework/ai/vectorstore/CassandraRichSchemaVectorStoreIT.java @@ -134,7 +134,8 @@ void ensureSchemaNoCreation() { @Test void ensureSchemaPartialCreation() { this.contextRunner.run(context -> { - for (int i = 0; i < 4; ++i) { + int PARTIAL_FILES = 5; + for (int i = 0; i < PARTIAL_FILES; ++i) { executeCqlFile(context, format("test_wiki_partial_%d_schema.cql", i)); var wrapper = createStore(context, List.of(), false, false); try { @@ -148,6 +149,10 @@ void ensureSchemaPartialCreation() { wrapper.store().close(); } } + // make sure there's not more files to test + Assertions.assertThrows(IOException.class, () -> { + executeCqlFile(context, format("test_wiki_partial_%d_schema.cql", PARTIAL_FILES)); + }); }); } diff --git a/vector-stores/spring-ai-cassandra/src/test/resources/test_wiki_partial_4_schema.cql b/vector-stores/spring-ai-cassandra/src/test/resources/test_wiki_partial_4_schema.cql new file mode 100644 index 00000000000..68b4583c491 --- /dev/null +++ b/vector-stores/spring-ai-cassandra/src/test/resources/test_wiki_partial_4_schema.cql @@ -0,0 +1,10 @@ +CREATE KEYSPACE IF NOT EXISTS test_wikidata WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1}; + +CREATE TABLE IF NOT EXISTS test_wikidata.articles ( + wiki text, + language text, + title text, + chunk_no int, + messages text, + PRIMARY KEY ((wiki, language, title), chunk_no) +); \ No newline at end of file From 6fd3df08e01cd129f8c90b370d10f3563cae2f6e Mon Sep 17 00:00:00 2001 From: Jemin Huh Date: Mon, 25 Mar 2024 20:58:15 +0900 Subject: [PATCH 32/46] add support for OpenSearch vector store --- .../spring-ai-opensearch-store/pom.xml | 85 ++++ ...archAiSearchFilterExpressionConverter.java | 150 +++++++ .../ai/vectorstore/OpenSearchVectorStore.java | 202 ++++++++++ ...AiSearchFilterExpressionConverterTest.java | 117 ++++++ .../vectorstore/OpenSearchVectorStoreIT.java | 367 ++++++++++++++++++ 5 files changed, 921 insertions(+) create mode 100644 vector-stores/spring-ai-opensearch-store/pom.xml create mode 100644 vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchAiSearchFilterExpressionConverter.java create mode 100644 vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchVectorStore.java create mode 100644 vector-stores/spring-ai-opensearch-store/src/test/java/org/springframework/ai/vectorstore/OpenSearchAiSearchFilterExpressionConverterTest.java create mode 100644 vector-stores/spring-ai-opensearch-store/src/test/java/org/springframework/ai/vectorstore/OpenSearchVectorStoreIT.java diff --git a/vector-stores/spring-ai-opensearch-store/pom.xml b/vector-stores/spring-ai-opensearch-store/pom.xml new file mode 100644 index 00000000000..3681050f526 --- /dev/null +++ b/vector-stores/spring-ai-opensearch-store/pom.xml @@ -0,0 +1,85 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../pom.xml + + spring-ai-opensearch-store + jar + Spring AI Vector Store - OpenSearch + Spring AI OpenSearch 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} + + + + org.opensearch.client + opensearch-java + 2.9.1 + + + + org.apache.httpcomponents.client5 + httpclient5 + 5.3.1 + + + + + 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.opensearch + opensearch-testcontainers + 2.0.1 + test + + + + org.testcontainers + junit-jupiter + ${testcontainers.version} + test + + + + + diff --git a/vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchAiSearchFilterExpressionConverter.java b/vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchAiSearchFilterExpressionConverter.java new file mode 100644 index 00000000000..9035a86d299 --- /dev/null +++ b/vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchAiSearchFilterExpressionConverter.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 OpenSearchAiSearchFilterExpressionConverter 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 OpenSearchAiSearchFilterExpressionConverter() { + 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-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchVectorStore.java b/vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchVectorStore.java new file mode 100644 index 00000000000..af2ee7055d2 --- /dev/null +++ b/vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchVectorStore.java @@ -0,0 +1,202 @@ +/* + * 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.opensearch.client.json.JsonData; +import org.opensearch.client.opensearch.OpenSearchClient; +import org.opensearch.client.opensearch._types.mapping.Property; +import org.opensearch.client.opensearch._types.query_dsl.Query; +import org.opensearch.client.opensearch.core.BulkRequest; +import org.opensearch.client.opensearch.core.BulkResponse; +import org.opensearch.client.opensearch.core.search.Hit; +import org.opensearch.client.opensearch.indices.CreateIndexRequest; +import org.opensearch.client.opensearch.indices.CreateIndexResponse; +import org.opensearch.client.transport.endpoints.BooleanResponse; +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.util.*; +import java.util.stream.Collectors; + +/** + * @author Jemin Huh + * @since 1.0.0 + */ +public class OpenSearchVectorStore implements VectorStore, InitializingBean { + + public static final String COSINE_SIMILARITY_FUNCTION = "cosinesimil"; + + private static final Logger logger = LoggerFactory.getLogger(OpenSearchVectorStore.class); + + private static final String INDEX_NAME = "spring-ai-document-index"; + + private final EmbeddingClient embeddingClient; + + private final OpenSearchClient openSearchClient; + + private final String index; + + private final FilterExpressionConverter filterExpressionConverter; + + private String similarityFunction; + + public OpenSearchVectorStore(OpenSearchClient openSearchClient, EmbeddingClient embeddingClient) { + this(INDEX_NAME, openSearchClient, embeddingClient); + } + + public OpenSearchVectorStore(String index, OpenSearchClient openSearchClient, + EmbeddingClient embeddingClient) { + Objects.requireNonNull(embeddingClient, "RestClient must not be null"); + Objects.requireNonNull(embeddingClient, "EmbeddingClient must not be null"); + this.openSearchClient = openSearchClient; + this.embeddingClient = embeddingClient; + this.index = index; + this.filterExpressionConverter = new OpenSearchAiSearchFilterExpressionConverter(); + // the potential functions for vector fields at + // https://opensearch.org/docs/latest/search-plugins/knn/approximate-knn/#spaces + this.similarityFunction = COSINE_SIMILARITY_FUNCTION; + } + + public OpenSearchVectorStore 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.openSearchClient.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(), + searchRequest.getSimilarityThreshold(), searchRequest.getFilterExpression()); + } + + public List similaritySearch(List embedding, int topK, double similarityThreshold, + Filter.Expression filterExpression) { + return similaritySearch(new org.opensearch.client.opensearch.core.SearchRequest.Builder() + .query(getOpenSearchSimilarityQuery(embedding, filterExpression)) + .size(topK) + .minScore(similarityThreshold) + .build()); + } + + private Query getOpenSearchSimilarityQuery(List embedding, Filter.Expression filterExpression) { + return Query.of(queryBuilder -> queryBuilder.scriptScore(scriptScoreQueryBuilder -> { + scriptScoreQueryBuilder.query( + queryBuilder2 -> queryBuilder2.queryString(queryStringQuerybuilder -> queryStringQuerybuilder + .query(getOpenSearchQueryString(filterExpression)))) + .script(scriptBuilder -> scriptBuilder + .inline(inlineScriptBuilder -> inlineScriptBuilder.source("knn_score") + .lang("knn") + .params("field", JsonData.of("embedding")) + .params("query_value", JsonData.of(embedding)) + .params("space_type", JsonData.of(this.similarityFunction)))); + // https://opensearch.org/docs/latest/search-plugins/knn/knn-score-script + // k-NN ensures non-negative scores by adding 1 to cosine similarity, extending OpenSearch scores to 0-2. + // A 0.5 boost normalizes to 0-1. + return this.similarityFunction.equals(COSINE_SIMILARITY_FUNCTION) ? scriptScoreQueryBuilder.boost( + 0.5f) : scriptScoreQueryBuilder; + })); + } + + private String getOpenSearchQueryString(Filter.Expression filterExpression) { + return Objects.isNull(filterExpression) ? "*" + : this.filterExpressionConverter.convertExpression(filterExpression); + + } + + private List similaritySearch(org.opensearch.client.opensearch.core.SearchRequest searchRequest) { + try { + return this.openSearchClient.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.openSearchClient.indices() + .exists(existRequestBuilder -> existRequestBuilder.index(targetIndex)); + return response.value(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public CreateIndexResponse createIndexMapping(String index, Map properties) { + try { + return this.openSearchClient.indices() + .create(new CreateIndexRequest.Builder().index(index).settings(setting -> setting.knn(true)) + .mappings(propertiesBuilder -> propertiesBuilder.properties(properties)).build()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public void afterPropertiesSet() { + if (!exists(this.index)) { + createIndexMapping(this.index, Map.of("embedding", Property.of(p -> p.knnVector(k -> k.dimension(1536))))); + } + } +} \ No newline at end of file diff --git a/vector-stores/spring-ai-opensearch-store/src/test/java/org/springframework/ai/vectorstore/OpenSearchAiSearchFilterExpressionConverterTest.java b/vector-stores/spring-ai-opensearch-store/src/test/java/org/springframework/ai/vectorstore/OpenSearchAiSearchFilterExpressionConverterTest.java new file mode 100644 index 00000000000..274f132730e --- /dev/null +++ b/vector-stores/spring-ai-opensearch-store/src/test/java/org/springframework/ai/vectorstore/OpenSearchAiSearchFilterExpressionConverterTest.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 OpenSearchAiSearchFilterExpressionConverterTest { + + final FilterExpressionConverter converter = new OpenSearchAiSearchFilterExpressionConverter(); + + @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-opensearch-store/src/test/java/org/springframework/ai/vectorstore/OpenSearchVectorStoreIT.java b/vector-stores/spring-ai-opensearch-store/src/test/java/org/springframework/ai/vectorstore/OpenSearchVectorStoreIT.java new file mode 100644 index 00000000000..3d8d97f53f3 --- /dev/null +++ b/vector-stores/spring-ai-opensearch-store/src/test/java/org/springframework/ai/vectorstore/OpenSearchVectorStoreIT.java @@ -0,0 +1,367 @@ +/* + * 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.apache.hc.core5.http.HttpHost; +import org.awaitility.Awaitility; +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.opensearch.client.opensearch.OpenSearchClient; +import org.opensearch.client.transport.OpenSearchTransport; +import org.opensearch.client.transport.httpclient5.ApacheHttpClient5Transport; +import org.opensearch.client.transport.httpclient5.ApacheHttpClient5TransportBuilder; +import org.opensearch.testcontainers.OpensearchContainer; +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 org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import javax.net.ssl.SSLEngine; +import java.io.IOException; +import java.net.URISyntaxException; +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 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 OpenSearchVectorStoreIT { + + @Container + private static final OpensearchContainer opensearchContainer = + new OpensearchContainer<>(DockerImageName.parse("opensearchproject/opensearch:2.12.0")); + + private static final String DEFAULT = "cosinesimil"; + + 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, "l1", "l2", "linf"}) + public void addAndSearchTest(String similarityFunction) { + + getContextRunner().run(context -> { + OpenSearchVectorStore vectorStore = context.getBean(OpenSearchVectorStore.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, "l1", "l2", "linf"}) + public void searchWithFilters(String similarityFunction) { + + getContextRunner().run(context -> { + OpenSearchVectorStore vectorStore = context.getBean(OpenSearchVectorStore.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, "l1", "l2", "linf"}) + public void documentUpdateTest(String similarityFunction) { + + getContextRunner().run(context -> { + OpenSearchVectorStore vectorStore = context.getBean(OpenSearchVectorStore.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, "l1", "l2", "linf"}) + public void searchThresholdTest(String similarityFunction) { + + getContextRunner().run(context -> { + OpenSearchVectorStore vectorStore = context.getBean(OpenSearchVectorStore.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 OpenSearchVectorStore vectorStore(EmbeddingClient embeddingClient) { + try { + return new OpenSearchVectorStore(new OpenSearchClient(ApacheHttpClient5TransportBuilder.builder( + HttpHost.create(opensearchContainer.getHttpHostAddress())).build()), embeddingClient); + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } + } + + @Bean + public EmbeddingClient embeddingClient() { + return new OpenAiEmbeddingClient(new OpenAiApi(System.getenv("OPENAI_API_KEY"))); + } + + } + +} From d30e989f17940a67e4df7fb2f2a4ff9baf52ea67 Mon Sep 17 00:00:00 2001 From: Jemin Huh Date: Thu, 4 Apr 2024 22:34:12 +0900 Subject: [PATCH 33/46] moving versions for OpenSearch as a property in the parent POM --- pom.xml | 2 ++ vector-stores/spring-ai-opensearch-store/pom.xml | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 1aa98332298..38e984a8366 100644 --- a/pom.xml +++ b/pom.xml @@ -151,6 +151,8 @@ 11.6.1 4.5.1 1.7.1 + 2.9.1 + 5.3.1 0.0.4 diff --git a/vector-stores/spring-ai-opensearch-store/pom.xml b/vector-stores/spring-ai-opensearch-store/pom.xml index 3681050f526..4c11603369d 100644 --- a/vector-stores/spring-ai-opensearch-store/pom.xml +++ b/vector-stores/spring-ai-opensearch-store/pom.xml @@ -35,13 +35,13 @@ org.opensearch.client opensearch-java - 2.9.1 + ${opensearch-client.version} org.apache.httpcomponents.client5 httpclient5 - 5.3.1 + ${httpclient5.version} From 6f24154be96a3a8937883d451505920c2eb9b889 Mon Sep 17 00:00:00 2001 From: Jemin Huh Date: Mon, 15 Apr 2024 00:22:08 +0900 Subject: [PATCH 34/46] Add mappingJson parameter to OpenSearchVectorStore --- .../ai/vectorstore/OpenSearchVectorStore.java | 41 +++++++++++++++---- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchVectorStore.java b/vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchVectorStore.java index af2ee7055d2..608ce87cf24 100644 --- a/vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchVectorStore.java +++ b/vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchVectorStore.java @@ -16,9 +16,11 @@ package org.springframework.ai.vectorstore; +import jakarta.json.stream.JsonParser; import org.opensearch.client.json.JsonData; +import org.opensearch.client.json.JsonpMapper; import org.opensearch.client.opensearch.OpenSearchClient; -import org.opensearch.client.opensearch._types.mapping.Property; +import org.opensearch.client.opensearch._types.mapping.TypeMapping; import org.opensearch.client.opensearch._types.query_dsl.Query; import org.opensearch.client.opensearch.core.BulkRequest; import org.opensearch.client.opensearch.core.BulkResponse; @@ -36,6 +38,7 @@ import org.springframework.util.Assert; import java.io.IOException; +import java.io.StringReader; import java.util.*; import java.util.stream.Collectors; @@ -49,7 +52,17 @@ public class OpenSearchVectorStore implements VectorStore, InitializingBean { private static final Logger logger = LoggerFactory.getLogger(OpenSearchVectorStore.class); - private static final String INDEX_NAME = "spring-ai-document-index"; + public static final String DEFAULT_INDEX_NAME = "spring-ai-document-index"; + public static final String DEFAULT_MAPPING_EMBEDDING_TYPE_KNN_VECTOR_DIMENSION_1536 = """ + { + "properties":{ + "embedding":{ + "type":"knn_vector", + "dimension":1536 + } + } + } + """; private final EmbeddingClient embeddingClient; @@ -59,19 +72,27 @@ public class OpenSearchVectorStore implements VectorStore, InitializingBean { private final FilterExpressionConverter filterExpressionConverter; + private final String mappingJson; + private String similarityFunction; public OpenSearchVectorStore(OpenSearchClient openSearchClient, EmbeddingClient embeddingClient) { - this(INDEX_NAME, openSearchClient, embeddingClient); + this(openSearchClient, embeddingClient, DEFAULT_MAPPING_EMBEDDING_TYPE_KNN_VECTOR_DIMENSION_1536); + } + + public OpenSearchVectorStore(OpenSearchClient openSearchClient, EmbeddingClient embeddingClient, + String mappingJson) { + this(DEFAULT_INDEX_NAME, openSearchClient, embeddingClient, mappingJson); } public OpenSearchVectorStore(String index, OpenSearchClient openSearchClient, - EmbeddingClient embeddingClient) { + EmbeddingClient embeddingClient, String mappingJson) { Objects.requireNonNull(embeddingClient, "RestClient must not be null"); Objects.requireNonNull(embeddingClient, "EmbeddingClient must not be null"); this.openSearchClient = openSearchClient; this.embeddingClient = embeddingClient; this.index = index; + this.mappingJson = mappingJson; this.filterExpressionConverter = new OpenSearchAiSearchFilterExpressionConverter(); // the potential functions for vector fields at // https://opensearch.org/docs/latest/search-plugins/knn/approximate-knn/#spaces @@ -183,11 +204,13 @@ public boolean exists(String targetIndex) { } } - public CreateIndexResponse createIndexMapping(String index, Map properties) { + private CreateIndexResponse createIndexMapping(String index, String mappingJson) { + JsonpMapper mapper = openSearchClient._transport().jsonpMapper(); + JsonParser parser = mapper.jsonProvider().createParser(new StringReader(mappingJson)); try { - return this.openSearchClient.indices() - .create(new CreateIndexRequest.Builder().index(index).settings(setting -> setting.knn(true)) - .mappings(propertiesBuilder -> propertiesBuilder.properties(properties)).build()); + return this.openSearchClient.indices().create(new CreateIndexRequest.Builder().index(index) + .settings(settingsBuilder -> settingsBuilder.knn(true)) + .mappings(TypeMapping._DESERIALIZER.deserialize(parser, mapper)).build()); } catch (IOException e) { throw new RuntimeException(e); } @@ -196,7 +219,7 @@ public CreateIndexResponse createIndexMapping(String index, Map p.knnVector(k -> k.dimension(1536))))); + createIndexMapping(this.index, mappingJson); } } } \ No newline at end of file From f5d9d70f9746de3390193f6100dd874d4ac3fba9 Mon Sep 17 00:00:00 2001 From: Jemin Huh Date: Mon, 15 Apr 2024 00:27:52 +0900 Subject: [PATCH 35/46] Add opensearch auto-configuration and boot starter --- pom.xml | 6 + spring-ai-bom/pom.xml | 6 + spring-ai-spring-boot-autoconfigure/pom.xml | 14 ++ ...penSearchVectorStoreAutoConfiguration.java | 62 +++++++++ .../OpenSearchVectorStoreProperties.java | 60 +++++++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + ...nSearchVectorStoreAutoConfigurationIT.java | 125 ++++++++++++++++++ .../pom.xml | 42 ++++++ 8 files changed, 316 insertions(+) create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreAutoConfiguration.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreProperties.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreAutoConfigurationIT.java create mode 100644 spring-ai-spring-boot-starters/spring-ai-starter-opensearch-store/pom.xml diff --git a/pom.xml b/pom.xml index 38e984a8366..96b1f4f3371 100644 --- a/pom.xml +++ b/pom.xml @@ -74,6 +74,8 @@ vector-stores/spring-ai-elasticsearch-store spring-ai-spring-boot-starters/spring-ai-starter-watsonx-ai spring-ai-spring-boot-starters/spring-ai-starter-elasticsearch-store + vector-stores/spring-ai-opensearch-store + spring-ai-spring-boot-starters/spring-ai-starter-opensearch-store @@ -154,6 +156,10 @@ 2.9.1 5.3.1 + + 1.19.7 + 2.0.1 + 0.0.4 1.6.2 diff --git a/spring-ai-bom/pom.xml b/spring-ai-bom/pom.xml index d1c857f9da9..43a481c157d 100644 --- a/spring-ai-bom/pom.xml +++ b/spring-ai-bom/pom.xml @@ -210,6 +210,12 @@ ${project.version} + + org.springframework.ai + spring-ai-opensearch-store + ${project.version} + + org.springframework.ai diff --git a/spring-ai-spring-boot-autoconfigure/pom.xml b/spring-ai-spring-boot-autoconfigure/pom.xml index d48275f3d83..a01b11affc9 100644 --- a/spring-ai-spring-boot-autoconfigure/pom.xml +++ b/spring-ai-spring-boot-autoconfigure/pom.xml @@ -267,6 +267,13 @@ true + + org.springframework.ai + spring-ai-opensearch-store + ${project.parent.version} + true + + @@ -354,6 +361,13 @@ test + + org.opensearch + opensearch-testcontainers + ${testcontainers.opensearch.version} + test + + org.skyscreamer jsonassert diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreAutoConfiguration.java new file mode 100644 index 00000000000..538571dd758 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreAutoConfiguration.java @@ -0,0 +1,62 @@ +/* + * 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.autoconfigure.vectorstore.opensearch; + +import org.apache.hc.core5.http.HttpHost; +import org.opensearch.client.opensearch.OpenSearchClient; +import org.opensearch.client.transport.httpclient5.ApacheHttpClient5TransportBuilder; +import org.springframework.ai.embedding.EmbeddingClient; +import org.springframework.ai.vectorstore.OpenSearchVectorStore; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +import java.net.URISyntaxException; +import java.util.Optional; + +@AutoConfiguration +@ConditionalOnClass({OpenSearchVectorStore.class, EmbeddingClient.class, OpenSearchClient.class}) +@EnableConfigurationProperties(OpenSearchVectorStoreProperties.class) +class OpenSearchVectorStoreAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + OpenSearchVectorStore vectorStore(OpenSearchVectorStoreProperties properties, OpenSearchClient openSearchClient, + EmbeddingClient embeddingClient) { + return new OpenSearchVectorStore( + Optional.ofNullable(properties.getIndexName()).orElse(OpenSearchVectorStore.DEFAULT_INDEX_NAME), + openSearchClient, embeddingClient, Optional.ofNullable(properties.getMappingJson()) + .orElse(OpenSearchVectorStore.DEFAULT_MAPPING_EMBEDDING_TYPE_KNN_VECTOR_DIMENSION_1536)); + } + + @Bean + @ConditionalOnMissingBean + OpenSearchClient openSearchClient(OpenSearchVectorStoreProperties properties) { + return new OpenSearchClient(ApacheHttpClient5TransportBuilder.builder( + properties.getUris().stream().map(s -> creatHttpHost(s)).toArray(HttpHost[]::new)).build()); + } + + private HttpHost creatHttpHost(String s) { + try { + return HttpHost.create(s); + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreProperties.java new file mode 100644 index 00000000000..4e45b4da9ee --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreProperties.java @@ -0,0 +1,60 @@ +/* + * 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.autoconfigure.vectorstore.opensearch; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.List; + +@ConfigurationProperties(prefix = OpenSearchVectorStoreProperties.CONFIG_PREFIX) +public class OpenSearchVectorStoreProperties { + + public static final String CONFIG_PREFIX = "spring.ai.vectorstore.opensearch"; + + /** + * Comma-separated list of the OpenSearch instances to use. + */ + private List uris; + + private String indexName; + + private String mappingJson; + + public String getMappingJson() { + return mappingJson; + } + + public void setMappingJson(String mappingJson) { + this.mappingJson = mappingJson; + } + + public List getUris() { + return uris; + } + + public void setUris(List uris) { + this.uris = uris; + } + + public String getIndexName() { + return this.indexName; + } + + public void setIndexName(String indexName) { + this.indexName = indexName; + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index c2816002bcd..1d1532873d9 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -32,3 +32,4 @@ org.springframework.ai.autoconfigure.anthropic.AnthropicAutoConfiguration org.springframework.ai.autoconfigure.watsonxai.WatsonxAiAutoConfiguration org.springframework.ai.autoconfigure.vectorstore.elasticsearch.ElasticsearchVectorStoreAutoConfiguration org.springframework.ai.autoconfigure.vectorstore.cassandra.CassandraVectorStoreAutoConfiguration +org.springframework.ai.autoconfigure.vectorstore.opensearch.OpenSearchVectorStoreAutoConfiguration diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreAutoConfigurationIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreAutoConfigurationIT.java new file mode 100644 index 00000000000..bfa3dab48f2 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreAutoConfigurationIT.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.autoconfigure.vectorstore.opensearch; + +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Test; +import org.opensearch.testcontainers.OpensearchContainer; +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.document.Document; +import org.springframework.ai.embedding.EmbeddingClient; +import org.springframework.ai.transformers.TransformersEmbeddingClient; +import org.springframework.ai.vectorstore.OpenSearchVectorStore; +import org.springframework.ai.vectorstore.SearchRequest; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.DefaultResourceLoader; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.hasSize; + +@Testcontainers +class OpenSearchVectorStoreAutoConfigurationIT { + + @Container + private static final OpensearchContainer opensearchContainer = + new OpensearchContainer<>(DockerImageName.parse("opensearchproject/opensearch:2.12.0")); + + private static final String DOCUMENT_INDEX = "auto-spring-ai-document-index"; + + 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"))); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner().withConfiguration( + AutoConfigurations.of(OpenSearchVectorStoreAutoConfiguration.class, SpringAiRetryAutoConfiguration.class)) + .withUserConfiguration(Config.class) + .withPropertyValues( + OpenSearchVectorStoreProperties.CONFIG_PREFIX + ".uris=" + opensearchContainer.getHttpHostAddress(), + OpenSearchVectorStoreProperties.CONFIG_PREFIX + ".indexName=" + DOCUMENT_INDEX, + OpenSearchVectorStoreProperties.CONFIG_PREFIX + ".mappingJson=" + """ + { + "properties":{ + "embedding":{ + "type":"knn_vector", + "dimension":384 + } + } + } + """); + + @Test + public void addAndSearchTest() { + + this.contextRunner.run(context -> { + OpenSearchVectorStore vectorStore = context.getBean(OpenSearchVectorStore.class); + + 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)); + }); + } + + 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); + } + } + + @Configuration(proxyBeanMethods = false) + static class Config { + + @Bean + public EmbeddingClient embeddingClient() { + return new TransformersEmbeddingClient(); + } + + } + +} diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-opensearch-store/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-opensearch-store/pom.xml new file mode 100644 index 00000000000..c97eb81ad68 --- /dev/null +++ b/spring-ai-spring-boot-starters/spring-ai-starter-opensearch-store/pom.xml @@ -0,0 +1,42 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../pom.xml + + spring-ai-opensearch-store-spring-boot-starter + jar + Spring AI Starter - OpenSearch Store + Spring AI OpenSearch Store Auto Configuration + 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 + + + + + + org.springframework.boot + spring-boot-starter + + + + org.springframework.ai + spring-ai-spring-boot-autoconfigure + ${project.parent.version} + + + + org.springframework.ai + spring-ai-opensearch-store + ${project.parent.version} + + + + From 3d0710177f1257321bfa5e5e4dc41fa0f03a9e05 Mon Sep 17 00:00:00 2001 From: Jemin Huh Date: Mon, 29 Apr 2024 00:35:23 +0900 Subject: [PATCH 36/46] Add additional properties for OpenSearch auto-configuration --- ...penSearchVectorStoreAutoConfiguration.java | 25 ++++++++++++-- .../OpenSearchVectorStoreProperties.java | 34 +++++++++++++++---- 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreAutoConfiguration.java index 538571dd758..e5c24d4ddf0 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreAutoConfiguration.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreAutoConfiguration.java @@ -15,6 +15,9 @@ */ package org.springframework.ai.autoconfigure.vectorstore.opensearch; +import org.apache.hc.client5.http.auth.AuthScope; +import org.apache.hc.client5.http.auth.UsernamePasswordCredentials; +import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider; import org.apache.hc.core5.http.HttpHost; import org.opensearch.client.opensearch.OpenSearchClient; import org.opensearch.client.transport.httpclient5.ApacheHttpClient5TransportBuilder; @@ -47,11 +50,27 @@ OpenSearchVectorStore vectorStore(OpenSearchVectorStoreProperties properties, Op @Bean @ConditionalOnMissingBean OpenSearchClient openSearchClient(OpenSearchVectorStoreProperties properties) { - return new OpenSearchClient(ApacheHttpClient5TransportBuilder.builder( - properties.getUris().stream().map(s -> creatHttpHost(s)).toArray(HttpHost[]::new)).build()); + HttpHost[] httpHosts = properties.getUris().stream().map(s -> createHttpHost(s)).toArray(HttpHost[]::new); + ApacheHttpClient5TransportBuilder transportBuilder = ApacheHttpClient5TransportBuilder.builder(httpHosts); + + Optional.ofNullable(properties.getUsername()) + .map(username -> createBasicCredentialsProvider(httpHosts[0], username, properties.getPassword())) + .ifPresent(basicCredentialsProvider -> transportBuilder.setHttpClientConfigCallback( + httpAsyncClientBuilder -> httpAsyncClientBuilder.setDefaultCredentialsProvider( + basicCredentialsProvider))); + + return new OpenSearchClient(transportBuilder.build()); + } + + private BasicCredentialsProvider createBasicCredentialsProvider(HttpHost httpHost, String username, + String password) { + BasicCredentialsProvider basicCredentialsProvider = new BasicCredentialsProvider(); + basicCredentialsProvider.setCredentials(new AuthScope(httpHost), + new UsernamePasswordCredentials(username, password.toCharArray())); + return basicCredentialsProvider; } - private HttpHost creatHttpHost(String s) { + private HttpHost createHttpHost(String s) { try { return HttpHost.create(s); } catch (URISyntaxException e) { diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreProperties.java index 4e45b4da9ee..900cdbd233b 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreProperties.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreProperties.java @@ -31,15 +31,11 @@ public class OpenSearchVectorStoreProperties { private String indexName; - private String mappingJson; + private String username; - public String getMappingJson() { - return mappingJson; - } + private String password; - public void setMappingJson(String mappingJson) { - this.mappingJson = mappingJson; - } + private String mappingJson; public List getUris() { return uris; @@ -57,4 +53,28 @@ public void setIndexName(String indexName) { this.indexName = indexName; } + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getMappingJson() { + return mappingJson; + } + + public void setMappingJson(String mappingJson) { + this.mappingJson = mappingJson; + } + } From 639073e57a7dd9cdf649494a7b663d1002d02634 Mon Sep 17 00:00:00 2001 From: Jemin Huh Date: Mon, 29 Apr 2024 00:36:42 +0900 Subject: [PATCH 37/46] Update Docker version for OpenSearchVectorStoreIT and opensearch-client version --- pom.xml | 2 +- .../ai/vectorstore/OpenSearchVectorStoreIT.java | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/pom.xml b/pom.xml index 96b1f4f3371..30e19770d96 100644 --- a/pom.xml +++ b/pom.xml @@ -153,7 +153,7 @@ 11.6.1 4.5.1 1.7.1 - 2.9.1 + 2.10.1 5.3.1 diff --git a/vector-stores/spring-ai-opensearch-store/src/test/java/org/springframework/ai/vectorstore/OpenSearchVectorStoreIT.java b/vector-stores/spring-ai-opensearch-store/src/test/java/org/springframework/ai/vectorstore/OpenSearchVectorStoreIT.java index 3d8d97f53f3..5632ea16454 100644 --- a/vector-stores/spring-ai-opensearch-store/src/test/java/org/springframework/ai/vectorstore/OpenSearchVectorStoreIT.java +++ b/vector-stores/spring-ai-opensearch-store/src/test/java/org/springframework/ai/vectorstore/OpenSearchVectorStoreIT.java @@ -23,8 +23,6 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.opensearch.client.opensearch.OpenSearchClient; -import org.opensearch.client.transport.OpenSearchTransport; -import org.opensearch.client.transport.httpclient5.ApacheHttpClient5Transport; import org.opensearch.client.transport.httpclient5.ApacheHttpClient5TransportBuilder; import org.opensearch.testcontainers.OpensearchContainer; import org.springframework.ai.document.Document; @@ -41,7 +39,6 @@ import org.testcontainers.junit.jupiter.Testcontainers; import org.testcontainers.utility.DockerImageName; -import javax.net.ssl.SSLEngine; import java.io.IOException; import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; @@ -63,7 +60,7 @@ class OpenSearchVectorStoreIT { @Container private static final OpensearchContainer opensearchContainer = - new OpensearchContainer<>(DockerImageName.parse("opensearchproject/opensearch:2.12.0")); + new OpensearchContainer<>(DockerImageName.parse("opensearchproject/opensearch:2.13.0")); private static final String DEFAULT = "cosinesimil"; From 4c6876ba0c1e3da708a98c9a9ff66eb19e84d12c Mon Sep 17 00:00:00 2001 From: Jemin Huh Date: Mon, 25 Mar 2024 20:58:15 +0900 Subject: [PATCH 38/46] add support for OpenSearch vector store --- .../spring-ai-opensearch-store/pom.xml | 85 ++++ ...archAiSearchFilterExpressionConverter.java | 150 +++++++ .../ai/vectorstore/OpenSearchVectorStore.java | 202 ++++++++++ ...AiSearchFilterExpressionConverterTest.java | 117 ++++++ .../vectorstore/OpenSearchVectorStoreIT.java | 367 ++++++++++++++++++ 5 files changed, 921 insertions(+) create mode 100644 vector-stores/spring-ai-opensearch-store/pom.xml create mode 100644 vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchAiSearchFilterExpressionConverter.java create mode 100644 vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchVectorStore.java create mode 100644 vector-stores/spring-ai-opensearch-store/src/test/java/org/springframework/ai/vectorstore/OpenSearchAiSearchFilterExpressionConverterTest.java create mode 100644 vector-stores/spring-ai-opensearch-store/src/test/java/org/springframework/ai/vectorstore/OpenSearchVectorStoreIT.java diff --git a/vector-stores/spring-ai-opensearch-store/pom.xml b/vector-stores/spring-ai-opensearch-store/pom.xml new file mode 100644 index 00000000000..3681050f526 --- /dev/null +++ b/vector-stores/spring-ai-opensearch-store/pom.xml @@ -0,0 +1,85 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../pom.xml + + spring-ai-opensearch-store + jar + Spring AI Vector Store - OpenSearch + Spring AI OpenSearch 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} + + + + org.opensearch.client + opensearch-java + 2.9.1 + + + + org.apache.httpcomponents.client5 + httpclient5 + 5.3.1 + + + + + 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.opensearch + opensearch-testcontainers + 2.0.1 + test + + + + org.testcontainers + junit-jupiter + ${testcontainers.version} + test + + + + + diff --git a/vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchAiSearchFilterExpressionConverter.java b/vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchAiSearchFilterExpressionConverter.java new file mode 100644 index 00000000000..9035a86d299 --- /dev/null +++ b/vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchAiSearchFilterExpressionConverter.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 OpenSearchAiSearchFilterExpressionConverter 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 OpenSearchAiSearchFilterExpressionConverter() { + 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-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchVectorStore.java b/vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchVectorStore.java new file mode 100644 index 00000000000..af2ee7055d2 --- /dev/null +++ b/vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchVectorStore.java @@ -0,0 +1,202 @@ +/* + * 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.opensearch.client.json.JsonData; +import org.opensearch.client.opensearch.OpenSearchClient; +import org.opensearch.client.opensearch._types.mapping.Property; +import org.opensearch.client.opensearch._types.query_dsl.Query; +import org.opensearch.client.opensearch.core.BulkRequest; +import org.opensearch.client.opensearch.core.BulkResponse; +import org.opensearch.client.opensearch.core.search.Hit; +import org.opensearch.client.opensearch.indices.CreateIndexRequest; +import org.opensearch.client.opensearch.indices.CreateIndexResponse; +import org.opensearch.client.transport.endpoints.BooleanResponse; +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.util.*; +import java.util.stream.Collectors; + +/** + * @author Jemin Huh + * @since 1.0.0 + */ +public class OpenSearchVectorStore implements VectorStore, InitializingBean { + + public static final String COSINE_SIMILARITY_FUNCTION = "cosinesimil"; + + private static final Logger logger = LoggerFactory.getLogger(OpenSearchVectorStore.class); + + private static final String INDEX_NAME = "spring-ai-document-index"; + + private final EmbeddingClient embeddingClient; + + private final OpenSearchClient openSearchClient; + + private final String index; + + private final FilterExpressionConverter filterExpressionConverter; + + private String similarityFunction; + + public OpenSearchVectorStore(OpenSearchClient openSearchClient, EmbeddingClient embeddingClient) { + this(INDEX_NAME, openSearchClient, embeddingClient); + } + + public OpenSearchVectorStore(String index, OpenSearchClient openSearchClient, + EmbeddingClient embeddingClient) { + Objects.requireNonNull(embeddingClient, "RestClient must not be null"); + Objects.requireNonNull(embeddingClient, "EmbeddingClient must not be null"); + this.openSearchClient = openSearchClient; + this.embeddingClient = embeddingClient; + this.index = index; + this.filterExpressionConverter = new OpenSearchAiSearchFilterExpressionConverter(); + // the potential functions for vector fields at + // https://opensearch.org/docs/latest/search-plugins/knn/approximate-knn/#spaces + this.similarityFunction = COSINE_SIMILARITY_FUNCTION; + } + + public OpenSearchVectorStore 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.openSearchClient.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(), + searchRequest.getSimilarityThreshold(), searchRequest.getFilterExpression()); + } + + public List similaritySearch(List embedding, int topK, double similarityThreshold, + Filter.Expression filterExpression) { + return similaritySearch(new org.opensearch.client.opensearch.core.SearchRequest.Builder() + .query(getOpenSearchSimilarityQuery(embedding, filterExpression)) + .size(topK) + .minScore(similarityThreshold) + .build()); + } + + private Query getOpenSearchSimilarityQuery(List embedding, Filter.Expression filterExpression) { + return Query.of(queryBuilder -> queryBuilder.scriptScore(scriptScoreQueryBuilder -> { + scriptScoreQueryBuilder.query( + queryBuilder2 -> queryBuilder2.queryString(queryStringQuerybuilder -> queryStringQuerybuilder + .query(getOpenSearchQueryString(filterExpression)))) + .script(scriptBuilder -> scriptBuilder + .inline(inlineScriptBuilder -> inlineScriptBuilder.source("knn_score") + .lang("knn") + .params("field", JsonData.of("embedding")) + .params("query_value", JsonData.of(embedding)) + .params("space_type", JsonData.of(this.similarityFunction)))); + // https://opensearch.org/docs/latest/search-plugins/knn/knn-score-script + // k-NN ensures non-negative scores by adding 1 to cosine similarity, extending OpenSearch scores to 0-2. + // A 0.5 boost normalizes to 0-1. + return this.similarityFunction.equals(COSINE_SIMILARITY_FUNCTION) ? scriptScoreQueryBuilder.boost( + 0.5f) : scriptScoreQueryBuilder; + })); + } + + private String getOpenSearchQueryString(Filter.Expression filterExpression) { + return Objects.isNull(filterExpression) ? "*" + : this.filterExpressionConverter.convertExpression(filterExpression); + + } + + private List similaritySearch(org.opensearch.client.opensearch.core.SearchRequest searchRequest) { + try { + return this.openSearchClient.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.openSearchClient.indices() + .exists(existRequestBuilder -> existRequestBuilder.index(targetIndex)); + return response.value(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public CreateIndexResponse createIndexMapping(String index, Map properties) { + try { + return this.openSearchClient.indices() + .create(new CreateIndexRequest.Builder().index(index).settings(setting -> setting.knn(true)) + .mappings(propertiesBuilder -> propertiesBuilder.properties(properties)).build()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public void afterPropertiesSet() { + if (!exists(this.index)) { + createIndexMapping(this.index, Map.of("embedding", Property.of(p -> p.knnVector(k -> k.dimension(1536))))); + } + } +} \ No newline at end of file diff --git a/vector-stores/spring-ai-opensearch-store/src/test/java/org/springframework/ai/vectorstore/OpenSearchAiSearchFilterExpressionConverterTest.java b/vector-stores/spring-ai-opensearch-store/src/test/java/org/springframework/ai/vectorstore/OpenSearchAiSearchFilterExpressionConverterTest.java new file mode 100644 index 00000000000..274f132730e --- /dev/null +++ b/vector-stores/spring-ai-opensearch-store/src/test/java/org/springframework/ai/vectorstore/OpenSearchAiSearchFilterExpressionConverterTest.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 OpenSearchAiSearchFilterExpressionConverterTest { + + final FilterExpressionConverter converter = new OpenSearchAiSearchFilterExpressionConverter(); + + @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-opensearch-store/src/test/java/org/springframework/ai/vectorstore/OpenSearchVectorStoreIT.java b/vector-stores/spring-ai-opensearch-store/src/test/java/org/springframework/ai/vectorstore/OpenSearchVectorStoreIT.java new file mode 100644 index 00000000000..3d8d97f53f3 --- /dev/null +++ b/vector-stores/spring-ai-opensearch-store/src/test/java/org/springframework/ai/vectorstore/OpenSearchVectorStoreIT.java @@ -0,0 +1,367 @@ +/* + * 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.apache.hc.core5.http.HttpHost; +import org.awaitility.Awaitility; +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.opensearch.client.opensearch.OpenSearchClient; +import org.opensearch.client.transport.OpenSearchTransport; +import org.opensearch.client.transport.httpclient5.ApacheHttpClient5Transport; +import org.opensearch.client.transport.httpclient5.ApacheHttpClient5TransportBuilder; +import org.opensearch.testcontainers.OpensearchContainer; +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 org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import javax.net.ssl.SSLEngine; +import java.io.IOException; +import java.net.URISyntaxException; +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 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 OpenSearchVectorStoreIT { + + @Container + private static final OpensearchContainer opensearchContainer = + new OpensearchContainer<>(DockerImageName.parse("opensearchproject/opensearch:2.12.0")); + + private static final String DEFAULT = "cosinesimil"; + + 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, "l1", "l2", "linf"}) + public void addAndSearchTest(String similarityFunction) { + + getContextRunner().run(context -> { + OpenSearchVectorStore vectorStore = context.getBean(OpenSearchVectorStore.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, "l1", "l2", "linf"}) + public void searchWithFilters(String similarityFunction) { + + getContextRunner().run(context -> { + OpenSearchVectorStore vectorStore = context.getBean(OpenSearchVectorStore.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, "l1", "l2", "linf"}) + public void documentUpdateTest(String similarityFunction) { + + getContextRunner().run(context -> { + OpenSearchVectorStore vectorStore = context.getBean(OpenSearchVectorStore.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, "l1", "l2", "linf"}) + public void searchThresholdTest(String similarityFunction) { + + getContextRunner().run(context -> { + OpenSearchVectorStore vectorStore = context.getBean(OpenSearchVectorStore.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 OpenSearchVectorStore vectorStore(EmbeddingClient embeddingClient) { + try { + return new OpenSearchVectorStore(new OpenSearchClient(ApacheHttpClient5TransportBuilder.builder( + HttpHost.create(opensearchContainer.getHttpHostAddress())).build()), embeddingClient); + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } + } + + @Bean + public EmbeddingClient embeddingClient() { + return new OpenAiEmbeddingClient(new OpenAiApi(System.getenv("OPENAI_API_KEY"))); + } + + } + +} From dce8b80568644ba4399f12d4f23aee5b27091806 Mon Sep 17 00:00:00 2001 From: Jemin Huh Date: Thu, 4 Apr 2024 22:34:12 +0900 Subject: [PATCH 39/46] moving versions for OpenSearch as a property in the parent POM --- pom.xml | 2 ++ vector-stores/spring-ai-opensearch-store/pom.xml | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 1aa98332298..38e984a8366 100644 --- a/pom.xml +++ b/pom.xml @@ -151,6 +151,8 @@ 11.6.1 4.5.1 1.7.1 + 2.9.1 + 5.3.1 0.0.4 diff --git a/vector-stores/spring-ai-opensearch-store/pom.xml b/vector-stores/spring-ai-opensearch-store/pom.xml index 3681050f526..4c11603369d 100644 --- a/vector-stores/spring-ai-opensearch-store/pom.xml +++ b/vector-stores/spring-ai-opensearch-store/pom.xml @@ -35,13 +35,13 @@ org.opensearch.client opensearch-java - 2.9.1 + ${opensearch-client.version} org.apache.httpcomponents.client5 httpclient5 - 5.3.1 + ${httpclient5.version} From bd1ac01a55b129a4f65e7d5496934b7d1563c09a Mon Sep 17 00:00:00 2001 From: Jemin Huh Date: Mon, 15 Apr 2024 00:22:08 +0900 Subject: [PATCH 40/46] Add mappingJson parameter to OpenSearchVectorStore --- .../ai/vectorstore/OpenSearchVectorStore.java | 41 +++++++++++++++---- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchVectorStore.java b/vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchVectorStore.java index af2ee7055d2..608ce87cf24 100644 --- a/vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchVectorStore.java +++ b/vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchVectorStore.java @@ -16,9 +16,11 @@ package org.springframework.ai.vectorstore; +import jakarta.json.stream.JsonParser; import org.opensearch.client.json.JsonData; +import org.opensearch.client.json.JsonpMapper; import org.opensearch.client.opensearch.OpenSearchClient; -import org.opensearch.client.opensearch._types.mapping.Property; +import org.opensearch.client.opensearch._types.mapping.TypeMapping; import org.opensearch.client.opensearch._types.query_dsl.Query; import org.opensearch.client.opensearch.core.BulkRequest; import org.opensearch.client.opensearch.core.BulkResponse; @@ -36,6 +38,7 @@ import org.springframework.util.Assert; import java.io.IOException; +import java.io.StringReader; import java.util.*; import java.util.stream.Collectors; @@ -49,7 +52,17 @@ public class OpenSearchVectorStore implements VectorStore, InitializingBean { private static final Logger logger = LoggerFactory.getLogger(OpenSearchVectorStore.class); - private static final String INDEX_NAME = "spring-ai-document-index"; + public static final String DEFAULT_INDEX_NAME = "spring-ai-document-index"; + public static final String DEFAULT_MAPPING_EMBEDDING_TYPE_KNN_VECTOR_DIMENSION_1536 = """ + { + "properties":{ + "embedding":{ + "type":"knn_vector", + "dimension":1536 + } + } + } + """; private final EmbeddingClient embeddingClient; @@ -59,19 +72,27 @@ public class OpenSearchVectorStore implements VectorStore, InitializingBean { private final FilterExpressionConverter filterExpressionConverter; + private final String mappingJson; + private String similarityFunction; public OpenSearchVectorStore(OpenSearchClient openSearchClient, EmbeddingClient embeddingClient) { - this(INDEX_NAME, openSearchClient, embeddingClient); + this(openSearchClient, embeddingClient, DEFAULT_MAPPING_EMBEDDING_TYPE_KNN_VECTOR_DIMENSION_1536); + } + + public OpenSearchVectorStore(OpenSearchClient openSearchClient, EmbeddingClient embeddingClient, + String mappingJson) { + this(DEFAULT_INDEX_NAME, openSearchClient, embeddingClient, mappingJson); } public OpenSearchVectorStore(String index, OpenSearchClient openSearchClient, - EmbeddingClient embeddingClient) { + EmbeddingClient embeddingClient, String mappingJson) { Objects.requireNonNull(embeddingClient, "RestClient must not be null"); Objects.requireNonNull(embeddingClient, "EmbeddingClient must not be null"); this.openSearchClient = openSearchClient; this.embeddingClient = embeddingClient; this.index = index; + this.mappingJson = mappingJson; this.filterExpressionConverter = new OpenSearchAiSearchFilterExpressionConverter(); // the potential functions for vector fields at // https://opensearch.org/docs/latest/search-plugins/knn/approximate-knn/#spaces @@ -183,11 +204,13 @@ public boolean exists(String targetIndex) { } } - public CreateIndexResponse createIndexMapping(String index, Map properties) { + private CreateIndexResponse createIndexMapping(String index, String mappingJson) { + JsonpMapper mapper = openSearchClient._transport().jsonpMapper(); + JsonParser parser = mapper.jsonProvider().createParser(new StringReader(mappingJson)); try { - return this.openSearchClient.indices() - .create(new CreateIndexRequest.Builder().index(index).settings(setting -> setting.knn(true)) - .mappings(propertiesBuilder -> propertiesBuilder.properties(properties)).build()); + return this.openSearchClient.indices().create(new CreateIndexRequest.Builder().index(index) + .settings(settingsBuilder -> settingsBuilder.knn(true)) + .mappings(TypeMapping._DESERIALIZER.deserialize(parser, mapper)).build()); } catch (IOException e) { throw new RuntimeException(e); } @@ -196,7 +219,7 @@ public CreateIndexResponse createIndexMapping(String index, Map p.knnVector(k -> k.dimension(1536))))); + createIndexMapping(this.index, mappingJson); } } } \ No newline at end of file From b05d9b705e7a38ffc949d09970cb035dbdd7db09 Mon Sep 17 00:00:00 2001 From: Jemin Huh Date: Mon, 15 Apr 2024 00:27:52 +0900 Subject: [PATCH 41/46] Add opensearch auto-configuration and boot starter --- pom.xml | 6 + spring-ai-bom/pom.xml | 6 + spring-ai-spring-boot-autoconfigure/pom.xml | 14 ++ ...penSearchVectorStoreAutoConfiguration.java | 62 +++++++++ .../OpenSearchVectorStoreProperties.java | 60 +++++++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + ...nSearchVectorStoreAutoConfigurationIT.java | 125 ++++++++++++++++++ .../pom.xml | 42 ++++++ 8 files changed, 316 insertions(+) create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreAutoConfiguration.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreProperties.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreAutoConfigurationIT.java create mode 100644 spring-ai-spring-boot-starters/spring-ai-starter-opensearch-store/pom.xml diff --git a/pom.xml b/pom.xml index 38e984a8366..96b1f4f3371 100644 --- a/pom.xml +++ b/pom.xml @@ -74,6 +74,8 @@ vector-stores/spring-ai-elasticsearch-store spring-ai-spring-boot-starters/spring-ai-starter-watsonx-ai spring-ai-spring-boot-starters/spring-ai-starter-elasticsearch-store + vector-stores/spring-ai-opensearch-store + spring-ai-spring-boot-starters/spring-ai-starter-opensearch-store @@ -154,6 +156,10 @@ 2.9.1 5.3.1 + + 1.19.7 + 2.0.1 + 0.0.4 1.6.2 diff --git a/spring-ai-bom/pom.xml b/spring-ai-bom/pom.xml index d1c857f9da9..43a481c157d 100644 --- a/spring-ai-bom/pom.xml +++ b/spring-ai-bom/pom.xml @@ -210,6 +210,12 @@ ${project.version} + + org.springframework.ai + spring-ai-opensearch-store + ${project.version} + + org.springframework.ai diff --git a/spring-ai-spring-boot-autoconfigure/pom.xml b/spring-ai-spring-boot-autoconfigure/pom.xml index d48275f3d83..a01b11affc9 100644 --- a/spring-ai-spring-boot-autoconfigure/pom.xml +++ b/spring-ai-spring-boot-autoconfigure/pom.xml @@ -267,6 +267,13 @@ true + + org.springframework.ai + spring-ai-opensearch-store + ${project.parent.version} + true + + @@ -354,6 +361,13 @@ test + + org.opensearch + opensearch-testcontainers + ${testcontainers.opensearch.version} + test + + org.skyscreamer jsonassert diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreAutoConfiguration.java new file mode 100644 index 00000000000..538571dd758 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreAutoConfiguration.java @@ -0,0 +1,62 @@ +/* + * 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.autoconfigure.vectorstore.opensearch; + +import org.apache.hc.core5.http.HttpHost; +import org.opensearch.client.opensearch.OpenSearchClient; +import org.opensearch.client.transport.httpclient5.ApacheHttpClient5TransportBuilder; +import org.springframework.ai.embedding.EmbeddingClient; +import org.springframework.ai.vectorstore.OpenSearchVectorStore; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +import java.net.URISyntaxException; +import java.util.Optional; + +@AutoConfiguration +@ConditionalOnClass({OpenSearchVectorStore.class, EmbeddingClient.class, OpenSearchClient.class}) +@EnableConfigurationProperties(OpenSearchVectorStoreProperties.class) +class OpenSearchVectorStoreAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + OpenSearchVectorStore vectorStore(OpenSearchVectorStoreProperties properties, OpenSearchClient openSearchClient, + EmbeddingClient embeddingClient) { + return new OpenSearchVectorStore( + Optional.ofNullable(properties.getIndexName()).orElse(OpenSearchVectorStore.DEFAULT_INDEX_NAME), + openSearchClient, embeddingClient, Optional.ofNullable(properties.getMappingJson()) + .orElse(OpenSearchVectorStore.DEFAULT_MAPPING_EMBEDDING_TYPE_KNN_VECTOR_DIMENSION_1536)); + } + + @Bean + @ConditionalOnMissingBean + OpenSearchClient openSearchClient(OpenSearchVectorStoreProperties properties) { + return new OpenSearchClient(ApacheHttpClient5TransportBuilder.builder( + properties.getUris().stream().map(s -> creatHttpHost(s)).toArray(HttpHost[]::new)).build()); + } + + private HttpHost creatHttpHost(String s) { + try { + return HttpHost.create(s); + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreProperties.java new file mode 100644 index 00000000000..4e45b4da9ee --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreProperties.java @@ -0,0 +1,60 @@ +/* + * 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.autoconfigure.vectorstore.opensearch; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.List; + +@ConfigurationProperties(prefix = OpenSearchVectorStoreProperties.CONFIG_PREFIX) +public class OpenSearchVectorStoreProperties { + + public static final String CONFIG_PREFIX = "spring.ai.vectorstore.opensearch"; + + /** + * Comma-separated list of the OpenSearch instances to use. + */ + private List uris; + + private String indexName; + + private String mappingJson; + + public String getMappingJson() { + return mappingJson; + } + + public void setMappingJson(String mappingJson) { + this.mappingJson = mappingJson; + } + + public List getUris() { + return uris; + } + + public void setUris(List uris) { + this.uris = uris; + } + + public String getIndexName() { + return this.indexName; + } + + public void setIndexName(String indexName) { + this.indexName = indexName; + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index c2816002bcd..1d1532873d9 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -32,3 +32,4 @@ org.springframework.ai.autoconfigure.anthropic.AnthropicAutoConfiguration org.springframework.ai.autoconfigure.watsonxai.WatsonxAiAutoConfiguration org.springframework.ai.autoconfigure.vectorstore.elasticsearch.ElasticsearchVectorStoreAutoConfiguration org.springframework.ai.autoconfigure.vectorstore.cassandra.CassandraVectorStoreAutoConfiguration +org.springframework.ai.autoconfigure.vectorstore.opensearch.OpenSearchVectorStoreAutoConfiguration diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreAutoConfigurationIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreAutoConfigurationIT.java new file mode 100644 index 00000000000..bfa3dab48f2 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreAutoConfigurationIT.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.autoconfigure.vectorstore.opensearch; + +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Test; +import org.opensearch.testcontainers.OpensearchContainer; +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.document.Document; +import org.springframework.ai.embedding.EmbeddingClient; +import org.springframework.ai.transformers.TransformersEmbeddingClient; +import org.springframework.ai.vectorstore.OpenSearchVectorStore; +import org.springframework.ai.vectorstore.SearchRequest; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.DefaultResourceLoader; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.hasSize; + +@Testcontainers +class OpenSearchVectorStoreAutoConfigurationIT { + + @Container + private static final OpensearchContainer opensearchContainer = + new OpensearchContainer<>(DockerImageName.parse("opensearchproject/opensearch:2.12.0")); + + private static final String DOCUMENT_INDEX = "auto-spring-ai-document-index"; + + 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"))); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner().withConfiguration( + AutoConfigurations.of(OpenSearchVectorStoreAutoConfiguration.class, SpringAiRetryAutoConfiguration.class)) + .withUserConfiguration(Config.class) + .withPropertyValues( + OpenSearchVectorStoreProperties.CONFIG_PREFIX + ".uris=" + opensearchContainer.getHttpHostAddress(), + OpenSearchVectorStoreProperties.CONFIG_PREFIX + ".indexName=" + DOCUMENT_INDEX, + OpenSearchVectorStoreProperties.CONFIG_PREFIX + ".mappingJson=" + """ + { + "properties":{ + "embedding":{ + "type":"knn_vector", + "dimension":384 + } + } + } + """); + + @Test + public void addAndSearchTest() { + + this.contextRunner.run(context -> { + OpenSearchVectorStore vectorStore = context.getBean(OpenSearchVectorStore.class); + + 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)); + }); + } + + 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); + } + } + + @Configuration(proxyBeanMethods = false) + static class Config { + + @Bean + public EmbeddingClient embeddingClient() { + return new TransformersEmbeddingClient(); + } + + } + +} diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-opensearch-store/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-opensearch-store/pom.xml new file mode 100644 index 00000000000..c97eb81ad68 --- /dev/null +++ b/spring-ai-spring-boot-starters/spring-ai-starter-opensearch-store/pom.xml @@ -0,0 +1,42 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../pom.xml + + spring-ai-opensearch-store-spring-boot-starter + jar + Spring AI Starter - OpenSearch Store + Spring AI OpenSearch Store Auto Configuration + 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 + + + + + + org.springframework.boot + spring-boot-starter + + + + org.springframework.ai + spring-ai-spring-boot-autoconfigure + ${project.parent.version} + + + + org.springframework.ai + spring-ai-opensearch-store + ${project.parent.version} + + + + From 7bb833e245d35034b4f428ad33135d8aa22830b1 Mon Sep 17 00:00:00 2001 From: Jemin Huh Date: Mon, 25 Mar 2024 20:58:15 +0900 Subject: [PATCH 42/46] add support for OpenSearch vector store --- .../spring-ai-opensearch-store/pom.xml | 4 +- .../ai/vectorstore/OpenSearchVectorStore.java | 41 ++++--------------- 2 files changed, 11 insertions(+), 34 deletions(-) diff --git a/vector-stores/spring-ai-opensearch-store/pom.xml b/vector-stores/spring-ai-opensearch-store/pom.xml index 4c11603369d..3681050f526 100644 --- a/vector-stores/spring-ai-opensearch-store/pom.xml +++ b/vector-stores/spring-ai-opensearch-store/pom.xml @@ -35,13 +35,13 @@ org.opensearch.client opensearch-java - ${opensearch-client.version} + 2.9.1 org.apache.httpcomponents.client5 httpclient5 - ${httpclient5.version} + 5.3.1 diff --git a/vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchVectorStore.java b/vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchVectorStore.java index 608ce87cf24..af2ee7055d2 100644 --- a/vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchVectorStore.java +++ b/vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchVectorStore.java @@ -16,11 +16,9 @@ package org.springframework.ai.vectorstore; -import jakarta.json.stream.JsonParser; import org.opensearch.client.json.JsonData; -import org.opensearch.client.json.JsonpMapper; import org.opensearch.client.opensearch.OpenSearchClient; -import org.opensearch.client.opensearch._types.mapping.TypeMapping; +import org.opensearch.client.opensearch._types.mapping.Property; import org.opensearch.client.opensearch._types.query_dsl.Query; import org.opensearch.client.opensearch.core.BulkRequest; import org.opensearch.client.opensearch.core.BulkResponse; @@ -38,7 +36,6 @@ import org.springframework.util.Assert; import java.io.IOException; -import java.io.StringReader; import java.util.*; import java.util.stream.Collectors; @@ -52,17 +49,7 @@ public class OpenSearchVectorStore implements VectorStore, InitializingBean { private static final Logger logger = LoggerFactory.getLogger(OpenSearchVectorStore.class); - public static final String DEFAULT_INDEX_NAME = "spring-ai-document-index"; - public static final String DEFAULT_MAPPING_EMBEDDING_TYPE_KNN_VECTOR_DIMENSION_1536 = """ - { - "properties":{ - "embedding":{ - "type":"knn_vector", - "dimension":1536 - } - } - } - """; + private static final String INDEX_NAME = "spring-ai-document-index"; private final EmbeddingClient embeddingClient; @@ -72,27 +59,19 @@ public class OpenSearchVectorStore implements VectorStore, InitializingBean { private final FilterExpressionConverter filterExpressionConverter; - private final String mappingJson; - private String similarityFunction; public OpenSearchVectorStore(OpenSearchClient openSearchClient, EmbeddingClient embeddingClient) { - this(openSearchClient, embeddingClient, DEFAULT_MAPPING_EMBEDDING_TYPE_KNN_VECTOR_DIMENSION_1536); - } - - public OpenSearchVectorStore(OpenSearchClient openSearchClient, EmbeddingClient embeddingClient, - String mappingJson) { - this(DEFAULT_INDEX_NAME, openSearchClient, embeddingClient, mappingJson); + this(INDEX_NAME, openSearchClient, embeddingClient); } public OpenSearchVectorStore(String index, OpenSearchClient openSearchClient, - EmbeddingClient embeddingClient, String mappingJson) { + EmbeddingClient embeddingClient) { Objects.requireNonNull(embeddingClient, "RestClient must not be null"); Objects.requireNonNull(embeddingClient, "EmbeddingClient must not be null"); this.openSearchClient = openSearchClient; this.embeddingClient = embeddingClient; this.index = index; - this.mappingJson = mappingJson; this.filterExpressionConverter = new OpenSearchAiSearchFilterExpressionConverter(); // the potential functions for vector fields at // https://opensearch.org/docs/latest/search-plugins/knn/approximate-knn/#spaces @@ -204,13 +183,11 @@ public boolean exists(String targetIndex) { } } - private CreateIndexResponse createIndexMapping(String index, String mappingJson) { - JsonpMapper mapper = openSearchClient._transport().jsonpMapper(); - JsonParser parser = mapper.jsonProvider().createParser(new StringReader(mappingJson)); + public CreateIndexResponse createIndexMapping(String index, Map properties) { try { - return this.openSearchClient.indices().create(new CreateIndexRequest.Builder().index(index) - .settings(settingsBuilder -> settingsBuilder.knn(true)) - .mappings(TypeMapping._DESERIALIZER.deserialize(parser, mapper)).build()); + return this.openSearchClient.indices() + .create(new CreateIndexRequest.Builder().index(index).settings(setting -> setting.knn(true)) + .mappings(propertiesBuilder -> propertiesBuilder.properties(properties)).build()); } catch (IOException e) { throw new RuntimeException(e); } @@ -219,7 +196,7 @@ private CreateIndexResponse createIndexMapping(String index, String mappingJson) @Override public void afterPropertiesSet() { if (!exists(this.index)) { - createIndexMapping(this.index, mappingJson); + createIndexMapping(this.index, Map.of("embedding", Property.of(p -> p.knnVector(k -> k.dimension(1536))))); } } } \ No newline at end of file From 13c504e78b9ec337498d83ff0fb5aa32c61ab7e8 Mon Sep 17 00:00:00 2001 From: Jemin Huh Date: Thu, 4 Apr 2024 22:34:12 +0900 Subject: [PATCH 43/46] moving versions for OpenSearch as a property in the parent POM --- vector-stores/spring-ai-opensearch-store/pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vector-stores/spring-ai-opensearch-store/pom.xml b/vector-stores/spring-ai-opensearch-store/pom.xml index 3681050f526..4c11603369d 100644 --- a/vector-stores/spring-ai-opensearch-store/pom.xml +++ b/vector-stores/spring-ai-opensearch-store/pom.xml @@ -35,13 +35,13 @@ org.opensearch.client opensearch-java - 2.9.1 + ${opensearch-client.version} org.apache.httpcomponents.client5 httpclient5 - 5.3.1 + ${httpclient5.version} From f6e55378901dc2c0c5f9f334b101dfc1c47cf562 Mon Sep 17 00:00:00 2001 From: Jemin Huh Date: Mon, 15 Apr 2024 00:22:08 +0900 Subject: [PATCH 44/46] Add mappingJson parameter to OpenSearchVectorStore --- .../ai/vectorstore/OpenSearchVectorStore.java | 41 +++++++++++++++---- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchVectorStore.java b/vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchVectorStore.java index af2ee7055d2..608ce87cf24 100644 --- a/vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchVectorStore.java +++ b/vector-stores/spring-ai-opensearch-store/src/main/java/org/springframework/ai/vectorstore/OpenSearchVectorStore.java @@ -16,9 +16,11 @@ package org.springframework.ai.vectorstore; +import jakarta.json.stream.JsonParser; import org.opensearch.client.json.JsonData; +import org.opensearch.client.json.JsonpMapper; import org.opensearch.client.opensearch.OpenSearchClient; -import org.opensearch.client.opensearch._types.mapping.Property; +import org.opensearch.client.opensearch._types.mapping.TypeMapping; import org.opensearch.client.opensearch._types.query_dsl.Query; import org.opensearch.client.opensearch.core.BulkRequest; import org.opensearch.client.opensearch.core.BulkResponse; @@ -36,6 +38,7 @@ import org.springframework.util.Assert; import java.io.IOException; +import java.io.StringReader; import java.util.*; import java.util.stream.Collectors; @@ -49,7 +52,17 @@ public class OpenSearchVectorStore implements VectorStore, InitializingBean { private static final Logger logger = LoggerFactory.getLogger(OpenSearchVectorStore.class); - private static final String INDEX_NAME = "spring-ai-document-index"; + public static final String DEFAULT_INDEX_NAME = "spring-ai-document-index"; + public static final String DEFAULT_MAPPING_EMBEDDING_TYPE_KNN_VECTOR_DIMENSION_1536 = """ + { + "properties":{ + "embedding":{ + "type":"knn_vector", + "dimension":1536 + } + } + } + """; private final EmbeddingClient embeddingClient; @@ -59,19 +72,27 @@ public class OpenSearchVectorStore implements VectorStore, InitializingBean { private final FilterExpressionConverter filterExpressionConverter; + private final String mappingJson; + private String similarityFunction; public OpenSearchVectorStore(OpenSearchClient openSearchClient, EmbeddingClient embeddingClient) { - this(INDEX_NAME, openSearchClient, embeddingClient); + this(openSearchClient, embeddingClient, DEFAULT_MAPPING_EMBEDDING_TYPE_KNN_VECTOR_DIMENSION_1536); + } + + public OpenSearchVectorStore(OpenSearchClient openSearchClient, EmbeddingClient embeddingClient, + String mappingJson) { + this(DEFAULT_INDEX_NAME, openSearchClient, embeddingClient, mappingJson); } public OpenSearchVectorStore(String index, OpenSearchClient openSearchClient, - EmbeddingClient embeddingClient) { + EmbeddingClient embeddingClient, String mappingJson) { Objects.requireNonNull(embeddingClient, "RestClient must not be null"); Objects.requireNonNull(embeddingClient, "EmbeddingClient must not be null"); this.openSearchClient = openSearchClient; this.embeddingClient = embeddingClient; this.index = index; + this.mappingJson = mappingJson; this.filterExpressionConverter = new OpenSearchAiSearchFilterExpressionConverter(); // the potential functions for vector fields at // https://opensearch.org/docs/latest/search-plugins/knn/approximate-knn/#spaces @@ -183,11 +204,13 @@ public boolean exists(String targetIndex) { } } - public CreateIndexResponse createIndexMapping(String index, Map properties) { + private CreateIndexResponse createIndexMapping(String index, String mappingJson) { + JsonpMapper mapper = openSearchClient._transport().jsonpMapper(); + JsonParser parser = mapper.jsonProvider().createParser(new StringReader(mappingJson)); try { - return this.openSearchClient.indices() - .create(new CreateIndexRequest.Builder().index(index).settings(setting -> setting.knn(true)) - .mappings(propertiesBuilder -> propertiesBuilder.properties(properties)).build()); + return this.openSearchClient.indices().create(new CreateIndexRequest.Builder().index(index) + .settings(settingsBuilder -> settingsBuilder.knn(true)) + .mappings(TypeMapping._DESERIALIZER.deserialize(parser, mapper)).build()); } catch (IOException e) { throw new RuntimeException(e); } @@ -196,7 +219,7 @@ public CreateIndexResponse createIndexMapping(String index, Map p.knnVector(k -> k.dimension(1536))))); + createIndexMapping(this.index, mappingJson); } } } \ No newline at end of file From ac61b828707457335f1ef4f93f04360efb797ff0 Mon Sep 17 00:00:00 2001 From: Jemin Huh Date: Mon, 29 Apr 2024 00:35:23 +0900 Subject: [PATCH 45/46] Add additional properties for OpenSearch auto-configuration --- ...penSearchVectorStoreAutoConfiguration.java | 25 ++++++++++++-- .../OpenSearchVectorStoreProperties.java | 34 +++++++++++++++---- 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreAutoConfiguration.java index 538571dd758..e5c24d4ddf0 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreAutoConfiguration.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreAutoConfiguration.java @@ -15,6 +15,9 @@ */ package org.springframework.ai.autoconfigure.vectorstore.opensearch; +import org.apache.hc.client5.http.auth.AuthScope; +import org.apache.hc.client5.http.auth.UsernamePasswordCredentials; +import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider; import org.apache.hc.core5.http.HttpHost; import org.opensearch.client.opensearch.OpenSearchClient; import org.opensearch.client.transport.httpclient5.ApacheHttpClient5TransportBuilder; @@ -47,11 +50,27 @@ OpenSearchVectorStore vectorStore(OpenSearchVectorStoreProperties properties, Op @Bean @ConditionalOnMissingBean OpenSearchClient openSearchClient(OpenSearchVectorStoreProperties properties) { - return new OpenSearchClient(ApacheHttpClient5TransportBuilder.builder( - properties.getUris().stream().map(s -> creatHttpHost(s)).toArray(HttpHost[]::new)).build()); + HttpHost[] httpHosts = properties.getUris().stream().map(s -> createHttpHost(s)).toArray(HttpHost[]::new); + ApacheHttpClient5TransportBuilder transportBuilder = ApacheHttpClient5TransportBuilder.builder(httpHosts); + + Optional.ofNullable(properties.getUsername()) + .map(username -> createBasicCredentialsProvider(httpHosts[0], username, properties.getPassword())) + .ifPresent(basicCredentialsProvider -> transportBuilder.setHttpClientConfigCallback( + httpAsyncClientBuilder -> httpAsyncClientBuilder.setDefaultCredentialsProvider( + basicCredentialsProvider))); + + return new OpenSearchClient(transportBuilder.build()); + } + + private BasicCredentialsProvider createBasicCredentialsProvider(HttpHost httpHost, String username, + String password) { + BasicCredentialsProvider basicCredentialsProvider = new BasicCredentialsProvider(); + basicCredentialsProvider.setCredentials(new AuthScope(httpHost), + new UsernamePasswordCredentials(username, password.toCharArray())); + return basicCredentialsProvider; } - private HttpHost creatHttpHost(String s) { + private HttpHost createHttpHost(String s) { try { return HttpHost.create(s); } catch (URISyntaxException e) { diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreProperties.java index 4e45b4da9ee..900cdbd233b 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreProperties.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/opensearch/OpenSearchVectorStoreProperties.java @@ -31,15 +31,11 @@ public class OpenSearchVectorStoreProperties { private String indexName; - private String mappingJson; + private String username; - public String getMappingJson() { - return mappingJson; - } + private String password; - public void setMappingJson(String mappingJson) { - this.mappingJson = mappingJson; - } + private String mappingJson; public List getUris() { return uris; @@ -57,4 +53,28 @@ public void setIndexName(String indexName) { this.indexName = indexName; } + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getMappingJson() { + return mappingJson; + } + + public void setMappingJson(String mappingJson) { + this.mappingJson = mappingJson; + } + } From a8cad115b5722aa48329a3d536ea6ab0753efd51 Mon Sep 17 00:00:00 2001 From: Jemin Huh Date: Mon, 29 Apr 2024 00:36:42 +0900 Subject: [PATCH 46/46] Update Docker version for OpenSearchVectorStoreIT and opensearch-client version --- pom.xml | 2 +- .../ai/vectorstore/OpenSearchVectorStoreIT.java | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/pom.xml b/pom.xml index 96b1f4f3371..30e19770d96 100644 --- a/pom.xml +++ b/pom.xml @@ -153,7 +153,7 @@ 11.6.1 4.5.1 1.7.1 - 2.9.1 + 2.10.1 5.3.1 diff --git a/vector-stores/spring-ai-opensearch-store/src/test/java/org/springframework/ai/vectorstore/OpenSearchVectorStoreIT.java b/vector-stores/spring-ai-opensearch-store/src/test/java/org/springframework/ai/vectorstore/OpenSearchVectorStoreIT.java index 3d8d97f53f3..5632ea16454 100644 --- a/vector-stores/spring-ai-opensearch-store/src/test/java/org/springframework/ai/vectorstore/OpenSearchVectorStoreIT.java +++ b/vector-stores/spring-ai-opensearch-store/src/test/java/org/springframework/ai/vectorstore/OpenSearchVectorStoreIT.java @@ -23,8 +23,6 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.opensearch.client.opensearch.OpenSearchClient; -import org.opensearch.client.transport.OpenSearchTransport; -import org.opensearch.client.transport.httpclient5.ApacheHttpClient5Transport; import org.opensearch.client.transport.httpclient5.ApacheHttpClient5TransportBuilder; import org.opensearch.testcontainers.OpensearchContainer; import org.springframework.ai.document.Document; @@ -41,7 +39,6 @@ import org.testcontainers.junit.jupiter.Testcontainers; import org.testcontainers.utility.DockerImageName; -import javax.net.ssl.SSLEngine; import java.io.IOException; import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; @@ -63,7 +60,7 @@ class OpenSearchVectorStoreIT { @Container private static final OpensearchContainer opensearchContainer = - new OpensearchContainer<>(DockerImageName.parse("opensearchproject/opensearch:2.12.0")); + new OpensearchContainer<>(DockerImageName.parse("opensearchproject/opensearch:2.13.0")); private static final String DEFAULT = "cosinesimil";