diff --git a/README.md b/README.md index 99fd91713..095c3f130 100644 --- a/README.md +++ b/README.md @@ -11,15 +11,15 @@ Let's make your `@Beans` intelligent! * [Documentation](https://docs.spring.io/spring-ai/reference/) * [Issues](https://github.com/spring-projects-experimental/spring-ai/issues) -* [Discussions](https://github.com/spring-projects-experimental/spring-ai/discussions) - Go here if you have a question, suggestion, or feedback! +* [Discussions](https://github.com/spring-projects-experimental/spring-ai/discussions) - Go here if you have a question, suggestion, or feedback! * [JavaDocs](https://docs.spring.io/spring-ai/docs/current-SNAPSHOT/) ## Educational Resources -* Follow the [Workshop](#workshop) -* Overview of Spring AI @ Devoxx 2023 +* Follow the [Workshop](#workshop) +* Overview of Spring AI @ Devoxx 2023
[![Watch the Devoxx 2023 video](https://img.youtube.com/vi/7OY9fKVxAFQ/default.jpg)](https://www.youtube.com/watch?v=7OY9fKVxAFQ) -* Introducing Spring AI - Add Generative AI to your Spring Applications +* Introducing Spring AI - Add Generative AI to your Spring Applications
[![Watch the video](https://img.youtube.com/vi/1g_wuincUdU/default.jpg)](https://www.youtube.com/watch?v=1g_wuincUdU) ## Dependencies @@ -49,7 +49,7 @@ And the Spring Boot Starter depending on if you are using Azure Open AI or Open org.springframework.experimental.ai spring-ai-azure-openai-spring-boot-starter - 0.7.0-SNAPSHOT + 0.7.1-SNAPSHOT ``` @@ -59,7 +59,7 @@ And the Spring Boot Starter depending on if you are using Azure Open AI or Open org.springframework.experimental.ai spring-ai-openai-spring-boot-starter - 0.7.0-SNAPSHOT + 0.7.1-SNAPSHOT ``` @@ -71,7 +71,7 @@ And the Spring Boot Starter depending on if you are using Azure Open AI or Open org.springframework.experimental.ai spring-ai-openai-spring-boot-starter - 0.7.0-SNAPSHOT + 0.7.1-SNAPSHOT ``` @@ -96,11 +96,11 @@ These Python libraries share foundational themes with Spring projects, such as: Taking inspiration from these libraries, the Spring AI project aims to provide a similar experience for Spring developers in the AI domain. -Note, that the Spring AI API is not a direct port of either LangChain or LlamaIndex. You will see significant differences in the API if you are familiar with those two projects, though concepts and ideas are fairly portable. +Note, that the Spring AI API is not a direct port of either LangChain or LlamaIndex. You will see significant differences in the API if you are familiar with those two projects, though concepts and ideas are fairly portable. ## Feature Overview -This is a high level feature overview. +This is a high level feature overview. The features that are implemented lay the foundation, with subsequent more complex features building upon them. You can find more details in the [Reference Documentation](https://docs.spring.io/spring-ai/reference/) @@ -134,7 +134,7 @@ For implementation details, visit the [OutputParser API guide](https://docs.spri Incorporating proprietary data into Generative AI without retraining the model has been a breakthrough. Retraining models, especially those with billions of parameters, is challenging due to the specialized hardware required. -The 'In-context' learning technique provides a simpler method to infuse your pre-trained model with data, whether from text files, HTML, or database results. +The 'In-context' learning technique provides a simpler method to infuse your pre-trained model with data, whether from text files, HTML, or database results. The right techniques are critical for developing successful solutions. @@ -150,9 +150,9 @@ The subsequent classes and interfaces support RAG's data preparation. **Documents:** -The `Document` class encapsulates your data, including text and metadata, for the AI model. +The `Document` class encapsulates your data, including text and metadata, for the AI model. While a Document can represent extensive content, such as an entire file, the RAG approach -segments content into smaller pieces for inclusion in the prompt. +segments content into smaller pieces for inclusion in the prompt. The ETL process uses the interfaces `DocumentReader`, `DocumentTransformer`, and `DocumentWriter`, ending with data storage in a Vector Database. This database later discerns the pieces of data that are pertinent to a user's query. @@ -160,7 +160,7 @@ This database later discerns the pieces of data that are pertinent to a user's q **Document Readers:** Document Readers produce a `List` from diverse sources like PDFs, Markdown files, and Word documents. -Given that many sources are unstructured, Document Readers often segment based on content semantics, avoiding splits within tables or code sections. +Given that many sources are unstructured, Document Readers often segment based on content semantics, avoiding splits within tables or code sections. After the initial creation of the `List`, the data flows through transformers for further refinement. **Document Transformers:** @@ -171,7 +171,7 @@ Each model has a context-window indicating its input and output data limits. Typ **Document Writers:** -The final ETL step within RAG involves committing the data segments to a Vector Database. +The final ETL step within RAG involves committing the data segments to a Vector Database. Though the `DocumentWriter` interface isn't exclusively for Vector Database writing, it the main type of implementation. **Vector Stores:** Vector Databases are instrumental in incorporating your data with AI models. diff --git a/document-readers/pdf-reader/pom.xml b/document-readers/pdf-reader/pom.xml index 072b1d3fb..bfafddd6c 100644 --- a/document-readers/pdf-reader/pom.xml +++ b/document-readers/pdf-reader/pom.xml @@ -5,7 +5,7 @@ org.springframework.experimental.ai spring-ai - 0.7.0-SNAPSHOT + 0.7.1-SNAPSHOT ../../pom.xml spring-ai-pdf-document-reader @@ -36,7 +36,7 @@ commons-logging commons-logging - + diff --git a/document-readers/tika-reader/pom.xml b/document-readers/tika-reader/pom.xml index 060243334..3c49cb1de 100644 --- a/document-readers/tika-reader/pom.xml +++ b/document-readers/tika-reader/pom.xml @@ -5,7 +5,7 @@ org.springframework.experimental.ai spring-ai - 0.7.0-SNAPSHOT + 0.7.1-SNAPSHOT ../../pom.xml spring-ai-tika-document-reader diff --git a/embedding-clients/spring-ai-postgresml-embedding-client/pom.xml b/embedding-clients/spring-ai-postgresml-embedding-client/pom.xml index e6ef6f0ff..91c99f6a5 100644 --- a/embedding-clients/spring-ai-postgresml-embedding-client/pom.xml +++ b/embedding-clients/spring-ai-postgresml-embedding-client/pom.xml @@ -5,7 +5,7 @@ org.springframework.experimental.ai spring-ai - 0.7.0-SNAPSHOT + 0.7.1-SNAPSHOT ../../pom.xml spring-ai-postgresml-embedding-client diff --git a/embedding-clients/transformers-embedding/README.md b/embedding-clients/transformers-embedding/README.md index a7a2b88dc..776ca502f 100644 --- a/embedding-clients/transformers-embedding/README.md +++ b/embedding-clients/transformers-embedding/README.md @@ -36,7 +36,7 @@ Add the `transformers-embedding` project to your maven dependencies: org.springframework.experimental.ai transformers-embedding - 0.7.0-SNAPSHOT + 0.7.1-SNAPSHOT ``` diff --git a/embedding-clients/transformers-embedding/pom.xml b/embedding-clients/transformers-embedding/pom.xml index 1cd562d41..69f52566e 100644 --- a/embedding-clients/transformers-embedding/pom.xml +++ b/embedding-clients/transformers-embedding/pom.xml @@ -5,7 +5,7 @@ org.springframework.experimental.ai spring-ai - 0.7.0-SNAPSHOT + 0.7.1-SNAPSHOT ../../pom.xml transformers-embedding diff --git a/pom.xml b/pom.xml index 59229491d..3fc803002 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ org.springframework.experimental.ai spring-ai - 0.7.0-SNAPSHOT + 0.7.1-SNAPSHOT pom https://github.com/spring-projects-experimental/spring-ai @@ -87,7 +87,7 @@ 3.0.0 0.1.3 42.6.0 - 2.3.0 + 2.3.3 0.6.0 3.24.4 diff --git a/spring-ai-azure-openai/pom.xml b/spring-ai-azure-openai/pom.xml index ffe0b62d3..dab351b01 100644 --- a/spring-ai-azure-openai/pom.xml +++ b/spring-ai-azure-openai/pom.xml @@ -4,7 +4,7 @@ org.springframework.experimental.ai spring-ai - 0.7.0-SNAPSHOT + 0.7.1-SNAPSHOT spring-ai-azure-openai jar diff --git a/spring-ai-core/pom.xml b/spring-ai-core/pom.xml index a9679ca7f..84b1ae49c 100644 --- a/spring-ai-core/pom.xml +++ b/spring-ai-core/pom.xml @@ -5,7 +5,7 @@ org.springframework.experimental.ai spring-ai - 0.7.0-SNAPSHOT + 0.7.1-SNAPSHOT spring-ai-core jar diff --git a/spring-ai-core/src/main/java/org/springframework/ai/ResourceUtils.java b/spring-ai-core/src/main/java/org/springframework/ai/ResourceUtils.java new file mode 100644 index 000000000..2c17f6532 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/ResourceUtils.java @@ -0,0 +1,39 @@ +/* + * Copyright 2023-2023 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; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.springframework.core.io.DefaultResourceLoader; + +/** + * @author Christian Tzolov + */ +public class ResourceUtils { + + public static String getText(String uri) { + var resource = new DefaultResourceLoader().getResource(uri); + try { + return resource.getContentAsString(StandardCharsets.UTF_8); + } + catch (IOException e) { + throw new RuntimeException(e); + } + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/retriever/VectorStoreRetriever.java b/spring-ai-core/src/main/java/org/springframework/ai/retriever/VectorStoreRetriever.java index 7c416216c..1e01c03a4 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/retriever/VectorStoreRetriever.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/retriever/VectorStoreRetriever.java @@ -1,13 +1,14 @@ package org.springframework.ai.retriever; -import org.springframework.ai.document.Document; -import org.springframework.ai.document.DocumentRetriever; -import org.springframework.ai.vectorstore.VectorStore; - import java.util.List; import java.util.Objects; import java.util.Optional; +import org.springframework.ai.document.Document; +import org.springframework.ai.document.DocumentRetriever; +import org.springframework.ai.vectorstore.SearchRequest; +import org.springframework.ai.vectorstore.VectorStore; + public class VectorStoreRetriever implements DocumentRetriever { private VectorStore vectorStore; @@ -47,12 +48,13 @@ public class VectorStoreRetriever implements DocumentRetriever { @Override public List retrieve(String query) { + + SearchRequest request = SearchRequest.query(query).withTopK(this.k); if (threshold.isPresent()) { - return this.vectorStore.similaritySearch(query, this.k, this.threshold.get()); - } - else { - return this.vectorStore.similaritySearch(query, this.k); + request.withSimilarityThreshold(this.threshold.get()); } + + return this.vectorStore.similaritySearch(request); } } diff --git a/spring-ai-core/src/main/java/org/springframework/ai/vectorstore/InMemoryVectorStore.java b/spring-ai-core/src/main/java/org/springframework/ai/vectorstore/InMemoryVectorStore.java index 2d6c0531f..25ec4dde3 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/vectorstore/InMemoryVectorStore.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/vectorstore/InMemoryVectorStore.java @@ -12,6 +12,7 @@ import java.util.concurrent.ConcurrentHashMap; * @author Raphael Yu * @author Dingmeng Xue * @author Mark Pollack + * @author Christian Tzolov */ public class InMemoryVectorStore implements VectorStore { @@ -45,36 +46,23 @@ public class InMemoryVectorStore implements VectorStore { } @Override - public List similaritySearch(String query) { - return similaritySearch(query, 4); - } + public List similaritySearch(SearchRequest request) { + if (request.getFilterExpression() != null) { + throw new UnsupportedOperationException( + "The [" + this.getClass() + "] doesn't support metadata filtering!"); + } - @Override - public List similaritySearch(String query, int k) { - List userQueryEmbedding = getUserQueryEmbedding(query); + List userQueryEmbedding = getUserQueryEmbedding(request.getQuery()); var similarities = this.store.values() .stream() .map(entry -> new Similarity(entry.getId(), EmbeddingMath.cosineSimilarity(userQueryEmbedding, entry.getEmbedding()))) + .filter(s -> s.similarity >= request.getSimilarityThreshold()) .sorted(Comparator.comparingDouble(s -> s.similarity).reversed()) - .limit(k) - .map(s -> store.get(s.key)) + .limit(request.getTopK()) + .map(s -> this.store.get(s.key)) .toList(); - return similarities; - } - @Override - public List similaritySearch(String query, int k, double threshold) { - List userQueryEmbedding = getUserQueryEmbedding(query); - var similarities = this.store.values() - .stream() - .map(entry -> new Similarity(entry.getId(), - EmbeddingMath.cosineSimilarity(userQueryEmbedding, entry.getEmbedding()))) - .filter(s -> s.similarity >= threshold) - .sorted(Comparator.comparingDouble(s -> s.similarity).reversed()) - .limit(k) - .map(s -> store.get(s.key)) - .toList(); return similarities; } diff --git a/spring-ai-core/src/main/java/org/springframework/ai/vectorstore/SearchRequest.java b/spring-ai-core/src/main/java/org/springframework/ai/vectorstore/SearchRequest.java new file mode 100644 index 000000000..d7c54b9db --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/vectorstore/SearchRequest.java @@ -0,0 +1,251 @@ +/* + * Copyright 2023-2023 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.document.Document; +import org.springframework.ai.vectorstore.filter.Filter; +import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder; +import org.springframework.ai.vectorstore.filter.FilterExpressionTextParser; +import org.springframework.util.Assert; + +/** + * Similarity search request builder. Use the {@link #query(String)}, {@link #defaults()} + * or {@link #from(SearchRequest)} factory methods to create a new {@link SearchRequest} + * instance and then apply the 'with' methods to alter the default values. + * + * @author Christian Tzolov + */ +public class SearchRequest { + + /** + * Similarity threshold that accepts all search scores. A threshold value of 0.0 means + * any similarity is accepted or disable the similarity threshold filtering. A + * threshold value of 1.0 means an exact match is required. + */ + public static final double SIMILARITY_THRESHOLD_ACCEPT_ALL = 0.0; + + /** + * Default value for the top 'k' similar results to return. + */ + public static final int DEFAULT_TOP_K = 4; + + public String query; + + private int topK = DEFAULT_TOP_K; + + private double similarityThreshold = SIMILARITY_THRESHOLD_ACCEPT_ALL; + + private Filter.Expression filterExpression; + + private SearchRequest(String query) { + this.query = query; + } + + /** + * Create a new {@link SearchRequest} builder instance with specified embedding query + * string. + * @param query Text to use for embedding similarity comparison. + * @return Returns new {@link SearchRequest} builder instance. + */ + public static SearchRequest query(String query) { + Assert.notNull(query, "Query can not be null."); + return new SearchRequest(query); + } + + /** + * Create a new {@link SearchRequest} builder instance with an empty embedding query + * string. Use the {@link #withQuery(String query)} to set/update the embedding query + * text. + * @return Returns new {@link SearchRequest} builder instance. + */ + public static SearchRequest defaults() { + return new SearchRequest(""); + } + + /** + * Copy an existing {@link SearchRequest} instance. + * @param originalSearchRequest {@link SearchRequest} instance to copy. + * @return Returns new {@link SearchRequest} builder instance. + */ + public static SearchRequest from(SearchRequest originalSearchRequest) { + return new SearchRequest(originalSearchRequest.getQuery()).withTopK(originalSearchRequest.getTopK()) + .withSimilarityThreshold(originalSearchRequest.getSimilarityThreshold()) + .withFilterExpression(originalSearchRequest.getFilterExpression()); + } + + /** + * @param query Text to use for embedding similarity comparison. + * @return this builder. + */ + public SearchRequest withQuery(String query) { + Assert.notNull(query, "Query can not be null."); + this.query = query; + return this; + } + + /** + * @param topK the top 'k' similar results to return. + * @return this builder. + */ + public SearchRequest withTopK(int topK) { + Assert.isTrue(topK >= 0, "TopK should be positive."); + this.topK = topK; + return this; + } + + /** + * Similarity threshold score to filter the search response by. Only documents with + * similarity score equal or greater than the 'threshold' will be returned. Note that + * this is a post processing step performed on the client not the server side. A + * threshold value of 0.0 means any similarity is accepted or disable the similarity + * threshold filtering. A threshold value of 1.0 means an exact match is required. + * @param threshold The lower bound of the similarity score. + * @return this builder. + */ + public SearchRequest withSimilarityThreshold(double threshold) { + Assert.isTrue(threshold >= 0 && threshold <= 1, "Similarity threshold must be in [0,1] range."); + this.similarityThreshold = threshold; + return this; + } + + /** + * Sets disables the similarity threshold by setting it to 0.0 - all results are + * accepted. + * @return this builder. + */ + public SearchRequest withSimilarityThresholdAll() { + return withSimilarityThreshold(SIMILARITY_THRESHOLD_ACCEPT_ALL); + } + + /** + * Retrieves documents by query embedding similarity and matching the filters. Value + * of 'null' means that no metadata filters will be applied to the search. + * + * For example if the {@link Document#getMetadata()} schema is: + * + *
{@code
+	 * {
+	 * "country": ,
+	 * "city": ,
+	 * "year": ,
+	 * "price": ,
+	 * "isActive": 
+	 * }
+	 * }
+ * + * you can constrain the search result to only UK countries with isActive=true and + * year equal or greater 2020. You can build this such metadata filter + * programmatically like this: + * + *
{@code
+	 * var exp = new Filter.Expression(AND,
+	 * 		new Expression(EQ, new Key("country"), new Value("UK")),
+	 * 		new Expression(AND,
+	 * 				new Expression(GTE, new Key("year"), new Value(2020)),
+	 * 				new Expression(EQ, new Key("isActive"), new Value(true))));
+	 * }
+ * + * The {@link Filter.Expression} is portable across all vector stores.
+ * + * + * The {@link FilterExpressionBuilder} is a DSL creating expressions programmatically: + * + *
{@code
+	 * var b = new FilterExpressionBuilder();
+	 * var exp = b.and(
+	 * 		b.eq("country", "UK"),
+	 * 		b.and(
+	 * 			b.gte("year", 2020),
+	 * 			b.eq("isActive", true)));
+	 * }
+ * + * The {@link FilterExpressionTextParser} converts textual, SQL like filter expression + * language into {@link Filter.Expression}: + * + *
{@code
+	 * var parser = new FilterExpressionTextParser();
+	 * var exp = parser.parse("country == 'UK' && isActive == true && year >=2020");
+	 * }
+ * @param expression {@link Filter.Expression} instance used to define the metadata + * filter criteria. The 'null' value stands for no expression filters. + * @return this builder. + */ + public SearchRequest withFilterExpression(Filter.Expression expression) { + this.filterExpression = expression; + return this; + } + + /** + * Document metadata filter expression. For example if your + * {@link Document#getMetadata()} has a schema like: + * + *
{@code
+	 * {
+	 * "country": ,
+	 * "city": ,
+	 * "year": ,
+	 * "price": ,
+	 * "isActive": 
+	 * }
+	 * }
+ * + * then you can constrain the search result with metadata filter expressions like: + * + *
{@code
+	 * country == 'UK' && year >= 2020 && isActive == true
+	 * Or
+	 * country == 'BG' && (city NOT IN ['Sofia', 'Plovdiv'] || price < 134.34)
+	 * }
+ * + * This ensures that the response contains only embeddings that match the specified + * filer criteria.
+ * + * The declarative, SQL like, filter syntax is portable across all vector stores + * supporting the filter search feature.
+ * + * The {@link FilterExpressionTextParser} is used to convert the text filter + * expression into {@link Filter.Expression}. + * @param textExpression declarative, portable, SQL like, metadata filter syntax. The + * 'null' value stands for no expression filters. + * @return this.builder + */ + public SearchRequest withFilterExpression(String textExpression) { + this.filterExpression = (textExpression != null) ? Filter.parser().parse(textExpression) : null; + return this; + } + + public String getQuery() { + return query; + } + + public int getTopK() { + return topK; + } + + public double getSimilarityThreshold() { + return similarityThreshold; + } + + public Filter.Expression getFilterExpression() { + return filterExpression; + } + + public boolean hasFilterExpression() { + return this.filterExpression != null; + } + +} \ No newline at end of file diff --git a/spring-ai-core/src/main/java/org/springframework/ai/vectorstore/VectorStore.java b/spring-ai-core/src/main/java/org/springframework/ai/vectorstore/VectorStore.java index 69968ece6..ac8dbc866 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/vectorstore/VectorStore.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/vectorstore/VectorStore.java @@ -5,10 +5,15 @@ import java.util.Optional; import org.springframework.ai.document.Document; import org.springframework.ai.document.DocumentWriter; -import org.springframework.ai.vectorstore.filter.Filter; -import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder; -import org.springframework.ai.vectorstore.filter.FilterExpressionTextParser; +/** + * The {@code VectorStore} interface defines the operations for managing and querying + * documents in a vector database. It extends {@link DocumentWriter} to support document + * writing operations. Vector databases are specialized for AI applications, performing + * similarity searches based on vector representations of data rather than exact matches. + * This interface allows for adding, deleting, and searching documents based on their + * similarity to a given query. + */ public interface VectorStore extends DocumentWriter { /** @@ -30,107 +35,24 @@ public interface VectorStore extends DocumentWriter { */ Optional delete(List idList); - List similaritySearch(String query); - - List similaritySearch(String query, int k); - - /** - * @param query The query to send, it will be converted to an embedding based on the - * configuration of the vector store. - * @param k the top 'k' similar results - * @param threshold the lower bound of the similarity score - * @return similar documents - */ - List similaritySearch(String query, int k, double threshold); - /** * Retrieves documents by query embedding similarity and metadata filters to retrieve - * exactly the number of nearest-neighbor results that match the filters. - * - * For example if your {@link Document#getMetadata()} has a schema like: - * - *
{@code
-	 * {
-	 *  "country":  ,
-	 *  "city":     ,
-	 *  "year":     ,
-	 *  "price":    ,
-	 *  "isActive": 
-	 * }
-	 * }
- * - * then you can constrain the search result with metadata filter expressions - * equivalent to (country == 'UK' AND year >= 2020 AND isActive == true). You can - * build this filter programmatically like this: - * - *
{@code
-	 *
-	 * new Filter.Expression(AND,
-	 * 		new Expression(EQ, new Key("country"), new Value("UK")),
-	 * 		new Expression(AND,
-	 * 				new Expression(GTE, new Key("year"), new Value(2020)),
-	 * 				new Expression(EQ, new Key("isActive"), new Value(true))));
-	 *
-	 * }
- * - * and it will ensure that the response contains only embeddings that match the - * specified filer criteria.
- * - * The {@link Filter.Expression} is portable across all vector stores that offer - * metadata filtering. The {@link FilterExpressionBuilder} is expression DSL and - * {@link FilterExpressionTextParser} is text expression parser that build - * {@link Filter.Expression}. - * @param topK the top 'k' similar results to return. - * @param similarityThreshold the lower bound of the similarity score - * @param filterExpression portable metadata filter expression. - * @return similar documents that match the requested similarity threshold and filter. + * exactly the number of nearest-neighbor results that match the request criteria. + * @param request Search request for set search parameters, such as the query text, + * topK, similarity threshold and metadata filter expressions. + * @return Returns documents th match the query request conditions. */ - default List similaritySearch(String query, int topK, double similarityThreshold, - Filter.Expression filterExpression) { - throw new UnsupportedOperationException("This vector store doesn't support search filtering"); - } + List similaritySearch(SearchRequest request); /** - * Retrieves documents by query embedding similarity and metadata filters to retrieve - * exactly the number of nearest-neighbor results that match the filters. - * - * For example if your {@link Document#getMetadata()} has a schema like: - * - *
{@code
-	 * {
-	 *  "country":  ,
-	 *  "city":     ,
-	 *  "year":     ,
-	 *  "price":    ,
-	 *  "isActive": 
-	 * }
-	 * }
- * - * then you can constrain the search result with metadata filter expressions like: - * - *
{@code
-	 *country == 'UK' && year >= 2020 && isActive == true
-	 *                        Or
-	 *country == 'BG' && (city NOT IN ['Sofia', 'Plovdiv'] || price < 134.34)
-	 * }
- * - * This ensures that the response contains only embeddings that match the specified - * filer criteria.
- * - * The declarative, SQL like, filter syntax is portable across all vector stores - * supporting the filter search feature.
- * - * The {@link FilterExpressionTextParser} is used to convert the text filter - * expression into {@link Filter.Expression}. - * @param topK the top 'k' similar results to return. - * @param similarityThreshold the lower bound of the similarity score - * @param filterExpression portable metadata filter expression. - * @return similar documents that match the requested similarity threshold and filter. + * Retrieves documents by query embedding similarity using the default + * {@link SearchRequest}'s' search criteria. + * @param query Text to use for embedding similarity comparison. + * @return Returns a list of documents that have embeddings similar to the query text + * embedding. */ - default List similaritySearch(String query, int topK, double similarityThreshold, - String filterExpression) { - var filterExpressionObject = Filter.parser().parse(filterExpression); - return similaritySearch(query, topK, similarityThreshold, filterExpressionObject); + default List similaritySearch(String query) { + return this.similaritySearch(SearchRequest.query(query)); } } diff --git a/spring-ai-core/src/main/java/org/springframework/ai/vectorstore/filter/FilterExpressionTextParser.java b/spring-ai-core/src/main/java/org/springframework/ai/vectorstore/filter/FilterExpressionTextParser.java index 8ffc2a365..5494ccd9a 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/vectorstore/filter/FilterExpressionTextParser.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/vectorstore/filter/FilterExpressionTextParser.java @@ -40,7 +40,7 @@ import org.springframework.util.Assert; /** * - * Parse a text, vector-store agnostic, filter expression language into + * Parse a textual, vector-store agnostic, filter expression language into * {@link Filter.Expression}. * * The vector-store agnostic, filter expression language is defined by a formal ANTLR4 diff --git a/spring-ai-core/src/main/java/org/springframework/ai/vectorstore/filter/converter/AbstractFilterExpressionConverter.java b/spring-ai-core/src/main/java/org/springframework/ai/vectorstore/filter/converter/AbstractFilterExpressionConverter.java index 31e4de3df..fbf5246d3 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/vectorstore/filter/converter/AbstractFilterExpressionConverter.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/vectorstore/filter/converter/AbstractFilterExpressionConverter.java @@ -22,6 +22,7 @@ import org.springframework.ai.vectorstore.filter.Filter; import org.springframework.ai.vectorstore.filter.Filter.ExpressionType; import org.springframework.ai.vectorstore.filter.Filter.Group; import org.springframework.ai.vectorstore.filter.Filter.Operand; +import org.springframework.util.Assert; /** * @author Christian Tzolov @@ -29,6 +30,7 @@ import org.springframework.ai.vectorstore.filter.Filter.Operand; public abstract class AbstractFilterExpressionConverter { public String convert(Operand operand) { + Assert.notNull(operand, "Operand can't be null"); var context = new StringBuilder(); this.convert(operand, context); return context.toString(); diff --git a/spring-ai-core/src/main/resources/embedding/embedding-model-dimensions.properties b/spring-ai-core/src/main/resources/embedding/embedding-model-dimensions.properties index 99afca1a1..89f3b63cd 100644 --- a/spring-ai-core/src/main/resources/embedding/embedding-model-dimensions.properties +++ b/spring-ai-core/src/main/resources/embedding/embedding-model-dimensions.properties @@ -17,3 +17,4 @@ code-search-ada-code-001=1024 code-search-ada-text-001=1024 code-search-babbage-code-001=2048 code-search-babbage-text-001=2048 +sentence-transformers/all-MiniLM-L6-v2=384 \ No newline at end of file diff --git a/spring-ai-core/src/test/java/org/springframework/ai/vectorstore/filter/SearchRequestTests.java b/spring-ai-core/src/test/java/org/springframework/ai/vectorstore/filter/SearchRequestTests.java new file mode 100644 index 000000000..bbd87e06b --- /dev/null +++ b/spring-ai-core/src/test/java/org/springframework/ai/vectorstore/filter/SearchRequestTests.java @@ -0,0 +1,145 @@ +/* + * Copyright 2023-2023 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.filter; + +import org.junit.jupiter.api.Test; + +import org.springframework.ai.vectorstore.SearchRequest; +import org.springframework.ai.vectorstore.filter.FilterExpressionTextParser.FilterExpressionParseException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * @author Christian Tzolov + */ +public class SearchRequestTests { + + @Test + public void createDefaults() { + var emptyRequest = SearchRequest.defaults(); + assertThat(emptyRequest.getQuery()).isEqualTo(""); + checkDefaults(emptyRequest); + } + + @Test + public void createQuery() { + var emptyRequest = SearchRequest.query("New Query"); + assertThat(emptyRequest.getQuery()).isEqualTo("New Query"); + checkDefaults(emptyRequest); + } + + @Test + public void createFrom() { + var originalRequest = SearchRequest.query("New Query") + .withTopK(696) + .withSimilarityThreshold(0.678) + .withFilterExpression("country == 'NL'"); + + var newRequest = SearchRequest.from(originalRequest); + + assertThat(newRequest).isNotSameAs(originalRequest); + assertThat(newRequest.getQuery()).isEqualTo(originalRequest.getQuery()); + assertThat(newRequest.getTopK()).isEqualTo(originalRequest.getTopK()); + assertThat(newRequest.getFilterExpression()).isEqualTo(originalRequest.getFilterExpression()); + assertThat(newRequest.getSimilarityThreshold()).isEqualTo(originalRequest.getSimilarityThreshold()); + } + + @Test + public void withQuery() { + var emptyRequest = SearchRequest.defaults(); + assertThat(emptyRequest.getQuery()).isEqualTo(""); + + emptyRequest.withQuery("New Query"); + assertThat(emptyRequest.getQuery()).isEqualTo("New Query"); + } + + @Test() + public void withSimilarityThreshold() { + var request = SearchRequest.query("Test").withSimilarityThreshold(0.678); + assertThat(request.getSimilarityThreshold()).isEqualTo(0.678); + + request.withSimilarityThreshold(0.9); + assertThat(request.getSimilarityThreshold()).isEqualTo(0.9); + + assertThatThrownBy(() -> { + request.withSimilarityThreshold(-1); + }).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Similarity threshold must be in [0,1] range."); + + assertThatThrownBy(() -> { + request.withSimilarityThreshold(1.1); + }).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Similarity threshold must be in [0,1] range."); + + } + + @Test() + public void withTopK() { + var request = SearchRequest.query("Test").withTopK(66); + assertThat(request.getTopK()).isEqualTo(66); + + request.withTopK(89); + assertThat(request.getTopK()).isEqualTo(89); + + assertThatThrownBy(() -> { + request.withTopK(-1); + }).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("TopK should be positive."); + + } + + @Test() + public void withFilterExpression() { + + var request = SearchRequest.query("Test").withFilterExpression("country == 'BG' && year >= 2022"); + assertThat(request.getFilterExpression()).isEqualTo(new Filter.Expression(Filter.ExpressionType.AND, + new Filter.Expression(Filter.ExpressionType.EQ, new Filter.Key("country"), new Filter.Value("BG")), + new Filter.Expression(Filter.ExpressionType.GTE, new Filter.Key("year"), new Filter.Value(2022)))); + assertThat(request.hasFilterExpression()).isTrue(); + + request.withFilterExpression("active == true"); + assertThat(request.getFilterExpression()).isEqualTo( + new Filter.Expression(Filter.ExpressionType.EQ, new Filter.Key("active"), new Filter.Value(true))); + assertThat(request.hasFilterExpression()).isTrue(); + + request.withFilterExpression(Filter.builder().eq("country", "NL").build()); + assertThat(request.getFilterExpression()).isEqualTo( + new Filter.Expression(Filter.ExpressionType.EQ, new Filter.Key("country"), new Filter.Value("NL"))); + assertThat(request.hasFilterExpression()).isTrue(); + + request.withFilterExpression((String) null); + assertThat(request.getFilterExpression()).isNull(); + assertThat(request.hasFilterExpression()).isFalse(); + + request.withFilterExpression((Filter.Expression) null); + assertThat(request.getFilterExpression()).isNull(); + assertThat(request.hasFilterExpression()).isFalse(); + + assertThatThrownBy(() -> { + request.withFilterExpression("FooBar"); + }).isInstanceOf(FilterExpressionParseException.class) + .hasMessageContaining("Error: no viable alternative at input 'FooBar'"); + + } + + private void checkDefaults(SearchRequest request) { + assertThat(request.getFilterExpression()).isNull(); + assertThat(request.getSimilarityThreshold()).isEqualTo(SearchRequest.SIMILARITY_THRESHOLD_ACCEPT_ALL); + assertThat(request.getTopK()).isEqualTo(SearchRequest.DEFAULT_TOP_K); + } + +} diff --git a/spring-ai-docs/pom.xml b/spring-ai-docs/pom.xml index 41c6f3191..a629e15bc 100644 --- a/spring-ai-docs/pom.xml +++ b/spring-ai-docs/pom.xml @@ -4,7 +4,7 @@ org.springframework.experimental.ai spring-ai - 0.7.0-SNAPSHOT + 0.7.1-SNAPSHOT spring-ai-docs Spring AI Docs diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/aiclient.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/aiclient.adoc index bf8c1bf9c..8b92ca880 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/aiclient.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/aiclient.adoc @@ -146,7 +146,7 @@ Add the Spring Boot starter to you project's dependencies: org.springframework.experimental.ai spring-ai-azure-openai-spring-boot-starter - 0.7.0-SNAPSHOT + 0.7.1-SNAPSHOT ---- @@ -187,7 +187,7 @@ There is not yet a Spring Boot Starter for this client implementation, so you sh org.springframework.experimental.ai spring-ai-huggingface - 0.7.0-SNAPSHOT + 0.7.1-SNAPSHOT ---- @@ -208,7 +208,7 @@ There is not yet a Spring Boot Starter for this client implementation, so you sh org.springframework.experimental.ai spring-ai-ollama - 0.7.0-SNAPSHOT + 0.7.1-SNAPSHOT ---- diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs.adoc index f4922b7f7..5cb137e91 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs.adoc @@ -28,24 +28,43 @@ Here is the `VectorStore` interface definition: ```java public interface VectorStore { - void add(List documents); + void add(List documents); - Optional delete(List idList); + Optional delete(List idList); - List similaritySearch(String query); + List similaritySearch(String query); - List similaritySearch(String query, int k); - - List similaritySearch(String query, int k, double threshold); - - List similaritySearch(String query, int topK, double similarityThreshold, - Filter.Expression filterExpression); - - List similaritySearch(String query, int topK, double similarityThreshold, - String filterExpression); + List similaritySearch(SearchRequest request); } ``` +and the related `SearchRequest` builder: + +```java +public class SearchRequest { + + public final String query; + private int topK = 4; + private double similarityThreshold = SIMILARITY_THRESHOLD_ALL; + private Filter.Expression filterExpression; + + public static SearchRequest query(String query) { return new SearchRequest(query); } + private SearchRequest(String query) { this.query = query; } + + public SearchRequest withTopK(int topK) {...} + public SearchRequest withSimilarityThreshold(double threshold) {...} + public SearchRequest withSimilarityThresholdAll() {...} + public SearchRequest withFilterExpression(Filter.Expression expression) {...} + public SearchRequest withFilterExpression(String textExpression) {...} + + public String getQuery() {...} + public int getTopK() {...} + public double getSimilarityThreshold() {...} + public Filter.Expression getFilterExpression() {...} +} + +``` + To insert data into the vector database, encapsulate it within a `Document` object. The `Document` class encapsulates content from a data source, such as a PDF or Word document, and includes text represented as a string. It also contains metadata in the form of key-value pairs, including details such as the filename. @@ -59,7 +78,7 @@ The `similaritySearch` methods in the interface allow for retrieving documents s * `k`: An integer that specifies the maximum number of similar documents to return. This is often referred to as a 'top K' search, or 'K nearest neighbors' (KNN). * `threshold`: A double value ranging from 0 to 1, where values closer to 1 indicate higher similarity. By default, if you set a threshold of 0.75, for instance, only documents with a similarity above this value are returned. * `Filter.Expression`: A class used for passing a fluent DSL (Domain-Specific Language) expression that functions similarly to a 'where' clause in SQL, but it applies exclusively to the metadata key-value pairs of a `Document`. -* `filterExpression`: An external DSL based on ANTLR4 that accepts filter expressions as strings. For example, with metadata keys like country, year, and `isActive`, you could use an expression such as +* `filterExpression`: An external DSL based on ANTLR4 that accepts filter expressions as strings. For example, with metadata keys like country, year, and `isActive`, you could use an expression such as ``` java country == 'UK' && year >= 2020 && isActive == true. ``` 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 a99a7a468..6dde8efc6 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 @@ -62,7 +62,7 @@ Add the Spring Boot Starter, depending on whether you use Azure Open AI or Open org.springframework.experimental.ai spring-ai-azure-openai-spring-boot-starter - 0.7.0-SNAPSHOT + 0.7.1-SNAPSHOT ---- @@ -72,7 +72,7 @@ Add the Spring Boot Starter, depending on whether you use Azure Open AI or Open org.springframework.experimental.ai spring-ai-openai-spring-boot-starter - 0.7.0-SNAPSHOT + 0.7.1-SNAPSHOT ---- diff --git a/spring-ai-huggingface/pom.xml b/spring-ai-huggingface/pom.xml index 9a1f16fc7..1eb873e2a 100644 --- a/spring-ai-huggingface/pom.xml +++ b/spring-ai-huggingface/pom.xml @@ -4,7 +4,7 @@ org.springframework.experimental.ai spring-ai - 0.7.0-SNAPSHOT + 0.7.1-SNAPSHOT spring-ai-huggingface jar diff --git a/spring-ai-ollama/pom.xml b/spring-ai-ollama/pom.xml index 1277901aa..be9cb7ec8 100644 --- a/spring-ai-ollama/pom.xml +++ b/spring-ai-ollama/pom.xml @@ -6,7 +6,7 @@ org.springframework.experimental.ai spring-ai - 0.7.0-SNAPSHOT + 0.7.1-SNAPSHOT spring-ai-ollama diff --git a/spring-ai-openai/pom.xml b/spring-ai-openai/pom.xml index 9049b8539..be1a8f2a3 100644 --- a/spring-ai-openai/pom.xml +++ b/spring-ai-openai/pom.xml @@ -4,7 +4,7 @@ org.springframework.experimental.ai spring-ai - 0.7.0-SNAPSHOT + 0.7.1-SNAPSHOT spring-ai-openai jar diff --git a/spring-ai-spring-boot-autoconfigure/pom.xml b/spring-ai-spring-boot-autoconfigure/pom.xml index 0ed55f404..ac2773f2b 100644 --- a/spring-ai-spring-boot-autoconfigure/pom.xml +++ b/spring-ai-spring-boot-autoconfigure/pom.xml @@ -1,10 +1,11 @@ - + 4.0.0 org.springframework.experimental.ai spring-ai - 0.7.0-SNAPSHOT + 0.7.1-SNAPSHOT spring-ai-spring-boot-autoconfigure jar @@ -48,6 +49,45 @@ true + + + org.springframework.experimental.ai + spring-ai-pinecone + ${project.parent.version} + true + + + + + org.springframework.experimental.ai + spring-ai-milvus-store + ${project.parent.version} + true + + + + + org.springframework.experimental.ai + spring-ai-pgvector-store + ${project.parent.version} + true + + + + com.pgvector + pgvector + ${pgvector.version} + true + + + org.postgresql + postgresql + ${postgresql.version} + true + + + + org.springframework.boot spring-boot-configuration-processor @@ -61,11 +101,58 @@ + + + org.springframework.experimental.ai + spring-ai-test + ${project.parent.version} + test + + org.springframework.boot spring-boot-starter-test test + + org.springframework.boot + spring-boot-starter-jdbc + test + + + org.postgresql + postgresql + test + + + + org.springframework.experimental.ai + transformers-embedding + ${parent.version} + test + + + + org.testcontainers + testcontainers + ${testcontainers.version} + test + + + + org.testcontainers + junit-jupiter + ${testcontainers.version} + test + + + + org.awaitility + awaitility + 3.0.0 + test + + diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/milvus/MilvusServiceClientProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/milvus/MilvusServiceClientProperties.java new file mode 100644 index 000000000..35e543c49 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/milvus/MilvusServiceClientProperties.java @@ -0,0 +1,280 @@ +/* + * Copyright 2023-2023 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.milvus; + +import java.util.concurrent.TimeUnit; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import static org.springframework.ai.autoconfigure.vectorstore.milvus.MilvusServiceClientProperties.CONFIG_PREFIX; + +/** + * Parameters for Milvus client connection. + * + * @author Christian Tzolov + */ +@ConfigurationProperties(CONFIG_PREFIX) +public class MilvusServiceClientProperties { + + public static final String CONFIG_PREFIX = "spring.ai.vectorstore.milvus.client"; + + /** + * Milvus host name/address. + */ + private String host = "localhost"; + + /** + * Milvus the connection port. Value must be greater than zero and less than 65536. + */ + private int port = 19530; + + /** + * The uri of Milvus instance + */ + private String uri; + + /** + * Token serving as the key for identification and authentication purposes. + */ + private String token; + + /** + * Connection timeout value of client channel. The timeout value must be greater than + * zero. + */ + private long connectTimeoutMs = 10000; + + /** + * Keep-alive time value of client channel. The keep-alive value must be greater than + * zero. Default is 55000 ms. + */ + private long keepAliveTimeMs = 55000; + + /** + * The keep-alive timeout value of client channel. The timeout value must be greater + * than zero. Default value is 20000 ms. + */ + private long keepAliveTimeoutMs = 20000; + + /** + * Enables the keep-alive function for client channel. + */ + // private boolean keepAliveWithoutCalls = false; + + /** + * Deadline for how long you are willing to wait for a reply from the server. With a + * deadline setting, the client will wait when encounter fast RPC fail caused by + * network fluctuations. The deadline value must be larger than or equal to zero. + * Default value is 0, deadline is disabled. + */ + private long rpcDeadlineMs = 0; // Disabling deadline + + /** + * The client.key path for tls two-way authentication, only takes effect when "secure" + * is True. + */ + private String clientKeyPath; + + /** + * The client.pem path for tls two-way authentication, only takes effect when "secure" + * is True. + */ + private String clientPemPath; + + /** + * The ca.pem path for tls two-way authentication, only takes effect when "secure" is + * True. + */ + private String caPemPath; + + /** + * server.pem path for tls one-way authentication, only takes effect when "secure" is + * True. + */ + private String serverPemPath; + + /** + * Sets the target name override for SSL host name checking, only takes effect when + * "secure" is True. Note: this value is passed to grpc.ssl_target_name_override + */ + private String serverName; + + /** + * Secure the authorization for this connection, set to True to enable TLS. + */ + protected boolean secure = false; + + /** + * Idle timeout value of client channel. The timeout value must be larger than zero. + */ + private long idleTimeoutMs = TimeUnit.MILLISECONDS.convert(24, TimeUnit.HOURS); + + /** + * The username and password for this connection + */ + private String username = "root"; + + /** + * The password for this connection + */ + private String password = "milvus"; + + public String getHost() { + return host; + } + + public void setHost(String host) { + this.host = host; + } + + public int getPort() { + return port; + } + + public void setPort(int port) { + this.port = port; + } + + public String getUri() { + return uri; + } + + public void setUri(String uri) { + this.uri = uri; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public long getConnectTimeoutMs() { + return connectTimeoutMs; + } + + public void setConnectTimeoutMs(long connectTimeoutMs) { + this.connectTimeoutMs = connectTimeoutMs; + } + + public long getKeepAliveTimeMs() { + return keepAliveTimeMs; + } + + public void setKeepAliveTimeMs(long keepAliveTimeMs) { + this.keepAliveTimeMs = keepAliveTimeMs; + } + + public long getKeepAliveTimeoutMs() { + return keepAliveTimeoutMs; + } + + public void setKeepAliveTimeoutMs(long keepAliveTimeoutMs) { + this.keepAliveTimeoutMs = keepAliveTimeoutMs; + } + + // public boolean isKeepAliveWithoutCalls() { + // return keepAliveWithoutCalls; + // } + + // public void setKeepAliveWithoutCalls(boolean keepAliveWithoutCalls) { + // this.keepAliveWithoutCalls = keepAliveWithoutCalls; + // } + + public long getRpcDeadlineMs() { + return rpcDeadlineMs; + } + + public void setRpcDeadlineMs(long rpcDeadlineMs) { + this.rpcDeadlineMs = rpcDeadlineMs; + } + + public String getClientKeyPath() { + return clientKeyPath; + } + + public void setClientKeyPath(String clientKeyPath) { + this.clientKeyPath = clientKeyPath; + } + + public String getClientPemPath() { + return clientPemPath; + } + + public void setClientPemPath(String clientPemPath) { + this.clientPemPath = clientPemPath; + } + + public String getCaPemPath() { + return caPemPath; + } + + public void setCaPemPath(String caPemPath) { + this.caPemPath = caPemPath; + } + + public String getServerPemPath() { + return serverPemPath; + } + + public void setServerPemPath(String serverPemPath) { + this.serverPemPath = serverPemPath; + } + + public String getServerName() { + return serverName; + } + + public void setServerName(String serverName) { + this.serverName = serverName; + } + + public boolean isSecure() { + return secure; + } + + public void setSecure(boolean secure) { + this.secure = secure; + } + + public long getIdleTimeoutMs() { + return idleTimeoutMs; + } + + public void setIdleTimeoutMs(long idleTimeoutMs) { + this.idleTimeoutMs = idleTimeoutMs; + } + + 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; + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/milvus/MilvusVectorStoreAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/milvus/MilvusVectorStoreAutoConfiguration.java new file mode 100644 index 000000000..5d6434cd5 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/milvus/MilvusVectorStoreAutoConfiguration.java @@ -0,0 +1,107 @@ +/* + * Copyright 2023-2023 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.milvus; + +import java.util.concurrent.TimeUnit; + +import io.milvus.client.MilvusServiceClient; +import io.milvus.param.ConnectParam; + +import org.springframework.ai.embedding.EmbeddingClient; +import org.springframework.ai.vectorstore.MilvusVectorStore; +import org.springframework.ai.vectorstore.MilvusVectorStore.MilvusVectorStoreConfig; +import org.springframework.ai.vectorstore.VectorStore; +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 org.springframework.util.StringUtils; + +/** + * @author Christian Tzolov + */ +@AutoConfiguration +@ConditionalOnClass({ MilvusVectorStore.class, EmbeddingClient.class }) +@EnableConfigurationProperties({ MilvusServiceClientProperties.class, MilvusVectorStoreProperties.class }) +public class MilvusVectorStoreAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public VectorStore vectorStore(MilvusServiceClient milvusClient, EmbeddingClient embeddingClient, + MilvusVectorStoreProperties properties) { + + MilvusVectorStoreConfig config = MilvusVectorStoreConfig.builder() + .withCollectionName(properties.getCollectionName()) + .withDatabaseName(properties.getDatabaseName()) + .withIndexType(properties.getIndexType()) + .withMetricType(properties.getMetricType()) + .withIndexParameters(properties.getIndexParameters()) + .build(); + + return new MilvusVectorStore(milvusClient, embeddingClient, config); + } + + @Bean + @ConditionalOnMissingBean + public MilvusServiceClient milvusClient(MilvusVectorStoreProperties serverProperties, + MilvusServiceClientProperties clientProperties) { + + var builder = ConnectParam.newBuilder() + .withHost(clientProperties.getHost()) + .withPort(clientProperties.getPort()) + .withDatabaseName(serverProperties.getDatabaseName()) + .withConnectTimeout(clientProperties.getConnectTimeoutMs(), TimeUnit.MILLISECONDS) + .withKeepAliveTime(clientProperties.getKeepAliveTimeMs(), TimeUnit.MILLISECONDS) + .withKeepAliveTimeout(clientProperties.getKeepAliveTimeoutMs(), TimeUnit.MILLISECONDS) + .withRpcDeadline(clientProperties.getRpcDeadlineMs(), TimeUnit.MILLISECONDS) + .withSecure(clientProperties.isSecure()) + .withIdleTimeout(clientProperties.getIdleTimeoutMs(), TimeUnit.MILLISECONDS) + .withAuthorization(clientProperties.getUsername(), clientProperties.getPassword()); + + if (clientProperties.isSecure() && StringUtils.hasText(clientProperties.getUri())) { + builder.withUri(clientProperties.getUri()); + } + + if (clientProperties.isSecure() && StringUtils.hasText(clientProperties.getToken())) { + builder.withUri(clientProperties.getToken()); + } + + if (clientProperties.isSecure() && StringUtils.hasText(clientProperties.getClientKeyPath())) { + builder.withUri(clientProperties.getClientKeyPath()); + } + + if (clientProperties.isSecure() && StringUtils.hasText(clientProperties.getClientPemPath())) { + builder.withUri(clientProperties.getClientPemPath()); + } + + if (clientProperties.isSecure() && StringUtils.hasText(clientProperties.getCaPemPath())) { + builder.withUri(clientProperties.getCaPemPath()); + } + + if (clientProperties.isSecure() && StringUtils.hasText(clientProperties.getServerPemPath())) { + builder.withUri(clientProperties.getServerPemPath()); + } + + if (clientProperties.isSecure() && StringUtils.hasText(clientProperties.getServerName())) { + builder.withUri(clientProperties.getServerName()); + } + + return new MilvusServiceClient(builder.build()); + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/milvus/MilvusVectorStoreProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/milvus/MilvusVectorStoreProperties.java new file mode 100644 index 000000000..9094ab9fc --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/milvus/MilvusVectorStoreProperties.java @@ -0,0 +1,109 @@ +/* + * Copyright 2023-2023 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.milvus; + +import io.milvus.param.IndexType; +import io.milvus.param.MetricType; + +import org.springframework.ai.vectorstore.MilvusVectorStore; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.util.Assert; + +import static org.springframework.ai.autoconfigure.vectorstore.milvus.MilvusVectorStoreProperties.CONFIG_PREFIX; + +/** + * @author Christian Tzolov + */ +@ConfigurationProperties(CONFIG_PREFIX) +public class MilvusVectorStoreProperties { + + public static final String CONFIG_PREFIX = "spring.ai.vectorstore.milvus"; + + /** + * The database name + */ + private String databaseName = MilvusVectorStore.DEFAULT_DATABASE_NAME; + + private String collectionName = MilvusVectorStore.DEFAULT_COLLECTION_NAME; + + private int embeddingDimension = MilvusVectorStore.OPENAI_EMBEDDING_DIMENSION_SIZE; + + private IndexType indexType = IndexType.IVF_FLAT; + + private MetricType metricType = MetricType.COSINE; + + private String indexParameters = "{\"nlist\":1024}"; + + public static String getConfigPrefix() { + return CONFIG_PREFIX; + } + + public String getDatabaseName() { + return databaseName; + } + + public void setDatabaseName(String databaseName) { + Assert.hasText(databaseName, "Database name should not be empty."); + this.databaseName = databaseName; + } + + public String getCollectionName() { + return collectionName; + } + + public void setCollectionName(String collectionName) { + Assert.hasText(collectionName, "Collection name should not be empty."); + this.collectionName = collectionName; + } + + public int getEmbeddingDimension() { + return embeddingDimension; + } + + public void setEmbeddingDimension(int embeddingDimension) { + Assert.isTrue(embeddingDimension > 0, "Embedding dimension should be a positive value."); + this.embeddingDimension = embeddingDimension; + } + + public IndexType getIndexType() { + return indexType; + } + + public void setIndexType(IndexType indexType) { + Assert.notNull(indexType, "Index type can not be null"); + this.indexType = indexType; + } + + public MetricType getMetricType() { + return metricType; + } + + public void setMetricType(MetricType metricType) { + Assert.notNull(metricType, "MetricType can not be null"); + this.metricType = metricType; + } + + public String getIndexParameters() { + return indexParameters; + } + + public void setIndexParameters(String indexParameters) { + Assert.notNull(indexParameters, "indexParameters can not be null"); + this.indexParameters = indexParameters; + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/pgvector/PgVectorStoreAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/pgvector/PgVectorStoreAutoConfiguration.java new file mode 100644 index 000000000..cbdd9ed45 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/pgvector/PgVectorStoreAutoConfiguration.java @@ -0,0 +1,49 @@ +/* + * Copyright 2023-2023 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.pgvector; + +import javax.sql.DataSource; + +import org.springframework.ai.embedding.EmbeddingClient; +import org.springframework.ai.vectorstore.PgVectorStore; +import org.springframework.ai.vectorstore.VectorStore; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * @author Christian Tzolov + */ +@AutoConfiguration(after = JdbcTemplateAutoConfiguration.class) +@ConditionalOnClass({ PgVectorStore.class, DataSource.class, JdbcTemplate.class }) +@EnableConfigurationProperties(PgVectorStoreProperties.class) +public class PgVectorStoreAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public VectorStore vectorStore(JdbcTemplate jdbcTemplate, EmbeddingClient embeddingClient, + PgVectorStoreProperties properties) { + + return new PgVectorStore(jdbcTemplate, embeddingClient, properties.getDimensions(), + properties.getDistanceType(), properties.isRemoveExistingVectorStoreTable(), properties.getIndexType()); + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/pgvector/PgVectorStoreProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/pgvector/PgVectorStoreProperties.java new file mode 100644 index 000000000..77461d8ee --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/pgvector/PgVectorStoreProperties.java @@ -0,0 +1,77 @@ +/* + * Copyright 2023-2023 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.pgvector; + +import org.springframework.ai.vectorstore.PgVectorStore; +import org.springframework.ai.vectorstore.PgVectorStore.PgDistanceType; +import org.springframework.ai.vectorstore.PgVectorStore.PgIndexType; +import org.springframework.boot.context.properties.ConfigurationProperties; +import static org.springframework.ai.autoconfigure.vectorstore.pgvector.PgVectorStoreProperties.CONFIG_PREFIX; + +/** + * @author Christian Tzolov + */ +@ConfigurationProperties(CONFIG_PREFIX) +public class PgVectorStoreProperties { + + public static final String CONFIG_PREFIX = "spring.ai.vectorstore.pgvector"; + + private int dimensions = PgVectorStore.INVALID_EMBEDDING_DIMENSION; + + private PgIndexType indexType = PgIndexType.HNSW; + + private PgDistanceType distanceType = PgDistanceType.CosineDistance; + + private boolean removeExistingVectorStoreTable = false; + + public static String getConfigPrefix() { + return CONFIG_PREFIX; + } + + public int getDimensions() { + return dimensions; + } + + public void setDimensions(int dimensions) { + this.dimensions = dimensions; + } + + public PgIndexType getIndexType() { + return indexType; + } + + public void setIndexType(PgIndexType createIndexMethod) { + this.indexType = createIndexMethod; + } + + public PgDistanceType getDistanceType() { + return distanceType; + } + + public void setDistanceType(PgDistanceType distanceType) { + this.distanceType = distanceType; + } + + public boolean isRemoveExistingVectorStoreTable() { + return removeExistingVectorStoreTable; + } + + public void setRemoveExistingVectorStoreTable(boolean removeExistingVectorStoreTable) { + this.removeExistingVectorStoreTable = removeExistingVectorStoreTable; + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/pinecone/PineconeVectorStoreAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/pinecone/PineconeVectorStoreAutoConfiguration.java new file mode 100644 index 000000000..4d5fef254 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/pinecone/PineconeVectorStoreAutoConfiguration.java @@ -0,0 +1,53 @@ +/* + * Copyright 2023-2023 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.pinecone; + +import org.springframework.ai.embedding.EmbeddingClient; +import org.springframework.ai.vectorstore.PineconeVectorStore; +import org.springframework.ai.vectorstore.PineconeVectorStore.PineconeVectorStoreConfig; +import org.springframework.ai.vectorstore.VectorStore; +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; + +/** + * @author Christian Tzolov + */ +@AutoConfiguration +@ConditionalOnClass({ PineconeVectorStore.class, EmbeddingClient.class }) +@EnableConfigurationProperties(PineconeVectorStoreProperties.class) +public class PineconeVectorStoreAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public VectorStore vectorStore(EmbeddingClient embeddingClient, PineconeVectorStoreProperties properties) { + + var config = PineconeVectorStoreConfig.builder() + .withApiKey(properties.getApiKey()) + .withEnvironment(properties.getEnvironment()) + .withProjectId(properties.getProjectId()) + .withIndexName(properties.getIndexName()) + .withNamespace(properties.getNamespace()) + .withServerSideTimeout(properties.getServerSideTimeout()) + .build(); + + return new PineconeVectorStore(config, embeddingClient); + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/pinecone/PineconeVectorStoreProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/pinecone/PineconeVectorStoreProperties.java new file mode 100644 index 000000000..ab5ef8dc9 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/pinecone/PineconeVectorStoreProperties.java @@ -0,0 +1,92 @@ +/* + * Copyright 2023-2023 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.pinecone; + +import java.time.Duration; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import static org.springframework.ai.autoconfigure.vectorstore.pinecone.PineconeVectorStoreProperties.CONFIG_PREFIX; + +/** + * @author Christian Tzolov + */ +@ConfigurationProperties(CONFIG_PREFIX) +public class PineconeVectorStoreProperties { + + public static final String CONFIG_PREFIX = "spring.ai.vectorstore.pinecone"; + + private String apiKey; + + private String environment = "gcp-starter"; + + private String projectId; + + private String indexName; + + private String namespace = ""; + + private Duration serverSideTimeout = Duration.ofSeconds(20); + + public String getApiKey() { + return this.apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getEnvironment() { + return this.environment; + } + + public void setEnvironment(String environment) { + this.environment = environment; + } + + public String getProjectId() { + return this.projectId; + } + + public void setProjectId(String projectId) { + this.projectId = projectId; + } + + public String getNamespace() { + return this.namespace; + } + + public void setNamespace(String namespace) { + this.namespace = namespace; + } + + public String getIndexName() { + return this.indexName; + } + + public void setIndexName(String indexName) { + this.indexName = indexName; + } + + public Duration getServerSideTimeout() { + return this.serverSideTimeout; + } + + public void setServerSideTimeout(Duration serverSideTimeout) { + this.serverSideTimeout = serverSideTimeout; + } + +} 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 343f10957..410e34066 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 @@ -1,2 +1,5 @@ org.springframework.ai.autoconfigure.openai.OpenAiAutoConfiguration -org.springframework.ai.autoconfigure.azure.openai.AzureOpenAiAutoConfiguration \ No newline at end of file +org.springframework.ai.autoconfigure.azure.openai.AzureOpenAiAutoConfiguration +org.springframework.ai.autoconfigure.vectorstore.pgvector.PgVectorStoreAutoConfiguration +org.springframework.ai.autoconfigure.vectorstore.pinecone.PineconeVectorStoreAutoConfiguration +org.springframework.ai.autoconfigure.vectorstore.milvus.MilvusVectorStoreAutoConfiguration diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/milvus/MilvusVectorStoreAutoConfigurationIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/milvus/MilvusVectorStoreAutoConfigurationIT.java new file mode 100644 index 000000000..313e6b7f6 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/milvus/MilvusVectorStoreAutoConfigurationIT.java @@ -0,0 +1,139 @@ +/* + * Copyright 2023-2023 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.milvus; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.DefaultResourceLoader; +import org.testcontainers.containers.DockerComposeContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.junit.jupiter.Testcontainers; + +import org.springframework.ai.ResourceUtils; +import org.springframework.ai.document.Document; +import org.springframework.ai.embedding.EmbeddingClient; +import org.springframework.ai.embedding.TransformersEmbeddingClient; +import org.springframework.ai.vectorstore.SearchRequest; +import org.springframework.ai.vectorstore.VectorStore; +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.util.FileSystemUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Christian Tzolov + */ +@Testcontainers +public class MilvusVectorStoreAutoConfigurationIT { + + private static DockerComposeContainer milvusContainer; + + private static final File TEMP_FOLDER = new File("target/test-" + UUID.randomUUID().toString()); + + List documents = List.of( + new Document(getText("classpath:/test/data/spring.ai.txt"), Map.of("spring", "great")), + new Document(getText("classpath:/test/data/time.shelter.txt")), + new Document(getText("classpath:/test/data/great.depression.txt"), Map.of("depression", "bad"))); + + public static String getText(String uri) { + var resource = new DefaultResourceLoader().getResource(uri); + try { + return resource.getContentAsString(StandardCharsets.UTF_8); + } + catch (IOException e) { + throw new RuntimeException(e); + } + } + + @BeforeAll + public static void beforeAll() { + FileSystemUtils.deleteRecursively(TEMP_FOLDER); + TEMP_FOLDER.mkdirs(); + + milvusContainer = new DockerComposeContainer(new File("src/test/resources/milvus/docker-compose.yml")) + .withEnv("DOCKER_VOLUME_DIRECTORY", TEMP_FOLDER.getAbsolutePath()) + .withExposedService("standalone", 19530) + .withExposedService("standalone", 9091, + Wait.forHttp("/healthz").forPort(9091).forStatusCode(200).forStatusCode(401)) + .waitingFor("standalone", Wait.forLogMessage(".*Proxy successfully started.*\\s", 1) + .withStartupTimeout(Duration.ofSeconds(100))); + milvusContainer.start(); + } + + @AfterAll + public static void afterAll() { + milvusContainer.stop(); + FileSystemUtils.deleteRecursively(TEMP_FOLDER); + } + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(MilvusVectorStoreAutoConfiguration.class)) + .withUserConfiguration(Config.class); + + @Test + public void addAndSearch() { + contextRunner.withPropertyValues("spring.ai.vectorstore.milvus.metricType=COSINE", + "spring.ai.vectorstore.milvus.indexType=IVF_FLAT", + "spring.ai.vectorstore.milvus.embeddingDimension=384", + "spring.ai.vectorstore.milvus.collectionName=myTestCollection", + + "spring.ai.vectorstore.milvus.client.host=localhost", "spring.ai.vectorstore.milvus.client.port=19530") + .run(context -> { + VectorStore vectorStore = context.getBean(VectorStore.class); + vectorStore.add(documents); + + List results = vectorStore.similaritySearch(SearchRequest.query("Spring").withTopK(1)); + + assertThat(results).hasSize(1); + Document resultDoc = results.get(0); + assertThat(resultDoc.getId()).isEqualTo(documents.get(0).getId()); + assertThat(resultDoc.getContent()).contains( + "Spring AI provides abstractions that serve as the foundation for developing AI applications."); + assertThat(resultDoc.getMetadata()).hasSize(2); + assertThat(resultDoc.getMetadata()).containsKeys("spring", "distance"); + + // Remove all documents from the store + vectorStore.delete(documents.stream().map(doc -> doc.getId()).toList()); + + results = vectorStore.similaritySearch(SearchRequest.query("Spring").withTopK(1)); + assertThat(results).hasSize(0); + }); + } + + @Configuration(proxyBeanMethods = false) + static class Config { + + @Bean + public EmbeddingClient embeddingClient() { + return new TransformersEmbeddingClient(); + } + + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/pgvector/PgVectorStoreAutoConfigurationIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/pgvector/PgVectorStoreAutoConfigurationIT.java new file mode 100644 index 000000000..7ef80d715 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/pgvector/PgVectorStoreAutoConfigurationIT.java @@ -0,0 +1,115 @@ +/* + * Copyright 2023-2023 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.pgvector; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.DefaultResourceLoader; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import org.springframework.ai.ResourceUtils; +import org.springframework.ai.document.Document; +import org.springframework.ai.embedding.EmbeddingClient; +import org.springframework.ai.embedding.TransformersEmbeddingClient; +import org.springframework.ai.vectorstore.SearchRequest; +import org.springframework.ai.vectorstore.VectorStore; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Christian Tzolov + */ +@Testcontainers +public class PgVectorStoreAutoConfigurationIT { + + @Container + static GenericContainer postgresContainer = new GenericContainer<>("ankane/pgvector") + .withEnv("POSTGRES_USER", "postgres") + .withEnv("POSTGRES_PASSWORD", "postgres") + .withExposedPorts(5432); + + List documents = List.of( + new Document(getText("classpath:/test/data/spring.ai.txt"), Map.of("spring", "great")), + new Document(getText("classpath:/test/data/time.shelter.txt")), + new Document(getText("classpath:/test/data/great.depression.txt"), Map.of("depression", "bad"))); + + public static 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 final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(PgVectorStoreAutoConfiguration.class, + JdbcTemplateAutoConfiguration.class, DataSourceAutoConfiguration.class)) + .withUserConfiguration(Config.class) + .withPropertyValues("spring.ai.vectorstore.pgvector.distanceType=CosineDistance", + // JdbcTemplate configuration + String.format("spring.datasource.url=jdbc:postgresql://localhost:%d/%s", + postgresContainer.getMappedPort(5432), "postgres"), + "spring.datasource.username=postgres", "spring.datasource.password=postgres"); + + @Test + public void addAndSearch() { + contextRunner.run(context -> { + + VectorStore vectorStore = context.getBean(VectorStore.class); + + vectorStore.add(documents); + + List results = vectorStore + .similaritySearch(SearchRequest.query("What is Great Depression?").withTopK(1)); + + assertThat(results).hasSize(1); + Document resultDoc = results.get(0); + assertThat(resultDoc.getId()).isEqualTo(documents.get(2).getId()); + assertThat(resultDoc.getMetadata()).containsKeys("depression", "distance"); + + // Remove all documents from the store + vectorStore.delete(documents.stream().map(doc -> doc.getId()).toList()); + results = vectorStore.similaritySearch(SearchRequest.query("Great Depression").withTopK(1)); + assertThat(results).hasSize(0); + }); + } + + @Configuration(proxyBeanMethods = false) + static class Config { + + @Bean + public EmbeddingClient embeddingClient() { + return new TransformersEmbeddingClient(); + } + + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/pgvector/PgVectorStorePropertiesTests.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/pgvector/PgVectorStorePropertiesTests.java new file mode 100644 index 000000000..d2441a8a0 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/pgvector/PgVectorStorePropertiesTests.java @@ -0,0 +1,56 @@ +/* + * Copyright 2023-2023 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.pgvector; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import org.springframework.ai.vectorstore.PgVectorStore; +import org.springframework.ai.vectorstore.PgVectorStore.PgDistanceType; +import org.springframework.ai.vectorstore.PgVectorStore.PgIndexType; + +/** + * @author Christian Tzolov + */ +public class PgVectorStorePropertiesTests { + + @Test + public void defaultValues() { + var props = new PgVectorStoreProperties(); + assertThat(props.getDimensions()).isEqualTo(PgVectorStore.INVALID_EMBEDDING_DIMENSION); + assertThat(props.getDistanceType()).isEqualTo(PgDistanceType.CosineDistance); + assertThat(props.getIndexType()).isEqualTo(PgIndexType.HNSW); + assertThat(props.isRemoveExistingVectorStoreTable()).isFalse(); + } + + @Test + public void customValues() { + var props = new PgVectorStoreProperties(); + + props.setDimensions(1536); + props.setDistanceType(PgDistanceType.EuclideanDistance); + props.setIndexType(PgIndexType.IVFFLAT); + props.setRemoveExistingVectorStoreTable(true); + + assertThat(props.getDimensions()).isEqualTo(1536); + assertThat(props.getDistanceType()).isEqualTo(PgDistanceType.EuclideanDistance); + assertThat(props.getIndexType()).isEqualTo(PgIndexType.IVFFLAT); + assertThat(props.isRemoveExistingVectorStoreTable()).isTrue(); + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/pinecone/PineconeVectorStoreAutoConfigurationIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/pinecone/PineconeVectorStoreAutoConfigurationIT.java new file mode 100644 index 000000000..7f94bd01d --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/pinecone/PineconeVectorStoreAutoConfigurationIT.java @@ -0,0 +1,124 @@ +/* + * Copyright 2023-2023 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.pinecone; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.awaitility.Awaitility; +import org.awaitility.Duration; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import org.springframework.ai.ResourceUtils; +import org.springframework.ai.document.Document; +import org.springframework.ai.embedding.EmbeddingClient; +import org.springframework.ai.embedding.TransformersEmbeddingClient; +import org.springframework.ai.vectorstore.SearchRequest; +import org.springframework.ai.vectorstore.VectorStore; +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 static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.hasSize; + +/** + * @author Christian Tzolov + */ +@EnabledIfEnvironmentVariable(named = "PINECONE_API_KEY", matches = ".+") +public class PineconeVectorStoreAutoConfigurationIT { + + List documents = List.of( + new Document("1", getText("classpath:/test/data/spring.ai.txt"), Map.of("spring", "great")), + new Document("2", getText("classpath:/test/data/time.shelter.txt"), Map.of()), + new Document("3", getText("classpath:/test/data/great.depression.txt"), Map.of("depression", "bad"))); + + public static 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 final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(PineconeVectorStoreAutoConfiguration.class)) + .withUserConfiguration(Config.class) + .withPropertyValues("spring.ai.vectorstore.pinecone.apiKey=" + System.getenv("PINECONE_API_KEY"), + "spring.ai.vectorstore.pinecone.environment=gcp-starter", + "spring.ai.vectorstore.pinecone.projectId=814621f", + "spring.ai.vectorstore.pinecone.indexName=spring-ai-test-index"); + + @BeforeAll + public static void beforeAll() { + Awaitility.setDefaultPollInterval(2, TimeUnit.SECONDS); + Awaitility.setDefaultPollDelay(Duration.ZERO); + Awaitility.setDefaultTimeout(Duration.ONE_MINUTE); + } + + @Test + public void addAndSearchTest() { + + contextRunner.run(context -> { + + VectorStore vectorStore = context.getBean(VectorStore.class); + + vectorStore.add(documents); + + Awaitility.await().until(() -> { + return vectorStore.similaritySearch(SearchRequest.query("Spring").withTopK(1)); + }, hasSize(1)); + + List results = vectorStore.similaritySearch(SearchRequest.query("Spring").withTopK(1)); + + assertThat(results).hasSize(1); + Document resultDoc = results.get(0); + assertThat(resultDoc.getId()).isEqualTo(documents.get(0).getId()); + assertThat(resultDoc.getContent()).contains( + "Spring AI provides abstractions that serve as the foundation for developing AI applications."); + assertThat(resultDoc.getMetadata()).hasSize(2); + assertThat(resultDoc.getMetadata()).containsKeys("spring", "distance"); + + // Remove all documents from the store + vectorStore.delete(documents.stream().map(doc -> doc.getId()).toList()); + + Awaitility.await().until(() -> { + return vectorStore.similaritySearch(SearchRequest.query("Spring").withTopK(1)); + }, hasSize(0)); + }); + } + + @Configuration(proxyBeanMethods = false) + static class Config { + + @Bean + public EmbeddingClient embeddingClient() { + return new TransformersEmbeddingClient(); + } + + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/pinecone/PineconeVectorStorePropertiesTests.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/pinecone/PineconeVectorStorePropertiesTests.java new file mode 100644 index 000000000..4584e0eff --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/pinecone/PineconeVectorStorePropertiesTests.java @@ -0,0 +1,59 @@ +/* + * Copyright 2023-2023 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.pinecone; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Christian Tzolov + */ +public class PineconeVectorStorePropertiesTests { + + @Test + public void defaultValues() { + var props = new PineconeVectorStoreProperties(); + assertThat(props.getEnvironment()).isEqualTo("gcp-starter"); + assertThat(props.getNamespace()).isEqualTo(""); + assertThat(props.getApiKey()).isNull(); + assertThat(props.getProjectId()).isNull(); + assertThat(props.getIndexName()).isNull(); + assertThat(props.getServerSideTimeout()).isEqualTo(Duration.ofSeconds(20)); + } + + @Test + public void customValues() { + var props = new PineconeVectorStoreProperties(); + props.setApiKey("key"); + props.setEnvironment("env"); + props.setIndexName("index"); + props.setNamespace("namespace"); + props.setProjectId("project"); + props.setServerSideTimeout(Duration.ofSeconds(60)); + + assertThat(props.getEnvironment()).isEqualTo("env"); + assertThat(props.getNamespace()).isEqualTo("namespace"); + assertThat(props.getApiKey()).isEqualTo("key"); + assertThat(props.getProjectId()).isEqualTo("project"); + assertThat(props.getIndexName()).isEqualTo("index"); + assertThat(props.getServerSideTimeout()).isEqualTo(Duration.ofSeconds(60)); + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/test/resources/milvus/docker-compose.yml b/spring-ai-spring-boot-autoconfigure/src/test/resources/milvus/docker-compose.yml new file mode 100644 index 000000000..1be95096d --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/resources/milvus/docker-compose.yml @@ -0,0 +1,62 @@ +version: '3.5' + +services: + etcd: + image: quay.io/coreos/etcd:v3.5.5 + ports: + - "2379:2379" + environment: + - ETCD_AUTO_COMPACTION_MODE=revision + - ETCD_AUTO_COMPACTION_RETENTION=1000 + - ETCD_QUOTA_BACKEND_BYTES=4294967296 + - ETCD_SNAPSHOT_COUNT=50000 + volumes: + - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/etcd:/etcd + command: etcd -advertise-client-urls=http://127.0.0.1:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd + healthcheck: + test: ["CMD", "etcdctl", "endpoint", "health"] + interval: 30s + timeout: 20s + retries: 3 + + minio: + image: minio/minio:RELEASE.2023-11-11T08-14-41Z + environment: + MINIO_ACCESS_KEY: minioadmin + MINIO_SECRET_KEY: minioadmin + ports: + - "9001:9001" + - "9000:9000" + volumes: + - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/minio:/minio_data + command: minio server /minio_data --console-address ":9001" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 30s + timeout: 20s + retries: 3 + + standalone: + image: milvusdb/milvus:v2.3.1 + command: ["milvus", "run", "standalone"] + environment: + ETCD_ENDPOINTS: etcd:2379 + MINIO_ADDRESS: minio:9000 + volumes: + - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9091/healthz"] + interval: 30s + start_period: 90s + timeout: 20s + retries: 3 + ports: + - "19530:19530" + - "9091:9091" + depends_on: + - "etcd" + - "minio" + +networks: + default: + name: milvus diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-azure-openai/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-azure-openai/pom.xml index c58508f62..2913784ac 100644 --- a/spring-ai-spring-boot-starters/spring-ai-starter-azure-openai/pom.xml +++ b/spring-ai-spring-boot-starters/spring-ai-starter-azure-openai/pom.xml @@ -4,7 +4,7 @@ org.springframework.experimental.ai spring-ai - 0.7.0-SNAPSHOT + 0.7.1-SNAPSHOT ../../pom.xml spring-ai-azure-openai-spring-boot-starter diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-openai/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-openai/pom.xml index aacc3640d..354c54f05 100644 --- a/spring-ai-spring-boot-starters/spring-ai-starter-openai/pom.xml +++ b/spring-ai-spring-boot-starters/spring-ai-starter-openai/pom.xml @@ -4,7 +4,7 @@ org.springframework.experimental.ai spring-ai - 0.7.0-SNAPSHOT + 0.7.1-SNAPSHOT ../../pom.xml spring-ai-openai-spring-boot-starter diff --git a/spring-ai-test/pom.xml b/spring-ai-test/pom.xml index 1da1031e2..a978a7efe 100644 --- a/spring-ai-test/pom.xml +++ b/spring-ai-test/pom.xml @@ -4,7 +4,7 @@ org.springframework.experimental.ai spring-ai - 0.7.0-SNAPSHOT + 0.7.1-SNAPSHOT spring-ai-test jar diff --git a/spring-ai-test/src/main/resources/test/data/great.depression.txt b/spring-ai-test/src/main/resources/test/data/great.depression.txt new file mode 100644 index 000000000..ce3d79a5b --- /dev/null +++ b/spring-ai-test/src/main/resources/test/data/great.depression.txt @@ -0,0 +1,4 @@ +The Great Depression (1929–1939) was an economic shock that affected most countries across the world. It was a period of economic depression that became evident after a major fall in stock prices in the United States.[1] The economic contagion began around September 1929 and led to the Wall Street stock market crash of October 24 (Black Thursday). It was the longest, deepest, and most widespread depression of the 20th century.[2] +Between 1929 and 1932, worldwide gross domestic product (GDP) fell by an estimated 15%. By comparison, worldwide GDP fell by less than 1% from 2008 to 2009 during the Great Recession.[3] Some economies started to recover by the mid-1930s. However, in many countries,[specify] the negative effects of the Great Depression lasted until the beginning of World War II. Devastating effects were seen in both rich and poor countries with falling personal income, prices, tax revenues, and profits. International trade fell by more than 50%, unemployment in the U.S. rose to 23% and in some countries rose as high as 33%.[4] +Cities around the world were hit hard, especially those dependent on heavy industry. Construction was virtually halted in many countries. Farming communities and rural areas suffered as crop prices fell by about 60%.[5][6][7] Faced with plummeting demand and few job alternatives, areas dependent on primary sector industries suffered the most.[8] +Economic historians usually consider the catalyst of the Great Depression to be the sudden devastating collapse of U.S. stock market prices, starting on October 24, 1929. However, some dispute this conclusion, seeing the stock crash less as a cause of the Depression and more as a symptom of the rising nervousness of investors partly due to gradual price declines caused by falling sales of consumer goods (as a result of overproduction because of new production techniques, falling exports and income inequality, among other factors) that had already been underway as part of a gradual Depression \ No newline at end of file diff --git a/spring-ai-test/src/main/resources/test/data/spring.ai.txt b/spring-ai-test/src/main/resources/test/data/spring.ai.txt new file mode 100644 index 000000000..b5ac0c094 --- /dev/null +++ b/spring-ai-test/src/main/resources/test/data/spring.ai.txt @@ -0,0 +1,6 @@ +The Spring AI project aims to streamline the development of applications that incorporate artificial intelligence functionality without unnecessary complexity. +The project draws inspiration from notable Python projects, such as LangChain and LlamaIndex, but Spring AI is not a direct port of those projects. The project was founded with the belief that the next wave of Generative AI applications will not be only for Python developers but will be ubiquitous across many programming languages. +At its core, Spring AI provides abstractions that serve as the foundation for developing AI applications. These abstractions have multiple implementations, enabling easy component swapping with minimal code changes. For example, Spring AI introduces the AiClient interface with implementations for OpenAI and Azure OpenAI. +In addition to these core abstractions, Spring AI aims to provide higher-level functionalities to address common use cases such as “Q&A over your documentation” or “Chat with your documentation.” As the complexity of the use cases increases, the Spring AI project will integrate with other projects in the Spring Ecosystem, such as Spring Integration, Spring Batch, and Spring Data. +To simplify setup, Spring Boot starters are available to help set up essential dependencies and classes. There is also a collection of sample applications to help you explore the project’s features. Lastly, the new Spring CLI project also enables you to get started quickly by using the spring boot new ai command for new projects or spring boot add ai for adding AI capabilities to your existing application. + diff --git a/spring-ai-test/src/main/resources/test/data/time.shelter.txt b/spring-ai-test/src/main/resources/test/data/time.shelter.txt new file mode 100644 index 000000000..4ae95027f --- /dev/null +++ b/spring-ai-test/src/main/resources/test/data/time.shelter.txt @@ -0,0 +1,2 @@ +Somewhere in the Andes, they believe to this very day that the future is behind you. It comes up from behind your back, surprising and unforeseeable, while the past is always before your eyes, that which has already happened. When they talk about the past, the people of the Aymara tribe point in front of them. You walk forward facing the past and you turn back toward the future. +― Georgi Gospodinov, Time Shelter \ No newline at end of file diff --git a/vector-stores/spring-ai-milvus-store/pom.xml b/vector-stores/spring-ai-milvus-store/pom.xml index 644e90925..a413e50fb 100644 --- a/vector-stores/spring-ai-milvus-store/pom.xml +++ b/vector-stores/spring-ai-milvus-store/pom.xml @@ -5,7 +5,7 @@ org.springframework.experimental.ai spring-ai - 0.7.0-SNAPSHOT + 0.7.1-SNAPSHOT ../../pom.xml spring-ai-milvus-store @@ -36,11 +36,20 @@ org.springframework.experimental.ai - spring-ai-openai-spring-boot-starter + spring-ai-openai + ${parent.version} test + + org.springframework.experimental.ai + spring-ai-test + ${parent.version} + test + + + org.springframework.boot spring-boot-starter-test diff --git a/vector-stores/spring-ai-milvus-store/src/main/java/org/springframework/ai/vectorstore/MilvusVectorStore.java b/vector-stores/spring-ai-milvus-store/src/main/java/org/springframework/ai/vectorstore/MilvusVectorStore.java index e239aa7a4..0f15f8641 100644 --- a/vector-stores/spring-ai-milvus-store/src/main/java/org/springframework/ai/vectorstore/MilvusVectorStore.java +++ b/vector-stores/spring-ai-milvus-store/src/main/java/org/springframework/ai/vectorstore/MilvusVectorStore.java @@ -54,7 +54,6 @@ 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.converter.MilvusFilterExpressionConverter; import org.springframework.beans.factory.InitializingBean; import org.springframework.util.Assert; @@ -150,7 +149,7 @@ public class MilvusVectorStore implements VectorStore, InitializingBean { private IndexType indexType = IndexType.IVF_FLAT; - private MetricType metricType = MetricType.L2; + private MetricType metricType = MetricType.COSINE; private String indexParameters = "{\"nlist\":1024}"; @@ -159,13 +158,14 @@ public class MilvusVectorStore implements VectorStore, InitializingBean { /** * Configures the Milvus metric type to use. Leave {@literal null} or blank to - * use the metric metric. + * use the metric metric: https://milvus.io/docs/metric.md#floating * @param metricType the metric type to use * @return this builder */ public Builder withMetricType(MetricType metricType) { Assert.notNull(metricType, "Collection Name must not be empty"); - Assert.isTrue(metricType == MetricType.IP || metricType == MetricType.L2, + Assert.isTrue( + metricType == MetricType.IP || metricType == MetricType.L2 || metricType == MetricType.COSINE, "Only the text metric types IP and L2 are supported"); this.metricType = metricType; @@ -321,43 +321,26 @@ public class MilvusVectorStore implements VectorStore, InitializingBean { } @Override - public List similaritySearch(String query) { - return this.similaritySearch(query, 4); - } + public List similaritySearch(SearchRequest request) { - @Override - public List similaritySearch(String query, int topK) { - return similaritySearch(query, topK, 0.0D); - } + String nativeFilterExpressions = (request.getFilterExpression() != null) + ? this.filterExpressionConverter.convert(request.getFilterExpression()) : ""; - @Override - public List similaritySearch(String query, int topK, double similarityThreshold) { - return internalSimilaritySearch(query, topK, similarityThreshold, ""); - } + Assert.notNull(request.getQuery(), "Query string must not be null"); - @Override - public List similaritySearch(String query, int k, double threshold, Filter.Expression filterExpression) { - String pgVectorFilterExpression = this.filterExpressionConverter.convert(filterExpression); - return this.internalSimilaritySearch(query, k, threshold, pgVectorFilterExpression); - } - - List internalSimilaritySearch(String query, int topK, double similarityThreshold, - String filterExpressions) { - Assert.notNull(query, "Query string must not be null"); - - List embedding = this.embeddingClient.embed(query); + List embedding = this.embeddingClient.embed(request.getQuery()); var searchParamBuilder = SearchParam.newBuilder() .withCollectionName(this.config.collectionName) .withConsistencyLevel(ConsistencyLevelEnum.STRONG) .withMetricType(this.config.metricType) .withOutFields(SEARCH_OUTPUT_FIELDS) - .withTopK(topK) + .withTopK(request.getTopK()) .withVectors(List.of(toFloatList(embedding))) .withVectorFieldName(EMBEDDING_FIELD_NAME); - if (StringUtils.hasText(filterExpressions)) { - searchParamBuilder.withExpr(filterExpressions); + if (StringUtils.hasText(nativeFilterExpressions)) { + searchParamBuilder.withExpr(nativeFilterExpressions); } R respSearch = milvusClient.search(searchParamBuilder.build()); @@ -368,9 +351,9 @@ public class MilvusVectorStore implements VectorStore, InitializingBean { SearchResultsWrapper wrapperSearch = new SearchResultsWrapper(respSearch.getData().getResults()); - return wrapperSearch.getRowRecords() + return wrapperSearch.getRowRecords(0) .stream() - .filter(rowRecord -> getResultSimilarity(rowRecord) >= similarityThreshold) + .filter(rowRecord -> getResultSimilarity(rowRecord) >= request.getSimilarityThreshold()) .map(rowRecord -> { String docId = (String) rowRecord.get(DOC_ID_FIELD_NAME); String content = (String) rowRecord.get(CONTENT_FIELD_NAME); @@ -384,7 +367,8 @@ public class MilvusVectorStore implements VectorStore, InitializingBean { private float getResultSimilarity(RowRecord rowRecord) { Float distance = (Float) rowRecord.get(DISTANCE_FIELD_NAME); - return (this.config.metricType == MetricType.IP) ? distance : (1 - distance); + return (this.config.metricType == MetricType.IP || this.config.metricType == MetricType.COSINE) ? distance + : (1 - distance); } private List toFloatList(List embeddingDouble) { diff --git a/vector-stores/spring-ai-milvus-store/src/test/java/org/springframework/ai/vectorstore/MilvusVectorStoreIT.java b/vector-stores/spring-ai-milvus-store/src/test/java/org/springframework/ai/vectorstore/MilvusVectorStoreIT.java index 88411925b..5081ca53b 100644 --- a/vector-stores/spring-ai-milvus-store/src/test/java/org/springframework/ai/vectorstore/MilvusVectorStoreIT.java +++ b/vector-stores/spring-ai-milvus-store/src/test/java/org/springframework/ai/vectorstore/MilvusVectorStoreIT.java @@ -17,31 +17,40 @@ package org.springframework.ai.vectorstore; import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.UUID; +import com.theokanning.openai.client.OpenAiApi; +import com.theokanning.openai.service.OpenAiService; import io.milvus.client.MilvusServiceClient; import io.milvus.param.ConnectParam; import io.milvus.param.IndexType; import io.milvus.param.MetricType; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.core.io.DefaultResourceLoader; import org.testcontainers.containers.DockerComposeContainer; import org.testcontainers.containers.wait.strategy.Wait; import org.testcontainers.junit.jupiter.Testcontainers; +import retrofit2.Retrofit; +import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory; +import retrofit2.converter.jackson.JacksonConverterFactory; -import org.springframework.ai.autoconfigure.openai.OpenAiAutoConfiguration; +import org.springframework.ai.ResourceUtils; import org.springframework.ai.document.Document; import org.springframework.ai.embedding.EmbeddingClient; +import org.springframework.ai.openai.embedding.OpenAiEmbeddingClient; import org.springframework.ai.vectorstore.MilvusVectorStore.MilvusVectorStoreConfig; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.SpringBootConfiguration; -import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; import org.springframework.boot.test.context.runner.ApplicationContextRunner; @@ -54,6 +63,7 @@ import static org.assertj.core.api.Assertions.assertThat; * @author Christian Tzolov */ @Testcontainers +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") public class MilvusVectorStoreIT { private static DockerComposeContainer milvusContainer; @@ -61,16 +71,22 @@ public class MilvusVectorStoreIT { private static final File TEMP_FOLDER = new File("target/test-" + UUID.randomUUID().toString()); private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withUserConfiguration(TestApplication.class) - .withPropertyValues("spring.ai.openai.apiKey=" + System.getenv("OPENAI_API_KEY")); + .withUserConfiguration(TestApplication.class); List documents = List.of( - new Document("Spring AI rocks!! Spring AI rocks!! Spring AI rocks!! Spring AI rocks!! Spring AI rocks!!", - Collections.singletonMap("meta1", "meta1")), - new Document("Hello World Hello World Hello World Hello World Hello World Hello World Hello World"), - new Document( - "Great Depression Great Depression Great Depression Great Depression Great Depression Great Depression", - Collections.singletonMap("meta2", "meta2"))); + new Document(getText("classpath:/test/data/spring.ai.txt"), Map.of("meta1", "meta1")), + new Document(getText("classpath:/test/data/time.shelter.txt")), + new Document(getText("classpath:/test/data/great.depression.txt"), Map.of("meta2", "meta2"))); + + public static String getText(String uri) { + var resource = new DefaultResourceLoader().getResource(uri); + try { + return resource.getContentAsString(StandardCharsets.UTF_8); + } + catch (IOException e) { + throw new RuntimeException(e); + } + } @BeforeAll public static void beforeAll() { @@ -99,161 +115,159 @@ public class MilvusVectorStoreIT { } @ParameterizedTest(name = "{0} : {displayName} ") - @ValueSource(strings = { "L2", "IP" }) + @ValueSource(strings = { "COSINE", "L2", "IP" }) public void addAndSearch(String metricType) { - contextRunner.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) - .withPropertyValues("test.spring.ai.vectorstore.milvus.metricType=" + metricType) - .run(context -> { + contextRunner.withPropertyValues("test.spring.ai.vectorstore.milvus.metricType=" + metricType).run(context -> { - VectorStore vectorStore = context.getBean(VectorStore.class); + VectorStore vectorStore = context.getBean(VectorStore.class); - resetCollection(vectorStore); + resetCollection(vectorStore); - vectorStore.add(documents); + vectorStore.add(documents); - List results = vectorStore.similaritySearch("Great", 1); + List results = vectorStore.similaritySearch(SearchRequest.query("Spring").withTopK(1)); - assertThat(results).hasSize(1); - Document resultDoc = results.get(0); - assertThat(resultDoc.getId()).isEqualTo(documents.get(2).getId()); - assertThat(resultDoc.getContent()).isEqualTo( - "Great Depression Great Depression Great Depression Great Depression Great Depression Great Depression"); - assertThat(resultDoc.getMetadata()).hasSize(2); - assertThat(resultDoc.getMetadata()).containsKey("meta2"); - assertThat(resultDoc.getMetadata()).containsKey("distance"); + assertThat(results).hasSize(1); + Document resultDoc = results.get(0); + assertThat(resultDoc.getId()).isEqualTo(documents.get(0).getId()); + assertThat(resultDoc.getContent()).contains( + "Spring AI provides abstractions that serve as the foundation for developing AI applications."); + assertThat(resultDoc.getMetadata()).hasSize(2); + assertThat(resultDoc.getMetadata()).containsKeys("meta1", "distance"); - // Remove all documents from the store - vectorStore.delete(documents.stream().map(doc -> doc.getId()).toList()); + // Remove all documents from the store + vectorStore.delete(documents.stream().map(doc -> doc.getId()).toList()); - List results2 = vectorStore.similaritySearch("Hello", 1); - assertThat(results2).hasSize(0); - }); + results = vectorStore.similaritySearch(SearchRequest.query("Spring").withTopK(1)); + assertThat(results).hasSize(0); + }); } @ParameterizedTest(name = "{0} : {displayName} ") - @ValueSource(strings = { "L2" }) - // @ValueSource(strings = { "IP", "L2" }) + @ValueSource(strings = { "COSINE" }) + // @ValueSource(strings = { "COSINE", "IP", "L2" }) public void searchWithFilters(String metricType) throws InterruptedException { // https://milvus.io/docs/json_data_type.md - final double THRESHOLD_ALL = 0.0; + contextRunner.withPropertyValues("test.spring.ai.vectorstore.milvus.metricType=" + metricType).run(context -> { + VectorStore vectorStore = context.getBean(VectorStore.class); - contextRunner.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) - .withPropertyValues("test.spring.ai.vectorstore.milvus.metricType=" + metricType) - .run(context -> { - VectorStore vectorStore = context.getBean(VectorStore.class); + var bgDocument = new Document("The World is Big and Salvation Lurks Around the Corner", + Map.of("country", "BG", "year", 2020)); + var nlDocument = new Document("The World is Big and Salvation Lurks Around the Corner", + Map.of("country", "NL")); + var bgDocument2 = new Document("The World is Big and Salvation Lurks Around the Corner", + Map.of("country", "BG", "year", 2023)); - var bgDocument = new Document("The World is Big and Salvation Lurks Around the Corner", - Map.of("country", "BG", "year", 2020)); - var nlDocument = new Document("The World is Big and Salvation Lurks Around the Corner", - Map.of("country", "NL")); - var bgDocument2 = new Document("The World is Big and Salvation Lurks Around the Corner", - Map.of("country", "BG", "year", 2023)); + vectorStore.add(List.of(bgDocument, nlDocument, bgDocument2)); - vectorStore.add(List.of(bgDocument, nlDocument, bgDocument2)); + List results = vectorStore.similaritySearch(SearchRequest.query("The World").withTopK(5)); + assertThat(results).hasSize(3); - List results = vectorStore.similaritySearch("The World", 5); - assertThat(results).hasSize(3); + results = vectorStore.similaritySearch(SearchRequest.query("The World") + .withTopK(5) + .withSimilarityThresholdAll() + .withFilterExpression("country == 'NL'")); + assertThat(results).hasSize(1); + assertThat(results.get(0).getId()).isEqualTo(nlDocument.getId()); - results = vectorStore.similaritySearch("The World", 5, THRESHOLD_ALL, "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'")); - results = vectorStore.similaritySearch("The World", 5, THRESHOLD_ALL, "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()); + 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("The World", 5, THRESHOLD_ALL, - "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 == 'BG' && year == 2020")); + + assertThat(results).hasSize(1); + assertThat(results.get(0).getId()).isEqualTo(bgDocument.getId()); + }); } @ParameterizedTest(name = "{0} : {displayName} ") - @ValueSource(strings = { "L2", "IP" }) + @ValueSource(strings = { "COSINE", "L2", "IP" }) public void documentUpdate(String metricType) { - contextRunner.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) - .withPropertyValues("test.spring.ai.vectorstore.milvus.metricType=" + metricType) - .run(context -> { + contextRunner.withPropertyValues("test.spring.ai.vectorstore.milvus.metricType=" + metricType).run(context -> { - VectorStore vectorStore = context.getBean(VectorStore.class); + VectorStore vectorStore = context.getBean(VectorStore.class); - resetCollection(vectorStore); + resetCollection(vectorStore); - Document document = new Document(UUID.randomUUID().toString(), "Spring AI rocks!!", - Collections.singletonMap("meta1", "meta1")); + Document document = new Document(UUID.randomUUID().toString(), "Spring AI rocks!!", + Collections.singletonMap("meta1", "meta1")); - vectorStore.add(List.of(document)); + vectorStore.add(List.of(document)); - List results = vectorStore.similaritySearch("Spring", 5); + List results = vectorStore.similaritySearch(SearchRequest.query("Spring").withTopK(5)); - assertThat(results).hasSize(1); - Document resultDoc = results.get(0); - assertThat(resultDoc.getId()).isEqualTo(document.getId()); - assertThat(resultDoc.getContent()).isEqualTo("Spring AI rocks!!"); - assertThat(resultDoc.getMetadata()).containsKey("meta1"); - assertThat(resultDoc.getMetadata()).containsKey("distance"); + 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", - Collections.singletonMap("meta2", "meta2")); + Document sameIdDocument = new Document(document.getId(), + "The World is Big and Salvation Lurks Around the Corner", + Collections.singletonMap("meta2", "meta2")); - vectorStore.add(List.of(sameIdDocument)); + vectorStore.add(List.of(sameIdDocument)); - results = vectorStore.similaritySearch("FooBar", 5); + results = vectorStore.similaritySearch(SearchRequest.query("FooBar").withTopK(5)); - assertThat(results).hasSize(1); - resultDoc = results.get(0); - assertThat(resultDoc.getId()).isEqualTo(document.getId()); - assertThat(resultDoc.getContent()).isEqualTo("The World is Big and Salvation Lurks Around the Corner"); - assertThat(resultDoc.getMetadata()).containsKey("meta2"); - assertThat(resultDoc.getMetadata()).containsKey("distance"); + 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"); - vectorStore.delete(List.of(document.getId())); + vectorStore.delete(List.of(document.getId())); - }); + }); } @ParameterizedTest(name = "{0} : {displayName} ") - @ValueSource(strings = { "L2", "IP" }) + @ValueSource(strings = { "COSINE", "L2", "IP" }) public void searchWithThreshold(String metricType) { - contextRunner.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) - .withPropertyValues("test.spring.ai.vectorstore.milvus.metricType=" + metricType) - .run(context -> { + contextRunner.withPropertyValues("test.spring.ai.vectorstore.milvus.metricType=" + metricType).run(context -> { - VectorStore vectorStore = context.getBean(VectorStore.class); + VectorStore vectorStore = context.getBean(VectorStore.class); - resetCollection(vectorStore); + resetCollection(vectorStore); - vectorStore.add(documents); + vectorStore.add(documents); - List fullResult = vectorStore.similaritySearch("Great", 5, 0.0); + List fullResult = vectorStore + .similaritySearch(SearchRequest.query("Spring").withTopK(5).withSimilarityThresholdAll()); - List distances = fullResult.stream() - .map(doc -> (Float) doc.getMetadata().get("distance")) - .toList(); + List distances = fullResult.stream().map(doc -> (Float) doc.getMetadata().get("distance")).toList(); - assertThat(distances).hasSize(3); + assertThat(distances).hasSize(3); - float threshold = (distances.get(0) + distances.get(1)) / 2; + float threshold = (distances.get(0) + distances.get(1)) / 2; - List results = vectorStore.similaritySearch("Great", 5, (1 - threshold)); + List results = vectorStore + .similaritySearch(SearchRequest.query("Spring").withTopK(5).withSimilarityThreshold(1 - threshold)); - assertThat(results).hasSize(1); - Document resultDoc = results.get(0); - assertThat(resultDoc.getId()).isEqualTo(documents.get(2).getId()); - assertThat(resultDoc.getContent()).isEqualTo( - "Great Depression Great Depression Great Depression Great Depression Great Depression Great Depression"); - assertThat(resultDoc.getMetadata()).containsKey("meta2"); - assertThat(resultDoc.getMetadata()).containsKey("distance"); + assertThat(results).hasSize(1); + Document resultDoc = results.get(0); + assertThat(resultDoc.getId()).isEqualTo(documents.get(0).getId()); + assertThat(resultDoc.getContent()).contains( + "Spring AI provides abstractions that serve as the foundation for developing AI applications."); + assertThat(resultDoc.getMetadata()).containsKeys("meta1", "distance"); - }); + }); } @SpringBootConfiguration @@ -282,6 +296,25 @@ public class MilvusVectorStoreIT { .build()); } + @Bean + public EmbeddingClient embeddingClient() { + + Retrofit retrofit = new Retrofit.Builder().baseUrl("https://api.openai.com") + .client(OpenAiService.defaultClient(System.getenv("OPENAI_API_KEY"), Duration.ofSeconds(60))) + .addConverterFactory(JacksonConverterFactory.create(OpenAiService.defaultObjectMapper())) + .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) + .build(); + + OpenAiApi api = retrofit.create(OpenAiApi.class); + + return new OpenAiEmbeddingClient(new OpenAiService(api), "text-embedding-ada-002"); + } + + // @Bean + // public EmbeddingClient embeddingClient() { + // return new TransformersEmbeddingClient(); + // } + } } \ No newline at end of file diff --git a/vector-stores/spring-ai-milvus-store/src/test/resources/docker-compose.yml b/vector-stores/spring-ai-milvus-store/src/test/resources/docker-compose.yml index aec1e5238..1be95096d 100644 --- a/vector-stores/spring-ai-milvus-store/src/test/resources/docker-compose.yml +++ b/vector-stores/spring-ai-milvus-store/src/test/resources/docker-compose.yml @@ -20,7 +20,7 @@ services: retries: 3 minio: - image: minio/minio:RELEASE.2023-09-23T03-47-50Z + image: minio/minio:RELEASE.2023-11-11T08-14-41Z environment: MINIO_ACCESS_KEY: minioadmin MINIO_SECRET_KEY: minioadmin diff --git a/vector-stores/spring-ai-neo4j-store/README.md b/vector-stores/spring-ai-neo4j-store/README.md index 871574862..6dc5dfee2 100644 --- a/vector-stores/spring-ai-neo4j-store/README.md +++ b/vector-stores/spring-ai-neo4j-store/README.md @@ -6,7 +6,7 @@ This readme walks you through setting up `Neo4jVectorStore` to store document em [Neo4j](https://neo4j.com) is an open source NoSQL graph database. It is a fully transactional database (ACID) that stores data structured as graphs consisting of nodes, connected by relationships. -Inspired by the structure of the real world, it allows for high query performance on complex data, while remaining intuitive and simple for the developer. +Inspired by the structure of the real world, it allows for high query performance on complex data, while remaining intuitive and simple for the developer. ## What is Neo4j Vector Search? @@ -18,7 +18,7 @@ Those indexes are powered by Lucene using a Hierarchical Navigable Small World G 1. OpenAI Account: Create an account at [OpenAI Signup](https://platform.openai.com/signup) and generate the token at [API Keys](https://platform.openai.com/account/api-keys). -2. A running Neo4j (5.13+) instance +2. A running Neo4j (5.13+) instance 1. [Docker](https://hub.docker.com/_/neo4j) image _neo4j:5.13_ 2. [Neo4j Desktop](https://neo4j.com/download/) 3. [Neo4j Aura](https://neo4j.com/cloud/aura-free/) @@ -53,22 +53,22 @@ To acquire Spring AI artifacts, declare the Spring Snapshot repository: Add these dependencies to your project: -1. OpenAI: Required for calculating embeddings. +1. OpenAI: Required for calculating embeddings. ```xml org.springframework.experimental.ai spring-ai-openai-spring-boot-starter - 0.7.0-SNAPSHOT + 0.7.1-SNAPSHOT ``` - + 2. Neo4j Vector Store ```xml org.springframework.experimental.ai spring-ai-neo4j-store - 0.7.0-SNAPSHOT + 0.7.1-SNAPSHOT ``` @@ -112,7 +112,7 @@ vectorStore.add(List.of(document)); And finally, retrieve documents similar to a query: ```java -List results = vectorStore.similaritySearch("Spring", 5); +List results = vectorStore.similaritySearch(SearchRequest.query("Spring").withTopK(5)); ``` If all goes well, you should retrieve the document containing the text "Spring AI rocks!!" as the first result. @@ -124,7 +124,7 @@ The default configuration should fit for most of the basic use-cases, but if you The default params * embedding dimension = 1536 -* distance type = cosine +* distance type = cosine * document node label = "Document" * node property for embedding = "embedding" * database name = "neo4j" diff --git a/vector-stores/spring-ai-neo4j-store/pom.xml b/vector-stores/spring-ai-neo4j-store/pom.xml index 76d63d55a..5293afa74 100644 --- a/vector-stores/spring-ai-neo4j-store/pom.xml +++ b/vector-stores/spring-ai-neo4j-store/pom.xml @@ -5,7 +5,7 @@ org.springframework.experimental.ai spring-ai - 0.7.0-SNAPSHOT + 0.7.1-SNAPSHOT ../../pom.xml spring-ai-neo4j-store @@ -41,7 +41,7 @@ org.springframework.experimental.ai - spring-ai-openai-spring-boot-starter + spring-ai-openai ${parent.version} test diff --git a/vector-stores/spring-ai-neo4j-store/src/main/java/org/springframework/ai/vectorstore/Neo4jVectorStore.java b/vector-stores/spring-ai-neo4j-store/src/main/java/org/springframework/ai/vectorstore/Neo4jVectorStore.java index f042e3f93..bdd848fa9 100644 --- a/vector-stores/spring-ai-neo4j-store/src/main/java/org/springframework/ai/vectorstore/Neo4jVectorStore.java +++ b/vector-stores/spring-ai-neo4j-store/src/main/java/org/springframework/ai/vectorstore/Neo4jVectorStore.java @@ -258,23 +258,17 @@ public class Neo4jVectorStore implements VectorStore, InitializingBean { } @Override - public List similaritySearch(String query) { - return this.similaritySearch(query, 5); - } + public List similaritySearch(SearchRequest request) { + if (request.getFilterExpression() != null) { + throw new UnsupportedOperationException( + "The [" + this.getClass() + "] doesn't support metadata filtering!"); + } - @Override - public List similaritySearch(String query, int k) { - return this.similaritySearch(query, k, 0); - } - - @Override - public List similaritySearch(String query, int k, double threshold) { - - Assert.isTrue(k > 0, "The number of documents to returned must be greater than zero"); - Assert.isTrue(threshold >= 0 && threshold <= 1, + Assert.isTrue(request.getTopK() > 0, "The number of documents to returned must be greater than zero"); + Assert.isTrue(request.getSimilarityThreshold() >= 0 && request.getSimilarityThreshold() <= 1, "The similarity score is bounded between 0 and 1; least to most similar respectively."); - var embedding = Values.value(toFloatArray(this.embeddingClient.embed(query))); + var embedding = Values.value(toFloatArray(this.embeddingClient.embed(request.getQuery()))); try (var session = this.driver.session(this.config.sessionConfig)) { return session .run(""" @@ -282,8 +276,9 @@ public class Neo4jVectorStore implements VectorStore, InitializingBean { YIELD node, score WHERE score >= $threshold RETURN node, score - """, Map.of("indexName", INDEX_NAME, "numberOfNearestNeighbours", k, "embeddingValue", - embedding, "threshold", threshold)) + """, + Map.of("indexName", INDEX_NAME, "numberOfNearestNeighbours", request.getTopK(), + "embeddingValue", embedding, "threshold", request.getSimilarityThreshold())) .list(Neo4jVectorStore::recordToDocument); } } diff --git a/vector-stores/spring-ai-neo4j-store/src/test/java/org/springframework/ai/vectorstore/Neo4jVectorStoreIT.java b/vector-stores/spring-ai-neo4j-store/src/test/java/org/springframework/ai/vectorstore/Neo4jVectorStoreIT.java index ebe33c1bd..0555b815e 100644 --- a/vector-stores/spring-ai-neo4j-store/src/test/java/org/springframework/ai/vectorstore/Neo4jVectorStoreIT.java +++ b/vector-stores/spring-ai-neo4j-store/src/test/java/org/springframework/ai/vectorstore/Neo4jVectorStoreIT.java @@ -1,11 +1,15 @@ package org.springframework.ai.vectorstore; +import java.time.Duration; import java.util.Collections; import java.util.List; import java.util.UUID; +import com.theokanning.openai.client.OpenAiApi; +import com.theokanning.openai.service.OpenAiService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import org.neo4j.driver.AuthTokens; import org.neo4j.driver.Driver; import org.neo4j.driver.GraphDatabase; @@ -13,12 +17,14 @@ import org.testcontainers.containers.Neo4jContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; import org.testcontainers.utility.DockerImageName; +import retrofit2.Retrofit; +import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory; +import retrofit2.converter.jackson.JacksonConverterFactory; -import org.springframework.ai.autoconfigure.openai.OpenAiAutoConfiguration; import org.springframework.ai.document.Document; import org.springframework.ai.embedding.EmbeddingClient; +import org.springframework.ai.openai.embedding.OpenAiEmbeddingClient; import org.springframework.boot.SpringBootConfiguration; -import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; import org.springframework.boot.test.context.runner.ApplicationContextRunner; @@ -29,8 +35,10 @@ import static org.assertj.core.api.Assertions.assertThat; /** * @author Gerrit Meier * @author Michael Simons + * @author Christian Tzolov */ @Testcontainers +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") class Neo4jVectorStoreIT { // Neo4j 5.12 has a bug wrt checking limits, so either 5.11 or anything higher than @@ -48,8 +56,7 @@ class Neo4jVectorStoreIT { Collections.singletonMap("meta2", "meta2"))); private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withUserConfiguration(TestApplication.class) - .withPropertyValues("spring.ai.openai.apiKey=" + System.getenv("OPENAI_API_KEY")); + .withUserConfiguration(TestApplication.class); @BeforeEach void cleanDatabase() { @@ -59,13 +66,13 @@ class Neo4jVectorStoreIT { @Test void addAndSearchTest() { - this.contextRunner.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)).run(context -> { + this.contextRunner.run(context -> { VectorStore vectorStore = context.getBean(VectorStore.class); vectorStore.add(this.documents); - List results = vectorStore.similaritySearch("Great", 1); + List results = vectorStore.similaritySearch(SearchRequest.query("Great").withTopK(1)); assertThat(results).hasSize(1); Document resultDoc = results.get(0); @@ -78,7 +85,7 @@ class Neo4jVectorStoreIT { // Remove all documents from the store vectorStore.delete(this.documents.stream().map(Document::getId).toList()); - List results2 = vectorStore.similaritySearch("Great", 1); + List results2 = vectorStore.similaritySearch(SearchRequest.query("Great").withTopK(1)); assertThat(results2).isEmpty(); }); } @@ -86,7 +93,7 @@ class Neo4jVectorStoreIT { @Test void documentUpdateTest() { - this.contextRunner.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)).run(context -> { + this.contextRunner.run(context -> { VectorStore vectorStore = context.getBean(VectorStore.class); @@ -95,7 +102,7 @@ class Neo4jVectorStoreIT { vectorStore.add(List.of(document)); - List results = vectorStore.similaritySearch("Spring", 5); + List results = vectorStore.similaritySearch(SearchRequest.query("Spring").withTopK(5)); assertThat(results).hasSize(1); Document resultDoc = results.get(0); @@ -110,7 +117,7 @@ class Neo4jVectorStoreIT { vectorStore.add(List.of(sameIdDocument)); - results = vectorStore.similaritySearch("FooBar", 5); + results = vectorStore.similaritySearch(SearchRequest.query("FooBar").withTopK(5)); assertThat(results).hasSize(1); resultDoc = results.get(0); @@ -125,13 +132,14 @@ class Neo4jVectorStoreIT { @Test void searchThresholdTest() { - this.contextRunner.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)).run(context -> { + this.contextRunner.run(context -> { VectorStore vectorStore = context.getBean(VectorStore.class); vectorStore.add(this.documents); - List fullResult = vectorStore.similaritySearch("Great", 5, 0); + List fullResult = vectorStore + .similaritySearch(SearchRequest.query("Great").withTopK(5).withSimilarityThresholdAll()); List distances = fullResult.stream().map(doc -> (Float) doc.getMetadata().get("distance")).toList(); @@ -139,7 +147,8 @@ class Neo4jVectorStoreIT { float threshold = (distances.get(0) + distances.get(1)) / 2; - List results = vectorStore.similaritySearch("Great", 5, 1 - threshold); + List results = vectorStore + .similaritySearch(SearchRequest.query("Great").withTopK(5).withSimilarityThreshold(1 - threshold)); assertThat(results).hasSize(1); Document resultDoc = results.get(0); @@ -199,6 +208,20 @@ class Neo4jVectorStoreIT { AuthTokens.basic("neo4j", neo4jContainer.getAdminPassword())); } + @Bean + public EmbeddingClient embeddingClient() { + + Retrofit retrofit = new Retrofit.Builder().baseUrl("https://api.openai.com") + .client(OpenAiService.defaultClient(System.getenv("OPENAI_API_KEY"), Duration.ofSeconds(60))) + .addConverterFactory(JacksonConverterFactory.create(OpenAiService.defaultObjectMapper())) + .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) + .build(); + + OpenAiApi api = retrofit.create(OpenAiApi.class); + + return new OpenAiEmbeddingClient(new OpenAiService(api), "text-embedding-ada-002"); + } + } } diff --git a/vector-stores/spring-ai-pgvector-store/README.md b/vector-stores/spring-ai-pgvector-store/README.md index f0026a564..704cdd5b7 100644 --- a/vector-stores/spring-ai-pgvector-store/README.md +++ b/vector-stores/spring-ai-pgvector-store/README.md @@ -83,7 +83,7 @@ Add these dependencies to your project: org.springframework.experimental.ai spring-ai-openai-spring-boot-starter - 0.7.0-SNAPSHOT + 0.7.1-SNAPSHOT ``` @@ -93,7 +93,7 @@ Add these dependencies to your project: org.springframework.experimental.ai spring-ai-pgvector-store - 0.7.0-SNAPSHOT + 0.7.1-SNAPSHOT ``` @@ -139,7 +139,7 @@ vectorStore.add(List.of(document)); And finally, retrieve documents similar to a query: ```java -List results = vectorStore.similaritySearch("Spring", 5); +List results = vectorStore.similaritySearch(SearchRequest.query("Spring").withTopK(5)); ``` If all goes well, you should retrieve the document containing the text "Spring AI rocks!!". diff --git a/vector-stores/spring-ai-pgvector-store/pom.xml b/vector-stores/spring-ai-pgvector-store/pom.xml index 2cf590d8e..fdef6d8db 100644 --- a/vector-stores/spring-ai-pgvector-store/pom.xml +++ b/vector-stores/spring-ai-pgvector-store/pom.xml @@ -5,7 +5,7 @@ org.springframework.experimental.ai spring-ai - 0.7.0-SNAPSHOT + 0.7.1-SNAPSHOT ../../pom.xml spring-ai-pgvector-store @@ -53,7 +53,15 @@ org.springframework.experimental.ai - spring-ai-openai-spring-boot-starter + spring-ai-openai + ${parent.version} + test + + + + + org.springframework.experimental.ai + spring-ai-test ${parent.version} test 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 fb2948577..9739a5649 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 @@ -33,7 +33,6 @@ 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.converter.PgVectorFilterExpressionConverter; import org.springframework.beans.factory.InitializingBean; import org.springframework.jdbc.core.JdbcTemplate; @@ -102,11 +101,23 @@ public class PgVectorStore implements VectorStore, InitializingBean { } + /** + * Defaults to CosineDistance. But if vectors are normalized to length 1 (like OpenAI + * embeddings), use inner product (NegativeInnerProduct) for best performance. + */ public enum PgDistanceType { + // NOTE: works only if If vectors are normalized to length 1 (like OpenAI + // embeddings), use inner product for best performance. + // The Sentence transformers are NOT normalized: + // https://github.com/UKPLab/sentence-transformers/issues/233 EuclideanDistance("<->", "vector_l2_ops", "SELECT *, embedding <-> ? AS distance FROM %s WHERE embedding <-> ? < ? %s ORDER BY distance LIMIT ? "), + // NOTE: works only if If vectors are normalized to length 1 (like OpenAI + // embeddings), use inner product for best performance. + // The Sentence transformers are NOT normalized: + // https://github.com/UKPLab/sentence-transformers/issues/233 NegativeInnerProduct("<#>", "vector_ip_ops", "SELECT *, (1 + (embedding <#> ?)) AS distance FROM %s WHERE (1 + (embedding <#> ?)) < ? %s ORDER BY distance LIMIT ? "), @@ -259,44 +270,24 @@ public class PgVectorStore implements VectorStore, InitializingBean { } @Override - public List similaritySearch(String query) { - return this.similaritySearch(query, 4); - } + public List similaritySearch(SearchRequest request) { - @Override - public List similaritySearch(String query, int topK) { - return this.similaritySearch(query, topK, 0.0 /** ALL */ - ); - } - - @Override - public List similaritySearch(String query, int topK, double similarityThreshold) { - - return this.internalSimilaritySearch(query, topK, similarityThreshold, ""); - } - - @Override - public List similaritySearch(String query, int k, double threshold, Filter.Expression filterExpression) { - String pgVectorFilterExpression = this.filterExpressionConverter.convert(filterExpression); - return this.internalSimilaritySearch(query, k, threshold, pgVectorFilterExpression); - } - - List internalSimilaritySearch(String query, int topK, double similarityThreshold, - String filterExpression) { + String nativeFilterExpression = (request.getFilterExpression() != null) + ? this.filterExpressionConverter.convert(request.getFilterExpression()) : ""; String jsonPathFilter = ""; - if (StringUtils.hasText(filterExpression)) { - jsonPathFilter = " AND metadata::jsonb @@ '" + filterExpression + "'::jsonpath "; + if (StringUtils.hasText(nativeFilterExpression)) { + jsonPathFilter = " AND metadata::jsonb @@ '" + nativeFilterExpression + "'::jsonpath "; } - double distance = 1 - similarityThreshold; + double distance = 1 - request.getSimilarityThreshold(); - PGvector queryEmbedding = getQueryEmbedding(query); + PGvector queryEmbedding = getQueryEmbedding(request.getQuery()); return this.jdbcTemplate.query( String.format(this.getDistanceType().similaritySearchSqlTemplate, VECTOR_TABLE_NAME, jsonPathFilter), - new DocumentRowMapper(this.objectMapper), queryEmbedding, queryEmbedding, distance, topK); + new DocumentRowMapper(this.objectMapper), queryEmbedding, queryEmbedding, distance, request.getTopK()); } public List embeddingDistance(String query) { diff --git a/vector-stores/spring-ai-pgvector-store/src/test/java/org/springframework/ai/vectorstore/PgVectorStoreIT.java b/vector-stores/spring-ai-pgvector-store/src/test/java/org/springframework/ai/vectorstore/PgVectorStoreIT.java index eb905f3dc..6e433937f 100644 --- a/vector-stores/spring-ai-pgvector-store/src/test/java/org/springframework/ai/vectorstore/PgVectorStoreIT.java +++ b/vector-stores/spring-ai-pgvector-store/src/test/java/org/springframework/ai/vectorstore/PgVectorStoreIT.java @@ -16,6 +16,9 @@ package org.springframework.ai.vectorstore; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.util.Collections; import java.util.Iterator; import java.util.List; @@ -24,22 +27,29 @@ import java.util.UUID; import javax.sql.DataSource; +import com.theokanning.openai.client.OpenAiApi; +import com.theokanning.openai.service.OpenAiService; import com.zaxxer.hikari.HikariDataSource; import org.junit.Assert; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.core.io.DefaultResourceLoader; import org.testcontainers.containers.GenericContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; +import retrofit2.Retrofit; +import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory; +import retrofit2.converter.jackson.JacksonConverterFactory; -import org.springframework.ai.autoconfigure.openai.OpenAiAutoConfiguration; +import org.springframework.ai.ResourceUtils; import org.springframework.ai.document.Document; import org.springframework.ai.embedding.EmbeddingClient; +import org.springframework.ai.openai.embedding.OpenAiEmbeddingClient; import org.springframework.ai.vectorstore.PgVectorStore.PgIndexType; import org.springframework.ai.vectorstore.filter.FilterExpressionTextParser.FilterExpressionParseException; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.SpringBootConfiguration; -import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; @@ -57,6 +67,7 @@ import static org.assertj.core.api.Assertions.assertThat; * @author Christian Tzolov */ @Testcontainers +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") public class PgVectorStoreIT { @Container @@ -66,17 +77,23 @@ public class PgVectorStoreIT { .withExposedPorts(5432); List documents = List.of( - new Document("Spring AI rocks!! Spring AI rocks!! Spring AI rocks!! Spring AI rocks!! Spring AI rocks!!", - Collections.singletonMap("meta1", "meta1")), - new Document("Hello World Hello World Hello World Hello World Hello World Hello World Hello World"), - new Document( - "Great Depression Great Depression Great Depression Great Depression Great Depression Great Depression", - Collections.singletonMap("meta2", "meta2"))); + new Document(getText("classpath:/test/data/spring.ai.txt"), Map.of("meta1", "meta1")), + new Document(getText("classpath:/test/data/time.shelter.txt")), + new Document(getText("classpath:/test/data/great.depression.txt"), Map.of("meta2", "meta2"))); + + public static 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 final ApplicationContextRunner contextRunner = new ApplicationContextRunner() .withUserConfiguration(TestApplication.class) - .withPropertyValues("spring.ai.openai.apiKey=" + System.getenv("OPENAI_API_KEY"), - "test.spring.ai.vectorstore.pgvector.distanceType=CosineDistance", + .withPropertyValues("test.spring.ai.vectorstore.pgvector.distanceType=CosineDistance", // JdbcTemplate configuration String.format("app.datasource.url=jdbc:postgresql://localhost:%d/%s", @@ -92,27 +109,26 @@ public class PgVectorStoreIT { @ParameterizedTest(name = "{0} : {displayName} ") @ValueSource(strings = { "CosineDistance", "EuclideanDistance", "NegativeInnerProduct" }) public void addAndSearch(String distanceType) { - contextRunner.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) - .withPropertyValues("test.spring.ai.vectorstore.pgvector.distanceType=" + distanceType) + contextRunner.withPropertyValues("test.spring.ai.vectorstore.pgvector.distanceType=" + distanceType) .run(context -> { VectorStore vectorStore = context.getBean(VectorStore.class); vectorStore.add(documents); - List results = vectorStore.similaritySearch("Great", 1); + List results = vectorStore + .similaritySearch(SearchRequest.query("What is Great Depression").withTopK(1)); assertThat(results).hasSize(1); Document resultDoc = results.get(0); assertThat(resultDoc.getId()).isEqualTo(documents.get(2).getId()); - assertThat(resultDoc.getContent()).isEqualTo( - "Great Depression Great Depression Great Depression Great Depression Great Depression Great Depression"); assertThat(resultDoc.getMetadata()).containsKeys("meta2", "distance"); // Remove all documents from the store vectorStore.delete(documents.stream().map(doc -> doc.getId()).toList()); - List results2 = vectorStore.similaritySearch("Great", 1); + List results2 = vectorStore + .similaritySearch(SearchRequest.query("Great Depression").withTopK(1)); assertThat(results2).hasSize(0); dropTable(context); @@ -123,10 +139,7 @@ public class PgVectorStoreIT { @ValueSource(strings = { "CosineDistance", "EuclideanDistance", "NegativeInnerProduct" }) public void searchWithFilters(String distanceType) { - final double THRESHOLD_ALL = 0.0; - - contextRunner.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) - .withPropertyValues("test.spring.ai.vectorstore.pgvector.distanceType=" + distanceType) + contextRunner.withPropertyValues("test.spring.ai.vectorstore.pgvector.distanceType=" + distanceType) .run(context -> { VectorStore vectorStore = context.getBean(VectorStore.class); @@ -140,45 +153,44 @@ public class PgVectorStoreIT { vectorStore.add(List.of(bgDocument, nlDocument, bgDocument2)); - List results = vectorStore.similaritySearch("The World", 5); + SearchRequest searchRequest = SearchRequest.query("The World").withTopK(5).withSimilarityThresholdAll(); + + List results = vectorStore.similaritySearch(searchRequest); + assertThat(results).hasSize(3); - results = vectorStore.similaritySearch("The World", 5, THRESHOLD_ALL, "country == 'NL'"); + results = vectorStore.similaritySearch(searchRequest.withFilterExpression("country == 'NL'")); + assertThat(results).hasSize(1); assertThat(results.get(0).getId()).isEqualTo(nlDocument.getId()); - results = vectorStore.similaritySearch("The World", 5, THRESHOLD_ALL, "country == 'BG'"); + results = vectorStore.similaritySearch(searchRequest.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("The World", 5, THRESHOLD_ALL, - "country == 'BG' && year == 2020"); + results = vectorStore + .similaritySearch(searchRequest.withFilterExpression("country == 'BG' && year == 2020")); + assertThat(results).hasSize(1); assertThat(results.get(0).getId()).isEqualTo(bgDocument.getId()); - results = vectorStore.similaritySearch("The World", 5, THRESHOLD_ALL, - "(country == 'BG' && year == 2020) || (country == 'NL')"); + results = vectorStore.similaritySearch( + searchRequest.withFilterExpression("(country == 'BG' && year == 2020) || (country == 'NL')")); + assertThat(results).hasSize(2); assertThat(results.get(0).getId()).isIn(bgDocument.getId(), nlDocument.getId()); assertThat(results.get(1).getId()).isIn(bgDocument.getId(), nlDocument.getId()); try { - vectorStore.similaritySearch("The World", 5, THRESHOLD_ALL, "country == NL"); + vectorStore.similaritySearch(searchRequest.withFilterExpression("country == NL")); Assert.fail("Invalid filter expression should have been cached!"); } catch (FilterExpressionParseException e) { assertThat(e.getMessage()).contains("Line: 1:17, Error: no viable alternative at input 'NL'"); } - try { - results = ((PgVectorStore) vectorStore).internalSimilaritySearch("The World", 5, THRESHOLD_ALL, - "Invalid Expression"); - Assert.fail("Malicious jsonpath expressions should be detected!"); - } - catch (Exception e) { - } - // Remove all documents from the store dropTable(context); }); @@ -188,8 +200,7 @@ public class PgVectorStoreIT { @ValueSource(strings = { "CosineDistance", "EuclideanDistance", "NegativeInnerProduct" }) public void documentUpdate(String distanceType) { - contextRunner.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) - .withPropertyValues("test.spring.ai.vectorstore.pgvector.distanceType=" + distanceType) + contextRunner.withPropertyValues("test.spring.ai.vectorstore.pgvector.distanceType=" + distanceType) .run(context -> { VectorStore vectorStore = context.getBean(VectorStore.class); @@ -199,7 +210,7 @@ public class PgVectorStoreIT { vectorStore.add(List.of(document)); - List results = vectorStore.similaritySearch("Spring", 5); + List results = vectorStore.similaritySearch(SearchRequest.query("Spring").withTopK(5)); assertThat(results).hasSize(1); Document resultDoc = results.get(0); @@ -213,7 +224,7 @@ public class PgVectorStoreIT { vectorStore.add(List.of(sameIdDocument)); - results = vectorStore.similaritySearch("FooBar", 5); + results = vectorStore.similaritySearch(SearchRequest.query("FooBar").withTopK(5)); assertThat(results).hasSize(1); resultDoc = results.get(0); @@ -227,38 +238,35 @@ public class PgVectorStoreIT { @ParameterizedTest(name = "{0} : {displayName} ") @ValueSource(strings = { "CosineDistance", "EuclideanDistance", "NegativeInnerProduct" }) + // @ValueSource(strings = { "CosineDistance" }) public void searchWithThreshold(String distanceType) { - contextRunner.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) - .withPropertyValues("test.spring.ai.vectorstore.pgvector.distanceType=" + distanceType) + contextRunner.withPropertyValues("test.spring.ai.vectorstore.pgvector.distanceType=" + distanceType) .run(context -> { VectorStore vectorStore = context.getBean(VectorStore.class); vectorStore.add(documents); - List fullResult = vectorStore.similaritySearch("Great", 5, 0.0); - - List distances = fullResult.stream() - .map(doc -> (Float) doc.getMetadata().get("distance")) - .toList(); + List fullResult = vectorStore + .similaritySearch(SearchRequest.query("Time Shelter").withTopK(5).withSimilarityThresholdAll()); assertThat(fullResult).hasSize(3); assertThat(isSortedByDistance(fullResult)).isTrue(); - fullResult.stream().forEach(doc -> System.out.println(doc.getMetadata().get("distance"))); + List distances = fullResult.stream() + .map(doc -> (Float) doc.getMetadata().get("distance")) + .toList(); float threshold = (distances.get(0) + distances.get(1)) / 2; - List results = vectorStore.similaritySearch("Great", 5, (1 - threshold)); + List results = vectorStore.similaritySearch( + SearchRequest.query("Time Shelter").withTopK(5).withSimilarityThreshold(1 - threshold)); assertThat(results).hasSize(1); Document resultDoc = results.get(0); - assertThat(resultDoc.getId()).isEqualTo(documents.get(2).getId()); - assertThat(resultDoc.getContent()).isEqualTo( - "Great Depression Great Depression Great Depression Great Depression Great Depression Great Depression"); - assertThat(resultDoc.getMetadata()).containsKeys("meta2", "distance"); + assertThat(resultDoc.getId()).isEqualTo(documents.get(1).getId()); dropTable(context); }); @@ -314,6 +322,20 @@ public class PgVectorStoreIT { return dataSourceProperties.initializeDataSourceBuilder().type(HikariDataSource.class).build(); } + @Bean + public EmbeddingClient embeddingClient() { + + Retrofit retrofit = new Retrofit.Builder().baseUrl("https://api.openai.com") + .client(OpenAiService.defaultClient(System.getenv("OPENAI_API_KEY"), Duration.ofSeconds(60))) + .addConverterFactory(JacksonConverterFactory.create(OpenAiService.defaultObjectMapper())) + .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) + .build(); + + OpenAiApi api = retrofit.create(OpenAiApi.class); + + return new OpenAiEmbeddingClient(new OpenAiService(api), "text-embedding-ada-002"); + } + } } diff --git a/vector-stores/spring-ai-pinecone/README.md b/vector-stores/spring-ai-pinecone/README.md index c8fa9d1ad..d1aaa4b7b 100644 --- a/vector-stores/spring-ai-pinecone/README.md +++ b/vector-stores/spring-ai-pinecone/README.md @@ -59,7 +59,7 @@ Add these dependencies to your project: org.springframework.experimental.ai spring-ai-openai-spring-boot-starter - 0.7.0-SNAPSHOT + 0.7.1-SNAPSHOT ``` @@ -69,7 +69,7 @@ Add these dependencies to your project: org.springframework.experimental.ai spring-ai-pinecone - 0.7.0-SNAPSHOT + 0.7.1-SNAPSHOT ``` @@ -119,7 +119,7 @@ vectorStore.add(List.of(document)); And finally, retrieve documents similar to a query: ```java -List results = vectorStore.similaritySearch("Spring", 5); +List results = vectorStore.similaritySearch(SearchRequest.query("Spring").withTopK(5)); ``` If all goes well, you should retrieve the document containing the text "Spring AI rocks!!". diff --git a/vector-stores/spring-ai-pinecone/pom.xml b/vector-stores/spring-ai-pinecone/pom.xml index 729d1b083..be0231478 100644 --- a/vector-stores/spring-ai-pinecone/pom.xml +++ b/vector-stores/spring-ai-pinecone/pom.xml @@ -5,7 +5,7 @@ org.springframework.experimental.ai spring-ai - 0.7.0-SNAPSHOT + 0.7.1-SNAPSHOT ../../pom.xml spring-ai-pinecone @@ -45,12 +45,19 @@ - - org.springframework.experimental.ai - spring-ai-openai-spring-boot-starter - ${parent.version} - test - + + org.springframework.experimental.ai + transformers-embedding + ${parent.version} + test + + + + org.springframework.experimental.ai + spring-ai-test + ${parent.version} + test + org.springframework.boot diff --git a/vector-stores/spring-ai-pinecone/src/main/java/org/springframework/ai/vectorstore/PineconeVectorStore.java b/vector-stores/spring-ai-pinecone/src/main/java/org/springframework/ai/vectorstore/PineconeVectorStore.java index 5d109182f..49b851da7 100644 --- a/vector-stores/spring-ai-pinecone/src/main/java/org/springframework/ai/vectorstore/PineconeVectorStore.java +++ b/vector-stores/spring-ai-pinecone/src/main/java/org/springframework/ai/vectorstore/PineconeVectorStore.java @@ -36,7 +36,6 @@ import io.pinecone.proto.Vector; 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.converter.PineconeFilterExpressionConverter; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -54,8 +53,6 @@ public class PineconeVectorStore implements VectorStore { private static final String DISTANCE_METADATA_FIELD_NAME = "distance"; - private static final Double SIMILARITY_THRESHOLD_ALL = 0.0; - public final PineconeFilterExpressionConverter filterExpressionConverter = new PineconeFilterExpressionConverter(); private final EmbeddingClient embeddingClient; @@ -64,8 +61,6 @@ public class PineconeVectorStore implements VectorStore { private final String pineconeNamespace; - private final int defaultSimilarityTopK; - private final ObjectMapper objectMapper; /** @@ -81,7 +76,7 @@ public class PineconeVectorStore implements VectorStore { private final PineconeClientConfig clientConfig; - private final int defaultSimilarityTopK; + // private final int defaultSimilarityTopK; /** * Constructor using the builder. @@ -93,7 +88,7 @@ public class PineconeVectorStore implements VectorStore { */ public PineconeVectorStoreConfig(Builder builder) { this.namespace = builder.namespace; - this.defaultSimilarityTopK = builder.defaultSimilarityTopK; + // this.defaultSimilarityTopK = builder.defaultSimilarityTopK; this.connectionConfig = new PineconeConnectionConfig().withIndexName(builder.indexName); this.clientConfig = new PineconeClientConfig().withApiKey(builder.apiKey) .withEnvironment(builder.environment) @@ -130,8 +125,6 @@ public class PineconeVectorStore implements VectorStore { // The free-tier (gcp-starter) doesn't support Namespaces! private String namespace = ""; - private int defaultSimilarityTopK = 5; - /** * Optional server-side timeout in seconds for all operations. Default: 20 * seconds. @@ -202,16 +195,6 @@ public class PineconeVectorStore implements VectorStore { return this; } - /** - * Pinecone default top K similarity search response size. - * @param defaultSimilarityTopK default top K to use. - * @return this builder. - */ - public Builder withDefaultTopK(int defaultSimilarityTopK) { - this.defaultSimilarityTopK = defaultSimilarityTopK; - return this; - } - /** * {@return the immutable configuration} */ @@ -234,7 +217,6 @@ public class PineconeVectorStore implements VectorStore { this.embeddingClient = embeddingClient; this.pineconeNamespace = config.namespace; - this.defaultSimilarityTopK = config.defaultSimilarityTopK; this.pineconeConnection = new PineconeClient(config.clientConfig).connect(config.connectionConfig); this.objectMapper = new ObjectMapper(); } @@ -313,65 +295,37 @@ public class PineconeVectorStore implements VectorStore { return Optional.of(true); } - /** - * Searches for documents similar to the given query. Uses the default topK value. - * @param query The query string. - * @return A list of similar documents. - */ @Override - public List similaritySearch(String query) { - return similaritySearch(query, this.defaultSimilarityTopK); - } + public List similaritySearch(SearchRequest request) { + // return this.internalSimilaritySearch(request.getQuery(), request.getTopK(), + // request.getSimilarityThreshold(), + // request.getFilterExpression()); + // } - /** - * Searches for documents similar to the given query. - * @param query The query string. - * @param topK The maximum number of results to return. - * @return A list of similar documents. - */ - @Override - public List similaritySearch(String query, int topK) { - return similaritySearch(query, topK, SIMILARITY_THRESHOLD_ALL); - } + // List internalSimilaritySearch(String query, int topK, double + // similarityThreshold, + // Filter.Expression filterExpression) { - /** - * Searches for documents similar to the given query. - * @param query The query string. - * @param topK The maximum number of results to return. - * @param similarityThreshold The similarity threshold for results. - * @return A list of similar documents. - */ - @Override - public List similaritySearch(String query, int topK, double similarityThreshold) { + String nativeExpressionFilters = (request.getFilterExpression() != null) + ? this.filterExpressionConverter.convert(request.getFilterExpression()) : ""; - return internalSimilaritySearch(query, topK, similarityThreshold, ""); - } - - @Override - public List similaritySearch(String query, int k, double threshold, Filter.Expression filterExpression) { - String pgVectorFilterExpression = this.filterExpressionConverter.convert(filterExpression); - return this.internalSimilaritySearch(query, k, threshold, pgVectorFilterExpression); - } - - List internalSimilaritySearch(String query, int topK, double similarityThreshold, String filters) { - - List queryEmbedding = this.embeddingClient.embed(query); + List queryEmbedding = this.embeddingClient.embed(request.getQuery()); var queryRequestBuilder = QueryRequest.newBuilder() .addAllVector(toFloatList(queryEmbedding)) - .setTopK(topK) + .setTopK(request.getTopK()) .setIncludeMetadata(true) .setNamespace(this.pineconeNamespace); - if (StringUtils.hasText(filters)) { - queryRequestBuilder.setFilter(metadataFiltersToStruct(filters)); + if (StringUtils.hasText(nativeExpressionFilters)) { + queryRequestBuilder.setFilter(metadataFiltersToStruct(nativeExpressionFilters)); } QueryResponse queryResponse = this.pineconeConnection.getBlockingStub().query(queryRequestBuilder.build()); return queryResponse.getMatchesList() .stream() - .filter(scoredVector -> scoredVector.getScore() >= similarityThreshold) + .filter(scoredVector -> scoredVector.getScore() >= request.getSimilarityThreshold()) .map(scoredVector -> { var id = scoredVector.getId(); Struct metadataStruct = scoredVector.getMetadata(); diff --git a/vector-stores/spring-ai-pinecone/src/test/java/org/springframework/ai/vectorstore/PineconeVectorStoreIT.java b/vector-stores/spring-ai-pinecone/src/test/java/org/springframework/ai/vectorstore/PineconeVectorStoreIT.java index a7bd3a73e..58aa300d5 100644 --- a/vector-stores/spring-ai-pinecone/src/test/java/org/springframework/ai/vectorstore/PineconeVectorStoreIT.java +++ b/vector-stores/spring-ai-pinecone/src/test/java/org/springframework/ai/vectorstore/PineconeVectorStoreIT.java @@ -16,6 +16,8 @@ package org.springframework.ai.vectorstore; +import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.List; import java.util.Map; @@ -28,15 +30,16 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; -import org.springframework.ai.autoconfigure.openai.OpenAiAutoConfiguration; +import org.springframework.ai.ResourceUtils; import org.springframework.ai.document.Document; import org.springframework.ai.embedding.EmbeddingClient; +import org.springframework.ai.embedding.TransformersEmbeddingClient; import org.springframework.ai.vectorstore.PineconeVectorStore.PineconeVectorStoreConfig; import org.springframework.boot.SpringBootConfiguration; -import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.annotation.Bean; +import org.springframework.core.io.DefaultResourceLoader; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.equalTo; @@ -46,7 +49,6 @@ import static org.hamcrest.Matchers.hasSize; * @author Christian Tzolov */ @EnabledIfEnvironmentVariable(named = "PINECONE_API_KEY", matches = ".+") -@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") public class PineconeVectorStoreIT { // Replace the PINECONE_ENVIRONMENT, PINECONE_PROJECT_ID, PINECONE_INDEX_NAME and @@ -61,16 +63,22 @@ public class PineconeVectorStoreIT { private static final String PINECONE_NAMESPACE = ""; List documents = List.of( - new Document("Spring AI rocks!! Spring AI rocks!! Spring AI rocks!! Spring AI rocks!! Spring AI rocks!!", - Collections.singletonMap("meta1", "meta1")), - new Document("Hello World Hello World Hello World Hello World Hello World Hello World Hello World"), - new Document( - "Great Depression Great Depression Great Depression Great Depression Great Depression Great Depression", - Collections.singletonMap("meta2", "meta2"))); + 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"))); + + public static 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 final ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withUserConfiguration(TestApplication.class) - .withPropertyValues("spring.ai.openai.apiKey=" + System.getenv("OPENAI_API_KEY")); + .withUserConfiguration(TestApplication.class); @BeforeAll public static void beforeAll() { @@ -82,23 +90,22 @@ public class PineconeVectorStoreIT { @Test public void addAndSearchTest() { - contextRunner.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)).run(context -> { + contextRunner.run(context -> { VectorStore vectorStore = context.getBean(VectorStore.class); vectorStore.add(documents); Awaitility.await().until(() -> { - return vectorStore.similaritySearch("Great", 1); + return vectorStore.similaritySearch(SearchRequest.query("Great Depression").withTopK(1)); }, hasSize(1)); - List results = vectorStore.similaritySearch("Great", 1); + List results = vectorStore.similaritySearch(SearchRequest.query("Great Depression").withTopK(1)); assertThat(results).hasSize(1); Document resultDoc = results.get(0); assertThat(resultDoc.getId()).isEqualTo(documents.get(2).getId()); - assertThat(resultDoc.getContent()).isEqualTo( - "Great Depression Great Depression Great Depression Great Depression Great Depression Great Depression"); + assertThat(resultDoc.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"); @@ -107,7 +114,7 @@ public class PineconeVectorStoreIT { vectorStore.delete(documents.stream().map(doc -> doc.getId()).toList()); Awaitility.await().until(() -> { - return vectorStore.similaritySearch("Hello", 1); + return vectorStore.similaritySearch(SearchRequest.query("Hello").withTopK(1)); }, hasSize(0)); }); } @@ -118,9 +125,7 @@ public class PineconeVectorStoreIT { // Pinecone metadata filtering syntax: // https://docs.pinecone.io/docs/metadata-filtering - final double THRESHOLD_ALL = 0.0; - - contextRunner.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)).run(context -> { + contextRunner.run(context -> { VectorStore vectorStore = context.getBean(VectorStore.class); @@ -131,18 +136,24 @@ public class PineconeVectorStoreIT { vectorStore.add(List.of(bgDocument, nlDocument)); + SearchRequest searchRequest = SearchRequest.query("The World"); + Awaitility.await().until(() -> { - return vectorStore.similaritySearch("The World", 1); + return vectorStore.similaritySearch(searchRequest.withTopK(1)); }, hasSize(1)); - List results = vectorStore.similaritySearch("The World", 5); + List results = vectorStore.similaritySearch(searchRequest.withTopK(5)); assertThat(results).hasSize(2); - results = vectorStore.similaritySearch("The World", 5, THRESHOLD_ALL, "country == 'Bulgaria'"); + results = vectorStore.similaritySearch(searchRequest.withTopK(5) + .withSimilarityThresholdAll() + .withFilterExpression("country == 'Bulgaria'")); assertThat(results).hasSize(1); assertThat(results.get(0).getId()).isEqualTo(bgDocument.getId()); - results = vectorStore.similaritySearch("The World", 5, THRESHOLD_ALL, "country == 'Netherland'"); + results = vectorStore.similaritySearch(searchRequest.withTopK(5) + .withSimilarityThresholdAll() + .withFilterExpression("country == 'Netherland'")); assertThat(results).hasSize(1); assertThat(results.get(0).getId()).isEqualTo(nlDocument.getId()); @@ -150,7 +161,7 @@ public class PineconeVectorStoreIT { vectorStore.delete(List.of(bgDocument, nlDocument).stream().map(doc -> doc.getId()).toList()); Awaitility.await().until(() -> { - return vectorStore.similaritySearch("The World", 1); + return vectorStore.similaritySearch(searchRequest.withTopK(1)); }, hasSize(0)); }); } @@ -159,7 +170,7 @@ public class PineconeVectorStoreIT { public void documentUpdateTest() { // Note ,using OpenAI to calculate embeddings - contextRunner.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)).run(context -> { + contextRunner.run(context -> { VectorStore vectorStore = context.getBean(VectorStore.class); @@ -168,11 +179,13 @@ public class PineconeVectorStoreIT { vectorStore.add(List.of(document)); + SearchRequest springSearchRequest = SearchRequest.query("Spring").withTopK(5); + Awaitility.await().until(() -> { - return vectorStore.similaritySearch("Spring", 5); + return vectorStore.similaritySearch(springSearchRequest); }, hasSize(1)); - List results = vectorStore.similaritySearch("Spring", 5); + List results = vectorStore.similaritySearch(springSearchRequest); assertThat(results).hasSize(1); Document resultDoc = results.get(0); @@ -187,11 +200,13 @@ public class PineconeVectorStoreIT { vectorStore.add(List.of(sameIdDocument)); + SearchRequest fooBarSearchRequest = SearchRequest.query("FooBar").withTopK(5); + Awaitility.await().until(() -> { - return vectorStore.similaritySearch("FooBar", 5).get(0).getContent(); + return vectorStore.similaritySearch(fooBarSearchRequest).get(0).getContent(); }, equalTo("The World is Big and Salvation Lurks Around the Corner")); - results = vectorStore.similaritySearch("FooBar", 5); + results = vectorStore.similaritySearch(fooBarSearchRequest); assertThat(results).hasSize(1); resultDoc = results.get(0); @@ -203,7 +218,7 @@ public class PineconeVectorStoreIT { // Remove all documents from the store vectorStore.delete(List.of(document.getId())); Awaitility.await().until(() -> { - return vectorStore.similaritySearch("FooBar", 1); + return vectorStore.similaritySearch(fooBarSearchRequest); }, hasSize(0)); }); @@ -212,17 +227,19 @@ public class PineconeVectorStoreIT { @Test public void searchThresholdTest() { - contextRunner.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)).run(context -> { + contextRunner.run(context -> { VectorStore vectorStore = context.getBean(VectorStore.class); vectorStore.add(documents); Awaitility.await().until(() -> { - return vectorStore.similaritySearch("Great", 5); + return vectorStore + .similaritySearch(SearchRequest.query("Depression").withTopK(50).withSimilarityThresholdAll()); }, hasSize(3)); - List fullResult = vectorStore.similaritySearch("Great", 5, 0.0); + List fullResult = vectorStore + .similaritySearch(SearchRequest.query("Depression").withTopK(5).withSimilarityThresholdAll()); List distances = fullResult.stream().map(doc -> (Float) doc.getMetadata().get("distance")).toList(); @@ -230,20 +247,20 @@ public class PineconeVectorStoreIT { float threshold = (distances.get(0) + distances.get(1)) / 2; - List results = vectorStore.similaritySearch("Great", 5, (1 - threshold)); + List results = vectorStore + .similaritySearch(SearchRequest.query("Depression").withTopK(5).withSimilarityThreshold(1 - threshold)); assertThat(results).hasSize(1); Document resultDoc = results.get(0); assertThat(resultDoc.getId()).isEqualTo(documents.get(2).getId()); - assertThat(resultDoc.getContent()).isEqualTo( - "Great Depression Great Depression Great Depression Great Depression Great Depression Great Depression"); + assertThat(resultDoc.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(doc -> doc.getId()).toList()); Awaitility.await().until(() -> { - return vectorStore.similaritySearch("Hello", 1); + return vectorStore.similaritySearch(SearchRequest.query("Hello").withTopK(1)); }, hasSize(0)); }); } @@ -269,6 +286,11 @@ public class PineconeVectorStoreIT { return new PineconeVectorStore(config, embeddingClient); } + @Bean + public EmbeddingClient embeddingClient() { + return new TransformersEmbeddingClient(); + } + } } \ No newline at end of file