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:
committed by
Christian Tzolov
parent
e1462b86e3
commit
ea0b439dac
1
vector-stores/spring-ai-qdrant/README.md
Normal file
1
vector-stores/spring-ai-qdrant/README.md
Normal file
@@ -0,0 +1 @@
|
||||
Qdrant Vector Store
|
||||
87
vector-stores/spring-ai-qdrant/pom.xml
Normal file
87
vector-stores/spring-ai-qdrant/pom.xml
Normal 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>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user