diff --git a/spring-ai-modules/pom.xml b/spring-ai-modules/pom.xml index 876abd83095f..44c133498d10 100644 --- a/spring-ai-modules/pom.xml +++ b/spring-ai-modules/pom.xml @@ -18,6 +18,7 @@ spring-ai-mcp spring-ai-text-to-sql + spring-ai-vector-stores \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-vector-stores/pom.xml b/spring-ai-modules/spring-ai-vector-stores/pom.xml new file mode 100644 index 000000000000..f88fd70ced48 --- /dev/null +++ b/spring-ai-modules/spring-ai-vector-stores/pom.xml @@ -0,0 +1,23 @@ + + + 4.0.0 + + + com.baeldung + spring-ai-modules + 0.0.1 + ../pom.xml + + + spring-ai-vector-stores + 0.0.1 + pom + spring-ai-vector-stores + + + spring-ai-oracle + + + diff --git a/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/pom.xml b/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/pom.xml new file mode 100644 index 000000000000..3852f07ff3c9 --- /dev/null +++ b/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/pom.xml @@ -0,0 +1,79 @@ + + + 4.0.0 + + + com.baeldung + spring-ai-vector-stores + 0.0.1 + ../pom.xml + + + com.baeldung + spring-ai-oracle + 0.0.1 + spring-ai-oracle + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.ai + spring-ai-starter-vector-store-oracle + + + org.springframework.ai + spring-ai-advisors-vector-store + + + org.springframework.ai + spring-ai-starter-model-openai + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.ai + spring-ai-spring-boot-testcontainers + test + + + org.testcontainers + oracle-free + test + + + + + 21 + 1.0.0 + 3.5.4 + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + + + + org.springframework.ai + spring-ai-bom + ${spring-ai.version} + pom + import + + + + + \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/src/main/java/com/baeldung/springai/vectorstore/oracle/Application.java b/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/src/main/java/com/baeldung/springai/vectorstore/oracle/Application.java new file mode 100644 index 000000000000..ae7f203899bd --- /dev/null +++ b/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/src/main/java/com/baeldung/springai/vectorstore/oracle/Application.java @@ -0,0 +1,13 @@ +package com.baeldung.springai.vectorstore.oracle; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } + +} \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/src/main/java/com/baeldung/springai/vectorstore/oracle/Quote.java b/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/src/main/java/com/baeldung/springai/vectorstore/oracle/Quote.java new file mode 100644 index 000000000000..3106010dbd27 --- /dev/null +++ b/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/src/main/java/com/baeldung/springai/vectorstore/oracle/Quote.java @@ -0,0 +1,4 @@ +package com.baeldung.springai.vectorstore.oracle; + +record Quote(String quote, String author) { +} \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/src/main/java/com/baeldung/springai/vectorstore/oracle/QuoteFetcher.java b/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/src/main/java/com/baeldung/springai/vectorstore/oracle/QuoteFetcher.java new file mode 100644 index 000000000000..f822a8d9c2fa --- /dev/null +++ b/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/src/main/java/com/baeldung/springai/vectorstore/oracle/QuoteFetcher.java @@ -0,0 +1,27 @@ +package com.baeldung.springai.vectorstore.oracle; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.web.client.RestClient; + +import java.net.URI; +import java.util.List; + +class QuoteFetcher { + + private static final String BASE_URL = "https://api.breakingbadquotes.xyz/v1/quotes/"; + private static final int DEFAULT_COUNT = 150; + + static List fetch() { + return fetch(DEFAULT_COUNT); + } + + static List fetch(int count) { + return RestClient + .create() + .get() + .uri(URI.create(BASE_URL + count)) + .retrieve() + .body(new ParameterizedTypeReference<>() {}); + } + +} \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/src/main/java/com/baeldung/springai/vectorstore/oracle/RAGChatbotConfiguration.java b/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/src/main/java/com/baeldung/springai/vectorstore/oracle/RAGChatbotConfiguration.java new file mode 100644 index 000000000000..036a7904990c --- /dev/null +++ b/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/src/main/java/com/baeldung/springai/vectorstore/oracle/RAGChatbotConfiguration.java @@ -0,0 +1,60 @@ +package com.baeldung.springai.vectorstore.oracle; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.prompt.PromptTemplate; +import org.springframework.ai.template.st.StTemplateRenderer; +import org.springframework.ai.vectorstore.SearchRequest; +import org.springframework.ai.vectorstore.VectorStore; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.Resource; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +@Configuration +class RAGChatbotConfiguration { + + private static final int MAX_RESULTS = 10; + + @Bean + PromptTemplate promptTemplate( + @Value("classpath:prompt-template.st") Resource promptTemplate + ) throws IOException { + String template = promptTemplate.getContentAsString(StandardCharsets.UTF_8); + return PromptTemplate + .builder() + .renderer(StTemplateRenderer + .builder() + .startDelimiterToken('<') + .endDelimiterToken('>') + .build()) + .template(template) + .build(); + } + + @Bean + ChatClient chatClient( + ChatModel chatModel, + VectorStore vectorStore, + PromptTemplate promptTemplate + ) { + return ChatClient + .builder(chatModel) + .defaultAdvisors( + QuestionAnswerAdvisor + .builder(vectorStore) + .promptTemplate(promptTemplate) + .searchRequest(SearchRequest + .builder() + .topK(MAX_RESULTS) + .build()) + .build() + ) + .build(); + } + +} \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/src/main/java/com/baeldung/springai/vectorstore/oracle/VectorStoreInitializer.java b/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/src/main/java/com/baeldung/springai/vectorstore/oracle/VectorStoreInitializer.java new file mode 100644 index 000000000000..d18d2c6d7329 --- /dev/null +++ b/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/src/main/java/com/baeldung/springai/vectorstore/oracle/VectorStoreInitializer.java @@ -0,0 +1,34 @@ +package com.baeldung.springai.vectorstore.oracle; + +import org.springframework.ai.document.Document; +import org.springframework.ai.vectorstore.VectorStore; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +@Component +class VectorStoreInitializer implements ApplicationRunner { + + private final VectorStore vectorStore; + + VectorStoreInitializer(VectorStore vectorStore) { + this.vectorStore = vectorStore; + } + + @Override + public void run(ApplicationArguments args) { + List documents = QuoteFetcher + .fetch() + .stream() + .map(quote -> { + Map metadata = Map.of("author", quote.author()); + return new Document(quote.quote(), metadata); + }) + .toList(); + vectorStore.add(documents); + } + +} \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/src/main/resources/application.yaml b/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/src/main/resources/application.yaml new file mode 100644 index 000000000000..a40f4dbc7863 --- /dev/null +++ b/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/src/main/resources/application.yaml @@ -0,0 +1,13 @@ +spring: + ai: + vectorstore: + oracle: + initialize-schema: true + openai: + api-key: ${OPENAI_API_KEY} + embedding: + options: + model: text-embedding-3-large + chat: + options: + model: gpt-4o \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/src/main/resources/logback-spring.xml b/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/src/main/resources/logback-spring.xml new file mode 100644 index 000000000000..449efbdaebb0 --- /dev/null +++ b/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/src/main/resources/logback-spring.xml @@ -0,0 +1,15 @@ + + + + [%d{yyyy-MM-dd HH:mm:ss}] [%p] [%c{1}] - %m%n + + + + + + + + + + + \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/src/main/resources/prompt-template.st b/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/src/main/resources/prompt-template.st new file mode 100644 index 000000000000..f0d83e59c83c --- /dev/null +++ b/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/src/main/resources/prompt-template.st @@ -0,0 +1,16 @@ +You are a chatbot built for analyzing quotes from the 'Breaking Bad' television series. +Given the quotes in the CONTEXT section, answer the query in the USER_QUESTION section. +The response should follow the guidelines listed in the GUIDELINES section. + +CONTEXT: + + +USER_QUESTION: + + +GUIDELINES: +- Base your answer solely on the information found in the provided quotes. +- Provide concise, direct answers without mentioning "based on the context" or similar phrases. +- When referencing specific quotes, mention the character who said them. +- If the question cannot be answered using the context, respond with "The provided quotes do not contain information to answer this question." +- If the question is unrelated to the Breaking Bad show or the quotes provided, respond with "This question is outside the scope of the available Breaking Bad quotes." diff --git a/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/src/test/java/com/baeldung/springai/vectorstore/oracle/OracleDatabaseContainerConnectionDetailsFactory.java b/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/src/test/java/com/baeldung/springai/vectorstore/oracle/OracleDatabaseContainerConnectionDetailsFactory.java new file mode 100644 index 000000000000..9c5ce2f1f012 --- /dev/null +++ b/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/src/test/java/com/baeldung/springai/vectorstore/oracle/OracleDatabaseContainerConnectionDetailsFactory.java @@ -0,0 +1,40 @@ +package com.baeldung.springai.vectorstore.oracle; + +import org.springframework.boot.autoconfigure.jdbc.JdbcConnectionDetails; +import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactory; +import org.springframework.boot.testcontainers.service.connection.ContainerConnectionSource; +import org.testcontainers.oracle.OracleContainer; + +class OracleDatabaseContainerConnectionDetailsFactory + extends ContainerConnectionDetailsFactory { + + @Override + protected JdbcConnectionDetails getContainerConnectionDetails(ContainerConnectionSource source) { + return new OracleDatabaseContainerConnectionDetails(source); + } + + private static final class OracleDatabaseContainerConnectionDetails + extends ContainerConnectionDetails implements JdbcConnectionDetails { + + OracleDatabaseContainerConnectionDetails(ContainerConnectionSource source) { + super(source); + } + + @Override + public String getUsername() { + return getContainer().getUsername(); + } + + @Override + public String getPassword() { + return getContainer().getPassword(); + } + + @Override + public String getJdbcUrl() { + return getContainer().getJdbcUrl(); + } + + } + +} \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/src/test/java/com/baeldung/springai/vectorstore/oracle/RAGChatbotLiveTest.java b/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/src/test/java/com/baeldung/springai/vectorstore/oracle/RAGChatbotLiveTest.java new file mode 100644 index 000000000000..f70c85cd2833 --- /dev/null +++ b/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/src/test/java/com/baeldung/springai/vectorstore/oracle/RAGChatbotLiveTest.java @@ -0,0 +1,64 @@ +package com.baeldung.springai.vectorstore.oracle; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +@SpringBootTest +@Import(TestcontainersConfiguration.class) +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".*") +class RAGChatbotLiveTest { + + private static final String OUT_OF_SCOPE_MESSAGE = "This question is outside the scope of the available Breaking Bad quotes."; + private static final String NO_INFORMATION_MESSAGE = "The provided quotes do not contain information to answer this question."; + + @Autowired + private ChatClient chatClient; + + @ParameterizedTest + @ValueSource(strings = { + "How does the show portray the mentor-student dynamic?", + "Which characters in the show portray insecurity through their quotes?", + "Does the show contain quotes with mature themes inappropriate for young viewers?" + }) + void whenQuestionsRelatedToBreakingBadAsked_thenRelevantAnswerReturned(String userQuery) { + String response = chatClient + .prompt(userQuery) + .call() + .content(); + + assertThat(response) + .isNotBlank() + .doesNotContain(OUT_OF_SCOPE_MESSAGE, NO_INFORMATION_MESSAGE); + } + + @Test + void whenUnrelatedQuestionAsked_thenOutOfScopeMessageReturned() { + String response = chatClient + .prompt("Did Jon Jones duck Tom Aspinall?") + .call() + .content(); + + assertThat(response) + .isEqualTo(OUT_OF_SCOPE_MESSAGE); + } + + @Test + void whenQuestionWithNoRelevantQuotesAsked_thenNoInformationMessageReturned() { + String response = chatClient + .prompt("What does Walter White think about Albuquerque's weather?") + .call() + .content(); + + assertThat(response) + .isEqualTo(NO_INFORMATION_MESSAGE); + } + +} \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/src/test/java/com/baeldung/springai/vectorstore/oracle/SimilaritySearchLiveTest.java b/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/src/test/java/com/baeldung/springai/vectorstore/oracle/SimilaritySearchLiveTest.java new file mode 100644 index 000000000000..2be8a8add34f --- /dev/null +++ b/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/src/test/java/com/baeldung/springai/vectorstore/oracle/SimilaritySearchLiveTest.java @@ -0,0 +1,79 @@ +package com.baeldung.springai.vectorstore.oracle; + +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.ai.document.Document; +import org.springframework.ai.vectorstore.SearchRequest; +import org.springframework.ai.vectorstore.VectorStore; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@Import(TestcontainersConfiguration.class) +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".*") +class SimilaritySearchLiveTest { + + private static final int MAX_RESULTS = 5; + + @Autowired + private VectorStore vectorStore; + + @ParameterizedTest + @ValueSource(strings = { "Sarcasm", "Regret", "Violence and Threats", "Greed, Power, and Money" }) + void whenSearchingBreakingBadTheme_thenRelevantQuotesReturned(String theme) { + SearchRequest searchRequest = SearchRequest + .builder() + .query(theme) + .topK(MAX_RESULTS) + .build(); + + List documents = vectorStore.similaritySearch(searchRequest); + + assertThat(documents) + .hasSizeGreaterThan(0) + .hasSizeLessThanOrEqualTo(MAX_RESULTS) + .allSatisfy(document -> { + assertThat(document.getText()) + .isNotBlank(); + assertThat(String.valueOf(document.getMetadata().get("author"))) + .isNotBlank(); + }); + } + + @ParameterizedTest + @CsvSource({ + "Walter White, Pride", + "Walter White, Control", + "Jesse Pinkman, Abuse and foul language", + "Mike Ehrmantraut, Wisdom", + "Saul Goodman, Law" + }) + void whenSearchingCharacterTheme_thenRelevantQuotesReturned(String author, String theme) { + SearchRequest searchRequest = SearchRequest + .builder() + .query(theme) + .topK(MAX_RESULTS) + .filterExpression(String.format("author == '%s'", author)) + .build(); + + List documents = vectorStore.similaritySearch(searchRequest); + + assertThat(documents) + .hasSizeGreaterThan(0) + .hasSizeLessThanOrEqualTo(MAX_RESULTS) + .allSatisfy(document -> { + assertThat(document.getText()) + .isNotBlank(); + assertThat(String.valueOf(document.getMetadata().get("author"))) + .contains(author); + }); + } + +} \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/src/test/java/com/baeldung/springai/vectorstore/oracle/TestcontainersConfiguration.java b/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/src/test/java/com/baeldung/springai/vectorstore/oracle/TestcontainersConfiguration.java new file mode 100644 index 000000000000..14de1c21008b --- /dev/null +++ b/spring-ai-modules/spring-ai-vector-stores/spring-ai-oracle/src/test/java/com/baeldung/springai/vectorstore/oracle/TestcontainersConfiguration.java @@ -0,0 +1,17 @@ +package com.baeldung.springai.vectorstore.oracle; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.testcontainers.oracle.OracleContainer; + +@TestConfiguration(proxyBeanMethods = false) +class TestcontainersConfiguration { + + @Bean + @ServiceConnection + OracleContainer oracleContainer() { + return new OracleContainer("gvenzl/oracle-free:23-slim"); + } + +} \ No newline at end of file