Implement Qdrant vector store

- Implement QdrantVectorStore.
   Uses a custom parser for converting Spring AI metadata(Map<String, Object>) to Qdrant GRPC payload.
 - Implement Qdrant Expression Filter support.
   Uses a custom parser for converting Spring AI filters to Qdrant-compatible GRPC filters.
 - Add ITs using testcontainers.
 - Add antora docs adrant.adoc.
 - Add Qdrant vector store auto-configuraton and boot starter.

Additional (review) change:

 - Fix poms parent to 0.8.1-SNAPSHOT.
 - Rename ObjectFactory into QdrantObjectFactor.
 - Rename ValueFactory into QdrantValueFactory.
 - Move the org.springframework.ai.vectorstore package into org.springframework.ai.vectorstore.qdrant.
 - Add missing Autoconfigure definition.
 - Add missing license and JavaDocs.
 - Minor code style improvmentes.
 - Move the qdrant version to the main pom
 - Add QdrantVectorStoreAutoConfigurationIT
 - Remove guava dependency
 - Improve gdrant.adoc conent and structure.
 - Remove the grpc-protobuf dependency

Resolves #331
This commit is contained in:
Anush008
2024-02-22 10:30:04 +05:30
committed by Christian Tzolov
parent e1462b86e3
commit ea0b439dac
21 changed files with 1686 additions and 3 deletions

View File

@@ -162,7 +162,7 @@ Though the `DocumentWriter` interface isn't exclusively for Vector Database writ
**Vector Stores:** Vector Databases are instrumental in incorporating your data with AI models.
They ascertain which document sections the AI should use for generating responses.
Examples of Vector Databases include Chroma, Postgres, Pinecone, Weaviate, Mongo Atlas, and Redis. Spring AI's `VectorStore` abstraction permits effortless transitions between database implementations.
Examples of Vector Databases include Chroma, Postgres, Pinecone, Qdrant, Weaviate, Mongo Atlas, and Redis. Spring AI's `VectorStore` abstraction permits effortless transitions between database implementations.

View File

@@ -41,6 +41,7 @@
<module>spring-ai-spring-boot-starters/spring-ai-starter-redis</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-stability-ai</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-neo4j-store</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-qdrant-store</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-postgresml-embedding</module>
<module>spring-ai-docs</module>
<module>vector-stores/spring-ai-pgvector-store</module>
@@ -55,6 +56,7 @@
<module>vector-stores/spring-ai-redis</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-vertex-ai-palm2</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-vertex-ai-gemini</module>
<module>vector-stores/spring-ai-qdrant</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-bedrock-ai</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-mistral-ai</module>
</modules>
@@ -112,6 +114,7 @@
<djl.version>0.26.0</djl.version>
<onnxruntime.version>1.17.0</onnxruntime.version>
<com.google.cloud.version>26.33.0</com.google.cloud.version>
<qdrant.version>1.7.1</qdrant.version>
<!-- Protobuf -->
<protobuf-java.version>3.25.2</protobuf-java.version>
@@ -125,6 +128,7 @@
<fastjson.version>2.0.46</fastjson.version>
<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>
<!-- testing dependencies -->
<testcontainers.version>1.19.6</testcontainers.version>

View File

@@ -161,6 +161,12 @@
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-qdrant</artifactId>
<version>${project.version}</version>
</dependency>
<!-- Utilities -->
<dependency>
<groupId>org.springframework.ai</groupId>
@@ -283,6 +289,11 @@
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-qdrant-store-spring-boot-starter</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</dependencyManagement>

View File

@@ -43,6 +43,7 @@
*** xref:api/vectordbs/weaviate.adoc[]
*** xref:api/vectordbs/redis.adoc[]
*** xref:api/vectordbs/pinecone.adoc[]
*** xref:api/vectordbs/qdrant.adoc[]
** xref:api/etl-pipeline.adoc[]
** xref:api/testing.adoc[]
** xref:api/generic-model.adoc[]

View File

@@ -93,6 +93,7 @@ These are the available implementations of the `VectorStore` interface:
* xref:api/vectordbs/neo4j.adoc[Neo4jVectorStore] - The https://neo4j.com/[Neo4j] vector store.
* xref:api/vectordbs/pgvector.adoc[PgVectorStore] - The https://github.com/pgvector/pgvector[PostgreSQL/PGVector] vector store.
* xref:api/vectordbs/pinecone.adoc[PineconeVectorStore] - https://www.pinecone.io/[PineCone] vector store.
* xref:api/vectordbs/qdrant.adoc[QdrantVectorStore] - https://www.qdrant.tech/[Qdrant] vector store.
* xref:api/vectordbs/redis.adoc[RedisVectorStore] - The https://redis.io/[Redis] vector store.
* xref:api/vectordbs/weaviate.adoc[WeaviateVectorStore] - 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,212 @@
= Qdrant
This section walks you through setting up the Qdrant `VectorStore` to store document embeddings and perform similarity searches.
link:https://www.qdrant.tech/[Qdrant] is an open-source, high-performance vector search engine/database.
== Prerequisites
* Qdrant Instance: Set up a Qdrant instance by following the link:https://qdrant.tech/documentation/guides/installation/[installation instructions] in the Qdrant documentation.
* If required, an API key for the xref:api/embeddings.adoc#available-implementations[EmbeddingClient] to generate the embeddings stored by the `QdrantVectorStore`.
== Configuration
To set up `QdrantVectorStore`, you'll need the following information from your Qdrant instance:
* Qdrant Host
* Qdrant GRPC Port
* Qdrant Collection Name
* Optional Qdrant API Key (not required for local development)
[NOTE]
====
A Qdrant collection has to be link:https://qdrant.tech/documentation/concepts/collections/#create-a-collection[created] in advance with the appropriate dimensions and configurations.
For example if using the OpenAI `text-embedding-ada-002` embedding model, create a collection with a vector size of `1536`.
====
== Dependencies
* The Vector Store requires an `EmbeddingClient` instance to calculate embeddings for the documents.
You can pick one of the available xref:api/embeddings.adoc#available-implementations[EmbeddingClient Implementations]. For example ou can use the OpenAI boot starter:
[source,xml]
----
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>
----
or to your Gradle `build.gradle` build file.
[source,groovy]
----
dependencies {
implementation 'org.springframework.ai:spring-ai-openai-spring-boot-starter'
}
----
TIP: Additionally, you'll need to provide your OpenAI API Key. Set it as an environment variable like so:
`export SPRING_AI_OPENAI_API_KEY='Your_OpenAI_API_Key`
* Add the Qdrant Boot Starter dependency to your project:
[source,xml]
----
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-qdrant-store-spring-boot-starter</artifactId>
</dependency>
----
or to your Gradle `build.gradle` build file.
[source,groovy]
----
dependencies {
implementation 'org.springframework.ai:spring-ai-qdrant-store-spring-boot-starter'
}
----
TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file.
Please have a look at the list of xref:#qdrant-vectorstore-properties[configuration parameters] for the vector store to learn about the default values and configuration options.
TIP: Refer to the xref:getting-started.adoc#repositories[Repositories] section to add Milestone and/or Snapshot Repositories to your build file.
Now you can Auto-wire the Qdrant 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 Qdrant
vectorStore.add(List.of(document));
// Retrieve documents similar to a query
List<Document> results = vectorStore.similaritySearch(SearchRequest.query("Spring").withTopK(5));
----
== Configuration
To connect to Qdrant and use the `QdrantVectorStore`, you need to provide access details for your instance.
A simple configuration can either be provided via Spring Boot's _application.properties_,
[source,properties]
----
spring.ai.vectorstore.qdrant.host=<host of your qdrant instance>
spring.ai.vectorstore.qdrant.port=<port of your qdrant instance>
spring.ai.vectorstore.qdrant.api-key=<your api key>
spring.ai.vectorstore.qdrant.collection-name=<The name of the collection to use in Qdrant>
# API key if needed, e.g. OpenAI
spring.ai.openai.api.key=<api-key>
----
== Manual Configuration
Instead of using the Spring Boot auto-configuration, you can manually configure the `QdrantVectorStore`. For this you need to add the `spring-ai-qdrant` dependency to your project:
[source,xml]
----
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-qdrant</artifactId>
</dependency>
----
or to your Gradle `build.gradle` build file.
[source,groovy]
----
dependencies {
implementation 'org.springframework.ai:spring-ai-qdrant'
}
----
To configure Qdrant in your application, you can use the following setup:
[source,java]
----
@Bean
public QdrantVectorStoreConfig qdrantVectorStoreConfig() {
return QdrantVectorStoreConfig.builder()
.withHost("<QDRANT_HOSTNAME>")
.withPort(<QDRANT_GRPC_PORT>)
.withCollectionName("<QDRANT_COLLECTION_NAME>")
.withApiKey("<QDRANT_API_KEY>")
.build();
}
----
Integrate with OpenAI's embeddings by adding the Spring Boot OpenAI starter to your project.
This provides you with an implementation of the Embeddings client:
[source,java]
----
@Bean
public VectorStore vectorStore(QdrantVectorStoreConfig config, EmbeddingClient embeddingClient) {
return new QdrantVectorStore(config, embeddingClient);
}
----
=== Metadata filtering
You can leverage the generic, portable link:https://docs.spring.io/spring-ai/reference/api/vectordbs.html#_metadata_filters[metadata filters] with the Qdrant vector store.
For example, you can use either the text expression language:
[source,java]
----
vectorStore.similaritySearch(
SearchRequest.defaults()
.withQuery("The World")
.withTopK(TOP_K)
.withSimilarityThreshold(SIMILARITY_THRESHOLD)
.withFilterExpression("author in ['john', 'jill'] && article_type == 'blog'"));
----
or programmatically using the `Filter.Expression` DSL:
[source,java]
----
FilterExpressionBuilder b = new FilterExpressionBuilder();
vectorStore.similaritySearch(SearchRequest.defaults()
.withQuery("The World")
.withTopK(TOP_K)
.withSimilarityThreshold(SIMILARITY_THRESHOLD)
.withFilterExpression(b.and(
b.in("john", "jill"),
b.eq("article_type", "blog")).build()));
----
NOTE: These filter expressions are converted into the equivalent Qdrant link:https://qdrant.tech/documentation/concepts/filtering/[filters].
[[qdrant-vectorstore-properties]]
== Qdrant VectorStore properties
You can use the following properties in your Spring Boot configuration to customize the Qdrant vector store.
|===
|Property| Description | Default value
|`spring.ai.vectorstore.qdrant.host`| The host of the Qdrant server. | localhost
|`spring.ai.vectorstore.qdrant.port`| The port of the Qdrant server. | 6334
|`spring.ai.vectorstore.qdrant.api-key`| The API key to use for authentication with the Qdrant server. | -
|`spring.ai.vectorstore.qdrant.collection-name`| The name of the collection to use in Qdrant. | -
|`spring.ai.vectorstore.qdrant.use-tls`| Whether to use TLS(HTTPS). Defaults to false. | false
|===

View File

@@ -164,6 +164,7 @@ Each of the following sections in the documentation shows which dependencies you
** xref:api/vectordbs/neo4j.adoc[Neo4jVectorStore] - The https://neo4j.com/[Neo4j] vector store.
** xref:api/vectordbs/pgvector.adoc[PgVectorStore] - The https://github.com/pgvector/pgvector[PostgreSQL/PGVector] vector store.
** xref:api/vectordbs/pinecone.adoc[PineconeVectorStore] - https://www.pinecone.io/[PineCone] vector store.
** xref:api/vectordbs/qdrant.adoc[QdrantVectorStore] - https://www.qdrant.tech/[Qdrant] vector store.
** xref:api/vectordbs/redis.adoc[RedisVectorStore] - The https://redis.io/[Redis] vector store.
** xref:api/vectordbs/weaviate.adoc[WeaviateVectorStore] - 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 (in-memory) implementation of persistent vector storage, good for educational purposes.

View File

@@ -15,7 +15,7 @@ Spring AI provides the following features:
* Supported Model types are Chat and Text to Image with more on the way.
* Portable API across AI providers for Chat and for Embedding models. Both synchronous and stream API options are supported. Dropping down to access model specific features is also supported.
* Mapping of AI Model output to POJOs.
* Support for all major Vector Database providers such as Azure Vector Search, Chroma, Milvus, Neo4j, PostgreSQL/PGVector, PineCone, Redis, and Weaviate
* Support for all major Vector Database providers such as Azure Vector Search, Chroma, Milvus, Neo4j, PostgreSQL/PGVector, PineCone, Qdrant, Redis, and Weaviate
* Portable API across Vector Store providers, including a novel SQL-like metadata filter API that is also portable.
* Function calling
* Spring Boot Auto Configuration and Starters for AI Models and Vector Stores.

View File

@@ -215,6 +215,14 @@
<optional>true</optional>
</dependency>
<!-- Qdrant Vector Store-->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-qdrant</artifactId>
<version>${project.parent.version}</version>
<optional>true</optional>
</dependency>
<!-- test dependencies -->
<dependency>
@@ -287,6 +295,14 @@
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>qdrant</artifactId>
<version>1.19.6</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,53 @@
/*
* Copyright 2024-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.autoconfigure.vectorstore.qdrant;
import org.springframework.ai.embedding.EmbeddingClient;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.qdrant.QdrantVectorStore;
import org.springframework.ai.vectorstore.qdrant.QdrantVectorStore.QdrantVectorStoreConfig;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
/**
* @author Anush Shetty
* @since 0.8.1
*/
@AutoConfiguration
@ConditionalOnClass({ QdrantVectorStore.class, EmbeddingClient.class })
@EnableConfigurationProperties(QdrantVectorStoreProperties.class)
public class QdrantVectorStoreAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public VectorStore vectorStore(EmbeddingClient embeddingClient, QdrantVectorStoreProperties properties) {
var config = QdrantVectorStoreConfig.builder()
.withCollectionName(properties.getCollectionName())
.withHost(properties.getHost())
.withPort(properties.getPort())
.withTls(properties.useTls())
.withApiKey(properties.getApiKey())
.build();
return new QdrantVectorStore(config, embeddingClient);
}
}

View File

@@ -0,0 +1,95 @@
/*
* Copyright 2024-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.autoconfigure.vectorstore.qdrant;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* @author Anush Shetty
* @since 0.8.1
*/
@ConfigurationProperties(QdrantVectorStoreProperties.CONFIG_PREFIX)
public class QdrantVectorStoreProperties {
public static final String CONFIG_PREFIX = "spring.ai.vectorstore.qdrant";
/**
* The name of the collection to use in Qdrant.
*/
private String collectionName;
/**
* The host of the Qdrant server.
*/
private String host = "localhost";
/**
* The port of the Qdrant server.
*/
private int port = 6334;
/**
* Whether to use TLS(HTTPS). Defaults to false.
*/
private boolean useTls = false;
/**
* The API key to use for authentication with the Qdrant server.
*/
private String apiKey = null;
public String getCollectionName() {
return this.collectionName;
}
public void setCollectionName(String collectionName) {
this.collectionName = collectionName;
}
public String getHost() {
return this.host;
}
public void setHost(String host) {
this.host = host;
}
public int getPort() {
return this.port;
}
public void setPort(int port) {
this.port = port;
}
public boolean useTls() {
return this.useTls;
}
public void setUseTls(boolean useTls) {
this.useTls = useTls;
}
public String getApiKey() {
return this.apiKey;
}
public void setApiKey(String apiKey) {
this.apiKey = apiKey;
}
}

View File

@@ -21,4 +21,4 @@ org.springframework.ai.autoconfigure.vectorstore.chroma.ChromaVectorStoreAutoCon
org.springframework.ai.autoconfigure.vectorstore.azure.AzureVectorStoreAutoConfiguration
org.springframework.ai.autoconfigure.vectorstore.weaviate.WeaviateVectorStoreAutoConfiguration
org.springframework.ai.autoconfigure.vectorstore.neo4j.Neo4jVectorStoreAutoConfiguration
org.springframework.ai.autoconfigure.vectorstore.qdrant.QdrantVectorStoreAutoConfiguration

View File

@@ -0,0 +1,134 @@
/*
* Copyright 2024-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.autoconfigure.vectorstore.qdrant;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import io.qdrant.client.QdrantClient;
import io.qdrant.client.QdrantGrpcClient;
import io.qdrant.client.grpc.Collections.Distance;
import io.qdrant.client.grpc.Collections.VectorParams;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.qdrant.QdrantContainer;
import org.springframework.ai.document.Document;
import org.springframework.ai.embedding.EmbeddingClient;
import org.springframework.ai.transformers.TransformersEmbeddingClient;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.DefaultResourceLoader;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Christian Tzolov
* @since 0.8.1
*/
@Testcontainers
public class QdrantVectorStoreAutoConfigurationIT {
private static final String COLLECTION_NAME = "test_collection";
private static final int EMBEDDING_DIMENSION = 384;
private static final int QDRANT_GRPC_PORT = 6334;
@Container
static QdrantContainer qdrantContainer = new QdrantContainer("qdrant/qdrant:v1.7.4");
List<Document> documents = List.of(
new Document(getText("classpath:/test/data/spring.ai.txt"), Map.of("spring", "great")),
new Document(getText("classpath:/test/data/time.shelter.txt")),
new Document(getText("classpath:/test/data/great.depression.txt"), Map.of("depression", "bad")));
@BeforeAll
static void setup() throws InterruptedException, ExecutionException {
String host = qdrantContainer.getHost();
int port = qdrantContainer.getMappedPort(QDRANT_GRPC_PORT);
QdrantClient client = new QdrantClient(QdrantGrpcClient.newBuilder(host, port, false).build());
client
.createCollectionAsync(COLLECTION_NAME,
VectorParams.newBuilder().setDistance(Distance.Cosine).setSize(EMBEDDING_DIMENSION).build())
.get();
client.close();
}
public static String getText(String uri) {
var resource = new DefaultResourceLoader().getResource(uri);
try {
return resource.getContentAsString(StandardCharsets.UTF_8);
}
catch (IOException e) {
throw new RuntimeException(e);
}
}
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(QdrantVectorStoreAutoConfiguration.class))
.withUserConfiguration(Config.class)
.withPropertyValues("spring.ai.vectorstore.qdrant.port=" + qdrantContainer.getMappedPort(QDRANT_GRPC_PORT),
"spring.ai.vectorstore.qdrant.host=" + qdrantContainer.getHost(),
"spring.ai.vectorstore.qdrant.collectionName=" + COLLECTION_NAME);
@Test
public void addAndSearch() {
contextRunner.run(context -> {
VectorStore vectorStore = context.getBean(VectorStore.class);
vectorStore.add(documents);
List<Document> results = vectorStore
.similaritySearch(SearchRequest.query("What is Great Depression?").withTopK(1));
assertThat(results).hasSize(1);
Document resultDoc = results.get(0);
assertThat(resultDoc.getId()).isEqualTo(documents.get(2).getId());
assertThat(resultDoc.getMetadata()).containsKeys("depression", "distance");
// Remove all documents from the store
vectorStore.delete(documents.stream().map(doc -> doc.getId()).toList());
results = vectorStore.similaritySearch(SearchRequest.query("Great Depression").withTopK(1));
assertThat(results).hasSize(0);
});
}
@Configuration(proxyBeanMethods = false)
static class Config {
@Bean
public EmbeddingClient embeddingClient() {
return new TransformersEmbeddingClient();
}
}
}

View File

@@ -0,0 +1,42 @@
<?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/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai</artifactId>
<version>0.8.1-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<artifactId>spring-ai-qdrant-store-spring-boot-starter</artifactId>
<packaging>jar</packaging>
<name>Spring AI Starter - Qdrant Vector Store</name>
<description>Spring AI Qdrant Vector Store 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-qdrant</artifactId>
<version>${project.parent.version}</version>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1 @@
Qdrant Vector Store

View File

@@ -0,0 +1,87 @@
<?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 https://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>0.8.1-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<artifactId>spring-ai-qdrant</artifactId>
<packaging>jar</packaging>
<name>spring-ai-qdrant</name>
<description>spring-ai-qdrant</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>
<properties>
<maven.compiler.target>17</maven.compiler.target>
<maven.compiler.source>17</maven.compiler.source>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-core</artifactId>
<version>${project.parent.version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
</dependency>
<!-- <dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-protobuf</artifactId>
<version>1.59.0</version>
</dependency> -->
<dependency>
<groupId>io.qdrant</groupId>
<artifactId>client</artifactId>
<version>${qdrant.version}</version>
</dependency>
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java-util</artifactId>
<version>${protobuf-java.version}</version>
</dependency>
<!-- TESTING -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai</artifactId>
<version>${project.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>qdrant</artifactId>
<version>1.19.6</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,261 @@
/*
* Copyright 2024-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.vectorstore.qdrant;
import static io.qdrant.client.ConditionFactory.filter;
import static io.qdrant.client.ConditionFactory.match;
import static io.qdrant.client.ConditionFactory.matchExceptKeywords;
import static io.qdrant.client.ConditionFactory.matchExceptValues;
import static io.qdrant.client.ConditionFactory.matchKeyword;
import static io.qdrant.client.ConditionFactory.matchKeywords;
import static io.qdrant.client.ConditionFactory.matchValues;
import static io.qdrant.client.ConditionFactory.range;
import java.util.ArrayList;
import java.util.List;
import org.springframework.ai.vectorstore.filter.Filter.Expression;
import org.springframework.ai.vectorstore.filter.Filter.ExpressionType;
import org.springframework.ai.vectorstore.filter.Filter.Group;
import org.springframework.ai.vectorstore.filter.Filter.Key;
import org.springframework.ai.vectorstore.filter.Filter.Operand;
import org.springframework.ai.vectorstore.filter.Filter.Value;
import io.qdrant.client.grpc.Points.Condition;
import io.qdrant.client.grpc.Points.Filter;
import io.qdrant.client.grpc.Points.Range;
/**
* @author Anush Shetty
* @since 0.8.1
*/
class QdrantFilterExpressionConverter {
public Filter convertExpression(Expression expression) {
return this.convertOperand(expression);
}
protected Filter convertOperand(Operand operand) {
var context = Filter.newBuilder();
List<Condition> mustClauses = new ArrayList<Condition>();
List<Condition> shouldClauses = new ArrayList<Condition>();
List<Condition> mustNotClauses = new ArrayList<Condition>();
if (operand instanceof Expression expression) {
if (expression.type() == ExpressionType.NOT && expression.left() instanceof Group group) {
mustNotClauses.add(filter(convertOperand(group.content())));
}
else if (expression.type() == ExpressionType.AND) {
mustClauses.add(filter(convertOperand(expression.left())));
mustClauses.add(filter(convertOperand(expression.right())));
}
else if (expression.type() == ExpressionType.OR) {
shouldClauses.add(filter(convertOperand(expression.left())));
shouldClauses.add(filter(convertOperand(expression.right())));
}
else {
if (!(expression.right() instanceof Value)) {
throw new RuntimeException("Non AND/OR/NOT expression must have Value right argument!");
}
mustClauses.add(parseComparison((Key) expression.left(), (Value) expression.right(), expression));
}
}
return context.addAllMust(mustClauses).addAllShould(shouldClauses).addAllMustNot(mustNotClauses).build();
}
protected Condition parseComparison(Key key, Value value, Expression exp) {
ExpressionType type = exp.type();
switch (type) {
case EQ: {
return buildEqCondition(key, value);
}
case NE: {
return buildNeCondition(key, value);
}
case GT: {
return buildGtCondition(key, value);
}
case GTE: {
return buildGteCondition(key, value);
}
case LT: {
return buildLtCondition(key, value);
}
case LTE: {
return buildLteCondition(key, value);
}
case IN: {
return buildInCondition(key, value);
}
case NIN: {
return buildNInCondition(key, value);
}
default: {
throw new RuntimeException("Unsupported expression type: " + type);
}
}
}
protected Condition buildEqCondition(Key key, Value value) {
String identifier = doKey(key);
if (value.value() instanceof String valueStr) {
return matchKeyword(identifier, valueStr);
}
else if (value.value() instanceof Number valueNum) {
long lValue = Long.parseLong(valueNum.toString());
return match(identifier, lValue);
}
throw new IllegalArgumentException("Invalid value type for EQ. Can either be a string or Number");
}
protected Condition buildNeCondition(Key key, Value value) {
String identifier = doKey(key);
if (value.value() instanceof String valueStr) {
return filter(Filter.newBuilder().addMustNot(matchKeyword(identifier, valueStr)).build());
}
else if (value.value() instanceof Number valueNum) {
long lValue = Long.parseLong(valueNum.toString());
Condition condition = match(identifier, lValue);
return filter(Filter.newBuilder().addMustNot(condition).build());
}
throw new IllegalArgumentException("Invalid value type for NEQ. Can either be a string or Number");
}
protected Condition buildGtCondition(Key key, Value value) {
String identifier = doKey(key);
if (value.value() instanceof Number valueNum) {
Double dvalue = Double.parseDouble(valueNum.toString());
return range(identifier, Range.newBuilder().setGt(dvalue).build());
}
throw new RuntimeException("Unsupported value type for GT condition. Only supports Number");
}
protected Condition buildLtCondition(Key key, Value value) {
String identifier = doKey(key);
if (value.value() instanceof Number valueNum) {
Double dvalue = Double.parseDouble(valueNum.toString());
return range(identifier, Range.newBuilder().setLt(dvalue).build());
}
throw new RuntimeException("Unsupported value type for LT condition. Only supports Number");
}
protected Condition buildGteCondition(Key key, Value value) {
String identifier = doKey(key);
if (value.value() instanceof Number valueNum) {
Double dvalue = Double.parseDouble(valueNum.toString());
return range(identifier, Range.newBuilder().setGte(dvalue).build());
}
throw new RuntimeException("Unsupported value type for GTE condition. Only supports Number");
}
protected Condition buildLteCondition(Key key, Value value) {
String identifier = doKey(key);
if (value.value() instanceof Number valueNum) {
Double dvalue = Double.parseDouble(valueNum.toString());
return range(identifier, Range.newBuilder().setLte(dvalue).build());
}
throw new RuntimeException("Unsupported value type for LTE condition. Only supports Number");
}
protected Condition buildInCondition(Key key, Value value) {
if (value.value() instanceof List valueList && !valueList.isEmpty()) {
Object firstValue = valueList.get(0);
String identifier = doKey(key);
if (firstValue instanceof String) {
// If the first value is a string, then all values should be strings
List<String> stringValues = new ArrayList<String>();
for (Object valueObj : valueList) {
stringValues.add(valueObj.toString());
}
return matchKeywords(identifier, stringValues);
}
else if (firstValue instanceof Number) {
// If the first value is a number, then all values should be numbers
List<Long> longValues = new ArrayList<Long>();
for (Object valueObj : valueList) {
Long longValue = Long.parseLong(valueObj.toString());
longValues.add(longValue);
}
return matchValues(identifier, longValues);
}
else {
throw new RuntimeException("Unsupported value in IN value list. Only supports String or Number");
}
}
throw new RuntimeException(
"Unsupported value type for IN condition. Only supports non-empty List of String or Number");
}
protected Condition buildNInCondition(Key key, Value value) {
if (value.value() instanceof List valueList && !valueList.isEmpty()) {
Object firstValue = valueList.get(0);
String identifier = doKey(key);
if (firstValue instanceof String) {
// If the first value is a string, then all values should be strings
List<String> stringValues = new ArrayList<String>();
for (Object valueObj : valueList) {
stringValues.add(valueObj.toString());
}
return matchExceptKeywords(identifier, stringValues);
}
else if (firstValue instanceof Number) {
// If the first value is a number, then all values should be numbers
List<Long> longValues = new ArrayList<Long>();
for (Object valueObj : valueList) {
Long longValue = Long.parseLong(valueObj.toString());
longValues.add(longValue);
}
return matchExceptValues(identifier, longValues);
}
else {
throw new RuntimeException("Unsupported value in NIN value list. Only supports String or Number");
}
}
throw new RuntimeException(
"Unsupported value type for NIN condition. Only supports non-empty List of String or Number");
}
protected String doKey(Key key) {
var identifier = (hasOuterQuotes(key.key())) ? removeOuterQuotes(key.key()) : key.key();
return identifier;
}
protected boolean hasOuterQuotes(String str) {
str = str.trim();
return (str.startsWith("\"") && str.endsWith("\"")) || (str.startsWith("'") && str.endsWith("'"));
}
protected String removeOuterQuotes(String in) {
return in.substring(1, in.length() - 1);
}
}

View File

@@ -0,0 +1,75 @@
/*
* Copyright 2024-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.vectorstore.qdrant;
import java.util.Map;
import java.util.stream.Collectors;
import io.qdrant.client.grpc.JsonWithInt.ListValue;
import io.qdrant.client.grpc.JsonWithInt.Value;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.util.Assert;
/**
* Utility methods for building Java objects from io.qdrant.client.grpc.JsonWithInt.Value.
*
* @author Anush Shetty
* @since 0.8.1
*/
class QdrantObjectFactory {
private static final Log logger = LogFactory.getLog(QdrantObjectFactory.class);
private QdrantObjectFactory() {
}
public static Map<String, Object> toObjectMap(Map<String, Value> payload) {
Assert.notNull(payload, "Payload map must not be null");
return payload.entrySet().stream().collect(Collectors.toMap(e -> e.getKey(), e -> object(e.getValue())));
}
private static Object object(ListValue listValue) {
return listValue.getValuesList().stream().map(QdrantObjectFactory::object).collect(Collectors.toList());
}
private static Object object(Value value) {
switch (value.getKindCase()) {
case INTEGER_VALUE:
return value.getIntegerValue();
case STRING_VALUE:
return value.getStringValue();
case DOUBLE_VALUE:
return value.getDoubleValue();
case BOOL_VALUE:
return value.hasBoolValue();
case LIST_VALUE:
return object(value.getListValue());
case STRUCT_VALUE:
return toObjectMap(value.getStructValue().getFieldsMap());
case KIND_NOT_SET:
case NULL_VALUE:
default:
logger.warn("Unsupported value type: " + value.getKindCase());
return null;
}
}
}

View File

@@ -0,0 +1,100 @@
/*
* Copyright 2024-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.vectorstore.qdrant;
import java.lang.reflect.Array;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import io.qdrant.client.ValueFactory;
import io.qdrant.client.grpc.JsonWithInt.Struct;
import io.qdrant.client.grpc.JsonWithInt.Value;
import org.springframework.util.Assert;
/**
* Utility methods for building io.qdrant.client.grpc.JsonWithInt.Value from Java objects.
*
* @author Anush Shetty
* @since 0.8.1
*/
class QdrantValueFactory {
private QdrantValueFactory() {
}
public static Map<String, Value> toValueMap(Map<String, Object> inputMap) {
Assert.notNull(inputMap, "Input map must not be null");
return inputMap.entrySet().stream().collect(Collectors.toMap(e -> e.getKey(), e -> value(e.getValue())));
}
@SuppressWarnings("unchecked")
private static Value value(Object value) {
if (value == null) {
return ValueFactory.nullValue();
}
if (value.getClass().isArray()) {
int length = Array.getLength(value);
Object[] objectArray = new Object[length];
for (int i = 0; i < length; i++) {
objectArray[i] = Array.get(value, i);
}
return value(objectArray);
}
if (value instanceof Map) {
return value((Map<String, Object>) value);
}
switch (value.getClass().getSimpleName()) {
case "String":
return ValueFactory.value((String) value);
case "Integer":
return ValueFactory.value((Integer) value);
case "Double":
return ValueFactory.value((Double) value);
case "Float":
return ValueFactory.value((Float) value);
case "Boolean":
return ValueFactory.value((Boolean) value);
default:
throw new IllegalArgumentException("Unsupported Qdrant value type: " + value.getClass());
}
}
private static Value value(Object[] elements) {
List<Value> values = new ArrayList<Value>(elements.length);
for (Object element : elements) {
values.add(value(element));
}
return ValueFactory.list(values);
}
private static Value value(Map<String, Object> inputMap) {
Struct.Builder structBuilder = Struct.newBuilder();
Map<String, Value> map = toValueMap(inputMap);
structBuilder.putAllFields(map);
return Value.newBuilder().setStructValue(structBuilder).build();
}
}

View File

@@ -0,0 +1,329 @@
/*
* Copyright 2024-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.vectorstore.qdrant;
import static io.qdrant.client.PointIdFactory.id;
import static io.qdrant.client.ValueFactory.value;
import static io.qdrant.client.VectorsFactory.vectors;
import static io.qdrant.client.WithPayloadSelectorFactory.enable;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
import org.springframework.ai.document.Document;
import org.springframework.ai.embedding.EmbeddingClient;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.util.Assert;
import io.qdrant.client.QdrantClient;
import io.qdrant.client.QdrantGrpcClient;
import io.qdrant.client.grpc.JsonWithInt.Value;
import io.qdrant.client.grpc.Points.Filter;
import io.qdrant.client.grpc.Points.PointId;
import io.qdrant.client.grpc.Points.PointStruct;
import io.qdrant.client.grpc.Points.ScoredPoint;
import io.qdrant.client.grpc.Points.SearchPoints;
import io.qdrant.client.grpc.Points.UpdateStatus;
/**
* Qdrant vectorStore implementation. This store supports creating, updating, deleting,
* and similarity searching of documents in a Qdrant collection.
*
* @author Anush Shetty
* @since 0.8.1
*/
public class QdrantVectorStore implements VectorStore {
private static final String CONTENT_FIELD_NAME = "doc_content";
private static final String DISTANCE_FIELD_NAME = "distance";
private final EmbeddingClient embeddingClient;
private final QdrantClient qdrantClient;
private final String collectionName;
private final QdrantFilterExpressionConverter filterExpressionConverter = new QdrantFilterExpressionConverter();
/**
* Configuration class for the QdrantVectorStore.
*/
public static final class QdrantVectorStoreConfig {
private final String collectionName;
private QdrantClient qdrantClient;
/*
* Constructor using the builder.
*
* @param builder The configuration builder.
*/
private QdrantVectorStoreConfig(Builder builder) {
this.collectionName = builder.collectionName;
QdrantGrpcClient.Builder grpcClientBuilder = QdrantGrpcClient.newBuilder(builder.host, builder.port,
builder.useTls);
if (builder.apiKey != null) {
grpcClientBuilder.withApiKey(builder.apiKey);
}
this.qdrantClient = new QdrantClient(grpcClientBuilder.build());
}
/**
* Start building a new configuration.
* @return The entry point for creating a new configuration.
*/
public static Builder builder() {
return new Builder();
}
/**
* {@return the default config}
*/
public static QdrantVectorStoreConfig defaultConfig() {
return builder().build();
}
public static class Builder {
private String collectionName;
private String host = "localhost";
private int port = 6334;
private boolean useTls = false;
private String apiKey = null;
private Builder() {
}
/**
* @param host The host of the Qdrant instance. Defaults to "localhost".
*/
public Builder withHost(String host) {
Assert.notNull(host, "host cannot be null");
this.host = host;
return this;
}
/**
* @param collectionName REQUIRED. The name of the collection.
*/
public Builder withCollectionName(String collectionName) {
this.collectionName = collectionName;
return this;
}
/**
* @param port The GRPC port of the Qdrant instance. Defaults to 6334.
* @return
*/
public Builder withPort(int port) {
this.port = port;
return this;
}
/**
* @param useTls Whether to use TLS(HTTPS). Defaults to false.
* @return
*/
public Builder withTls(boolean useTls) {
this.useTls = useTls;
return this;
}
/**
* @param apiKey The Qdrant API key to authenticate with. Defaults to null.
*/
public Builder withApiKey(String apiKey) {
this.apiKey = apiKey;
return this;
}
/**
* {@return the immutable configuration}
*/
public QdrantVectorStoreConfig build() {
Assert.notNull(collectionName, "collectionName cannot be null");
return new QdrantVectorStoreConfig(this);
}
}
}
/**
* Constructs a new QdrantVectorStore.
* @param config The configuration for the store.
* @param embeddingClient The client for embedding operations.
*/
public QdrantVectorStore(QdrantVectorStoreConfig config, EmbeddingClient embeddingClient) {
this(config.qdrantClient, config.collectionName, embeddingClient);
}
/**
* Constructs a new QdrantVectorStore.
* @param qdrantClient A {@link QdrantClient} instance for interfacing with Qdrant.
* @param collectionName The name of the collection to use in Qdrant.
* @param embeddingClient The client for embedding operations.
*/
public QdrantVectorStore(QdrantClient qdrantClient, String collectionName, EmbeddingClient embeddingClient) {
Assert.notNull(qdrantClient, "QdrantClient must not be null");
Assert.notNull(collectionName, "collectionName must not be null");
Assert.notNull(embeddingClient, "EmbeddingClient must not be null");
this.embeddingClient = embeddingClient;
this.collectionName = collectionName;
this.qdrantClient = qdrantClient;
}
/**
* Adds a list of documents to the vector store.
* @param documents The list of documents to be added.
*/
@Override
public void add(List<Document> documents) {
try {
List<PointStruct> points = documents.stream().map(document -> {
// Compute and assign an embedding to the document.
document.setEmbedding(this.embeddingClient.embed(document));
return PointStruct.newBuilder()
.setId(id(UUID.fromString(document.getId())))
.setVectors(vectors(toFloatList(document.getEmbedding())))
.putAllPayload(toPayload(document))
.build();
}).toList();
this.qdrantClient.upsertAsync(this.collectionName, points).get();
}
catch (InterruptedException | ExecutionException | IllegalArgumentException e) {
throw new RuntimeException(e);
}
}
/**
* Deletes a list of documents by their IDs.
* @param documentIds The list of document IDs to be deleted.
* @return An optional boolean indicating the deletion status.
*/
@Override
public Optional<Boolean> delete(List<String> documentIds) {
try {
List<PointId> ids = documentIds.stream().map(id -> id(UUID.fromString(id))).toList();
var result = this.qdrantClient.deleteAsync(this.collectionName, ids)
.get()
.getStatus() == UpdateStatus.Completed;
return Optional.of(result);
}
catch (InterruptedException | ExecutionException | IllegalArgumentException e) {
throw new RuntimeException(e);
}
}
/**
* Performs a similarity search on the vector store.
* @param request The {@link SearchRequest} object containing the query and other
* search parameters.
* @return A list of documents that are similar to the query.
*/
@Override
public List<Document> similaritySearch(SearchRequest request) {
try {
Filter filter = (request.getFilterExpression() != null)
? this.filterExpressionConverter.convertExpression(request.getFilterExpression())
: Filter.getDefaultInstance();
List<Double> queryEmbedding = this.embeddingClient.embed(request.getQuery());
var searchPoints = SearchPoints.newBuilder()
.setCollectionName(this.collectionName)
.setLimit(request.getTopK())
.setWithPayload(enable(true))
.addAllVector(toFloatList(queryEmbedding))
.setFilter(filter)
.setScoreThreshold((float) request.getSimilarityThreshold())
.build();
var queryResponse = this.qdrantClient.searchAsync(searchPoints).get();
return queryResponse.stream().map(scoredPoint -> {
return toDocument(scoredPoint);
}).toList();
}
catch (InterruptedException | ExecutionException | IllegalArgumentException e) {
throw new RuntimeException(e);
}
}
/**
* Extracts metadata from a Protobuf Struct.
* @param metadataStruct The Protobuf Struct containing metadata.
* @return The metadata as a map.
*/
private Document toDocument(ScoredPoint point) {
try {
var id = point.getId().getUuid();
var payload = QdrantObjectFactory.toObjectMap(point.getPayloadMap());
payload.put(DISTANCE_FIELD_NAME, 1 - point.getScore());
var content = (String) payload.remove(CONTENT_FIELD_NAME);
return new Document(id, content, payload);
}
catch (Exception e) {
throw new RuntimeException(e);
}
}
/**
* Converts the document metadata to a Protobuf Struct.
* @param document The document containing metadata.
* @return The metadata as a Protobuf Struct.
*/
private Map<String, Value> toPayload(Document document) {
try {
var payload = QdrantValueFactory.toValueMap(document.getMetadata());
payload.put(CONTENT_FIELD_NAME, value(document.getContent()));
return payload;
}
catch (Exception e) {
throw new RuntimeException(e);
}
}
/**
* Converts a list of doubles to a list of floats.
* @param doubleList The list of doubles.
* @return The converted list of floats.
*/
private List<Float> toFloatList(List<Double> doubleList) {
return doubleList.stream().map(d -> d.floatValue()).toList();
}
}

View File

@@ -0,0 +1,260 @@
/*
* Copyright 2024-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.vectorstore.qdrant;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
import io.qdrant.client.QdrantClient;
import io.qdrant.client.QdrantGrpcClient;
import io.qdrant.client.grpc.Collections.Distance;
import io.qdrant.client.grpc.Collections.VectorParams;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.qdrant.QdrantContainer;
import org.springframework.ai.document.Document;
import org.springframework.ai.embedding.EmbeddingClient;
import org.springframework.ai.openai.OpenAiEmbeddingClient;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Anush Shetty
* @since 0.8.1
*/
@Testcontainers
public class QdrantVectorStoreIT {
private static final String COLLECTION_NAME = "test_collection";
private static final int EMBEDDING_DIMENSION = 1536;
private static final int QDRANT_GRPC_PORT = 6334;
@Container
static QdrantContainer qdrantContainer = new QdrantContainer("qdrant/qdrant:v1.7.4");
List<Document> documents = List.of(
new Document("Spring AI rocks!! Spring AI rocks!! Spring AI rocks!! Spring AI rocks!! Spring AI rocks!!",
Collections.singletonMap("meta1", "meta1")),
new Document("Hello World Hello World Hello World Hello World Hello World Hello World Hello World"),
new Document(
"Great Depression Great Depression Great Depression Great Depression Great Depression Great Depression",
Collections.singletonMap("meta2", "meta2")));
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withUserConfiguration(TestApplication.class)
.withPropertyValues("spring.ai.openai.apiKey=" + System.getenv("OPENAI_API_KEY"));
@BeforeAll
static void setup() throws InterruptedException, ExecutionException {
String host = qdrantContainer.getHost();
int port = qdrantContainer.getMappedPort(QDRANT_GRPC_PORT);
QdrantClient client = new QdrantClient(QdrantGrpcClient.newBuilder(host, port, false).build());
client
.createCollectionAsync(COLLECTION_NAME,
VectorParams.newBuilder().setDistance(Distance.Cosine).setSize(EMBEDDING_DIMENSION).build())
.get();
client.close();
}
@Test
public void addAndSearch() {
contextRunner.run(context -> {
VectorStore vectorStore = context.getBean(VectorStore.class);
vectorStore.add(documents);
List<Document> results = vectorStore.similaritySearch(SearchRequest.query("Great").withTopK(1));
assertThat(results).hasSize(1);
Document resultDoc = results.get(0);
assertThat(resultDoc.getId()).isEqualTo(documents.get(2).getId());
assertThat(resultDoc.getContent()).isEqualTo(
"Great Depression Great Depression Great Depression Great Depression Great Depression Great Depression");
assertThat(resultDoc.getMetadata()).containsKeys("meta2", "distance");
// Remove all documents from the store
vectorStore.delete(documents.stream().map(doc -> doc.getId()).toList());
List<Document> results2 = vectorStore.similaritySearch(SearchRequest.query("Great").withTopK(1));
assertThat(results2).hasSize(0);
});
}
@Test
public void addAndSearchWithFilters() {
contextRunner.run(context -> {
VectorStore vectorStore = context.getBean(VectorStore.class);
var bgDocument = new Document("The World is Big and Salvation Lurks Around the Corner",
Map.of("country", "Bulgaria", "number", 3));
var nlDocument = new Document("The World is Big and Salvation Lurks Around the Corner",
Map.of("country", "Netherlands", "number", 90));
vectorStore.add(List.of(bgDocument, nlDocument));
var request = SearchRequest.query("The World").withTopK(5);
List<Document> results = vectorStore.similaritySearch(request);
assertThat(results).hasSize(2);
results = vectorStore
.similaritySearch(request.withSimilarityThresholdAll().withFilterExpression("country == 'Bulgaria'"));
assertThat(results).hasSize(1);
assertThat(results.get(0).getId()).isEqualTo(bgDocument.getId());
results = vectorStore.similaritySearch(
request.withSimilarityThresholdAll().withFilterExpression("country == 'Netherlands'"));
assertThat(results).hasSize(1);
assertThat(results.get(0).getId()).isEqualTo(nlDocument.getId());
results = vectorStore.similaritySearch(
request.withSimilarityThresholdAll().withFilterExpression("NOT(country == 'Netherlands')"));
assertThat(results).hasSize(1);
assertThat(results.get(0).getId()).isEqualTo(bgDocument.getId());
results = vectorStore
.similaritySearch(request.withSimilarityThresholdAll().withFilterExpression("number in [3, 5, 12]"));
assertThat(results).hasSize(1);
assertThat(results.get(0).getId()).isEqualTo(bgDocument.getId());
results = vectorStore
.similaritySearch(request.withSimilarityThresholdAll().withFilterExpression("number nin [3, 5, 12]"));
assertThat(results).hasSize(1);
assertThat(results.get(0).getId()).isEqualTo(nlDocument.getId());
// Remove all documents from the store
vectorStore.delete(List.of(bgDocument, nlDocument).stream().map(doc -> doc.getId()).toList());
});
}
@Test
public void documentUpdateTest() {
contextRunner.run(context -> {
VectorStore vectorStore = context.getBean(VectorStore.class);
Document document = new Document(UUID.randomUUID().toString(), "Spring AI rocks!!",
Collections.singletonMap("meta1", "meta1"));
vectorStore.add(List.of(document));
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));
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()));
});
}
@Test
public void searchThresholdTest() {
contextRunner.run(context -> {
VectorStore vectorStore = context.getBean(VectorStore.class);
vectorStore.add(documents);
var request = SearchRequest.query("Great").withTopK(5);
List<Document> fullResult = vectorStore.similaritySearch(request.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(request.withSimilarityThreshold(1 - threshold));
assertThat(results).hasSize(1);
Document resultDoc = results.get(0);
assertThat(resultDoc.getId()).isEqualTo(documents.get(2).getId());
assertThat(resultDoc.getContent()).isEqualTo(
"Great Depression Great Depression Great Depression Great Depression Great Depression Great Depression");
assertThat(resultDoc.getMetadata()).containsKey("meta2");
assertThat(resultDoc.getMetadata()).containsKey("distance");
// Remove all documents from the store
vectorStore.delete(documents.stream().map(doc -> doc.getId()).toList());
});
}
@SpringBootConfiguration
public static class TestApplication {
@Bean
public QdrantClient qdrantClient() {
String host = qdrantContainer.getHost();
int port = qdrantContainer.getMappedPort(QDRANT_GRPC_PORT);
QdrantClient qdrantClient = new QdrantClient(QdrantGrpcClient.newBuilder(host, port, false).build());
return qdrantClient;
}
@Bean
public VectorStore qdrantVectorStore(EmbeddingClient embeddingClient, QdrantClient qdrantClient) {
return new QdrantVectorStore(qdrantClient, COLLECTION_NAME, embeddingClient);
}
@Bean
public EmbeddingClient embeddingClient() {
return new OpenAiEmbeddingClient(new OpenAiApi(System.getenv("OPENAI_API_KEY")));
}
}
}