From ea0b439dacf30c6a6d7bb9cd06f26d098eb66aff Mon Sep 17 00:00:00 2001 From: Anush008 Date: Thu, 22 Feb 2024 10:30:04 +0530 Subject: [PATCH] Implement Qdrant vector store - Implement QdrantVectorStore. Uses a custom parser for converting Spring AI metadata(Map) 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 --- README.md | 2 +- pom.xml | 4 + spring-ai-bom/pom.xml | 11 + .../src/main/antora/modules/ROOT/nav.adoc | 1 + .../modules/ROOT/pages/api/vectordbs.adoc | 1 + .../ROOT/pages/api/vectordbs/qdrant.adoc | 212 +++++++++++ .../modules/ROOT/pages/getting-started.adoc | 1 + .../main/antora/modules/ROOT/pages/index.adoc | 2 +- spring-ai-spring-boot-autoconfigure/pom.xml | 16 + .../QdrantVectorStoreAutoConfiguration.java | 53 +++ .../qdrant/QdrantVectorStoreProperties.java | 95 +++++ ...ot.autoconfigure.AutoConfiguration.imports | 2 +- .../QdrantVectorStoreAutoConfigurationIT.java | 134 +++++++ .../spring-ai-starter-qdrant-store/pom.xml | 42 +++ vector-stores/spring-ai-qdrant/README.md | 1 + vector-stores/spring-ai-qdrant/pom.xml | 87 +++++ .../QdrantFilterExpressionConverter.java | 261 ++++++++++++++ .../qdrant/QdrantObjectFactory.java | 75 ++++ .../qdrant/QdrantValueFactory.java | 100 ++++++ .../vectorstore/qdrant/QdrantVectorStore.java | 329 ++++++++++++++++++ .../qdrant/QdrantVectorStoreIT.java | 260 ++++++++++++++ 21 files changed, 1686 insertions(+), 3 deletions(-) create mode 100644 spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/qdrant.adoc create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/qdrant/QdrantVectorStoreAutoConfiguration.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/qdrant/QdrantVectorStoreProperties.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/qdrant/QdrantVectorStoreAutoConfigurationIT.java create mode 100644 spring-ai-spring-boot-starters/spring-ai-starter-qdrant-store/pom.xml create mode 100644 vector-stores/spring-ai-qdrant/README.md create mode 100644 vector-stores/spring-ai-qdrant/pom.xml create mode 100644 vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantFilterExpressionConverter.java create mode 100644 vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantObjectFactory.java create mode 100644 vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantValueFactory.java create mode 100644 vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStore.java create mode 100644 vector-stores/spring-ai-qdrant/src/test/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStoreIT.java 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"))); + } + + } + +}