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.
This commit is contained in:
PabloSanchi
2024-04-06 19:06:35 +02:00
committed by Christian Tzolov
parent 59a628cc02
commit 3e2ed8b9bf
17 changed files with 1367 additions and 4 deletions

10
pom.xml
View File

@@ -30,6 +30,7 @@
<module>vector-stores/spring-ai-cassandra-store</module>
<module>vector-stores/spring-ai-chroma-store</module>
<module>vector-stores/spring-ai-elasticsearch-store</module>
<module>vector-stores/spring-ai-gemfire-store</module>
<module>vector-stores/spring-ai-hanadb-store</module>
<module>vector-stores/spring-ai-milvus-store</module>
@@ -40,8 +41,9 @@
<module>vector-stores/spring-ai-pinecone-store</module>
<module>vector-stores/spring-ai-qdrant-store</module>
<module>vector-stores/spring-ai-redis-store</module>
<module>vector-stores/spring-ai-weaviate-store</module>
<module>vector-stores/spring-ai-typesense-store</module>
<module>vector-stores/spring-ai-weaviate-store</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-azure-store</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-cassandra-store</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-chroma-store</module>
@@ -55,8 +57,8 @@
<module>spring-ai-spring-boot-starters/spring-ai-starter-pinecone-store</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-qdrant-store</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-redis-store</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-typesense-store</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-weaviate-store</module>
<module>models/spring-ai-anthropic</module>
<module>models/spring-ai-azure-openai</module>
<module>models/spring-ai-bedrock</module>
@@ -72,7 +74,6 @@
<module>models/spring-ai-vertex-ai-palm2</module>
<module>models/spring-ai-watsonx-ai</module>
<module>models/spring-ai-zhipuai</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-anthropic</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-azure-openai</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-bedrock-ai</module>
@@ -88,7 +89,7 @@
<module>spring-ai-spring-boot-starters/spring-ai-starter-vertex-ai-palm2</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-watsonx-ai</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-zhipuai</module>
</modules>
</modules>
<organization>
<name>VMware Inc.</name>
@@ -167,6 +168,7 @@
<azure-search.version>11.6.1</azure-search.version>
<weaviate-client.version>4.5.1</weaviate-client.version>
<qdrant.version>1.7.1</qdrant.version>
<typesense.version>0.5.0</typesense.version>
<!-- documentation dependencies -->
<io.spring.maven.antora-version>0.0.4</io.spring.maven.antora-version>

View File

@@ -332,6 +332,12 @@
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-typesense-store</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pinecone-store-spring-boot-starter</artifactId>
@@ -397,12 +403,19 @@
<artifactId>spring-ai-mongodb-atlas-store-spring-boot-starter</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-anthropic-spring-boot-starter</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-typesense-store-spring-boot-starter</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-spring-boot-testcontainers</artifactId>

View File

@@ -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[]

View File

@@ -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.

View File

@@ -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]
----
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-typesense-spring-boot-starter</artifactId>
</dependency>
----
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 <Document> 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<Document> 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]
----
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-typesense</artifactId>
</dependency>
----
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<Node> 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<Document> 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<Document> 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).
====

View File

@@ -296,6 +296,7 @@
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-minimax</artifactId>
@@ -310,6 +311,14 @@
<optional>true</optional>
</dependency>
<!-- Typesense vector store -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-typesense-store</artifactId>
<version>${project.parent.version}</version>
<optional>true</optional>
</dependency>
<!-- test dependencies -->
<dependency>

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

View File

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

View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<artifactId>spring-ai-typesense-store-spring-boot-starter</artifactId>
<packaging>jar</packaging>
<name>Spring AI Starter - Typesense</name>
<description>Spring AI Typesense Auto Configuration</description>
<url>https://github.com/spring-projects/spring-ai</url>
<scm>
<url>https://github.com/spring-projects/spring-ai</url>
<connection>git://github.com/spring-projects/spring-ai.git</connection>
<developerConnection>git@github.com:spring-projects/spring-ai.git</developerConnection>
</scm>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-spring-boot-autoconfigure</artifactId>
<version>${project.parent.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-typesense-store</artifactId>
<version>${project.parent.version}</version>
</dependency>
</dependencies>
</project>

View File

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

View File

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

View File

@@ -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<Document> documents) {
Assert.notNull(documents, "Documents must not be null");
List<HashMap<String, Object>> documentList = documents.stream().map(document -> {
HashMap<String, Object> 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<Double> 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<Boolean> delete(List<String> 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<Document> 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<Double> 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<Document> documents = result.getResults()
.stream()
.flatMap(searchResult -> searchResult.getHits().stream().map(hit -> {
Map<String, Object> rawDocument = hit.getDocument();
String docId = rawDocument.get(DOC_ID_FIELD_NAME).toString();
String content = rawDocument.get(CONTENT_FIELD_NAME).toString();
Map<String, Object> metadata = rawDocument.get(METADATA_FIELD_NAME) instanceof Map
? (Map<String, Object>) 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<String, Object> 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;
}
}
}

View File

@@ -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<Document> 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<String, Object> info = ((TypesenseVectorStore) vectorStore).getCollectionInfo();
assertThat(info.get("num_documents")).isEqualTo(1L);
List<Document> results = vectorStore.similaritySearch(SearchRequest.query("Spring").withTopK(5));
assertThat(results).hasSize(1);
Document resultDoc = results.get(0);
assertThat(resultDoc.getId()).isEqualTo(document.getId());
assertThat(resultDoc.getContent()).isEqualTo("Spring AI rocks!!");
assertThat(resultDoc.getMetadata()).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<String, Object> info = ((TypesenseVectorStore) vectorStore).getCollectionInfo();
assertThat(info.get("num_documents")).isEqualTo(3L);
List<Document> 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<Document> results = vectorStore.similaritySearch(SearchRequest.query("The World").withTopK(5));
assertThat(results).hasSize(3);
results = vectorStore.similaritySearch(SearchRequest.query("The World")
.withTopK(5)
.withSimilarityThresholdAll()
.withFilterExpression("country == 'NL'"));
assertThat(results).hasSize(1);
assertThat(results.get(0).getId()).isEqualTo(nlDocument.getId());
results = vectorStore.similaritySearch(SearchRequest.query("The World")
.withTopK(5)
.withSimilarityThresholdAll()
.withFilterExpression("country 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<Document> fullResult = vectorStore
.similaritySearch(SearchRequest.query("Spring").withTopK(5).withSimilarityThresholdAll());
List<Float> 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<Document> 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<Node> 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();
}
}
}