Add OpenSearch Service Connection support

Service Connection support for Docker Compose and Testcontainers.
This commit is contained in:
Eddú Meléndez
2024-06-18 10:45:54 -05:00
committed by Christian Tzolov
parent 5844f9bf39
commit 37c222a5f3
15 changed files with 480 additions and 5 deletions

View File

@@ -37,6 +37,9 @@ The following service connection factories are provided in the `spring-ai-spring
| `OllamaConnectionDetails`
| Containers named `ollama/ollama`
| `OpenSearchConnectionDetails`
| Containers named `opensearchproject/opensearch`
| `QdrantConnectionDetails`
| Containers named `qdrant/qdrant`

View File

@@ -40,6 +40,9 @@ The following service connection factories are provided in the `spring-ai-spring
| `OllamaConnectionDetails`
| Containers of type `OllamaContainer`
| `OpenSearchConnectionDetails`
| Containers of type `OpensearchContainer`
| `QdrantConnectionDetails`
| Containers of type `QdrantContainer`

View File

@@ -0,0 +1,30 @@
/*
* 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.autoconfigure.vectorstore.opensearch;
import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails;
import java.util.List;
public interface OpenSearchConnectionDetails extends ConnectionDetails {
List<String> getUris();
String getUsername();
String getPassword();
}

View File

@@ -30,12 +30,19 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean;
import java.net.URISyntaxException;
import java.util.List;
import java.util.Optional;
@AutoConfiguration
@ConditionalOnClass({ OpenSearchVectorStore.class, EmbeddingModel.class, OpenSearchClient.class })
@EnableConfigurationProperties(OpenSearchVectorStoreProperties.class)
class OpenSearchVectorStoreAutoConfiguration {
public class OpenSearchVectorStoreAutoConfiguration {
@Bean
@ConditionalOnMissingBean(OpenSearchConnectionDetails.class)
PropertiesOpenSearchConnectionDetails openSearchConnectionDetails(OpenSearchVectorStoreProperties properties) {
return new PropertiesOpenSearchConnectionDetails(properties);
}
@Bean
@ConditionalOnMissingBean
@@ -49,12 +56,15 @@ class OpenSearchVectorStoreAutoConfiguration {
@Bean
@ConditionalOnMissingBean
OpenSearchClient openSearchClient(OpenSearchVectorStoreProperties properties) {
HttpHost[] httpHosts = properties.getUris().stream().map(s -> createHttpHost(s)).toArray(HttpHost[]::new);
OpenSearchClient openSearchClient(OpenSearchConnectionDetails connectionDetails) {
HttpHost[] httpHosts = connectionDetails.getUris()
.stream()
.map(s -> createHttpHost(s))
.toArray(HttpHost[]::new);
ApacheHttpClient5TransportBuilder transportBuilder = ApacheHttpClient5TransportBuilder.builder(httpHosts);
Optional.ofNullable(properties.getUsername())
.map(username -> createBasicCredentialsProvider(httpHosts[0], username, properties.getPassword()))
Optional.ofNullable(connectionDetails.getUsername())
.map(username -> createBasicCredentialsProvider(httpHosts[0], username, connectionDetails.getPassword()))
.ifPresent(basicCredentialsProvider -> transportBuilder
.setHttpClientConfigCallback(httpAsyncClientBuilder -> httpAsyncClientBuilder
.setDefaultCredentialsProvider(basicCredentialsProvider)));
@@ -79,4 +89,29 @@ class OpenSearchVectorStoreAutoConfiguration {
}
}
static class PropertiesOpenSearchConnectionDetails implements OpenSearchConnectionDetails {
private final OpenSearchVectorStoreProperties properties;
PropertiesOpenSearchConnectionDetails(OpenSearchVectorStoreProperties properties) {
this.properties = properties;
}
@Override
public List<String> getUris() {
return this.properties.getUris();
}
@Override
public String getUsername() {
return this.properties.getUsername();
}
@Override
public String getPassword() {
return this.properties.getPassword();
}
}
}

View File

@@ -115,6 +115,14 @@
<optional>true</optional>
</dependency>
<!-- OpenSearch Vector Store-->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-opensearch-store</artifactId>
<version>${project.parent.version}</version>
<optional>true</optional>
</dependency>
<!-- test dependencies -->
<dependency>

View File

@@ -0,0 +1,76 @@
/*
* 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.docker.compose.service.connection.opensearch;
import org.springframework.ai.autoconfigure.vectorstore.opensearch.OpenSearchConnectionDetails;
import org.springframework.boot.docker.compose.core.RunningService;
import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionDetailsFactory;
import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionSource;
import java.util.List;
/**
* @author Eddú Meléndez
*/
class OpenSearchDockerComposeConnectionDetailsFactory
extends DockerComposeConnectionDetailsFactory<OpenSearchConnectionDetails> {
private static final int OPENSEARCH_PORT = 9200;
protected OpenSearchDockerComposeConnectionDetailsFactory() {
super("opensearchproject/opensearch");
}
@Override
protected OpenSearchConnectionDetails getDockerComposeConnectionDetails(DockerComposeConnectionSource source) {
return new OpenSearchDockerComposeConnectionDetails(source.getRunningService());
}
/**
* {@link OpenSearchConnectionDetails} backed by a {@code OpenSearch}
* {@link RunningService}.
*/
static class OpenSearchDockerComposeConnectionDetails extends DockerComposeConnectionDetails
implements OpenSearchConnectionDetails {
private final OpenSearchEnvironment environment;
private final String uri;
OpenSearchDockerComposeConnectionDetails(RunningService service) {
super(service);
this.environment = new OpenSearchEnvironment(service.env());
this.uri = "http://" + service.host() + ":" + service.ports().get(OPENSEARCH_PORT);
}
@Override
public List<String> getUris() {
return List.of(this.uri);
}
@Override
public String getUsername() {
return "admin";
}
@Override
public String getPassword() {
return this.environment.getPassword();
}
}
}

View File

@@ -0,0 +1,32 @@
/*
* 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.docker.compose.service.connection.opensearch;
import java.util.Map;
class OpenSearchEnvironment {
private final String password;
OpenSearchEnvironment(Map<String, String> env) {
this.password = env.get("OPENSEARCH_INITIAL_ADMIN_PASSWORD");
}
String getPassword() {
return this.password;
}
}

View File

@@ -1,6 +1,7 @@
org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactory=\
org.springframework.ai.docker.compose.service.connection.chroma.ChromaDockerComposeConnectionDetailsFactory,\
org.springframework.ai.docker.compose.service.connection.ollama.OllamaDockerComposeConnectionDetailsFactory,\
org.springframework.ai.docker.compose.service.connection.opensearch.OpenSearchDockerComposeConnectionDetailsFactory,\
org.springframework.ai.docker.compose.service.connection.qdrant.QdrantDockerComposeConnectionDetailsFactory,\
org.springframework.ai.docker.compose.service.connection.redis.RedisDockerComposeConnectionDetailsFactory,\
org.springframework.ai.docker.compose.service.connection.typesense.TypesenseDockerComposeConnectionDetailsFactory,\

View File

@@ -0,0 +1,39 @@
/*
* 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.docker.compose.service.connection.opensearch;
import org.junit.jupiter.api.Test;
import org.springframework.ai.autoconfigure.vectorstore.opensearch.OpenSearchConnectionDetails;
import org.springframework.boot.docker.compose.service.connection.test.AbstractDockerComposeIntegrationTests;
import org.testcontainers.utility.DockerImageName;
import static org.assertj.core.api.Assertions.assertThat;
class OpenSearchDockerComposeConnectionDetailsFactoryTests extends AbstractDockerComposeIntegrationTests {
OpenSearchDockerComposeConnectionDetailsFactoryTests() {
super("opensearch-compose.yaml", DockerImageName.parse("opensearchproject/opensearch"));
}
@Test
void runCreatesConnectionDetails() {
OpenSearchConnectionDetails connectionDetails = run(OpenSearchConnectionDetails.class);
assertThat(connectionDetails.getUris()).isNotNull();
assertThat(connectionDetails.getUsername()).isEqualTo("admin");
assertThat(connectionDetails.getPassword()).isEqualTo("D3v3l0p-ment");
}
}

View File

@@ -0,0 +1,40 @@
/*
* 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.docker.compose.service.connection.opensearch;
import org.junit.jupiter.api.Test;
import java.util.Collections;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class OpenSearchEnvironmentTests {
@Test
void getPasswordWhenNoPassword() {
OpenSearchEnvironment environment = new OpenSearchEnvironment(Collections.emptyMap());
assertThat(environment.getPassword()).isNull();
}
@Test
void getPasswordWhenHasPassword() {
OpenSearchEnvironment environment = new OpenSearchEnvironment(
Map.of("OPENSEARCH_INITIAL_ADMIN_PASSWORD", "secret"));
assertThat(environment.getPassword()).isEqualTo("secret");
}
}

View File

@@ -0,0 +1,8 @@
services:
opensearch:
image: '{imageName}'
ports:
- '9200'
environment:
- OPENSEARCH_INITIAL_ADMIN_PASSWORD=D3v3l0p-ment
- discovery.type=single-node

View File

@@ -123,6 +123,14 @@
<optional>true</optional>
</dependency>
<!-- OpenSearch Vector Store-->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-opensearch-store</artifactId>
<version>${project.parent.version}</version>
<optional>true</optional>
</dependency>
<!-- test dependencies -->
<dependency>
@@ -216,6 +224,13 @@
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.opensearch</groupId>
<artifactId>opensearch-testcontainers</artifactId>
<version>2.0.1</version>
<optional>true</optional>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,64 @@
/*
* 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.testcontainers.service.connection.opensearch;
import org.opensearch.testcontainers.OpensearchContainer;
import org.springframework.ai.autoconfigure.vectorstore.opensearch.OpenSearchConnectionDetails;
import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactory;
import org.springframework.boot.testcontainers.service.connection.ContainerConnectionSource;
import java.util.List;
/**
* @author Eddú Meléndez
*/
class OpenSearchContainerConnectionDetailsFactory
extends ContainerConnectionDetailsFactory<OpensearchContainer<?>, OpenSearchConnectionDetails> {
@Override
public OpenSearchConnectionDetails getContainerConnectionDetails(
ContainerConnectionSource<OpensearchContainer<?>> source) {
return new OpenSearchContainerConnectionDetails(source);
}
/**
* {@link OpenSearchConnectionDetails} backed by a {@link ContainerConnectionSource}.
*/
private static final class OpenSearchContainerConnectionDetails
extends ContainerConnectionDetails<OpensearchContainer<?>> implements OpenSearchConnectionDetails {
private OpenSearchContainerConnectionDetails(ContainerConnectionSource<OpensearchContainer<?>> source) {
super(source);
}
@Override
public List<String> getUris() {
return List.of(getContainer().getHttpHostAddress());
}
@Override
public String getUsername() {
return getContainer().isSecurityEnabled() ? getContainer().getUsername() : null;
}
@Override
public String getPassword() {
return getContainer().isSecurityEnabled() ? getContainer().getPassword() : null;
}
}
}

View File

@@ -2,6 +2,7 @@ org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFacto
org.springframework.ai.testcontainers.service.connection.chroma.ChromaContainerConnectionDetailsFactory,\
org.springframework.ai.testcontainers.service.connection.milvus.MilvusContainerConnectionDetailsFactory,\
org.springframework.ai.testcontainers.service.connection.ollama.OllamaContainerConnectionDetailsFactory,\
org.springframework.ai.testcontainers.service.connection.opensearch.OpenSearchContainerConnectionDetailsFactory,\
org.springframework.ai.testcontainers.service.connection.qdrant.QdrantContainerConnectionDetailsFactory,\
org.springframework.ai.testcontainers.service.connection.redis.RedisContainerConnectionDetailsFactory,\
org.springframework.ai.testcontainers.service.connection.typesense.TypesenseContainerConnectionDetailsFactory,\

View File

@@ -0,0 +1,120 @@
/*
* 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.testcontainers.service.connection.opensearch;
import org.awaitility.Awaitility;
import org.junit.jupiter.api.Test;
import org.opensearch.testcontainers.OpensearchContainer;
import org.springframework.ai.autoconfigure.vectorstore.opensearch.OpenSearchVectorStoreAutoConfiguration;
import org.springframework.ai.document.Document;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.transformers.TransformersEmbeddingModel;
import org.springframework.ai.vectorstore.OpenSearchVectorStore;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.DefaultResourceLoader;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.hasSize;
@SpringBootTest(properties = {
"spring.ai.vectorstore.opensearch.index-name=" + OpenSearchContainerConnectionDetailsFactoryTest.DOCUMENT_INDEX,
"spring.ai.vectorstore.opensearch.mapping-json="
+ OpenSearchContainerConnectionDetailsFactoryTest.MAPPING_JSON })
@Testcontainers
class OpenSearchContainerConnectionDetailsFactoryTest {
@Container
@ServiceConnection
private static final OpensearchContainer<?> opensearch = new OpensearchContainer<>(
"opensearchproject/opensearch:2.12.0");
static final String DOCUMENT_INDEX = "auto-spring-ai-document-index";
static final String MAPPING_JSON = "{\"properties\":{\"embedding\":{\"type\":\"knn_vector\",\"dimension\":384}}}";
private final 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")));
@Autowired
private OpenSearchVectorStore vectorStore;
@Test
public void addAndSearchTest() {
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 (19291939) 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));
}
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);
}
}
@Configuration(proxyBeanMethods = false)
@ImportAutoConfiguration(OpenSearchVectorStoreAutoConfiguration.class)
static class Config {
@Bean
public EmbeddingModel embeddingModel() {
return new TransformersEmbeddingModel();
}
}
}