Implement Qdrant vector store

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

Additional (review) change:

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

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

View File

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

View File

@@ -0,0 +1,87 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai</artifactId>
<version>0.8.1-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<artifactId>spring-ai-qdrant</artifactId>
<packaging>jar</packaging>
<name>spring-ai-qdrant</name>
<description>spring-ai-qdrant</description>
<url>https://github.com/spring-projects/spring-ai</url>
<scm>
<url>https://github.com/spring-projects/spring-ai</url>
<connection>git://github.com/spring-projects/spring-ai.git</connection>
<developerConnection>git@github.com:spring-projects/spring-ai.git</developerConnection>
</scm>
<properties>
<maven.compiler.target>17</maven.compiler.target>
<maven.compiler.source>17</maven.compiler.source>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-core</artifactId>
<version>${project.parent.version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
</dependency>
<!-- <dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-protobuf</artifactId>
<version>1.59.0</version>
</dependency> -->
<dependency>
<groupId>io.qdrant</groupId>
<artifactId>client</artifactId>
<version>${qdrant.version}</version>
</dependency>
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java-util</artifactId>
<version>${protobuf-java.version}</version>
</dependency>
<!-- TESTING -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai</artifactId>
<version>${project.parent.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>qdrant</artifactId>
<version>1.19.6</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

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

View File

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

View File

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

View File

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

View File

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