Add Elasticsearch vector store integration
- Implement ElasticsearchVectoSotore and IT. - Add ElasticsearchAiSearchFilterExpressionConverter. - Add dependency to BOM and module to parent pom. - Fix ElasticsearchVectorStoreIT FilterExpression with Date type requires the use of epoch milliseconds. - Add license formatting.
This commit is contained in:
committed by
Christian Tzolov
parent
8e50802eb8
commit
9334d7d540
2
pom.xml
2
pom.xml
@@ -27,7 +27,6 @@
|
||||
<module>models/spring-ai-vertex-ai-palm2</module>
|
||||
<module>models/spring-ai-vertex-ai-gemini</module>
|
||||
<module>models/spring-ai-anthropic</module>
|
||||
|
||||
<module>spring-ai-test</module>
|
||||
<module>spring-ai-spring-boot-autoconfigure</module>
|
||||
<module>spring-ai-spring-boot-starters/spring-ai-starter-openai</module>
|
||||
@@ -67,6 +66,7 @@
|
||||
<module>spring-ai-spring-boot-starters/spring-ai-starter-mongodb-atlas-store</module>
|
||||
<module>spring-ai-spring-boot-testcontainers</module>
|
||||
<module>spring-ai-spring-boot-starters/spring-ai-starter-anthropic</module>
|
||||
<module>vector-stores/spring-ai-elasticsearch-store</module>
|
||||
</modules>
|
||||
|
||||
<organization>
|
||||
|
||||
@@ -182,7 +182,13 @@
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-mongodb-atlas-store</artifactId>
|
||||
<artifactId>spring-ai-mongodb-atlas-store</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-elasticsearch-store</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
|
||||
|
||||
@@ -15,8 +15,6 @@
|
||||
*/
|
||||
package org.springframework.ai.vectorstore.filter;
|
||||
|
||||
import org.springframework.ai.vectorstore.filter.Filter;
|
||||
|
||||
/**
|
||||
* Converters a generic, portable {@link Filter.Expression} into a
|
||||
* {@link org.springframework.ai.vectorstore.VectorStore} specific expression language
|
||||
|
||||
78
vector-stores/spring-ai-elasticsearch-store/pom.xml
Normal file
78
vector-stores/spring-ai-elasticsearch-store/pom.xml
Normal file
@@ -0,0 +1,78 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
<relativePath>../../pom.xml</relativePath>
|
||||
</parent>
|
||||
<artifactId>spring-ai-elasticsearch-store</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
<name>Spring AI Vector Store - Elasticsearch</name>
|
||||
<description>Spring AI Elasticsearch Vector Store</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>
|
||||
<!-- testing -->
|
||||
<hikari-cp.version>4.0.3</hikari-cp.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-core</artifactId>
|
||||
<version>${parent.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>co.elastic.clients</groupId>
|
||||
<artifactId>elasticsearch-java</artifactId>
|
||||
<version>8.12.2</version>
|
||||
</dependency>
|
||||
|
||||
<!-- TESTING -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-openai</artifactId>
|
||||
<version>${parent.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-test</artifactId>
|
||||
<version>${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>elasticsearch</artifactId>
|
||||
<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,150 @@
|
||||
/*
|
||||
* Copyright 2023 - 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;
|
||||
|
||||
import org.springframework.ai.vectorstore.filter.Filter;
|
||||
import org.springframework.ai.vectorstore.filter.Filter.Expression;
|
||||
import org.springframework.ai.vectorstore.filter.Filter.Key;
|
||||
import org.springframework.ai.vectorstore.filter.converter.AbstractFilterExpressionConverter;
|
||||
|
||||
import java.text.ParseException;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.TimeZone;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* @author Jemin Huh
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public class ElasticsearchAiSearchFilterExpressionConverter extends AbstractFilterExpressionConverter {
|
||||
|
||||
private static final Pattern DATE_FORMAT_PATTERN = Pattern.compile("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z");
|
||||
|
||||
private final SimpleDateFormat dateFormat;
|
||||
|
||||
public ElasticsearchAiSearchFilterExpressionConverter() {
|
||||
this.dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
|
||||
this.dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doExpression(Expression expression, StringBuilder context) {
|
||||
if (expression.type() == Filter.ExpressionType.IN || expression.type() == Filter.ExpressionType.NIN) {
|
||||
context.append(getOperationSymbol(expression));
|
||||
context.append("(");
|
||||
this.convertOperand(expression.left(), context);
|
||||
this.convertOperand(expression.right(), context);
|
||||
context.append(")");
|
||||
}
|
||||
else {
|
||||
this.convertOperand(expression.left(), context);
|
||||
context.append(getOperationSymbol(expression));
|
||||
this.convertOperand(expression.right(), context);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doStartValueRange(Filter.Value listValue, StringBuilder context) {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doEndValueRange(Filter.Value listValue, StringBuilder context) {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doAddValueRangeSpitter(Filter.Value listValue, StringBuilder context) {
|
||||
context.append(" OR ");
|
||||
}
|
||||
|
||||
private String getOperationSymbol(Expression exp) {
|
||||
return switch (exp.type()) {
|
||||
case AND -> " AND ";
|
||||
case OR -> " OR ";
|
||||
case EQ, IN -> "";
|
||||
case NE -> " NOT ";
|
||||
case LT -> "<";
|
||||
case LTE -> "<=";
|
||||
case GT -> ">";
|
||||
case GTE -> ">=";
|
||||
case NIN -> "NOT ";
|
||||
default -> throw new RuntimeException("Not supported expression type: " + exp.type());
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doKey(Key key, StringBuilder context) {
|
||||
var identifier = hasOuterQuotes(key.key()) ? removeOuterQuotes(key.key()) : key.key();
|
||||
var prefixedIdentifier = withMetaPrefix(identifier);
|
||||
context.append(prefixedIdentifier.trim()).append(":");
|
||||
}
|
||||
|
||||
public String withMetaPrefix(String identifier) {
|
||||
return "metadata." + identifier;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doValue(Filter.Value filterValue, StringBuilder context) {
|
||||
if (filterValue.value() instanceof List list) {
|
||||
int c = 0;
|
||||
for (Object v : list) {
|
||||
context.append(v);
|
||||
if (c++ < list.size() - 1) {
|
||||
this.doAddValueRangeSpitter(filterValue, context);
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.doSingleValue(filterValue.value(), context);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doSingleValue(Object value, StringBuilder context) {
|
||||
if (value instanceof Date date) {
|
||||
context.append(this.dateFormat.format(date));
|
||||
}
|
||||
else if (value instanceof String text) {
|
||||
if (DATE_FORMAT_PATTERN.matcher(text).matches()) {
|
||||
try {
|
||||
Date date = this.dateFormat.parse(text);
|
||||
context.append(this.dateFormat.format(date));
|
||||
}
|
||||
catch (ParseException e) {
|
||||
throw new IllegalArgumentException("Invalid date type:" + text, e);
|
||||
}
|
||||
}
|
||||
else {
|
||||
context.append(text);
|
||||
}
|
||||
}
|
||||
else {
|
||||
context.append(value);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doStartGroup(Filter.Group group, StringBuilder context) {
|
||||
context.append("(");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doEndGroup(Filter.Group group, StringBuilder context) {
|
||||
context.append(")");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/*
|
||||
* Copyright 2023 - 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;
|
||||
|
||||
import co.elastic.clients.elasticsearch.ElasticsearchClient;
|
||||
import co.elastic.clients.elasticsearch._types.query_dsl.Query;
|
||||
import co.elastic.clients.elasticsearch.core.BulkRequest;
|
||||
import co.elastic.clients.elasticsearch.core.BulkResponse;
|
||||
import co.elastic.clients.elasticsearch.core.search.Hit;
|
||||
import co.elastic.clients.elasticsearch.indices.CreateIndexResponse;
|
||||
import co.elastic.clients.json.JsonData;
|
||||
import co.elastic.clients.json.jackson.JacksonJsonpMapper;
|
||||
import co.elastic.clients.transport.endpoints.BooleanResponse;
|
||||
import co.elastic.clients.transport.rest_client.RestClientTransport;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.elasticsearch.client.RestClient;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.ai.document.Document;
|
||||
import org.springframework.ai.embedding.EmbeddingClient;
|
||||
import org.springframework.ai.vectorstore.filter.Filter;
|
||||
import org.springframework.ai.vectorstore.filter.FilterExpressionConverter;
|
||||
import org.springframework.beans.factory.InitializingBean;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.StringReader;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* @author Jemin Huh
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public class ElasticsearchVectorStore implements VectorStore, InitializingBean {
|
||||
|
||||
// divided by 2 to get score in the range [0, 1]
|
||||
public static final String COSINE_SIMILARITY_FUNCTION = "(cosineSimilarity(params.query_vector, 'embedding') + 1.0) / 2";
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ElasticsearchVectorStore.class);
|
||||
|
||||
private static final String INDEX_NAME = "spring-ai-document-index";
|
||||
|
||||
private final EmbeddingClient embeddingClient;
|
||||
|
||||
private final ElasticsearchClient elasticsearchClient;
|
||||
|
||||
private final String index;
|
||||
|
||||
private final FilterExpressionConverter filterExpressionConverter;
|
||||
|
||||
private String similarityFunction;
|
||||
|
||||
public ElasticsearchVectorStore(RestClient restClient, EmbeddingClient embeddingClient) {
|
||||
this(INDEX_NAME, restClient, embeddingClient);
|
||||
}
|
||||
|
||||
public ElasticsearchVectorStore(String index, RestClient restClient, EmbeddingClient embeddingClient) {
|
||||
Objects.requireNonNull(embeddingClient, "RestClient must not be null");
|
||||
Objects.requireNonNull(embeddingClient, "EmbeddingClient must not be null");
|
||||
this.elasticsearchClient = new ElasticsearchClient(new RestClientTransport(restClient, new JacksonJsonpMapper(
|
||||
new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false))));
|
||||
this.embeddingClient = embeddingClient;
|
||||
this.index = index;
|
||||
this.filterExpressionConverter = new ElasticsearchAiSearchFilterExpressionConverter();
|
||||
// the potential functions for vector fields at
|
||||
// https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-script-score-query.html#vector-functions
|
||||
this.similarityFunction = COSINE_SIMILARITY_FUNCTION;
|
||||
}
|
||||
|
||||
public ElasticsearchVectorStore withSimilarityFunction(String similarityFunction) {
|
||||
this.similarityFunction = similarityFunction;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void add(List<Document> documents) {
|
||||
BulkRequest.Builder builkRequestBuilder = new BulkRequest.Builder();
|
||||
for (Document document : documents) {
|
||||
if (Objects.isNull(document.getEmbedding()) || document.getEmbedding().isEmpty()) {
|
||||
logger.debug("Calling EmbeddingClient for document id = " + document.getId());
|
||||
document.setEmbedding(this.embeddingClient.embed(document));
|
||||
}
|
||||
builkRequestBuilder
|
||||
.operations(op -> op.index(idx -> idx.index(this.index).id(document.getId()).document(document)));
|
||||
}
|
||||
bulkRequest(builkRequestBuilder.build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Boolean> delete(List<String> idList) {
|
||||
BulkRequest.Builder builkRequestBuilder = new BulkRequest.Builder();
|
||||
for (String id : idList)
|
||||
builkRequestBuilder.operations(op -> op.delete(idx -> idx.index(this.index).id(id)));
|
||||
return Optional.of(bulkRequest(builkRequestBuilder.build()).errors());
|
||||
}
|
||||
|
||||
private BulkResponse bulkRequest(BulkRequest bulkRequest) {
|
||||
try {
|
||||
return this.elasticsearchClient.bulk(bulkRequest);
|
||||
}
|
||||
catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Document> similaritySearch(SearchRequest searchRequest) {
|
||||
Assert.notNull(searchRequest, "The search request must not be null.");
|
||||
return similaritySearch(this.embeddingClient.embed(searchRequest.getQuery()), searchRequest.getTopK(),
|
||||
Double.valueOf(searchRequest.getSimilarityThreshold()).floatValue(),
|
||||
searchRequest.getFilterExpression());
|
||||
}
|
||||
|
||||
public List<Document> similaritySearch(List<Double> embedding, int topK, double similarityThreshold,
|
||||
Filter.Expression filterExpression) {
|
||||
return similaritySearch(new co.elastic.clients.elasticsearch.core.SearchRequest.Builder()
|
||||
.query(getElasticsearchSimilarityQuery(embedding, filterExpression))
|
||||
.size(topK)
|
||||
.minScore(similarityThreshold)
|
||||
.build());
|
||||
}
|
||||
|
||||
private Query getElasticsearchSimilarityQuery(List<Double> embedding, Filter.Expression filterExpression) {
|
||||
return Query.of(queryBuilder -> queryBuilder.scriptScore(scriptScoreQueryBuilder -> scriptScoreQueryBuilder
|
||||
.query(queryBuilder2 -> queryBuilder2.queryString(queryStringQuerybuilder -> queryStringQuerybuilder
|
||||
.query(getElasticsearchQueryString(filterExpression))))
|
||||
.script(scriptBuilder -> scriptBuilder
|
||||
.inline(inlineScriptBuilder -> inlineScriptBuilder.source(this.similarityFunction)
|
||||
.params("query_vector", JsonData.of(embedding))))));
|
||||
}
|
||||
|
||||
private String getElasticsearchQueryString(Filter.Expression filterExpression) {
|
||||
return Objects.isNull(filterExpression) ? "*"
|
||||
: this.filterExpressionConverter.convertExpression(filterExpression);
|
||||
|
||||
}
|
||||
|
||||
private List<Document> similaritySearch(co.elastic.clients.elasticsearch.core.SearchRequest searchRequest) {
|
||||
try {
|
||||
return this.elasticsearchClient.search(searchRequest, Document.class)
|
||||
.hits()
|
||||
.hits()
|
||||
.stream()
|
||||
.map(this::toDocument)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private Document toDocument(Hit<Document> hit) {
|
||||
Document document = hit.source();
|
||||
document.getMetadata().put("distance", 1 - hit.score().floatValue());
|
||||
return document;
|
||||
}
|
||||
|
||||
public boolean exists(String targetIndex) {
|
||||
try {
|
||||
BooleanResponse response = this.elasticsearchClient.indices()
|
||||
.exists(existRequestBuilder -> existRequestBuilder.index(targetIndex));
|
||||
return response.value();
|
||||
}
|
||||
catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public CreateIndexResponse createIndexMapping(String index, String mappingJson) {
|
||||
try {
|
||||
return this.elasticsearchClient.indices()
|
||||
.create(createIndexBuilder -> createIndexBuilder.index(index)
|
||||
.mappings(typeMappingBuilder -> typeMappingBuilder.withJson(new StringReader(mappingJson))));
|
||||
}
|
||||
catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterPropertiesSet() {
|
||||
if (!exists(this.index)) {
|
||||
createIndexMapping(this.index, """
|
||||
{
|
||||
"properties": {
|
||||
"embedding": {
|
||||
"type": "dense_vector",
|
||||
"dims": 1536,
|
||||
"index": true,
|
||||
"similarity": "cosine"
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright 2023 - 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;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.vectorstore.filter.Filter;
|
||||
import org.springframework.ai.vectorstore.filter.FilterExpressionConverter;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.ai.vectorstore.filter.Filter.ExpressionType.*;
|
||||
|
||||
class ElasticsearchAiSearchFilterExpressionConverterTest {
|
||||
|
||||
final FilterExpressionConverter converter = new ElasticsearchAiSearchFilterExpressionConverter();
|
||||
|
||||
@Test
|
||||
public void testDate() {
|
||||
String vectorExpr = converter.convertExpression(new Filter.Expression(EQ, new Filter.Key("activationDate"),
|
||||
new Filter.Value(new Date(1704637752148L))));
|
||||
assertThat(vectorExpr).isEqualTo("metadata.activationDate:2024-01-07T14:29:12Z");
|
||||
|
||||
vectorExpr = converter.convertExpression(
|
||||
new Filter.Expression(EQ, new Filter.Key("activationDate"), new Filter.Value("1970-01-01T00:00:02Z")));
|
||||
assertThat(vectorExpr).isEqualTo("metadata.activationDate:1970-01-01T00:00:02Z");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testEQ() {
|
||||
String vectorExpr = converter
|
||||
.convertExpression(new Filter.Expression(EQ, new Filter.Key("country"), new Filter.Value("BG")));
|
||||
assertThat(vectorExpr).isEqualTo("metadata.country:BG");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tesEqAndGte() {
|
||||
String vectorExpr = converter.convertExpression(new Filter.Expression(AND,
|
||||
new Filter.Expression(EQ, new Filter.Key("genre"), new Filter.Value("drama")),
|
||||
new Filter.Expression(GTE, new Filter.Key("year"), new Filter.Value(2020))));
|
||||
assertThat(vectorExpr).isEqualTo("metadata.genre:drama AND metadata.year:>=2020");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tesIn() {
|
||||
String vectorExpr = converter.convertExpression(new Filter.Expression(IN, new Filter.Key("genre"),
|
||||
new Filter.Value(List.of("comedy", "documentary", "drama"))));
|
||||
assertThat(vectorExpr).isEqualTo("(metadata.genre:comedy OR documentary OR drama)");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testNe() {
|
||||
String vectorExpr = converter.convertExpression(
|
||||
new Filter.Expression(OR, new Filter.Expression(GTE, new Filter.Key("year"), new Filter.Value(2020)),
|
||||
new Filter.Expression(AND,
|
||||
new Filter.Expression(EQ, new Filter.Key("country"), new Filter.Value("BG")),
|
||||
new Filter.Expression(NE, new Filter.Key("city"), new Filter.Value("Sofia")))));
|
||||
assertThat(vectorExpr).isEqualTo("metadata.year:>=2020 OR metadata.country:BG AND metadata.city: NOT Sofia");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGroup() {
|
||||
String vectorExpr = converter.convertExpression(new Filter.Expression(AND,
|
||||
new Filter.Group(new Filter.Expression(OR,
|
||||
new Filter.Expression(GTE, new Filter.Key("year"), new Filter.Value(2020)),
|
||||
new Filter.Expression(EQ, new Filter.Key("country"), new Filter.Value("BG")))),
|
||||
new Filter.Expression(NIN, new Filter.Key("city"), new Filter.Value(List.of("Sofia", "Plovdiv")))));
|
||||
assertThat(vectorExpr)
|
||||
.isEqualTo("(metadata.year:>=2020 OR metadata.country:BG) AND NOT (metadata.city:Sofia OR Plovdiv)");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tesBoolean() {
|
||||
String vectorExpr = converter.convertExpression(new Filter.Expression(AND,
|
||||
new Filter.Expression(AND, new Filter.Expression(EQ, new Filter.Key("isOpen"), new Filter.Value(true)),
|
||||
new Filter.Expression(GTE, new Filter.Key("year"), new Filter.Value(2020))),
|
||||
new Filter.Expression(IN, new Filter.Key("country"), new Filter.Value(List.of("BG", "NL", "US")))));
|
||||
|
||||
assertThat(vectorExpr)
|
||||
.isEqualTo("metadata.isOpen:true AND metadata.year:>=2020 AND (metadata.country:BG OR NL OR US)");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDecimal() {
|
||||
String vectorExpr = converter.convertExpression(new Filter.Expression(AND,
|
||||
new Filter.Expression(GTE, new Filter.Key("temperature"), new Filter.Value(-15.6)),
|
||||
new Filter.Expression(LTE, new Filter.Key("temperature"), new Filter.Value(20.13))));
|
||||
|
||||
assertThat(vectorExpr).isEqualTo("metadata.temperature:>=-15.6 AND metadata.temperature:<=20.13");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testComplexIdentifiers() {
|
||||
String vectorExpr = converter
|
||||
.convertExpression(new Filter.Expression(EQ, new Filter.Key("\"country 1 2 3\""), new Filter.Value("BG")));
|
||||
assertThat(vectorExpr).isEqualTo("metadata.country 1 2 3:BG");
|
||||
|
||||
vectorExpr = converter
|
||||
.convertExpression(new Filter.Expression(EQ, new Filter.Key("'country 1 2 3'"), new Filter.Value("BG")));
|
||||
assertThat(vectorExpr).isEqualTo("metadata.country 1 2 3:BG");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
/*
|
||||
* Copyright 2023 - 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;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.apache.http.HttpHost;
|
||||
import org.awaitility.Awaitility;
|
||||
import org.elasticsearch.client.RestClient;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.testcontainers.elasticsearch.ElasticsearchContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
import org.testcontainers.shaded.com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
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.boot.SpringBootConfiguration;
|
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.core.io.DefaultResourceLoader;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.hamcrest.Matchers.equalTo;
|
||||
import static org.hamcrest.Matchers.hasSize;
|
||||
|
||||
@Testcontainers
|
||||
@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+")
|
||||
class ElasticsearchVectorStoreIT {
|
||||
|
||||
@Container
|
||||
private static final ElasticsearchContainer elasticsearchContainer = new ElasticsearchContainer(
|
||||
"docker.elastic.co/elasticsearch/elasticsearch:8.12.2")
|
||||
.withEnv("xpack.security.enabled", "false");
|
||||
|
||||
private static final String DEFAULT = "default cosine similarity";
|
||||
|
||||
protected final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
private List<Document> documents = List.of(
|
||||
new Document("1", getText("classpath:/test/data/spring.ai.txt"), Map.of("meta1", "meta1")),
|
||||
new Document("2", getText("classpath:/test/data/time.shelter.txt"), Map.of()),
|
||||
new Document("3", getText("classpath:/test/data/great.depression.txt"), Map.of("meta2", "meta2")));
|
||||
|
||||
@BeforeAll
|
||||
public static void beforeAll() {
|
||||
Awaitility.setDefaultPollInterval(2, TimeUnit.SECONDS);
|
||||
Awaitility.setDefaultPollDelay(Duration.ZERO);
|
||||
Awaitility.setDefaultTimeout(Duration.ofMinutes(1));
|
||||
}
|
||||
|
||||
private 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 ApplicationContextRunner getContextRunner() {
|
||||
return new ApplicationContextRunner().withUserConfiguration(TestApplication.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void cleanDatabase() {
|
||||
getContextRunner().run(context -> {
|
||||
VectorStore vectorStore = context.getBean(VectorStore.class);
|
||||
vectorStore.delete(List.of("_all"));
|
||||
});
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||
@ValueSource(strings = { DEFAULT, """
|
||||
double value = dotProduct(params.query_vector, 'embedding');
|
||||
return sigmoid(1, Math.E, -value);
|
||||
""", "1 / (1 + l1norm(params.query_vector, 'embedding'))",
|
||||
"1 / (1 + l2norm(params.query_vector, 'embedding'))" })
|
||||
public void addAndSearchTest(String similarityFunction) {
|
||||
|
||||
getContextRunner().run(context -> {
|
||||
ElasticsearchVectorStore vectorStore = context.getBean(ElasticsearchVectorStore.class);
|
||||
|
||||
if (!DEFAULT.equals(similarityFunction)) {
|
||||
vectorStore.withSimilarityFunction(similarityFunction);
|
||||
}
|
||||
|
||||
vectorStore.add(documents);
|
||||
|
||||
Awaitility.await()
|
||||
.until(() -> vectorStore
|
||||
.similaritySearch(SearchRequest.query("Great Depression").withTopK(1).withSimilarityThreshold(0)),
|
||||
hasSize(1));
|
||||
|
||||
List<Document> results = vectorStore
|
||||
.similaritySearch(SearchRequest.query("Great Depression").withTopK(1).withSimilarityThreshold(0));
|
||||
|
||||
assertThat(results).hasSize(1);
|
||||
Document resultDoc = results.get(0);
|
||||
assertThat(resultDoc.getId()).isEqualTo(documents.get(2).getId());
|
||||
assertThat(resultDoc.getContent()).contains("The Great Depression (1929–1939) was an economic shock");
|
||||
assertThat(resultDoc.getMetadata()).hasSize(2);
|
||||
assertThat(resultDoc.getMetadata()).containsKey("meta2");
|
||||
assertThat(resultDoc.getMetadata()).containsKey("distance");
|
||||
|
||||
// Remove all documents from the store
|
||||
vectorStore.delete(documents.stream().map(Document::getId).toList());
|
||||
|
||||
Awaitility.await()
|
||||
.until(() -> vectorStore
|
||||
.similaritySearch(SearchRequest.query("Great Depression").withTopK(1).withSimilarityThreshold(0)),
|
||||
hasSize(0));
|
||||
});
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||
@ValueSource(strings = { DEFAULT, """
|
||||
double value = dotProduct(params.query_vector, 'embedding');
|
||||
return sigmoid(1, Math.E, -value);
|
||||
""", "1 / (1 + l1norm(params.query_vector, 'embedding'))",
|
||||
"1 / (1 + l2norm(params.query_vector, 'embedding'))" })
|
||||
public void searchWithFilters(String similarityFunction) {
|
||||
|
||||
getContextRunner().run(context -> {
|
||||
ElasticsearchVectorStore vectorStore = context.getBean(ElasticsearchVectorStore.class);
|
||||
|
||||
if (!DEFAULT.equals(similarityFunction)) {
|
||||
vectorStore.withSimilarityFunction(similarityFunction);
|
||||
}
|
||||
|
||||
var bgDocument = new Document("1", "The World is Big and Salvation Lurks Around the Corner",
|
||||
Map.of("country", "BG", "year", 2020, "activationDate", new Date(1000)));
|
||||
var nlDocument = new Document("2", "The World is Big and Salvation Lurks Around the Corner",
|
||||
Map.of("country", "NL", "activationDate", new Date(2000)));
|
||||
var bgDocument2 = new Document("3", "The World is Big and Salvation Lurks Around the Corner",
|
||||
Map.of("country", "BG", "year", 2023, "activationDate", new Date(3000)));
|
||||
|
||||
vectorStore.add(List.of(bgDocument, nlDocument, bgDocument2));
|
||||
|
||||
Awaitility.await()
|
||||
.until(() -> vectorStore.similaritySearch(SearchRequest.query("The World").withTopK(5)), hasSize(3));
|
||||
|
||||
List<Document> results = vectorStore.similaritySearch(SearchRequest.query("The World")
|
||||
.withTopK(5)
|
||||
.withSimilarityThresholdAll()
|
||||
.withFilterExpression("country == 'NL'"));
|
||||
|
||||
assertThat(results).hasSize(1);
|
||||
assertThat(results.get(0).getId()).isEqualTo(nlDocument.getId());
|
||||
|
||||
results = vectorStore.similaritySearch(SearchRequest.query("The World")
|
||||
.withTopK(5)
|
||||
.withSimilarityThresholdAll()
|
||||
.withFilterExpression("country == 'BG'"));
|
||||
|
||||
assertThat(results).hasSize(2);
|
||||
assertThat(results.get(0).getId()).isIn(bgDocument.getId(), bgDocument2.getId());
|
||||
assertThat(results.get(1).getId()).isIn(bgDocument.getId(), bgDocument2.getId());
|
||||
|
||||
results = vectorStore.similaritySearch(SearchRequest.query("The World")
|
||||
.withTopK(5)
|
||||
.withSimilarityThresholdAll()
|
||||
.withFilterExpression("country == 'BG' && year == 2020"));
|
||||
|
||||
assertThat(results).hasSize(1);
|
||||
assertThat(results.get(0).getId()).isEqualTo(bgDocument.getId());
|
||||
|
||||
results = vectorStore.similaritySearch(SearchRequest.query("The World")
|
||||
.withTopK(5)
|
||||
.withSimilarityThresholdAll()
|
||||
.withFilterExpression("country in ['BG']"));
|
||||
|
||||
assertThat(results).hasSize(2);
|
||||
assertThat(results.get(0).getId()).isIn(bgDocument.getId(), bgDocument2.getId());
|
||||
assertThat(results.get(1).getId()).isIn(bgDocument.getId(), bgDocument2.getId());
|
||||
|
||||
results = vectorStore.similaritySearch(SearchRequest.query("The World")
|
||||
.withTopK(5)
|
||||
.withSimilarityThresholdAll()
|
||||
.withFilterExpression("country in ['BG','NL']"));
|
||||
|
||||
assertThat(results).hasSize(3);
|
||||
|
||||
results = vectorStore.similaritySearch(SearchRequest.query("The World")
|
||||
.withTopK(5)
|
||||
.withSimilarityThresholdAll()
|
||||
.withFilterExpression("country not in ['BG']"));
|
||||
|
||||
assertThat(results).hasSize(1);
|
||||
assertThat(results.get(0).getId()).isEqualTo(nlDocument.getId());
|
||||
|
||||
results = vectorStore.similaritySearch(SearchRequest.query("The World")
|
||||
.withTopK(5)
|
||||
.withSimilarityThresholdAll()
|
||||
.withFilterExpression("NOT(country not in ['BG'])"));
|
||||
|
||||
assertThat(results).hasSize(2);
|
||||
assertThat(results.get(0).getId()).isIn(bgDocument.getId(), bgDocument2.getId());
|
||||
assertThat(results.get(1).getId()).isIn(bgDocument.getId(), bgDocument2.getId());
|
||||
|
||||
results = vectorStore.similaritySearch(SearchRequest.query("The World")
|
||||
.withTopK(5)
|
||||
.withSimilarityThresholdAll()
|
||||
.withFilterExpression(
|
||||
"activationDate > " + ZonedDateTime.parse("1970-01-01T00:00:02Z").toInstant().toEpochMilli()));
|
||||
|
||||
assertThat(results).hasSize(1);
|
||||
assertThat(results.get(0).getId()).isEqualTo(bgDocument2.getId());
|
||||
|
||||
// Remove all documents from the store
|
||||
vectorStore.delete(documents.stream().map(Document::getId).toList());
|
||||
|
||||
Awaitility.await()
|
||||
.until(() -> vectorStore.similaritySearch(SearchRequest.query("The World").withTopK(1)), hasSize(0));
|
||||
});
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||
@ValueSource(strings = { DEFAULT, """
|
||||
double value = dotProduct(params.query_vector, 'embedding');
|
||||
return sigmoid(1, Math.E, -value);
|
||||
""", "1 / (1 + l1norm(params.query_vector, 'embedding'))",
|
||||
"1 / (1 + l2norm(params.query_vector, 'embedding'))" })
|
||||
public void documentUpdateTest(String similarityFunction) {
|
||||
|
||||
getContextRunner().run(context -> {
|
||||
ElasticsearchVectorStore vectorStore = context.getBean(ElasticsearchVectorStore.class);
|
||||
if (!DEFAULT.equals(similarityFunction)) {
|
||||
vectorStore.withSimilarityFunction(similarityFunction);
|
||||
}
|
||||
|
||||
Document document = new Document(UUID.randomUUID().toString(), "Spring AI rocks!!",
|
||||
Map.of("meta1", "meta1"));
|
||||
vectorStore.add(List.of(document));
|
||||
|
||||
Awaitility.await()
|
||||
.until(() -> vectorStore
|
||||
.similaritySearch(SearchRequest.query("Spring").withSimilarityThreshold(0).withTopK(5)),
|
||||
hasSize(1));
|
||||
|
||||
List<Document> results = vectorStore
|
||||
.similaritySearch(SearchRequest.query("Spring").withSimilarityThreshold(0).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", Map.of("meta2", "meta2"));
|
||||
|
||||
vectorStore.add(List.of(sameIdDocument));
|
||||
SearchRequest fooBarSearchRequest = SearchRequest.query("FooBar").withTopK(5);
|
||||
|
||||
Awaitility.await()
|
||||
.until(() -> vectorStore.similaritySearch(fooBarSearchRequest).get(0).getContent(),
|
||||
equalTo("The World is Big and Salvation Lurks Around the Corner"));
|
||||
|
||||
results = vectorStore.similaritySearch(fooBarSearchRequest);
|
||||
|
||||
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");
|
||||
|
||||
// Remove all documents from the store
|
||||
vectorStore.delete(List.of(document.getId()));
|
||||
|
||||
Awaitility.await().until(() -> vectorStore.similaritySearch(fooBarSearchRequest), hasSize(0));
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||
@ValueSource(strings = { DEFAULT, """
|
||||
double value = dotProduct(params.query_vector, 'embedding');
|
||||
return sigmoid(1, Math.E, -value);
|
||||
""", "1 / (1 + l1norm(params.query_vector, 'embedding'))",
|
||||
"1 / (1 + l2norm(params.query_vector, 'embedding'))" })
|
||||
public void searchThresholdTest(String similarityFunction) {
|
||||
|
||||
getContextRunner().run(context -> {
|
||||
ElasticsearchVectorStore vectorStore = context.getBean(ElasticsearchVectorStore.class);
|
||||
if (!DEFAULT.equals(similarityFunction)) {
|
||||
vectorStore.withSimilarityFunction(similarityFunction);
|
||||
}
|
||||
|
||||
vectorStore.add(documents);
|
||||
|
||||
SearchRequest query = SearchRequest.query("Great Depression")
|
||||
.withTopK(50)
|
||||
.withSimilarityThreshold(SearchRequest.SIMILARITY_THRESHOLD_ACCEPT_ALL);
|
||||
|
||||
Awaitility.await().until(() -> vectorStore.similaritySearch(query), hasSize(3));
|
||||
|
||||
List<Document> fullResult = vectorStore.similaritySearch(query);
|
||||
|
||||
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(
|
||||
SearchRequest.query("Great Depression").withTopK(50).withSimilarityThreshold(1 - threshold));
|
||||
|
||||
assertThat(results).hasSize(1);
|
||||
Document resultDoc = results.get(0);
|
||||
assertThat(resultDoc.getId()).isEqualTo(documents.get(2).getId());
|
||||
assertThat(resultDoc.getContent()).contains("The Great Depression (1929–1939) was an economic shock");
|
||||
assertThat(resultDoc.getMetadata()).containsKey("meta2");
|
||||
assertThat(resultDoc.getMetadata()).containsKey("distance");
|
||||
|
||||
// Remove all documents from the store
|
||||
vectorStore.delete(documents.stream().map(Document::getId).toList());
|
||||
|
||||
Awaitility.await()
|
||||
.until(() -> vectorStore
|
||||
.similaritySearch(SearchRequest.query("Great Depression").withTopK(50).withSimilarityThreshold(0)),
|
||||
hasSize(0));
|
||||
});
|
||||
}
|
||||
|
||||
@SpringBootConfiguration
|
||||
@EnableAutoConfiguration(exclude = { DataSourceAutoConfiguration.class })
|
||||
public static class TestApplication {
|
||||
|
||||
@Bean
|
||||
public ElasticsearchVectorStore vectorStore(EmbeddingClient embeddingClient) {
|
||||
return new ElasticsearchVectorStore(
|
||||
RestClient.builder(HttpHost.create(elasticsearchContainer.getHttpHostAddress())).build(),
|
||||
embeddingClient);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public EmbeddingClient embeddingClient() {
|
||||
return new OpenAiEmbeddingClient(new OpenAiApi(System.getenv("OPENAI_API_KEY")));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user