From 3e2ed8b9bf78f516b97949396292276132269a25 Mon Sep 17 00:00:00 2001 From: PabloSanchi Date: Sat, 6 Apr 2024 19:06:35 +0200 Subject: [PATCH] Add Typesense vector store integration - autoconfigure setup - add post bean initialization and create method - add embedding field - create collection add nested field options - add typesense tests - use embedding variable instead of word vec - check in runtime the number of documents in the collection - add typesense expression converter - add filter tests. add update document test and search with threshold test - distance threshold and add distance key into metadata - add typesesne boot starter - add typesense docs - add client properties in autoconfigure - add embedding dimension method - add typesense vector store autoconfiguration tests - add docs to nav.adoc and vectorsdb.adoc. - fix module name. - move the expression converter to the typesense project. --- pom.xml | 10 +- spring-ai-bom/pom.xml | 13 + .../src/main/antora/modules/ROOT/nav.adoc | 1 + .../modules/ROOT/pages/api/vectordbs.adoc | 1 + .../ROOT/pages/api/vectordbs/typesense.adoc | 242 +++++++++++++ spring-ai-spring-boot-autoconfigure/pom.xml | 9 + .../typesense/TypesenseConnectionDetails.java | 16 + .../TypesenseServiceClientProperties.java | 57 +++ ...TypesenseVectorStoreAutoConfiguration.java | 84 +++++ .../TypesenseVectorStoreProperties.java | 40 +++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + ...pesenseVectorStoreAutoConfigurationIT.java | 107 ++++++ .../spring-ai-starter-typesense-store/pom.xml | 44 +++ .../spring-ai-typesense-store/pom.xml | 67 ++++ .../TypesenseFilterExpressionConverter.java | 60 ++++ .../ai/vectorstore/TypesenseVectorStore.java | 337 ++++++++++++++++++ .../vectorstore/TypesenseVectorStoreIT.java | 282 +++++++++++++++ 17 files changed, 1367 insertions(+), 4 deletions(-) create mode 100644 spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/typesense.adoc create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/typesense/TypesenseConnectionDetails.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/typesense/TypesenseServiceClientProperties.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/typesense/TypesenseVectorStoreAutoConfiguration.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/typesense/TypesenseVectorStoreProperties.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/typesense/TypesenseVectorStoreAutoConfigurationIT.java create mode 100644 spring-ai-spring-boot-starters/spring-ai-starter-typesense-store/pom.xml create mode 100644 vector-stores/spring-ai-typesense-store/pom.xml create mode 100644 vector-stores/spring-ai-typesense-store/src/main/java/org/springframework/ai/vectorstore/TypesenseFilterExpressionConverter.java create mode 100644 vector-stores/spring-ai-typesense-store/src/main/java/org/springframework/ai/vectorstore/TypesenseVectorStore.java create mode 100644 vector-stores/spring-ai-typesense-store/src/test/java/org/springframework/ai/vectorstore/TypesenseVectorStoreIT.java diff --git a/pom.xml b/pom.xml index abe272dc4..84902529c 100644 --- a/pom.xml +++ b/pom.xml @@ -30,6 +30,7 @@ vector-stores/spring-ai-cassandra-store vector-stores/spring-ai-chroma-store vector-stores/spring-ai-elasticsearch-store + vector-stores/spring-ai-gemfire-store vector-stores/spring-ai-hanadb-store vector-stores/spring-ai-milvus-store @@ -40,8 +41,9 @@ vector-stores/spring-ai-pinecone-store vector-stores/spring-ai-qdrant-store vector-stores/spring-ai-redis-store - vector-stores/spring-ai-weaviate-store + vector-stores/spring-ai-typesense-store + vector-stores/spring-ai-weaviate-store spring-ai-spring-boot-starters/spring-ai-starter-azure-store spring-ai-spring-boot-starters/spring-ai-starter-cassandra-store spring-ai-spring-boot-starters/spring-ai-starter-chroma-store @@ -55,8 +57,8 @@ spring-ai-spring-boot-starters/spring-ai-starter-pinecone-store spring-ai-spring-boot-starters/spring-ai-starter-qdrant-store spring-ai-spring-boot-starters/spring-ai-starter-redis-store + spring-ai-spring-boot-starters/spring-ai-starter-typesense-store spring-ai-spring-boot-starters/spring-ai-starter-weaviate-store - models/spring-ai-anthropic models/spring-ai-azure-openai models/spring-ai-bedrock @@ -72,7 +74,6 @@ models/spring-ai-vertex-ai-palm2 models/spring-ai-watsonx-ai models/spring-ai-zhipuai - spring-ai-spring-boot-starters/spring-ai-starter-anthropic spring-ai-spring-boot-starters/spring-ai-starter-azure-openai spring-ai-spring-boot-starters/spring-ai-starter-bedrock-ai @@ -88,7 +89,7 @@ spring-ai-spring-boot-starters/spring-ai-starter-vertex-ai-palm2 spring-ai-spring-boot-starters/spring-ai-starter-watsonx-ai spring-ai-spring-boot-starters/spring-ai-starter-zhipuai - + VMware Inc. @@ -167,6 +168,7 @@ 11.6.1 4.5.1 1.7.1 + 0.5.0 0.0.4 diff --git a/spring-ai-bom/pom.xml b/spring-ai-bom/pom.xml index 19b569bc7..07249e06d 100644 --- a/spring-ai-bom/pom.xml +++ b/spring-ai-bom/pom.xml @@ -332,6 +332,12 @@ ${project.version} + + org.springframework.ai + spring-ai-typesense-store + ${project.version} + + org.springframework.ai spring-ai-pinecone-store-spring-boot-starter @@ -397,12 +403,19 @@ spring-ai-mongodb-atlas-store-spring-boot-starter ${project.version} + org.springframework.ai spring-ai-anthropic-spring-boot-starter ${project.version} + + org.springframework.ai + spring-ai-typesense-store-spring-boot-starter + ${project.version} + + org.springframework.ai spring-ai-spring-boot-testcontainers diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc index 6e625eaa3..52ea50314 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc @@ -68,6 +68,7 @@ *** xref:api/vectordbs/qdrant.adoc[] *** xref:api/vectordbs/redis.adoc[] *** xref:api/vectordbs/hana.adoc[SAP Hana] +*** xref:api/vectordbs/typesense.adoc[] *** xref:api/vectordbs/weaviate.adoc[] 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 9c9bfb8f3..f98d70b05 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 @@ -109,6 +109,7 @@ These are the available implementations of the `VectorStore` interface: * xref:api/vectordbs/qdrant.adoc[Qdrant Vector Store] - https://www.qdrant.tech/[Qdrant] vector store. * xref:api/vectordbs/redis.adoc[Redis Vector Store] - The https://redis.io/[Redis] vector store. * xref:api/vectordbs/hana.adoc[SAP Hana Vector Store] - The https://news.sap.com/2024/04/sap-hana-cloud-vector-engine-ai-with-business-context/[SAP HANA] vector store. +* xref:api/vectordbs/typesense.adoc[Typesense Vector Store] - The https://typesense.org/docs/0.24.0/api/vector-search.html[Typesense] vector store. * xref:api/vectordbs/weaviate.adoc[Weaviate Vector Store] - The https://weaviate.io/[Weaviate] vector store. * link:https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/vectorstore/SimpleVectorStore.java[SimpleVectorStore] - A simple implementation of persistent vector storage, good for educational purposes. diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/typesense.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/typesense.adoc new file mode 100644 index 000000000..e48aaa975 --- /dev/null +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/typesense.adoc @@ -0,0 +1,242 @@ += Typesense + +This section walks you through setting up `TypesenseVectorStore` to store document embeddings and perform similarity searches. + +link:https://typesense.org[Typesense] Typesense is an open source, typo tolerant search engine that is optimized for instant sub-50ms searches, while providing an intuitive developer experience. + +== Prerequisites + +1. A Typesense instance +- link:https://typesense.org/docs/guide/install-typesense.html[Typesense Cloud] (recommended) +- link:https://hub.docker.com/r/typesense/typesense/[Docker] image _typesense/typesense:latest_ + +2. `EmbeddingClient` instance to compute the document embeddings. Several options are available: +- If required, an API key for the xref:api/embeddings.adoc#available-implementations[EmbeddingClient] to generate the embeddings stored by the `TypesenseVectorStore`. + +== Auto-configuration + +Spring AI provides Spring Boot auto-configuration for the Typesense Vector Sore. +To enable it, add the following dependency to your project's Maven `pom.xml` file: + +[source, xml] +---- + + org.springframework.ai + spring-ai-typesense-spring-boot-starter + +---- + +or to your Gradle `build.gradle` build file. + +[source,groovy] +---- +dependencies { + implementation 'org.springframework.ai:spring-ai-typesense-spring-boot-starter' +} +---- + +TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file. + +TIP: Refer to the xref:getting-started.adoc#repositories[Repositories] section to add Milestone and/or Snapshot Repositories to your build file. + +Additionally, you will need a configured `EmbeddingClient` bean. Refer to the xref:api/embeddings.adoc#available-implementations[EmbeddingClient] section for more information. + +Here is an example of the needed bean: + +[source,java] +---- +@Bean +public EmbeddingClient embeddingClient() { + // Can be any other EmbeddingClient implementation. + return new OpenAiEmbeddingClient(new OpenAiApi(System.getenv("SPRING_AI_OPENAI_API_KEY"))); +} +---- + +To connect to Typesense you need to provide access details for your instance. +A simple configuration can either be provided via Spring Boot's _application.yml_, + +[source,yaml] +---- +spring: + ai: + vectorstore: + typesense: + collectionName: "vector_store" + embeddingDimension: 1536 + client: + protocl: http + host: localhost + port: 8108 + apiKey: xyz +---- + +Please have a look at the list of xref:#_configuration_properties[configuration parameters] for the vector store to learn about the default values and configuration options. + +Now you can Auto-wire the Typesense Vector Store in your application and use it + +[source,java] +---- +@Autowired VectorStore vectorStore; + +// ... + +List documents = List.of( + new Document("Spring AI rocks!! Spring AI rocks!! Spring AI rocks!! Spring AI rocks!! Spring AI rocks!!", Map.of("meta1", "meta1")), + new Document("The World is Big and Salvation Lurks Around the Corner"), + new Document("You walk forward facing the past and you turn back toward the future.", Map.of("meta2", "meta2"))); + +// Add the documents to Typesense +vectorStore.add(documents); + +// Retrieve documents similar to a query +List results = vectorStore.similaritySearch(SearchRequest.query("Spring").withTopK(5)); +---- + +=== Configuration properties + +You can use the following properties in your Spring Boot configuration to customize the Typesense vector store. + +|=== +|Property| Description | Default value + +|`spring.ai.vectorstore.typesense.client.protocol`| HTTP Protocol | `http` +|`spring.ai.vectorstore.typesense.client.host`| Hostname | `localhost` +|`spring.ai.vectorstore.typesense.client.port`| Port | `8108` +|`spring.ai.vectorstore.typesense.client.apiKey`| ApiKey | `xyz` +|`spring.ai.vectorstore.typesense.collectionName`| Collection Name | `vector_store` +|`spring.ai.vectorstore.typesense.embeddingDimension`| Embedding Dimension | `1536` + +|=== + +== Metadata filtering + +You can leverage the generic, portable link:https://docs.spring.io/spring-ai/reference/api/vectordbs.html#_metadata_filters[metadata filters] with `TypesenseVectorStore` as well. + +For example, you can use either the text expression language: + +[source,java] +---- +vectorStore.similaritySearch( + SearchRequest + .query("The World") + .withTopK(TOP_K) + .withSimilarityThreshold(SIMILARITY_THRESHOLD) + .withFilterExpression("country in ['UK', 'NL'] && year >= 2020")); +---- + +or programmatically using the expression DSL: + +[source,java] +---- +FilterExpressionBuilder b = new FilterExpressionBuilder(); + +vectorStore.similaritySearch( + SearchRequest + .query("The World") + .withTopK(TOP_K) + .withSimilarityThreshold(SIMILARITY_THRESHOLD) + .withFilterExpression(b.and( + b.in("country", "UK", "NL"), + b.gte("year", 2020)).build())); +---- + +The portable filter expressions get automatically converted into link:https://typesense.org/docs/0.24.0/api/search.html#filter-parameters[Typesense Search Filters]. +For example, the following portable filter expression: + +[source,sql] +---- +country in ['UK', 'NL'] && year >= 2020 +---- + +is converted into Typesense filter: + +[source] +---- +country: ['UK', 'NL'] && year: >=2020 +---- + +== Manual configuration + +If you prefer not to use the auto-configuration, you can manually configure the Typesense Vector Store. +Add the Typesense Vector Store and Jedis dependencies + +[source,xml] +---- + + org.springframework.ai + spring-ai-typesense + +---- + +TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file. + +Then, create a `TypesenseVectorStore` bean in your Spring configuration: + +[source,java] +---- +@Bean +public VectorStore vectorStore(Client client, EmbeddingClient embeddingClient) { + + TypesenseVectorStoreConfig config = TypesenseVectorStoreConfig.builder() + .withCollectionName("test_vector_store") + .withEmbeddingDimension(embeddingClient.dimensions()) + .build(); + + return new TypesenseVectorStore(client, embeddingClient, config); +} + +@Bean +public Client typesenseClient() { + List nodes = new ArrayList<>(); + nodes + .add(new Node("http", typesenseContainer.getHost(), typesenseContainer.getMappedPort(8108).toString())); + + Configuration configuration = new Configuration(nodes, Duration.ofSeconds(5), "xyz"); + return new Client(configuration); +} +---- + +[NOTE] +==== +It is more convenient and preferred to create the `TypesenseVectorStore` as a Bean. +But if you decide to create it manually, then you must call the `TypesenseVectorStore#afterPropertiesSet()` after setting the properties and before using the client. +==== + + +Then in your main code, create some documents: + +[source,java] +---- +List documents = List.of( + new Document("Spring AI rocks!! Spring AI rocks!! Spring AI rocks!! Spring AI rocks!! Spring AI rocks!!", Map.of("country", "UK", "year", 2020)), + new Document("The World is Big and Salvation Lurks Around the Corner", Map.of()), + new Document("You walk forward facing the past and you turn back toward the future.", Map.of("country", "NL", "year", 2023))); +---- + +Now add the documents to your vector store: + + +[source,java] +---- +vectorStore.add(documents); +---- + +And finally, retrieve documents similar to a query: + +[source,java] +---- +List results = vectorStore.similaritySearch( + SearchRequest + .query("Spring") + .withTopK(5)); +---- + +If all goes well, you should retrieve the document containing the text "Spring AI rocks!!". + +[NOTE] +==== +If you are not retrieveing the documents in the expected order or the search results are not as expected, check the embedding model you are using. + +Embedding models can have a significant impact on the search results (i.e. make sure if your data is in Spanish to use a Spanish or multilingual embedding model). +==== + diff --git a/spring-ai-spring-boot-autoconfigure/pom.xml b/spring-ai-spring-boot-autoconfigure/pom.xml index 9c8f275ae..aaba27c8d 100644 --- a/spring-ai-spring-boot-autoconfigure/pom.xml +++ b/spring-ai-spring-boot-autoconfigure/pom.xml @@ -296,6 +296,7 @@ true + org.springframework.ai spring-ai-minimax @@ -310,6 +311,14 @@ true + + + org.springframework.ai + spring-ai-typesense-store + ${project.parent.version} + true + + diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/typesense/TypesenseConnectionDetails.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/typesense/TypesenseConnectionDetails.java new file mode 100644 index 000000000..7c342996a --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/typesense/TypesenseConnectionDetails.java @@ -0,0 +1,16 @@ +package org.springframework.ai.autoconfigure.vectorstore.typesense; + +import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails; + +/** + * @author Pablo Sanchidrian Herrera + */ +public interface TypesenseConnectionDetails extends ConnectionDetails { + + String getHost(); + + String getProtocol(); + + String getPort(); + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/typesense/TypesenseServiceClientProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/typesense/TypesenseServiceClientProperties.java new file mode 100644 index 000000000..2ede36c66 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/typesense/TypesenseServiceClientProperties.java @@ -0,0 +1,57 @@ +package org.springframework.ai.autoconfigure.vectorstore.typesense; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * @author Pablo Sanchidrian Herrera + */ +@ConfigurationProperties(TypesenseServiceClientProperties.CONFIG_PREFIX) +public class TypesenseServiceClientProperties { + + public static final String CONFIG_PREFIX = "spring.ai.vectorstore.typesense.client"; + + private String protocol = "http"; + + private String host = "localhost"; + + private String port = "8108"; + + /** + * Typesense API key. This is the default api key when the user follows the Typesense + * quick start guide. + */ + private String apiKey = "xyz"; + + public String getProtocol() { + return protocol; + } + + public void setProtocol(String protocol) { + this.protocol = protocol; + } + + public String getHost() { + return host; + } + + public void setHost(String host) { + this.host = host; + } + + public String getPort() { + return port; + } + + public void setPort(String port) { + this.port = port; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/typesense/TypesenseVectorStoreAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/typesense/TypesenseVectorStoreAutoConfiguration.java new file mode 100644 index 000000000..f16a04df8 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/typesense/TypesenseVectorStoreAutoConfiguration.java @@ -0,0 +1,84 @@ +package org.springframework.ai.autoconfigure.vectorstore.typesense; + +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.vectorstore.TypesenseVectorStore; +import org.springframework.ai.vectorstore.TypesenseVectorStore.TypesenseVectorStoreConfig; +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.typesense.api.Client; +import org.typesense.api.Configuration; +import org.typesense.resources.Node; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +/** + * @author Pablo Sanchidrian Herrera + */ +@AutoConfiguration +@ConditionalOnClass({ TypesenseVectorStore.class, EmbeddingModel.class }) +@EnableConfigurationProperties({ TypesenseServiceClientProperties.class, TypesenseVectorStoreProperties.class }) +public class TypesenseVectorStoreAutoConfiguration { + + @Bean + @ConditionalOnMissingBean(TypesenseConnectionDetails.class) + TypesenseVectorStoreAutoConfiguration.PropertiesTypesenseConnectionDetails typesenseServiceClientConnectionDetails( + TypesenseServiceClientProperties properties) { + return new TypesenseVectorStoreAutoConfiguration.PropertiesTypesenseConnectionDetails(properties); + } + + @Bean + @ConditionalOnMissingBean + public VectorStore vectorStore(Client typesenseClient, EmbeddingModel embeddingClient, + TypesenseVectorStoreProperties properties) { + + TypesenseVectorStoreConfig config = TypesenseVectorStoreConfig.builder() + .withCollectionName(properties.getCollectionName()) + .withEmbeddingDimension(properties.getEmbeddingDimension()) + .build(); + + return new TypesenseVectorStore(typesenseClient, embeddingClient, config); + } + + @Bean + @ConditionalOnMissingBean + public Client typesenseClient(TypesenseServiceClientProperties clientProperties, + TypesenseConnectionDetails connectionDetails) { + List nodes = new ArrayList<>(); + nodes.add(new Node(clientProperties.getProtocol(), clientProperties.getHost(), clientProperties.getPort())); + + Configuration configuration = new Configuration(nodes, Duration.ofSeconds(5), clientProperties.getApiKey()); + return new Client(configuration); + } + + private static class PropertiesTypesenseConnectionDetails implements TypesenseConnectionDetails { + + private final TypesenseServiceClientProperties properties; + + PropertiesTypesenseConnectionDetails(TypesenseServiceClientProperties properties) { + this.properties = properties; + } + + @Override + public String getProtocol() { + return this.properties.getProtocol(); + } + + @Override + public String getHost() { + return this.properties.getHost(); + } + + @Override + public String getPort() { + return this.properties.getPort(); + } + + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/typesense/TypesenseVectorStoreProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/typesense/TypesenseVectorStoreProperties.java new file mode 100644 index 000000000..006c071c4 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/typesense/TypesenseVectorStoreProperties.java @@ -0,0 +1,40 @@ +package org.springframework.ai.autoconfigure.vectorstore.typesense; + +import org.springframework.ai.vectorstore.TypesenseVectorStore; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * @author Pablo Sanchidrian Herrera + */ +@ConfigurationProperties(TypesenseVectorStoreProperties.CONFIG_PREFIX) +public class TypesenseVectorStoreProperties { + + public static final String CONFIG_PREFIX = "spring.ai.vectorstore.typesense"; + + /** + * Typesense collection name to store the vectors. + */ + private String collectionName = TypesenseVectorStore.DEFAULT_COLLECTION_NAME; + + /** + * The dimension of the vectors to be stored in the Typesense collection. + */ + private int embeddingDimension = TypesenseVectorStore.OPENAI_EMBEDDING_DIMENSION_SIZE; + + public String getCollectionName() { + return collectionName; + } + + public void setCollectionName(String collectionName) { + this.collectionName = collectionName; + } + + public int getEmbeddingDimension() { + return embeddingDimension; + } + + public void setEmbeddingDimension(int embeddingDimension) { + this.embeddingDimension = embeddingDimension; + } + +} 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 d39b769ec..0666cd0e6 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 @@ -35,3 +35,4 @@ org.springframework.ai.autoconfigure.vectorstore.elasticsearch.ElasticsearchVect org.springframework.ai.autoconfigure.vectorstore.cassandra.CassandraVectorStoreAutoConfiguration org.springframework.ai.autoconfigure.zhipuai.ZhiPuAiAutoConfiguration org.springframework.ai.autoconfigure.chat.client.ChatClientAutoConfiguration +org.springframework.ai.autoconfigure.vectorstore.typesense.TypesenseVectorStoreAutoConfiguration diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/typesense/TypesenseVectorStoreAutoConfigurationIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/typesense/TypesenseVectorStoreAutoConfigurationIT.java new file mode 100644 index 000000000..b6c937f96 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/typesense/TypesenseVectorStoreAutoConfigurationIT.java @@ -0,0 +1,107 @@ +package org.springframework.ai.autoconfigure.vectorstore.typesense; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.ai.ResourceUtils; +import org.springframework.ai.document.Document; +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.transformers.TransformersEmbeddingModel; +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 org.testcontainers.containers.BindMode; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.io.File; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Pablo Sanchidrian Herrera + */ +@Testcontainers +public class TypesenseVectorStoreAutoConfigurationIT { + + private static GenericContainer typesenseContainer; + + private static final File TEMP_FOLDER = new File("target/test-" + UUID.randomUUID().toString()); + + List documents = List.of( + new Document(ResourceUtils.getText("classpath:/test/data/spring.ai.txt"), Map.of("spring", "great")), + new Document(ResourceUtils.getText("classpath:/test/data/time.shelter.txt")), new Document( + ResourceUtils.getText("classpath:/test/data/great.depression.txt"), Map.of("depression", "bad"))); + + @BeforeAll + public static void beforeAll() { + FileSystemUtils.deleteRecursively(TEMP_FOLDER); + TEMP_FOLDER.mkdirs(); + + typesenseContainer = new GenericContainer<>("typesense/typesense:26.0").withExposedPorts(8108) + .withCommand("--data-dir", "/data", "--api-key=xyz", "--enable-cors") + .withFileSystemBind(TEMP_FOLDER.getAbsolutePath(), "/data", BindMode.READ_WRITE) + .withStartupTimeout(Duration.ofSeconds(100)); + + typesenseContainer.start(); + } + + @AfterAll + public static void afterAll() { + typesenseContainer.stop(); + FileSystemUtils.deleteRecursively(TEMP_FOLDER); + } + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(TypesenseVectorStoreAutoConfiguration.class)) + .withUserConfiguration(Config.class); + + @Test + public void addAndSearch() { + contextRunner + .withPropertyValues("spring.ai.vectorstore.typesense.embeddingDimension=384", + "spring.ai.vectorstore.typesense.collectionName=myTestCollection", + "spring.ai.vectorstore.typesense.client.apiKey=xyz", + "spring.ai.vectorstore.typesense.client.protocol=http", + "spring.ai.vectorstore.typesense.client.host=" + typesenseContainer.getHost(), + "spring.ai.vectorstore.typesense.client.port=" + typesenseContainer.getMappedPort(8108).toString()) + .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"); + + 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 EmbeddingModel embeddingClient() { + return new TransformersEmbeddingModel(); + } + + } + +} diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-typesense-store/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-typesense-store/pom.xml new file mode 100644 index 000000000..de5170aa8 --- /dev/null +++ b/spring-ai-spring-boot-starters/spring-ai-starter-typesense-store/pom.xml @@ -0,0 +1,44 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../pom.xml + + spring-ai-typesense-store-spring-boot-starter + jar + Spring AI Starter - Typesense + Spring AI Typesense Auto Configuration + https://github.com/spring-projects/spring-ai + + + https://github.com/spring-projects/spring-ai + git://github.com/spring-projects/spring-ai.git + git@github.com:spring-projects/spring-ai.git + + + + + + org.springframework.boot + spring-boot-starter + + + + org.springframework.ai + spring-ai-spring-boot-autoconfigure + ${project.parent.version} + + + + org.springframework.ai + spring-ai-typesense-store + ${project.parent.version} + + + + \ No newline at end of file diff --git a/vector-stores/spring-ai-typesense-store/pom.xml b/vector-stores/spring-ai-typesense-store/pom.xml new file mode 100644 index 000000000..df2484c19 --- /dev/null +++ b/vector-stores/spring-ai-typesense-store/pom.xml @@ -0,0 +1,67 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../pom.xml + + + spring-ai-typesense-store + jar + Spring AI Typesense Vector Store + Spring AI Typesense Vector Store + https://github.com/spring-projects/spring-ai + + + https://github.com/spring-projects/spring-ai + git://github.com/spring-projects/spring-ai.git + git@github.com:spring-projects/spring-ai.git + + + + + org.springframework.ai + spring-ai-core + ${parent.version} + + + + org.typesense + typesense-java + ${typesense.version} + + + + + org.springframework.ai + spring-ai-test + ${parent.version} + test + + + + org.springframework.ai + spring-ai-transformers + ${parent.version} + test + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.testcontainers + junit-jupiter + test + + + + + \ No newline at end of file diff --git a/vector-stores/spring-ai-typesense-store/src/main/java/org/springframework/ai/vectorstore/TypesenseFilterExpressionConverter.java b/vector-stores/spring-ai-typesense-store/src/main/java/org/springframework/ai/vectorstore/TypesenseFilterExpressionConverter.java new file mode 100644 index 000000000..0f19340d8 --- /dev/null +++ b/vector-stores/spring-ai-typesense-store/src/main/java/org/springframework/ai/vectorstore/TypesenseFilterExpressionConverter.java @@ -0,0 +1,60 @@ +package org.springframework.ai.vectorstore; + +import org.springframework.ai.vectorstore.filter.Filter; +import org.springframework.ai.vectorstore.filter.converter.AbstractFilterExpressionConverter; + +/** + * Converts {@link Filter.Expression} into Typesense metadata filter expression format. + * (https://typesense.org/docs/0.24.0/api/search.html#filter-parameters) + * + * @author Pablo Sanchidrian + */ +public class TypesenseFilterExpressionConverter extends AbstractFilterExpressionConverter { + + @Override + protected void doExpression(Filter.Expression exp, StringBuilder context) { + this.convertOperand(exp.left(), context); + context.append(getOperationSymbol(exp)); + this.convertOperand(exp.right(), context); + } + + private String getOperationSymbol(Filter.Expression exp) { + switch (exp.type()) { + case AND: + return " && "; + case OR: + return " || "; + case EQ: + return " "; // in typesense "EQ" operator looks like -> country:USA + case NE: + return " != "; + case LT: + return " < "; + case LTE: + return " <= "; + case GT: + return " > "; + case GTE: + return " >= "; + case IN: + return " "; // in typesense "IN" operator looks like -> country: [USA, UK] + case NIN: + return " != "; // in typesense "NIN" operator looks like -> country: + // !=[USA, UK] + default: + throw new RuntimeException("Not supported expression type:" + exp.type()); + } + } + + @Override + protected void doGroup(Filter.Group group, StringBuilder context) { + this.convertOperand(new Filter.Expression(Filter.ExpressionType.AND, group.content(), group.content()), + context); // trick + } + + @Override + protected void doKey(Filter.Key key, StringBuilder context) { + context.append("metadata." + key.key() + ":"); + } + +} \ No newline at end of file diff --git a/vector-stores/spring-ai-typesense-store/src/main/java/org/springframework/ai/vectorstore/TypesenseVectorStore.java b/vector-stores/spring-ai-typesense-store/src/main/java/org/springframework/ai/vectorstore/TypesenseVectorStore.java new file mode 100644 index 000000000..45dd233eb --- /dev/null +++ b/vector-stores/spring-ai-typesense-store/src/main/java/org/springframework/ai/vectorstore/TypesenseVectorStore.java @@ -0,0 +1,337 @@ +package org.springframework.ai.vectorstore; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.document.Document; +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.vectorstore.filter.FilterExpressionConverter; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.util.Assert; +import org.typesense.api.Client; +import org.typesense.api.FieldTypes; +import org.typesense.model.CollectionResponse; +import org.typesense.model.CollectionSchema; +import org.typesense.model.DeleteDocumentsParameters; +import org.typesense.model.Field; +import org.typesense.model.ImportDocumentsParameters; +import org.typesense.model.MultiSearchCollectionParameters; +import org.typesense.model.MultiSearchResult; +import org.typesense.model.MultiSearchSearchesParameter; + +/** + * @author Pablo Sanchidrian Herrera + */ +public class TypesenseVectorStore implements VectorStore, InitializingBean { + + private static final Logger logger = LoggerFactory.getLogger(TypesenseVectorStore.class); + + /** + * The name of the field that contains the document ID. It is mandatory to set "id" as + * the field name because that is the name that typesense is going to look for. + */ + public static final String DOC_ID_FIELD_NAME = "id"; + + public static final String CONTENT_FIELD_NAME = "content"; + + public static final String METADATA_FIELD_NAME = "metadata"; + + public static final String EMBEDDING_FIELD_NAME = "embedding"; + + public static final int OPENAI_EMBEDDING_DIMENSION_SIZE = 1536; + + public static final String DEFAULT_COLLECTION_NAME = "vector_store"; + + public static final int INVALID_EMBEDDING_DIMENSION = -1; + + private final Client client; + + private final EmbeddingModel embeddingClient; + + private final TypesenseVectorStoreConfig config; + + public final FilterExpressionConverter filterExpressionConverter = new TypesenseFilterExpressionConverter(); + + public static class TypesenseVectorStoreConfig { + + private final String collectionName; + + private final int embeddingDimension; + + public TypesenseVectorStoreConfig(String collectionName, int embeddingDimension) { + this.collectionName = collectionName; + this.embeddingDimension = embeddingDimension; + } + + /** + * {@return the default config} + */ + public static TypesenseVectorStoreConfig defaultConfig() { + return builder().build(); + } + + private TypesenseVectorStoreConfig(Builder builder) { + this.collectionName = builder.collectionName; + this.embeddingDimension = builder.embeddingDimension; + } + + /** + * Start building a new configuration. + * @return The entry point for creating a new configuration. + */ + public static Builder builder() { + + return new Builder(); + } + + public static class Builder { + + private String collectionName; + + private int embeddingDimension; + + /** + * Set the collection name. + * @param collectionName The collection name. + * @return The builder. + */ + public Builder withCollectionName(String collectionName) { + this.collectionName = collectionName; + return this; + } + + /** + * Set the embedding dimension. + * @param embeddingDimension The embedding dimension. + * @return The builder. + */ + public Builder withEmbeddingDimension(int embeddingDimension) { + this.embeddingDimension = embeddingDimension; + return this; + } + + /** + * Build the configuration. + * @return The configuration. + */ + public TypesenseVectorStoreConfig build() { + return new TypesenseVectorStoreConfig(this); + } + + } + + } + + public TypesenseVectorStore(Client client, EmbeddingModel embeddingClient) { + this(client, embeddingClient, TypesenseVectorStoreConfig.defaultConfig()); + } + + public TypesenseVectorStore(Client client, EmbeddingModel embeddingClient, TypesenseVectorStoreConfig config) { + Assert.notNull(client, "Typesense must not be null"); + Assert.notNull(embeddingClient, "EmbeddingClient must not be null"); + + this.client = client; + this.embeddingClient = embeddingClient; + this.config = config; + } + + @Override + public void add(List documents) { + Assert.notNull(documents, "Documents must not be null"); + + List> documentList = documents.stream().map(document -> { + HashMap typesenseDoc = new HashMap<>(); + typesenseDoc.put(DOC_ID_FIELD_NAME, document.getId()); + typesenseDoc.put(CONTENT_FIELD_NAME, document.getContent()); + typesenseDoc.put(METADATA_FIELD_NAME, document.getMetadata()); + List embedding = this.embeddingClient.embed(document.getContent()); + typesenseDoc.put(EMBEDDING_FIELD_NAME, embedding); + + return typesenseDoc; + }).toList(); + + ImportDocumentsParameters importDocumentsParameters = new ImportDocumentsParameters(); + importDocumentsParameters.action("upsert"); + + try { + this.client.collections(this.config.collectionName) + .documents() + .import_(documentList, importDocumentsParameters); + + logger.info("Added {} documents", documentList.size()); + } + catch (Exception e) { + logger.error("Failed to add documents", e); + } + } + + @Override + public Optional delete(List idList) { + DeleteDocumentsParameters deleteDocumentsParameters = new DeleteDocumentsParameters(); + deleteDocumentsParameters.filterBy(DOC_ID_FIELD_NAME + ":=[" + String.join(",", idList) + "]"); + + try { + int deletedDocs = (Integer) this.client.collections(this.config.collectionName) + .documents() + .delete(deleteDocumentsParameters) + .getOrDefault("num_deleted", 0); + + if (deletedDocs < idList.size()) { + logger.warn("Failed to delete all documents"); + } + + return Optional.of(deletedDocs > 0); + } + catch (Exception e) { + logger.error("Failed to delete documents", e); + return Optional.of(Boolean.FALSE); + } + } + + @Override + public List similaritySearch(SearchRequest request) { + Assert.notNull(request.getQuery(), "Query string must not be null"); + + String nativeFilterExpressions = (request.getFilterExpression() != null) + ? this.filterExpressionConverter.convertExpression(request.getFilterExpression()) : ""; + + logger.info("Filter expression: {}", nativeFilterExpressions); + + List embedding = this.embeddingClient.embed(request.getQuery()); + + MultiSearchCollectionParameters multiSearchCollectionParameters = new MultiSearchCollectionParameters(); + multiSearchCollectionParameters.collection(this.config.collectionName); + multiSearchCollectionParameters.q("*"); + + // typesnese uses only cosine similarity + String vectorQuery = EMBEDDING_FIELD_NAME + ":(" + "[" + + String.join(",", embedding.stream().map(String::valueOf).toList()) + "], " + "k: " + request.getTopK() + + ", " + "distance_threshold: " + (1 - request.getSimilarityThreshold()) + ")"; + + multiSearchCollectionParameters.vectorQuery(vectorQuery); + multiSearchCollectionParameters.filterBy(nativeFilterExpressions); + + MultiSearchSearchesParameter multiSearchesParameter = new MultiSearchSearchesParameter() + .addSearchesItem(multiSearchCollectionParameters); + + try { + MultiSearchResult result = this.client.multiSearch.perform(multiSearchesParameter, + Map.of("query_by", EMBEDDING_FIELD_NAME)); + + List documents = result.getResults() + .stream() + .flatMap(searchResult -> searchResult.getHits().stream().map(hit -> { + Map rawDocument = hit.getDocument(); + String docId = rawDocument.get(DOC_ID_FIELD_NAME).toString(); + String content = rawDocument.get(CONTENT_FIELD_NAME).toString(); + Map metadata = rawDocument.get(METADATA_FIELD_NAME) instanceof Map + ? (Map) rawDocument.get(METADATA_FIELD_NAME) : Map.of(); + metadata.put("distance", hit.getVectorDistance()); + return new Document(docId, content, metadata); + })) + .toList(); + + logger.info("Found {} documents", documents.size()); + return documents; + } + catch (Exception e) { + logger.error("Failed to search documents", e); + return List.of(); + } + } + + int embeddingDimensions() { + if (this.config.embeddingDimension != INVALID_EMBEDDING_DIMENSION) { + return this.config.embeddingDimension; + } + try { + int embeddingDimensions = this.embeddingClient.dimensions(); + if (embeddingDimensions > 0) { + return embeddingDimensions; + } + } + catch (Exception e) { + logger.warn("Failed to obtain the embedding dimensions from the embedding client and fall backs to default:" + + this.config.embeddingDimension, e); + } + return OPENAI_EMBEDDING_DIMENSION_SIZE; + } + + // --------------------------------------------------------------------------------- + // Initialization + // --------------------------------------------------------------------------------- + @Override + public void afterPropertiesSet() throws Exception { + this.createCollection(); + } + + private boolean hasCollection() { + try { + this.client.collections(this.config.collectionName).retrieve(); + return true; + } + catch (Exception e) { + return false; + } + } + + void createCollection() { + if (this.hasCollection()) { + logger.info("Collection {} already exists", this.config.collectionName); + return; + } + + CollectionSchema collectionSchema = new CollectionSchema(); + + collectionSchema.name(this.config.collectionName) + .addFieldsItem(new Field().name(DOC_ID_FIELD_NAME).type(FieldTypes.STRING).optional(false)) + .addFieldsItem(new Field().name(CONTENT_FIELD_NAME).type(FieldTypes.STRING).optional(false)) + .addFieldsItem(new Field().name(METADATA_FIELD_NAME).type(FieldTypes.OBJECT).optional(true)) + .addFieldsItem(new Field().name(EMBEDDING_FIELD_NAME) + .type(FieldTypes.FLOAT_ARRAY) + .numDim(this.embeddingDimensions()) + .optional(false)) + .enableNestedFields(true); + + try { + this.client.collections().create(collectionSchema); + logger.info("Collection {} created", this.config.collectionName); + } + catch (Exception e) { + logger.error("Failed to create collection {}", this.config.collectionName, e); + } + } + + void dropCollection() { + if (!this.hasCollection()) { + logger.info("Collection {} does not exist", this.config.collectionName); + return; + } + + try { + this.client.collections(this.config.collectionName).delete(); + logger.info("Collection {} dropped", this.config.collectionName); + } + catch (Exception e) { + logger.error("Failed to drop collection {}", this.config.collectionName, e); + } + } + + Map getCollectionInfo() { + try { + CollectionResponse retrievedCollection = this.client.collections(this.config.collectionName).retrieve(); + return Map.of("name", retrievedCollection.getName(), "num_documents", + retrievedCollection.getNumDocuments()); + } + catch (Exception e) { + logger.error("Failed to retrieve collection info", e); + return null; + } + + } + +} diff --git a/vector-stores/spring-ai-typesense-store/src/test/java/org/springframework/ai/vectorstore/TypesenseVectorStoreIT.java b/vector-stores/spring-ai-typesense-store/src/test/java/org/springframework/ai/vectorstore/TypesenseVectorStoreIT.java new file mode 100644 index 000000000..952458ea6 --- /dev/null +++ b/vector-stores/spring-ai-typesense-store/src/test/java/org/springframework/ai/vectorstore/TypesenseVectorStoreIT.java @@ -0,0 +1,282 @@ +package org.springframework.ai.vectorstore; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; +import org.springframework.ai.document.Document; +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.transformers.TransformersEmbeddingModel; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.core.io.DefaultResourceLoader; +import org.testcontainers.containers.BindMode; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.typesense.api.Client; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.*; + +import org.springframework.ai.vectorstore.TypesenseVectorStore.TypesenseVectorStoreConfig; +import org.typesense.api.Configuration; +import org.typesense.resources.Node; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Pablo Sanchidrian Herrera + */ +@Testcontainers +public class TypesenseVectorStoreIT { + + private static Path tempDirectory; + + static { + try { + tempDirectory = Files.createTempDirectory("typesense-test"); + } + catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Container + private static GenericContainer typesenseContainer = new GenericContainer<>("typesense/typesense:26.0") + .withExposedPorts(8108) + .withCommand("--data-dir", "/data", "--api-key=xyz", "--enable-cors") + .withFileSystemBind(tempDirectory.toString(), "/data", BindMode.READ_WRITE); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withUserConfiguration(TestApplication.class); + + List documents = List.of( + 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 void resetCollection(VectorStore vectorStore) { + ((TypesenseVectorStore) vectorStore).dropCollection(); + ((TypesenseVectorStore) vectorStore).createCollection(); + } + + @Test + void documentUpdate() { + contextRunner.run(context -> { + + VectorStore vectorStore = context.getBean(VectorStore.class); + + resetCollection(vectorStore); + + Document document = new Document(UUID.randomUUID().toString(), "Spring AI rocks!!", + Collections.singletonMap("meta1", "meta1")); + + vectorStore.add(List.of(document)); + + Map info = ((TypesenseVectorStore) vectorStore).getCollectionInfo(); + assertThat(info.get("num_documents")).isEqualTo(1L); + + 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"); + + 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)); + + info = ((TypesenseVectorStore) vectorStore).getCollectionInfo(); + assertThat(info.get("num_documents")).isEqualTo(1L); + + 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"); + + vectorStore.delete(List.of(document.getId())); + + info = ((TypesenseVectorStore) vectorStore).getCollectionInfo(); + assertThat(info.get("num_documents")).isEqualTo(0L); + + }); + } + + @Test + void addAndSearch() { + + contextRunner.run(context -> { + VectorStore vectorStore = context.getBean(VectorStore.class); + + resetCollection(vectorStore); + + vectorStore.add(documents); + + Map info = ((TypesenseVectorStore) vectorStore).getCollectionInfo(); + + assertThat(info.get("num_documents")).isEqualTo(3L); + + List results = vectorStore.similaritySearch(SearchRequest.query("Spring")); + + assertThat(results).hasSize(3); + }); + } + + @Test + void searchWithFilters() { + + contextRunner.run(context -> { + VectorStore vectorStore = context.getBean(VectorStore.class); + + resetCollection(vectorStore); + + 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)); + + List results = vectorStore.similaritySearch(SearchRequest.query("The World").withTopK(5)); + assertThat(results).hasSize(3); + + results = vectorStore.similaritySearch(SearchRequest.query("The World") + .withTopK(5) + .withSimilarityThresholdAll() + .withFilterExpression("country == 'NL'")); + assertThat(results).hasSize(1); + assertThat(results.get(0).getId()).isEqualTo(nlDocument.getId()); + + results = vectorStore.similaritySearch(SearchRequest.query("The World") + .withTopK(5) + .withSimilarityThresholdAll() + .withFilterExpression("country in ['BG']")); + + assertThat(results).hasSize(2); + assertThat(results.get(0).getId()).isIn(bgDocument.getId(), bgDocument2.getId()); + assertThat(results.get(1).getId()).isIn(bgDocument.getId(), bgDocument2.getId()); + + results = vectorStore.similaritySearch(SearchRequest.query("The World") + .withTopK(5) + .withSimilarityThresholdAll() + .withFilterExpression("country == 'BG' && year == 2020")); + + assertThat(results).hasSize(1); + assertThat(results.get(0).getId()).isEqualTo(bgDocument.getId()); + + results = vectorStore.similaritySearch(SearchRequest.query("The World") + .withTopK(5) + .withSimilarityThresholdAll() + .withFilterExpression("NOT(country == 'BG' && year == 2020)")); + + assertThat(results).hasSize(2); + assertThat(results.get(0).getId()).isIn(nlDocument.getId(), bgDocument2.getId()); + assertThat(results.get(1).getId()).isIn(nlDocument.getId(), bgDocument2.getId()); + + }); + } + + @Test + void searchWithThreshold() { + + contextRunner.run(context -> { + + VectorStore vectorStore = context.getBean(VectorStore.class); + + resetCollection(vectorStore); + + vectorStore.add(documents); + + List fullResult = vectorStore + .similaritySearch(SearchRequest.query("Spring").withTopK(5).withSimilarityThresholdAll()); + + List distances = fullResult.stream().map(doc -> (Float) doc.getMetadata().get("distance")).toList(); + + assertThat(distances).hasSize(3); + + float threshold = (distances.get(0) + distances.get(1)) / 2; + + List results = vectorStore + .similaritySearch(SearchRequest.query("Spring").withTopK(5).withSimilarityThreshold(1 - threshold)); + + 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 + @EnableAutoConfiguration(exclude = { DataSourceAutoConfiguration.class }) + public static class TestApplication { + + @Bean + public VectorStore vectorStore(Client client, EmbeddingModel embeddingClient) { + + TypesenseVectorStoreConfig config = TypesenseVectorStoreConfig.builder() + .withCollectionName("test_vector_store") + .withEmbeddingDimension(embeddingClient.dimensions()) + .build(); + + return new TypesenseVectorStore(client, embeddingClient, config); + } + + @Bean + public Client typesenseClient() { + List nodes = new ArrayList<>(); + nodes + .add(new Node("http", typesenseContainer.getHost(), typesenseContainer.getMappedPort(8108).toString())); + + Configuration configuration = new Configuration(nodes, Duration.ofSeconds(5), "xyz"); + return new Client(configuration); + } + + @Bean + public EmbeddingModel embeddingClient() { + return new TransformersEmbeddingModel(); + } + + } + + @AfterAll + static void deleteContainer() { + if (typesenseContainer != null) { + typesenseContainer.stop(); + } + + if (tempDirectory != null) { + tempDirectory.toFile().delete(); + } + } + +}