diff --git a/README.md b/README.md
index c58212221..296d747cc 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/pom.xml b/pom.xml
index 1c0854368..5a43bb1f0 100644
--- a/pom.xml
+++ b/pom.xml
@@ -41,6 +41,7 @@
spring-ai-spring-boot-starters/spring-ai-starter-redis
spring-ai-spring-boot-starters/spring-ai-starter-stability-ai
spring-ai-spring-boot-starters/spring-ai-starter-neo4j-store
+ spring-ai-spring-boot-starters/spring-ai-starter-qdrant-store
spring-ai-spring-boot-starters/spring-ai-starter-postgresml-embedding
spring-ai-docs
vector-stores/spring-ai-pgvector-store
@@ -55,6 +56,7 @@
vector-stores/spring-ai-redis
spring-ai-spring-boot-starters/spring-ai-starter-vertex-ai-palm2
spring-ai-spring-boot-starters/spring-ai-starter-vertex-ai-gemini
+ vector-stores/spring-ai-qdrant
spring-ai-spring-boot-starters/spring-ai-starter-bedrock-ai
spring-ai-spring-boot-starters/spring-ai-starter-mistral-ai
@@ -112,6 +114,7 @@
0.26.0
1.17.0
26.33.0
+ 1.7.1
3.25.2
@@ -125,6 +128,7 @@
2.0.46
11.6.1
4.5.1
+ 1.7.1
1.19.6
diff --git a/spring-ai-bom/pom.xml b/spring-ai-bom/pom.xml
index c20f87609..de05f143b 100644
--- a/spring-ai-bom/pom.xml
+++ b/spring-ai-bom/pom.xml
@@ -161,6 +161,12 @@
${project.version}
+
+ org.springframework.ai
+ spring-ai-qdrant
+ ${project.version}
+
+
org.springframework.ai
@@ -283,6 +289,11 @@
${project.version}
+
+ org.springframework.ai
+ spring-ai-qdrant-store-spring-boot-starter
+ ${project.version}
+
diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc
index 8bb6a1921..ffaad744e 100644
--- a/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc
+++ b/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc
@@ -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[]
diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs.adoc
index 8ab6c0382..0b4385c14 100644
--- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs.adoc
+++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs.adoc
@@ -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.
diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/qdrant.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/qdrant.adoc
new file mode 100644
index 000000000..f4df0fe0e
--- /dev/null
+++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/qdrant.adoc
@@ -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]
+----
+
+ org.springframework.ai
+ spring-ai-openai-spring-boot-starter
+
+----
+
+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]
+----
+
+ org.springframework.ai
+ spring-ai-qdrant-store-spring-boot-starter
+
+----
+
+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 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 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=
+spring.ai.vectorstore.qdrant.port=
+spring.ai.vectorstore.qdrant.api-key=
+spring.ai.vectorstore.qdrant.collection-name=
+
+# API key if needed, e.g. OpenAI
+spring.ai.openai.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]
+----
+
+ org.springframework.ai
+ spring-ai-qdrant
+
+----
+
+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("")
+ .withPort()
+ .withCollectionName("")
+ .withApiKey("")
+ .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
+|===
diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/getting-started.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/getting-started.adoc
index d2f941024..a0f687710 100644
--- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/getting-started.adoc
+++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/getting-started.adoc
@@ -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.
diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/index.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/index.adoc
index 904b99e70..a1e95f488 100644
--- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/index.adoc
+++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/index.adoc
@@ -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.
diff --git a/spring-ai-spring-boot-autoconfigure/pom.xml b/spring-ai-spring-boot-autoconfigure/pom.xml
index a63a3a826..d2d1bfef6 100644
--- a/spring-ai-spring-boot-autoconfigure/pom.xml
+++ b/spring-ai-spring-boot-autoconfigure/pom.xml
@@ -215,6 +215,14 @@
true
+
+
+ org.springframework.ai
+ spring-ai-qdrant
+ ${project.parent.version}
+ true
+
+
@@ -287,6 +295,14 @@
test
+
+ org.testcontainers
+ qdrant
+ 1.19.6
+ test
+
+
+
diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/qdrant/QdrantVectorStoreAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/qdrant/QdrantVectorStoreAutoConfiguration.java
new file mode 100644
index 000000000..ad2e0143c
--- /dev/null
+++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/qdrant/QdrantVectorStoreAutoConfiguration.java
@@ -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);
+ }
+
+}
diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/qdrant/QdrantVectorStoreProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/qdrant/QdrantVectorStoreProperties.java
new file mode 100644
index 000000000..46ee9b5d5
--- /dev/null
+++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/qdrant/QdrantVectorStoreProperties.java
@@ -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;
+ }
+
+}
diff --git a/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
index dfd3bb47f..e8b9f4540 100644
--- a/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
+++ b/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -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
diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/qdrant/QdrantVectorStoreAutoConfigurationIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/qdrant/QdrantVectorStoreAutoConfigurationIT.java
new file mode 100644
index 000000000..35d3239e9
--- /dev/null
+++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/qdrant/QdrantVectorStoreAutoConfigurationIT.java
@@ -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 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 results = vectorStore
+ .similaritySearch(SearchRequest.query("What is Great Depression?").withTopK(1));
+
+ assertThat(results).hasSize(1);
+ Document resultDoc = results.get(0);
+ assertThat(resultDoc.getId()).isEqualTo(documents.get(2).getId());
+ assertThat(resultDoc.getMetadata()).containsKeys("depression", "distance");
+
+ // Remove all documents from the store
+ vectorStore.delete(documents.stream().map(doc -> doc.getId()).toList());
+ results = vectorStore.similaritySearch(SearchRequest.query("Great Depression").withTopK(1));
+ assertThat(results).hasSize(0);
+ });
+ }
+
+ @Configuration(proxyBeanMethods = false)
+ static class Config {
+
+ @Bean
+ public EmbeddingClient embeddingClient() {
+ return new TransformersEmbeddingClient();
+ }
+
+ }
+
+}
diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-qdrant-store/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-qdrant-store/pom.xml
new file mode 100644
index 000000000..561129c7e
--- /dev/null
+++ b/spring-ai-spring-boot-starters/spring-ai-starter-qdrant-store/pom.xml
@@ -0,0 +1,42 @@
+
+
+ 4.0.0
+
+ org.springframework.ai
+ spring-ai
+ 0.8.1-SNAPSHOT
+ ../../pom.xml
+
+ spring-ai-qdrant-store-spring-boot-starter
+ jar
+ Spring AI Starter - Qdrant Vector Store
+ Spring AI Qdrant Vector Store Auto Configuration
+ https://github.com/spring-projects/spring-ai
+
+
+ https://github.com/spring-projects/spring-ai
+ git://github.com/spring-projects/spring-ai.git
+ git@github.com:spring-projects/spring-ai.git
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter
+
+
+
+ org.springframework.ai
+ spring-ai-spring-boot-autoconfigure
+ ${project.parent.version}
+
+
+
+ org.springframework.ai
+ spring-ai-qdrant
+ ${project.parent.version}
+
+
+
+
diff --git a/vector-stores/spring-ai-qdrant/README.md b/vector-stores/spring-ai-qdrant/README.md
new file mode 100644
index 000000000..00bf74013
--- /dev/null
+++ b/vector-stores/spring-ai-qdrant/README.md
@@ -0,0 +1 @@
+Qdrant Vector Store
\ No newline at end of file
diff --git a/vector-stores/spring-ai-qdrant/pom.xml b/vector-stores/spring-ai-qdrant/pom.xml
new file mode 100644
index 000000000..d990790f6
--- /dev/null
+++ b/vector-stores/spring-ai-qdrant/pom.xml
@@ -0,0 +1,87 @@
+
+
+ 4.0.0
+
+ org.springframework.ai
+ spring-ai
+ 0.8.1-SNAPSHOT
+ ../../pom.xml
+
+ spring-ai-qdrant
+ jar
+ spring-ai-qdrant
+ spring-ai-qdrant
+ https://github.com/spring-projects/spring-ai
+
+
+ https://github.com/spring-projects/spring-ai
+ git://github.com/spring-projects/spring-ai.git
+ git@github.com:spring-projects/spring-ai.git
+
+
+
+ 17
+ 17
+
+
+
+
+ org.springframework.ai
+ spring-ai-core
+ ${project.parent.version}
+
+
+
+ org.springframework
+ spring-web
+
+
+
+
+
+ io.qdrant
+ client
+ ${qdrant.version}
+
+
+
+ com.google.protobuf
+ protobuf-java-util
+ ${protobuf-java.version}
+
+
+
+
+ org.springframework.ai
+ spring-ai-openai
+ ${project.parent.version}
+ test
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+ org.testcontainers
+ qdrant
+ 1.19.6
+ test
+
+
+
+ org.testcontainers
+ junit-jupiter
+ ${testcontainers.version}
+ test
+
+
+
+
diff --git a/vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantFilterExpressionConverter.java b/vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantFilterExpressionConverter.java
new file mode 100644
index 000000000..ec1ecfae8
--- /dev/null
+++ b/vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantFilterExpressionConverter.java
@@ -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 mustClauses = new ArrayList();
+ List shouldClauses = new ArrayList();
+ List mustNotClauses = new ArrayList();
+
+ 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 stringValues = new ArrayList();
+ 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 longValues = new ArrayList();
+ 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 stringValues = new ArrayList();
+ 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 longValues = new ArrayList();
+ 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);
+ }
+
+}
diff --git a/vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantObjectFactory.java b/vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantObjectFactory.java
new file mode 100644
index 000000000..721efa012
--- /dev/null
+++ b/vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantObjectFactory.java
@@ -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 toObjectMap(Map 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;
+ }
+
+ }
+
+}
diff --git a/vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantValueFactory.java b/vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantValueFactory.java
new file mode 100644
index 000000000..72c2552c1
--- /dev/null
+++ b/vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantValueFactory.java
@@ -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 toValueMap(Map 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) 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 values = new ArrayList(elements.length);
+
+ for (Object element : elements) {
+ values.add(value(element));
+ }
+
+ return ValueFactory.list(values);
+ }
+
+ private static Value value(Map inputMap) {
+ Struct.Builder structBuilder = Struct.newBuilder();
+ Map map = toValueMap(inputMap);
+ structBuilder.putAllFields(map);
+ return Value.newBuilder().setStructValue(structBuilder).build();
+ }
+
+}
\ No newline at end of file
diff --git a/vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStore.java b/vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStore.java
new file mode 100644
index 000000000..aea487d5c
--- /dev/null
+++ b/vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStore.java
@@ -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 documents) {
+ try {
+ List 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 delete(List documentIds) {
+ try {
+ List 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 similaritySearch(SearchRequest request) {
+ try {
+ Filter filter = (request.getFilterExpression() != null)
+ ? this.filterExpressionConverter.convertExpression(request.getFilterExpression())
+ : Filter.getDefaultInstance();
+
+ List 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 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 toFloatList(List doubleList) {
+ return doubleList.stream().map(d -> d.floatValue()).toList();
+ }
+
+}
diff --git a/vector-stores/spring-ai-qdrant/src/test/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStoreIT.java b/vector-stores/spring-ai-qdrant/src/test/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStoreIT.java
new file mode 100644
index 000000000..d5506afcf
--- /dev/null
+++ b/vector-stores/spring-ai-qdrant/src/test/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStoreIT.java
@@ -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 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 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 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 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 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 fullResult = vectorStore.similaritySearch(request.withSimilarityThresholdAll());
+
+ List distances = fullResult.stream().map(doc -> (Float) doc.getMetadata().get("distance")).toList();
+
+ assertThat(distances).hasSize(3);
+
+ float threshold = (distances.get(0) + distances.get(1)) / 2;
+
+ List results = vectorStore.similaritySearch(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")));
+ }
+
+ }
+
+}