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:
committed by
Christian Tzolov
parent
59a628cc02
commit
3e2ed8b9bf
10
pom.xml
10
pom.xml
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[]
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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).
|
||||
====
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
67
vector-stores/spring-ai-typesense-store/pom.xml
Normal file
67
vector-stores/spring-ai-typesense-store/pom.xml
Normal 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>
|
||||
@@ -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() + ":");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user