Modular RAG - Query Analysis

Query Analysis
* Introduce Query Analysis Module
* Define QueryTransformer API and TranslationQueryTransformer implementation
* Define QueryExpander API and MultiQueryExpander implementation
* Support QueryTransformer in RetrievalAugmentationAdvisor (support for QueryExpander will be in the next PR together with the needed DocumentFuser API).

Improvements
* Refine Retrieval and Augmentation Modules for increased robustness
* Expand test coverage for both modules
* Define clone() method for ChatClient.Builder

Tests
* Introduce “spring-ai-integration-tests” for full-fledged integration tests
* Add integration tests for RAG modules
* Add integration tests for RAG advisor

Query Analysis
* Introduce Query Analysis Module
* Define QueryTransformer API and TranslationQueryTransformer implementation
* Define QueryExpander API and MultiQueryExpander implementation
* Support QueryTransformer in RetrievalAugmentationAdvisor (support for QueryExpander will be in the next PR together with the needed DocumentFuser API).

Improvements
* Refine Retrieval and Augmentation Modules for increased robustness
* Expand test coverage for both modules
* Define clone() method for ChatClient.Builder

Tests
* Introduce “spring-ai-integration-tests” for full-fledged integration tests
* Add integration tests for RAG modules
* Add integration tests for RAG advisor

Relates to #gh-1603

Signed-off-by: Thomas Vitale <ThomasVitale@users.noreply.github.com>
This commit is contained in:
Thomas Vitale
2024-11-10 14:11:13 +01:00
committed by Mark Pollack
parent b4e0a4598e
commit 263fe2fba7
38 changed files with 1710 additions and 72 deletions

View File

@@ -0,0 +1,91 @@
/*
* 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.openai.chat
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable
import org.slf4j.LoggerFactory
import org.springframework.ai.chat.messages.UserMessage
import org.springframework.ai.chat.prompt.Prompt
import org.springframework.ai.model.function.FunctionCallback
import org.springframework.ai.model.function.FunctionCallbackWrapper
import org.springframework.ai.openai.OpenAiChatModel
import org.springframework.ai.openai.OpenAiChatOptions
import org.springframework.ai.openai.api.OpenAiApi
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.SpringBootConfiguration
import org.springframework.boot.autoconfigure.AutoConfigurations
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.runner.ApplicationContextRunner
import org.springframework.context.annotation.Bean
@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+")
class FunctionCallbackWrapperKotlinIT {
private val logger = LoggerFactory.getLogger(FunctionCallbackWrapperKotlinIT::class.java)
private val contextRunner = ApplicationContextRunner()
.withUserConfiguration(Config::class.java)
@Test
fun functionCallTest() {
this.contextRunner.run {context ->
val chatModel = context.getBean(OpenAiChatModel::class.java)
assertThat(chatModel).isNotNull
val userMessage = UserMessage(
"What are the weather conditions in San Francisco, Tokyo, and Paris? Find the temperature in Celsius for each of the three locations.")
val response = chatModel
.call(Prompt(listOf(userMessage), OpenAiChatOptions.builder().withFunction("WeatherInfo").build()))
logger.info("Response: " + response)
assertThat(response.getResult().output.content).contains("30", "10", "15")
}
}
@SpringBootConfiguration
open class Config {
@Bean
open fun chatCompletionApi(): OpenAiApi {
return OpenAiApi(System.getenv("OPENAI_API_KEY"))
}
@Bean
open fun openAiClient(openAiApi: OpenAiApi): OpenAiChatModel {
return OpenAiChatModel(openAiApi)
}
@Bean
open fun weatherFunctionInfo(): FunctionCallback {
return FunctionCallbackWrapper.builder(MockKotlinWeatherService())
.withName("WeatherInfo")
.withInputType(KotlinRequest::class.java)
.withDescription(
"Find the weather conditions, forecasts, and temperatures for a location, like a city or state.")
.build();
}
}
}

View File

@@ -0,0 +1,92 @@
/*
* 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.openai.chat
import com.fasterxml.jackson.annotation.JsonClassDescription
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.annotation.JsonInclude.Include
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.annotation.JsonPropertyDescription
class MockKotlinWeatherService : Function1<KotlinRequest, KotlinResponse> {
override fun invoke(kotlinRequest: KotlinRequest): KotlinResponse {
var temperature = 10.0
if (kotlinRequest.location.contains("Paris")) {
temperature = 15.0
}
else if (kotlinRequest.location.contains("Tokyo")) {
temperature = 10.0
}
else if (kotlinRequest.location.contains("San Francisco")) {
temperature = 30.0
}
return KotlinResponse(temperature, 15.0, 20.0, 2.0, 53, 45, Unit.C);
}
}
/**
* Temperature units.
*/
enum class Unit(val unitName: String) {
/**
* Celsius.
*/
C("metric"),
/**
* Fahrenheit.
*/
F("imperial");
}
/**
* Weather Function request.
*/
@JsonInclude(Include.NON_NULL)
@JsonClassDescription("Weather API request")
data class KotlinRequest(
@get:JsonProperty(required = true, value = "location")
@get:JsonPropertyDescription("The city and state e.g. San Francisco, CA")
val location: String = "",
@get:JsonProperty(required = true, value = "lat")
@get:JsonPropertyDescription("The city latitude")
val lat: Double = 0.0,
@get:JsonProperty(required = true, value = "lon")
@get:JsonPropertyDescription("The city longitude")
val lon: Double = 0.0,
@get:JsonProperty(required = true, value = "unit")
@get:JsonPropertyDescription("Temperature unit")
val unit: Unit = Unit.C
)
/**
* Weather Function response.
*/
data class KotlinResponse(val temp: Double,
val feels_like: Double,
val temp_min: Double,
val temp_max: Double,
val pressure: Int,
val humidity: Int,
val unit: Unit
)

View File

@@ -123,6 +123,8 @@
<module>spring-ai-spring-boot-starters/spring-ai-starter-watsonx-ai</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-zhipuai</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-moonshot</module>
<module>spring-ai-integration-tests</module>
</modules>
<organization>

View File

@@ -289,6 +289,8 @@ public interface ChatClient {
Builder defaultToolContext(Map<String, Object> toolContext);
Builder clone();
ChatClient build();
}

View File

@@ -71,6 +71,10 @@ public class DefaultChatClientBuilder implements Builder {
return new DefaultChatClient(this.defaultRequest);
}
public Builder clone() {
return this.defaultRequest.mutate();
}
public Builder defaultAdvisors(Advisor... advisors) {
this.defaultRequest.advisors(advisors);
return this;

View File

@@ -16,6 +16,8 @@
package org.springframework.ai.chat.client.advisor;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -35,15 +37,16 @@ import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.ai.document.Document;
import org.springframework.ai.rag.Query;
import org.springframework.ai.rag.analysis.query.transformation.QueryTransformer;
import org.springframework.ai.rag.augmentation.ContextualQueryAugmentor;
import org.springframework.ai.rag.augmentation.QueryAugmentor;
import org.springframework.ai.rag.retrieval.source.DocumentRetriever;
import org.springframework.ai.rag.retrieval.search.DocumentRetriever;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* This advisor implements common Retrieval Augmented Generation (RAG) flows using the
* Advisor that implements common Retrieval Augmented Generation (RAG) flows using the
* building blocks defined in the {@link org.springframework.ai.rag} package and following
* the Modular RAG Architecture.
* <p>
@@ -55,10 +58,12 @@ import org.springframework.util.StringUtils;
* @see <a href="http://export.arxiv.org/abs/2407.21059">arXiv:2407.21059</a>
* @see <a href="https://export.arxiv.org/abs/2312.10997">arXiv:2312.10997</a>
*/
public class RetrievalAugmentationAdvisor implements CallAroundAdvisor, StreamAroundAdvisor {
public final class RetrievalAugmentationAdvisor implements CallAroundAdvisor, StreamAroundAdvisor {
public static final String DOCUMENT_CONTEXT = "rag_document_context";
private final List<QueryTransformer> queryTransformers;
private final DocumentRetriever documentRetriever;
private final QueryAugmentor queryAugmentor;
@@ -67,12 +72,15 @@ public class RetrievalAugmentationAdvisor implements CallAroundAdvisor, StreamAr
private final int order;
public RetrievalAugmentationAdvisor(DocumentRetriever documentRetriever, @Nullable QueryAugmentor queryAugmentor,
@Nullable Boolean protectFromBlocking, @Nullable Integer order) {
public RetrievalAugmentationAdvisor(List<QueryTransformer> queryTransformers, DocumentRetriever documentRetriever,
@Nullable QueryAugmentor queryAugmentor, @Nullable Boolean protectFromBlocking, @Nullable Integer order) {
Assert.notNull(queryTransformers, "queryTransformers cannot be null");
Assert.noNullElements(queryTransformers, "queryTransformers cannot contain null elements");
Assert.notNull(documentRetriever, "documentRetriever cannot be null");
this.queryTransformers = queryTransformers;
this.documentRetriever = documentRetriever;
this.queryAugmentor = queryAugmentor != null ? queryAugmentor : ContextualQueryAugmentor.builder().build();
this.protectFromBlocking = protectFromBlocking != null ? protectFromBlocking : false;
this.protectFromBlocking = protectFromBlocking != null ? protectFromBlocking : true;
this.order = order != null ? order : 0;
}
@@ -119,30 +127,45 @@ public class RetrievalAugmentationAdvisor implements CallAroundAdvisor, StreamAr
Map<String, Object> context = new HashMap<>(request.adviseContext());
// 0. Create a query from the user text and parameters.
Query query = new Query(new PromptTemplate(request.userText(), request.userParams()).render());
Query originalQuery = new Query(new PromptTemplate(request.userText(), request.userParams()).render());
// 1. Retrieve similar documents for the original query.
List<Document> documents = this.documentRetriever.retrieve(query);
// 1. Transform original user query based on a chain of query transformers.
Query transformedQuery = originalQuery;
for (var queryTransformer : this.queryTransformers) {
transformedQuery = queryTransformer.apply(transformedQuery);
}
// 2. Retrieve similar documents for the original query.
List<Document> documents = this.documentRetriever.retrieve(transformedQuery);
context.put(DOCUMENT_CONTEXT, documents);
// 2. Augment user query with the contextual data.
Query augmentedQuery = this.queryAugmentor.augment(query, documents);
// 3. Augment user query with the document contextual data.
Query augmentedQuery = this.queryAugmentor.augment(transformedQuery, documents);
return AdvisedRequest.from(request).withUserText(augmentedQuery.text()).withAdviseContext(context).build();
}
private AdvisedResponse after(AdvisedResponse advisedResponse) {
ChatResponse.Builder chatResponseBuilder = ChatResponse.builder().from(advisedResponse.response());
ChatResponse.Builder chatResponseBuilder;
if (advisedResponse.response() == null) {
chatResponseBuilder = ChatResponse.builder();
}
else {
chatResponseBuilder = ChatResponse.builder().from(advisedResponse.response());
}
chatResponseBuilder.withMetadata(DOCUMENT_CONTEXT, advisedResponse.adviseContext().get(DOCUMENT_CONTEXT));
return new AdvisedResponse(chatResponseBuilder.build(), advisedResponse.adviseContext());
}
private Predicate<AdvisedResponse> onFinishReason() {
return advisedResponse -> advisedResponse.response()
.getResults()
.stream()
.anyMatch(result -> result != null && result.getMetadata() != null
&& StringUtils.hasText(result.getMetadata().getFinishReason()));
return advisedResponse -> {
ChatResponse chatResponse = advisedResponse.response();
return chatResponse != null && chatResponse.getResults() != null
&& chatResponse.getResults()
.stream()
.anyMatch(result -> result != null && result.getMetadata() != null
&& StringUtils.hasText(result.getMetadata().getFinishReason()));
};
}
@Override
@@ -157,6 +180,8 @@ public class RetrievalAugmentationAdvisor implements CallAroundAdvisor, StreamAr
public static final class Builder {
private final List<QueryTransformer> queryTransformers = new ArrayList<>();
private DocumentRetriever documentRetriever;
private QueryAugmentor queryAugmentor;
@@ -168,6 +193,18 @@ public class RetrievalAugmentationAdvisor implements CallAroundAdvisor, StreamAr
private Builder() {
}
public Builder queryTransformers(List<QueryTransformer> queryTransformers) {
Assert.notNull(queryTransformers, "queryTransformers cannot be null");
this.queryTransformers.addAll(queryTransformers);
return this;
}
public Builder queryTransformers(QueryTransformer... queryTransformers) {
Assert.notNull(queryTransformers, "queryTransformers cannot be null");
this.queryTransformers.addAll(Arrays.asList(queryTransformers));
return this;
}
public Builder documentRetriever(DocumentRetriever documentRetriever) {
this.documentRetriever = documentRetriever;
return this;
@@ -189,7 +226,7 @@ public class RetrievalAugmentationAdvisor implements CallAroundAdvisor, StreamAr
}
public RetrievalAugmentationAdvisor build() {
return new RetrievalAugmentationAdvisor(this.documentRetriever, this.queryAugmentor,
return new RetrievalAugmentationAdvisor(this.queryTransformers, this.documentRetriever, this.queryAugmentor,
this.protectFromBlocking, this.order);
}

View File

@@ -0,0 +1,29 @@
/*
* 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.
*/
/**
* RAG Module: Query Analysis.
* <p>
* This package encompasses all components involved in the pre-retrieval phase of a
* retrieval augmented generation flow. Queries are transformed, expanded, or constructed
* so to enhance the effectiveness and accuracy of the subsequent retrieval phase.
*/
@NonNullApi
@NonNullFields
package org.springframework.ai.rag.analysis;
import org.springframework.lang.NonNullApi;
import org.springframework.lang.NonNullFields;

View File

@@ -0,0 +1,178 @@
/*
* 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.rag.analysis.query.expansion;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.ai.rag.Query;
import org.springframework.ai.util.PromptAssert;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
/**
* Expander that implements semantic query expansion for retrieval-augmented generation
* flows. It uses a large language model to generate multiple semantically diverse
* variations of an input query to capture different perspectives and improve document
* retrieval coverage.
*
* <p>
* Example usage: <pre>{@code
* MultiQueryExpander expander = MultiQueryExpander.builder()
* .chatClientBuilder(chatClientBuilder)
* .numberOfQueries(3)
* .build();
* List<Query> queries = expander.expand(new Query("How to run a Spring Boot app?"));
* }</pre>
*
* @author Thomas Vitale
* @since 1.0.0
*/
public final class MultiQueryExpander implements QueryExpander {
private static final Logger logger = LoggerFactory.getLogger(MultiQueryExpander.class);
private static final PromptTemplate DEFAULT_PROMPT_TEMPLATE = new PromptTemplate("""
You are an expert at information retrieval and search optimization.
Your task is to generate {number} different versions of the given query.
Each variant must cover different perspectives or aspects of the topic,
while maintaining the core intent of the original query. The goal is to
expand the search space and improve the chances of finding relevant information.
Do not explain your choices or add any other text.
Provide the query variants separated by newlines.
Original query: {query}
Query variants:
""");
private static final Boolean DEFAULT_INCLUDE_ORIGINAL = false;
private static final Integer DEFAULT_NUMBER_OF_QUERIES = 3;
private final ChatClient chatClient;
private final PromptTemplate promptTemplate;
private final boolean includeOriginal;
private final int numberOfQueries;
public MultiQueryExpander(ChatClient.Builder chatClientBuilder, @Nullable PromptTemplate promptTemplate,
@Nullable Boolean includeOriginal, @Nullable Integer numberOfQueries) {
Assert.notNull(chatClientBuilder, "chatClientBuilder cannot be null");
this.chatClient = chatClientBuilder.build();
this.promptTemplate = promptTemplate != null ? promptTemplate : DEFAULT_PROMPT_TEMPLATE;
this.includeOriginal = includeOriginal != null ? includeOriginal : DEFAULT_INCLUDE_ORIGINAL;
this.numberOfQueries = numberOfQueries != null ? numberOfQueries : DEFAULT_NUMBER_OF_QUERIES;
PromptAssert.templateHasRequiredPlaceholders(this.promptTemplate, "number", "query");
}
@Override
public List<Query> expand(Query query) {
Assert.notNull(query, "query cannot be null");
logger.debug("Generating {} query variants", this.numberOfQueries);
var response = this.chatClient.prompt()
.user(user -> user.text(this.promptTemplate.getTemplate())
.param("number", numberOfQueries)
.param("query", query.text()))
.call()
.content();
if (response == null) {
logger.warn("Query expansion result is null. Returning the input query unchanged.");
return List.of(query);
}
var queryVariants = Arrays.asList(response.split("\n"));
if (CollectionUtils.isEmpty(queryVariants) || this.numberOfQueries != queryVariants.size()) {
logger.warn(
"Query expansion result does not contain the requested {} variants. Returning the input query unchanged.",
this.numberOfQueries);
return List.of(query);
}
var queries = queryVariants.stream().filter(StringUtils::hasText).map(Query::new).collect(Collectors.toList());
if (this.includeOriginal) {
logger.debug("Including the original query in the result");
queries.add(0, query);
}
return queries;
}
public static Builder builder() {
return new Builder();
}
public static final class Builder {
private ChatClient.Builder chatClientBuilder;
private PromptTemplate promptTemplate;
private Boolean includeOriginal;
private Integer numberOfQueries;
private Builder() {
}
public Builder chatClientBuilder(ChatClient.Builder chatClientBuilder) {
this.chatClientBuilder = chatClientBuilder;
return this;
}
public Builder promptTemplate(PromptTemplate promptTemplate) {
this.promptTemplate = promptTemplate;
return this;
}
public Builder includeOriginal(Boolean includeOriginal) {
this.includeOriginal = includeOriginal;
return this;
}
public Builder numberOfQueries(Integer numberOfQueries) {
this.numberOfQueries = numberOfQueries;
return this;
}
public MultiQueryExpander build() {
return new MultiQueryExpander(this.chatClientBuilder, this.promptTemplate, this.includeOriginal,
this.numberOfQueries);
}
}
}

View File

@@ -0,0 +1,53 @@
/*
* 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.rag.analysis.query.expansion;
import java.util.List;
import java.util.function.Function;
import org.springframework.ai.rag.Query;
/**
* A component responsible for expanding the input query into a list of related queries
* based on a specified strategy. These expansions can be used to capture different
* perspectives or to break down complex queries into simpler, more manageable
* sub-queries, thereby improving the retrieval process.
*
* @author Thomas Vitale
* @since 1.0.0
*/
public interface QueryExpander extends Function<Query, List<Query>> {
/**
* Expands the given query into a list of related queries according to the implemented
* strategy.
* @param query The original query to be expanded
* @return A list of expanded queries
*/
List<Query> expand(Query query);
/**
* Expands the given query into a list of related queries according to the implemented
* strategy.
* @param query The original query to be expanded
* @return A list of expanded queries
*/
default List<Query> apply(Query query) {
return expand(query);
}
}

View File

@@ -15,15 +15,11 @@
*/
/**
* RAG Sub-Module: Source.
* <p>
* This package provides the functional building blocks for retrieving documents from a
* data source.
* RAG Component: Query Expansion.
*/
@NonNullApi
@NonNullFields
package org.springframework.ai.rag.retrieval.source;
package org.springframework.ai.rag.analysis.query.expansion;
import org.springframework.lang.NonNullApi;
import org.springframework.lang.NonNullFields;

View File

@@ -0,0 +1,49 @@
/*
* 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.rag.analysis.query.transformation;
import java.util.function.Function;
import org.springframework.ai.rag.Query;
/**
* Component responsible for transforming the input query based on a specified strategy.
* These transformations can be used to enhance the clarity, semantic meaning, or language
* of the query, thereby improving the effectiveness of the retrieval process.
*
* @author Thomas Vitale
* @since 1.0.0
*/
public interface QueryTransformer extends Function<Query, Query> {
/**
* Transforms the given query according to the implemented strategy.
* @param query The original query to transform
* @return The transformed query
*/
Query transform(Query query);
/**
* Transforms the given query according to the implemented strategy.
* @param query The original query to transform
* @return The transformed query
*/
default Query apply(Query query) {
return transform(query);
}
}

View File

@@ -0,0 +1,139 @@
/*
* 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.rag.analysis.query.transformation;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.prompt.ChatOptionsBuilder;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.ai.rag.Query;
import org.springframework.ai.util.PromptAssert;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* Transformer that handles translation of the input query to a target language using a
* large language model. It's aimed at optimizing similarity searches by translating a
* query into a language supported by the document store.
*
* <p>
* Example usage: <pre>{@code
* QueryTransformer transformer = TranslationQueryTransformer.builder()
* .chatClientBuilder(chatClientBuilder)
* .targetLanguage("english")
* .build();
* Query transformedQuery = transformer.transform(new Query("Hvad er Danmarks hovedstad?"));
* }</pre>
*
* @author Thomas Vitale
* @since 1.0.0
*/
public final class TranslationQueryTransformer implements QueryTransformer {
private static final Logger logger = LoggerFactory.getLogger(TranslationQueryTransformer.class);
private static final PromptTemplate DEFAULT_PROMPT_TEMPLATE = new PromptTemplate("""
Given a user query, translate it to {targetLanguage}.
If the query is already in {targetLanguage}, return it unchanged.
If you don't know the language of the query, return it unchanged.
Do not add explanations nor any other text.
Original query: {query}
Translated query:
""");
private final ChatClient chatClient;
private final PromptTemplate promptTemplate;
private final String targetLanguage;
public TranslationQueryTransformer(ChatClient.Builder chatClientBuilder, @Nullable PromptTemplate promptTemplate,
String targetLanguage) {
Assert.notNull(chatClientBuilder, "chatClientBuilder cannot be null");
Assert.hasText(targetLanguage, "targetLanguage cannot be null or empty");
this.chatClient = chatClientBuilder.build();
this.promptTemplate = promptTemplate != null ? promptTemplate : DEFAULT_PROMPT_TEMPLATE;
this.targetLanguage = targetLanguage;
PromptAssert.templateHasRequiredPlaceholders(this.promptTemplate, "targetLanguage", "query");
}
@Override
public Query transform(Query query) {
Assert.notNull(query, "query cannot be null");
logger.debug("Translating query to target language: {}", this.targetLanguage);
var translatedQuery = this.chatClient.prompt()
.user(user -> user.text(this.promptTemplate.getTemplate())
.param("targetLanguage", targetLanguage)
.param("query", query.text()))
.options(ChatOptionsBuilder.builder().withTemperature(0.0).build())
.call()
.content();
if (!StringUtils.hasText(translatedQuery)) {
logger.warn("Query translation result is null/empty. Returning the input query unchanged.");
return query;
}
return new Query(translatedQuery);
}
public static Builder builder() {
return new Builder();
}
public static final class Builder {
private ChatClient.Builder chatClientBuilder;
private PromptTemplate promptTemplate;
private String targetLanguage;
private Builder() {
}
public Builder chatClientBuilder(ChatClient.Builder chatClientBuilder) {
this.chatClientBuilder = chatClientBuilder;
return this;
}
public Builder promptTemplate(PromptTemplate promptTemplate) {
this.promptTemplate = promptTemplate;
return this;
}
public Builder targetLanguage(String targetLanguage) {
this.targetLanguage = targetLanguage;
return this;
}
public TranslationQueryTransformer build() {
return new TranslationQueryTransformer(this.chatClientBuilder, this.promptTemplate, this.targetLanguage);
}
}
}

View File

@@ -0,0 +1,25 @@
/*
* 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.
*/
/**
* RAG Component: Query Transformation.
*/
@NonNullApi
@NonNullFields
package org.springframework.ai.rag.analysis.query.transformation;
import org.springframework.lang.NonNullApi;
import org.springframework.lang.NonNullFields;

View File

@@ -20,6 +20,9 @@ import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.ai.document.Document;
import org.springframework.ai.model.Content;
@@ -29,14 +32,13 @@ import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
/**
* Augments the user query with contextual data.
* Augments the user query with contextual data from the content of the provided
* documents.
*
* <p>
* Example usage: <pre>{@code
* QueryAugmentor augmentor = ContextualQueryAugmentor.builder()
* .promptTemplate(promptTemplate)
* .emptyContextPromptTemplate(emptyContextPromptTemplate)
* .allowEmptyContext(allowEmptyContext)
* .allowEmptyContext(false)
* .build();
* Query augmentedQuery = augmentor.augment(query, documents);
* }</pre>
@@ -44,7 +46,9 @@ import org.springframework.util.Assert;
* @author Thomas Vitale
* @since 1.0.0
*/
public class ContextualQueryAugmentor implements QueryAugmentor {
public final class ContextualQueryAugmentor implements QueryAugmentor {
private static final Logger logger = LoggerFactory.getLogger(ContextualQueryAugmentor.class);
private static final PromptTemplate DEFAULT_PROMPT_TEMPLATE = new PromptTemplate("""
Context information is below.
@@ -92,6 +96,8 @@ public class ContextualQueryAugmentor implements QueryAugmentor {
Assert.notNull(query, "query cannot be null");
Assert.notNull(documents, "documents cannot be null");
logger.debug("Augmenting query with contextual data");
if (documents.isEmpty()) {
return augmentQueryWhenEmptyContext(query);
}
@@ -110,8 +116,10 @@ public class ContextualQueryAugmentor implements QueryAugmentor {
private Query augmentQueryWhenEmptyContext(Query query) {
if (this.allowEmptyContext) {
logger.debug("Empty context is allowed. Returning the original query.");
return query;
}
logger.debug("Empty context is not allowed. Returning a specific query for empty context.");
return new Query(this.emptyContextPromptTemplate.render());
}

View File

@@ -23,7 +23,8 @@ import org.springframework.ai.document.Document;
import org.springframework.ai.rag.Query;
/**
* Component for augmenting a query with contextual data based on a specific strategy.
* Component responsible for augmenting an input query with additional contextual data
* that can be used by a large language model to answer the query.
*
* @author Thomas Vitale
* @since 1.0.0

View File

@@ -15,12 +15,13 @@
*/
/**
* RAG Module: Augmentation.
* RAG Module: Query Augmentation.
* <p>
* This package provides the functional building blocks for augmenting a user query with
* contextual data.
* This package encompasses all components involved in the augmentation phase of a
* retrieval augmented generation flow. The goal of this phase is to enrich the user query
* with additional context that can be used to improve the quality of the generated
* response.
*/
@NonNullApi
@NonNullFields
package org.springframework.ai.rag.augmentation;

View File

@@ -16,20 +16,14 @@
/**
* This package contains the core interfaces and classes supporting Retrieval Augmented
* Generation.
* Generation flows.
* <p>
* It's based on the Modular RAG Architecture and provides the necessary building blocks
* to define and execute RAG flows. It includes three levels of abstraction:
* <ol>
* <li>Module</li>
* <li>Sub-Module</li>
* <li>Operator</li>
* </ol>
* It's inspired by the Modular RAG Architecture and provides the necessary building
* blocks to define and execute RAG flows.
*
* @see <a href="http://export.arxiv.org/abs/2407.21059">arXiv:2407.21059</a>
* @see <a href="https://export.arxiv.org/abs/2312.10997">arXiv:2312.10997</a>
*/
@NonNullApi
@NonNullFields
package org.springframework.ai.rag;

View File

@@ -15,11 +15,11 @@
*/
/**
* RAG Module: Retrieval.
* RAG Module: Information Retrieval.
* <p>
* This package includes submodules for handling the retrieval process in RAG flows.
* This package includes submodules for handling the retrieval process in
* retrieval-augmented generation flows.
*/
@NonNullApi
@NonNullFields
package org.springframework.ai.rag.retrieval;

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package org.springframework.ai.rag.retrieval.source;
package org.springframework.ai.rag.retrieval.search;
import java.util.List;
import java.util.function.Function;
@@ -23,7 +23,8 @@ import org.springframework.ai.document.Document;
import org.springframework.ai.rag.Query;
/**
* API for retrieving {@link Document}s from an underlying data source.
* Component responsible for retrieving {@link Document}s from an underlying data source,
* such as a search engine, a vector store, a database, or a knowledge graph.
*
* @author Christian Tzolov
* @author Thomas Vitale
@@ -32,22 +33,18 @@ import org.springframework.ai.rag.Query;
public interface DocumentRetriever extends Function<Query, List<Document>> {
/**
* Retrieves {@link Document}s from an underlying data source using the given
* {@link Query}.
* Retrieves relevant documents from an underlying data source based on the given
* query.
* @param query The query to use for retrieving documents
* @return The list of relevant documents
*/
List<Document> retrieve(Query query);
/**
* Retrieves {@link Document}s from an underlying data source using the given query
* string.
*/
default List<Document> retrieve(String query) {
return retrieve(new Query(query));
}
/**
* Retrieves {@link Document}s from an underlying data source using the given
* {@link Query}.
* Retrieves relevant documents from an underlying data source based on the given
* query.
* @param query The query to use for retrieving documents
* @return The list of relevant documents
*/
default List<Document> apply(Query query) {
return retrieve(query);

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package org.springframework.ai.rag.retrieval.source;
package org.springframework.ai.rag.retrieval.search;
import java.util.List;
import java.util.function.Supplier;
@@ -28,7 +28,7 @@ import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
/**
* A document retriever that uses a vector store to search for documents. It supports
* Document retriever that uses a vector store to search for documents. It supports
* filtering based on metadata, similarity threshold, and top-k results.
*
* <p>
@@ -39,15 +39,13 @@ import org.springframework.util.Assert;
* .topK(5)
* .filterExpression(filterExpression)
* .build();
* List<Document> documents = retriever.retrieve("example query");
* List<Document> documents = retriever.retrieve(new Query("example query"));
* }</pre>
*
* @author Thomas Vitale
* @since 1.0.0
* @see VectorStore
* @see Filter.Expression
*/
public class VectorStoreDocumentRetriever implements DocumentRetriever {
public final class VectorStoreDocumentRetriever implements DocumentRetriever {
private final VectorStore vectorStore;

View File

@@ -0,0 +1,25 @@
/*
* 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.
*/
/**
* RAG Component: Document Search.
*/
@NonNullApi
@NonNullFields
package org.springframework.ai.rag.retrieval.search;
import org.springframework.lang.NonNullApi;
import org.springframework.lang.NonNullFields;

View File

@@ -22,7 +22,9 @@ import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.core.io.ClassPathResource;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
@@ -33,6 +35,21 @@ import static org.mockito.Mockito.mock;
*/
class DefaultChatClientBuilderTests {
@Test
void whenCloneBuilder() {
var chatModel = mock(ChatModel.class);
var originalBuilder = new DefaultChatClientBuilder(chatModel);
originalBuilder.defaultSystem("first instructions");
var clonedBuilder = (DefaultChatClientBuilder) originalBuilder.clone();
originalBuilder.defaultSystem("second instructions");
assertThat(clonedBuilder).isNotSameAs(originalBuilder);
var clonedBuilderRequestSpec = (DefaultChatClient.DefaultChatClientRequestSpec) ReflectionTestUtils
.getField(clonedBuilder, "defaultRequest");
assertThat(clonedBuilderRequestSpec).isNotNull();
assertThat(clonedBuilderRequestSpec.getSystemText()).isEqualTo("first instructions");
}
@Test
void whenChatModelIsNullThenThrows() {
assertThatThrownBy(() -> new DefaultChatClientBuilder(null)).isInstanceOf(IllegalArgumentException.class)

View File

@@ -29,7 +29,8 @@ import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.document.Document;
import org.springframework.ai.rag.Query;
import org.springframework.ai.rag.retrieval.source.DocumentRetriever;
import org.springframework.ai.rag.analysis.query.transformation.QueryTransformer;
import org.springframework.ai.rag.retrieval.search.DocumentRetriever;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -43,6 +44,33 @@ import static org.mockito.Mockito.mock;
*/
class RetrievalAugmentationAdvisorTests {
@Test
void whenQueryTransformerListIsNullThenThrow() {
assertThatThrownBy(() -> RetrievalAugmentationAdvisor.builder()
.queryTransformers((List<QueryTransformer>) null)
.documentRetriever(mock(DocumentRetriever.class))
.build()).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("queryTransformers cannot be null");
}
@Test
void whenQueryTransformerArrayIsNullThenThrow() {
assertThatThrownBy(() -> RetrievalAugmentationAdvisor.builder()
.queryTransformers((QueryTransformer[]) null)
.documentRetriever(mock(DocumentRetriever.class))
.build()).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("queryTransformers cannot be null");
}
@Test
void whenQueryTransformersContainNullElementsThenThrow() {
assertThatThrownBy(() -> RetrievalAugmentationAdvisor.builder()
.queryTransformers(mock(QueryTransformer.class), null)
.documentRetriever(mock(DocumentRetriever.class))
.build()).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("queryTransformers cannot contain null elements");
}
@Test
void whenDocumentRetrieverIsNullThenThrow() {
assertThatThrownBy(() -> RetrievalAugmentationAdvisor.builder().documentRetriever(null).build())

View File

@@ -0,0 +1,70 @@
/*
* 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.rag.analysis.query.expansion;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.prompt.PromptTemplate;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
/**
* Unit tests for {@link MultiQueryExpander}.
*
* @author Thomas Vitale
*/
class MultiQueryExpanderTests {
@Test
void whenChatClientBuilderIsNullThenThrow() {
assertThatThrownBy(() -> MultiQueryExpander.builder().chatClientBuilder(null).build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("chatClientBuilder cannot be null");
}
@Test
void whenQueryIsNullThenThrow() {
QueryExpander queryExpander = MultiQueryExpander.builder()
.chatClientBuilder(mock(ChatClient.Builder.class))
.build();
assertThatThrownBy(() -> queryExpander.expand(null)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("query cannot be null");
}
@Test
void whenPromptHasMissingNumberPlaceholderThenThrow() {
PromptTemplate customPromptTemplate = new PromptTemplate("You are the boss. Original query: {query}");
assertThatThrownBy(() -> MultiQueryExpander.builder()
.chatClientBuilder(mock(ChatClient.Builder.class))
.promptTemplate(customPromptTemplate)
.build()).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("The following placeholders must be present in the prompt template")
.hasMessageContaining("number");
}
@Test
void whenPromptHasMissingQueryPlaceholderThenThrow() {
PromptTemplate customPromptTemplate = new PromptTemplate("You are the boss. Number of queries: {number}");
assertThatThrownBy(() -> MultiQueryExpander.builder()
.chatClientBuilder(mock(ChatClient.Builder.class))
.promptTemplate(customPromptTemplate)
.build()).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("The following placeholders must be present in the prompt template")
.hasMessageContaining("query");
}
}

View File

@@ -0,0 +1,73 @@
/*
* 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.rag.analysis.query.transformation;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.prompt.PromptTemplate;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
/**
* Unit tests for {@link TranslationQueryTransformer}.
*
* @author Thomas Vitale
*/
class TranslationQueryTransformerTests {
@Test
void whenChatClientBuilderIsNullThenThrow() {
assertThatThrownBy(() -> TranslationQueryTransformer.builder().chatClientBuilder(null).build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("chatClientBuilder cannot be null");
}
@Test
void whenQueryIsNullThenThrow() {
QueryTransformer queryTransformer = TranslationQueryTransformer.builder()
.chatClientBuilder(mock(ChatClient.Builder.class))
.targetLanguage("italian")
.build();
assertThatThrownBy(() -> queryTransformer.transform(null)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("query cannot be null");
}
@Test
void whenPromptHasMissingTargetLanguagePlaceholderThenThrow() {
PromptTemplate customPromptTemplate = new PromptTemplate("Translate {query}");
assertThatThrownBy(() -> TranslationQueryTransformer.builder()
.chatClientBuilder(mock(ChatClient.Builder.class))
.targetLanguage("italian")
.promptTemplate(customPromptTemplate)
.build()).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("The following placeholders must be present in the prompt template")
.hasMessageContaining("targetLanguage");
}
@Test
void whenPromptHasMissingQueryPlaceholderThenThrow() {
PromptTemplate customPromptTemplate = new PromptTemplate("Translate to {targetLanguage}");
assertThatThrownBy(() -> TranslationQueryTransformer.builder()
.chatClientBuilder(mock(ChatClient.Builder.class))
.targetLanguage("italian")
.promptTemplate(customPromptTemplate)
.build()).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("The following placeholders must be present in the prompt template")
.hasMessageContaining("query");
}
}

View File

@@ -35,6 +35,24 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
*/
class ContextualQueryAugmentorTests {
@Test
void whenPromptHasMissingContextPlaceholderThenThrow() {
PromptTemplate customPromptTemplate = new PromptTemplate("You are the boss. Query: {query}");
assertThatThrownBy(() -> ContextualQueryAugmentor.builder().promptTemplate(customPromptTemplate).build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("The following placeholders must be present in the prompt template")
.hasMessageContaining("context");
}
@Test
void whenPromptHasMissingQueryPlaceholderThenThrow() {
PromptTemplate customPromptTemplate = new PromptTemplate("You are the boss. Context: {context}");
assertThatThrownBy(() -> ContextualQueryAugmentor.builder().promptTemplate(customPromptTemplate).build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("The following placeholders must be present in the prompt template")
.hasMessageContaining("query");
}
@Test
void whenQueryIsNullThenThrow() {
QueryAugmentor augmenter = ContextualQueryAugmentor.builder().build();

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package org.springframework.ai.rag.retrieval.source;
package org.springframework.ai.rag.retrieval.search;
import java.util.List;
import java.util.Map;
@@ -41,6 +41,8 @@ import static org.springframework.ai.vectorstore.filter.Filter.ExpressionType.EQ
/**
* Unit tests for {@link VectorStoreDocumentRetriever}.
*
* @author Thomas Vitale
*/
class VectorStoreDocumentRetrieverTests {
@@ -61,7 +63,7 @@ class VectorStoreDocumentRetrieverTests {
.filterExpression(new Filter.Expression(EQ, new Filter.Key("location"), new Filter.Value("Rivendell")))
.build();
documentRetriever.retrieve("query");
documentRetriever.retrieve(new Query("query"));
var searchRequestCaptor = ArgumentCaptor.forClass(SearchRequest.class);
verify(mockVectorStore).similaritySearch(searchRequestCaptor.capture());
@@ -85,11 +87,11 @@ class VectorStoreDocumentRetrieverTests {
.build();
TenantContextHolder.setTenantIdentifier("tenant1");
documentRetriever.retrieve("query");
documentRetriever.retrieve(new Query("query"));
TenantContextHolder.clear();
TenantContextHolder.setTenantIdentifier("tenant2");
documentRetriever.retrieve("query");
documentRetriever.retrieve(new Query("query"));
TenantContextHolder.clear();
var searchRequestCaptor = ArgumentCaptor.forClass(SearchRequest.class);
@@ -120,7 +122,7 @@ class VectorStoreDocumentRetrieverTests {
var mockVectorStore = mock(VectorStore.class);
var documentRetriever = VectorStoreDocumentRetriever.builder().vectorStore(mockVectorStore).build();
documentRetriever.retrieve("test query");
documentRetriever.retrieve(new Query("test query"));
var searchRequestCaptor = ArgumentCaptor.forClass(SearchRequest.class);
verify(mockVectorStore).similaritySearch(searchRequestCaptor.capture());

View File

@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ 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.
-->
<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>
</parent>
<artifactId>spring-ai-integration-tests</artifactId>
<packaging>jar</packaging>
<name>Spring AI Integration Tests</name>
<description>Integration tests for Spring AI</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.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<maven.deploy.skip>true</maven.deploy.skip>
<maven.javadoc.skip>true</maven.javadoc.skip>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>${project.parent.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId>
<version>${project.parent.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-markdown-document-reader</artifactId>
<version>${project.parent.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<exclusions>
<exclusion>
<groupId>com.vaadin.external.google</groupId>
<artifactId>android-json</artifactId>
</exclusion>
</exclusions>
<scope>test</scope>
</dependency>
</dependencies>
</project>

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.integration.tests;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Import;
/**
* Test application for integration tests.
*
* @author Thomas Vitale
*/
@SpringBootApplication
@Import(TestcontainersConfiguration.class)
public class TestApplication {
}

View File

@@ -0,0 +1,37 @@
/*
* 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.integration.tests;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.containers.PostgreSQLContainer;
/**
* Test configuration for Testcontainers-based Dev Services.
*
* @author Thomas Vitale
*/
@TestConfiguration(proxyBeanMethods = false)
class TestcontainersConfiguration {
@Bean
@ServiceConnection
PostgreSQLContainer<?> pgvectorContainer() {
return new PostgreSQLContainer<>("pgvector/pgvector:pg17");
}
}

View File

@@ -0,0 +1,140 @@
/*
* 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.integration.tests.client.advisor;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.RetrievalAugmentationAdvisor;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.document.Document;
import org.springframework.ai.document.DocumentReader;
import org.springframework.ai.evaluation.EvaluationRequest;
import org.springframework.ai.evaluation.EvaluationResponse;
import org.springframework.ai.evaluation.RelevancyEvaluator;
import org.springframework.ai.integration.tests.TestApplication;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.rag.analysis.query.transformation.TranslationQueryTransformer;
import org.springframework.ai.rag.retrieval.search.VectorStoreDocumentRetriever;
import org.springframework.ai.reader.markdown.MarkdownDocumentReader;
import org.springframework.ai.reader.markdown.config.MarkdownDocumentReaderConfig;
import org.springframework.ai.vectorstore.PgVectorStore;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.core.io.Resource;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration tests for {@link RetrievalAugmentationAdvisor}.
*
* @author Thomas Vitale
*/
@SpringBootTest(classes = TestApplication.class)
@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".*")
class RetrievalAugmentationAdvisorIT {
private List<Document> knowledgeBaseDocuments;
@Autowired
OpenAiChatModel openAiChatModel;
@Autowired
PgVectorStore pgVectorStore;
@Value("${classpath:documents/knowledge-base.md}")
Resource knowledgeBaseResource;
@BeforeEach
void setUp() {
DocumentReader markdownReader = new MarkdownDocumentReader(knowledgeBaseResource,
MarkdownDocumentReaderConfig.defaultConfig());
knowledgeBaseDocuments = markdownReader.read();
pgVectorStore.add(knowledgeBaseDocuments);
}
@AfterEach
void tearDown() {
pgVectorStore.delete(knowledgeBaseDocuments.stream().map(Document::getId).toList());
}
@Test
void ragBasic() {
String question = "Where does the adventure of Anacletus and Birba take place?";
RetrievalAugmentationAdvisor ragAdvisor = RetrievalAugmentationAdvisor.builder()
.documentRetriever(VectorStoreDocumentRetriever.builder().vectorStore(pgVectorStore).build())
.build();
ChatResponse chatResponse = ChatClient.builder(openAiChatModel)
.build()
.prompt(question)
.advisors(ragAdvisor)
.call()
.chatResponse();
assertThat(chatResponse).isNotNull();
String response = chatResponse.getResult().getOutput().getContent();
System.out.println(response);
assertThat(response).containsIgnoringCase("Highlands");
evaluateRelevancy(question, chatResponse);
}
@Test
void ragWithTranslation() {
String question = "Hvor finder Anacletus og Birbas eventyr sted?";
RetrievalAugmentationAdvisor ragAdvisor = RetrievalAugmentationAdvisor.builder()
.queryTransformers(TranslationQueryTransformer.builder()
.chatClientBuilder(ChatClient.builder(openAiChatModel))
.targetLanguage("english")
.build())
.documentRetriever(VectorStoreDocumentRetriever.builder().vectorStore(pgVectorStore).build())
.build();
ChatResponse chatResponse = ChatClient.builder(openAiChatModel)
.build()
.prompt(question)
.advisors(ragAdvisor)
.call()
.chatResponse();
assertThat(chatResponse).isNotNull();
String response = chatResponse.getResult().getOutput().getContent();
System.out.println(response);
assertThat(response).containsIgnoringCase("Highlands");
evaluateRelevancy(question, chatResponse);
}
private void evaluateRelevancy(String question, ChatResponse chatResponse) {
EvaluationRequest evaluationRequest = new EvaluationRequest(question,
chatResponse.getMetadata().get(RetrievalAugmentationAdvisor.DOCUMENT_CONTEXT),
chatResponse.getResult().getOutput().getContent());
RelevancyEvaluator evaluator = new RelevancyEvaluator(ChatClient.builder(openAiChatModel));
EvaluationResponse evaluationResponse = evaluator.evaluate(evaluationRequest);
assertThat(evaluationResponse.isPass()).isTrue();
}
}

View File

@@ -0,0 +1,90 @@
/*
* 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.integration.tests.rag.analysis.query.expansion;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.integration.tests.TestApplication;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.rag.Query;
import org.springframework.ai.rag.analysis.query.expansion.MultiQueryExpander;
import org.springframework.ai.rag.analysis.query.expansion.QueryExpander;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration tests for {@link MultiQueryExpander}.
*
* @author Thomas Vitale
*/
@SpringBootTest(classes = TestApplication.class)
@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".*")
class MultiQueryExpanderIT {
@Autowired
OpenAiChatModel openAiChatModel;
@Test
void whenExpanderWithDefaults() {
Query query = new Query("What is the weather in Rome?");
QueryExpander queryExpander = MultiQueryExpander.builder()
.chatClientBuilder(ChatClient.builder(openAiChatModel))
.build();
List<Query> queries = queryExpander.apply(query);
assertThat(queries).isNotNull();
queries.forEach(System.out::println);
assertThat(queries).hasSize(3);
}
@Test
void whenExpanderWithCustomQueryNumber() {
Query query = new Query("What is the weather in Rome?");
QueryExpander queryExpander = MultiQueryExpander.builder()
.chatClientBuilder(ChatClient.builder(openAiChatModel))
.numberOfQueries(4)
.build();
List<Query> queries = queryExpander.apply(query);
assertThat(queries).isNotNull();
queries.forEach(System.out::println);
assertThat(queries).hasSize(4);
}
@Test
void whenExpanderWithOriginalQueryIncluded() {
Query query = new Query("What is the weather in Rome?");
QueryExpander queryExpander = MultiQueryExpander.builder()
.chatClientBuilder(ChatClient.builder(openAiChatModel))
.numberOfQueries(3)
.includeOriginal(true)
.build();
List<Query> queries = queryExpander.apply(query);
assertThat(queries).isNotNull();
queries.forEach(System.out::println);
assertThat(queries).hasSize(4);
}
}

View File

@@ -0,0 +1,58 @@
/*
* 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.integration.tests.rag.analysis.query.transformation;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.integration.tests.TestApplication;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.rag.Query;
import org.springframework.ai.rag.analysis.query.transformation.QueryTransformer;
import org.springframework.ai.rag.analysis.query.transformation.TranslationQueryTransformer;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration tests for {@link TranslationQueryTransformer}.
*
* @author Thomas Vitale
*/
@SpringBootTest(classes = TestApplication.class)
@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".*")
class TranslationQueryTransformerIT {
@Autowired
OpenAiChatModel openAiChatModel;
@Test
void whenTransformerWithDefaults() {
Query query = new Query("Hvad er Danmarks hovedstad?");
QueryTransformer queryTransformer = TranslationQueryTransformer.builder()
.chatClientBuilder(ChatClient.builder(openAiChatModel))
.targetLanguage("english")
.build();
Query transformedQuery = queryTransformer.apply(query);
assertThat(transformedQuery).isNotNull();
System.out.println(transformedQuery);
assertThat(transformedQuery.text()).containsIgnoringCase("Denmark").containsIgnoringCase("capital");
}
}

View File

@@ -0,0 +1,89 @@
/*
* 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.integration.tests.rag.augmentation;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.springframework.ai.document.Document;
import org.springframework.ai.integration.tests.TestApplication;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.rag.Query;
import org.springframework.ai.rag.augmentation.ContextualQueryAugmentor;
import org.springframework.ai.rag.augmentation.QueryAugmentor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration tests for {@link ContextualQueryAugmentor}.
*
* @author Thomas Vitale
*/
@SpringBootTest(classes = TestApplication.class)
@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".*")
class ContextualQueryAugmentorIT {
@Autowired
OpenAiChatModel openAiChatModel;
@Test
void whenContextIsProvided() {
QueryAugmentor queryAugmentor = ContextualQueryAugmentor.builder().build();
Query query = new Query("What is Iorek's dream?");
List<Document> documents = List
.of(new Document("Iorek was a little polar bear who lived in the Arctic circle."), new Document(
"Iorek loved to explore the snowy landscape and dreamt of one day going on an adventure around the North Pole."));
Query augmentedQuery = queryAugmentor.augment(query, documents);
String response = openAiChatModel.call(augmentedQuery.text());
assertThat(response).isNotEmpty();
System.out.println(response);
assertThat(response).containsIgnoringCase("North Pole");
assertThat(response).doesNotContainIgnoringCase("context");
assertThat(response).doesNotContainIgnoringCase("information");
}
@Test
void whenAllowEmptyContext() {
QueryAugmentor queryAugmentor = ContextualQueryAugmentor.builder().build();
Query query = new Query("What is Iorek's dream?");
List<Document> documents = List.of();
Query augmentedQuery = queryAugmentor.augment(query, documents);
String response = openAiChatModel.call(augmentedQuery.text());
assertThat(response).isNotEmpty();
System.out.println(response);
assertThat(response).containsIgnoringCase("Iorek");
}
@Test
void whenNotAllowEmptyContext() {
QueryAugmentor queryAugmentor = ContextualQueryAugmentor.builder().allowEmptyContext(false).build();
Query query = new Query("What is Iorek's dream?");
List<Document> documents = List.of();
Query augmentedQuery = queryAugmentor.augment(query, documents);
String response = openAiChatModel.call(augmentedQuery.text());
assertThat(response).isNotEmpty();
System.out.println(response);
assertThat(response).doesNotContainIgnoringCase("Iorek");
}
}

View File

@@ -0,0 +1,111 @@
/*
* 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.integration.tests.rag.retrieval.search;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.springframework.ai.document.Document;
import org.springframework.ai.integration.tests.TestApplication;
import org.springframework.ai.rag.Query;
import org.springframework.ai.rag.retrieval.search.DocumentRetriever;
import org.springframework.ai.rag.retrieval.search.VectorStoreDocumentRetriever;
import org.springframework.ai.vectorstore.PgVectorStore;
import org.springframework.ai.vectorstore.filter.Filter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.ai.vectorstore.filter.Filter.ExpressionType.EQ;
/**
* Integration tests for {@link VectorStoreDocumentRetriever}.
*
* @author Thomas Vitale
*/
@SpringBootTest(classes = TestApplication.class)
@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".*")
class VectorStoreDocumentRetrieverIT {
private static final Map<String, Document> documents = Map.of("1", new Document(
"Anacletus was a majestic snowy owl with unusually bright golden eyes and distinctive black speckles across his wings.",
Map.of("location", "Whispering Woods")), "2",
new Document(
"Anacletus made his home in an ancient hollow oak tree deep within the Whispering Woods, where local villagers often heard his haunting calls at midnight.",
Map.of("location", "Whispering Woods")),
"3",
new Document(
"Despite being a nocturnal hunter like other owls, Anacletus had developed a peculiar habit of collecting shiny objects, especially lost coins and jewelry that glinted in the moonlight.",
Map.of()),
"4",
new Document(
"Birba was a plump Siamese cat with mismatched eyes - one blue and one green - who spent her days lounging on velvet cushions and judging everyone with a perpetual look of disdain.",
Map.of("location", "Alfea")));
@Autowired
PgVectorStore pgVectorStore;
@BeforeEach
void setUp() {
pgVectorStore.add(List.copyOf(documents.values()));
}
@AfterEach
void tearDown() {
pgVectorStore.delete(documents.values().stream().map(Document::getId).toList());
}
@Test
void withFilter() {
DocumentRetriever documentRetriever = VectorStoreDocumentRetriever.builder()
.vectorStore(pgVectorStore)
.similarityThreshold(0.50)
.topK(3)
.filterExpression(
new Filter.Expression(EQ, new Filter.Key("location"), new Filter.Value("Whispering Woods")))
.build();
List<Document> retrievedDocuments = documentRetriever.retrieve(new Query("Who is Anacletus?"));
assertThat(retrievedDocuments).hasSize(2);
assertThat(retrievedDocuments).anyMatch(document -> document.getId().equals(documents.get("1").getId()));
assertThat(retrievedDocuments).anyMatch(document -> document.getId().equals(documents.get("2").getId()));
retrievedDocuments = documentRetriever.retrieve(new Query("Who is Birba?"));
assertThat(retrievedDocuments).noneMatch(document -> document.getId().equals(documents.get("4").getId()));
}
@Test
void withNoFilter() {
DocumentRetriever documentRetriever = VectorStoreDocumentRetriever.builder()
.vectorStore(pgVectorStore)
.similarityThreshold(0.50)
.topK(3)
.build();
List<Document> retrievedDocuments = documentRetriever.retrieve(new Query("Who is Anacletus?"));
assertThat(retrievedDocuments).hasSize(3);
assertThat(retrievedDocuments).anyMatch(document -> document.getId().equals(documents.get("1").getId()));
assertThat(retrievedDocuments).anyMatch(document -> document.getId().equals(documents.get("2").getId()));
assertThat(retrievedDocuments).anyMatch(document -> document.getId().equals(documents.get("3").getId()));
}
}

View File

@@ -0,0 +1,17 @@
spring:
main:
web-application-type: none
ai:
openai:
api-key: ${OPENAI_API_KEY}
chat:
options:
model: gpt-4o-mini
embedding:
options:
model: text-embedding-ada-002
retry:
max-attempts: 3
vectorstore:
pgvector:
initialize-schema: true

View File

@@ -0,0 +1,41 @@
# Anacletus and Birba's Quest for the Loch of the Stars
## Chapter 1: The Map and the Adventure
Once upon a time, in a cozy little cottage nestled at the edge of the Scottish Highlands, lived an owl named Anacletus and a curious cat named Birba. Anacletus was wise and careful, always reading maps and planning things thoroughly, while Birba was lively and adventurous, always ready to chase after the next interesting thing. Despite their differences, they were the best of friends and loved going on little adventures together.
## Chapter 2: The Journey Begins
One sunny morning, Anacletus showed Birba an old, crinkled map hed found in the attic. “Look, Birba,” he said, pointing with his feathery wing. “This map leads to the legendary Loch of the Stars. They say it shines brighter than any other lake at night.” Birbas eyes sparkled with excitement. “Oh, we have to go there!” she meowed. So, they packed a small bag with snacks, a compass, and a flashlight, and off they went, eager to find the legendary loch.
## Chapter 3: The Highland Adventure
Their journey began with a climb up the rolling hills covered in purple heather. Anacletus flapped his wings, soaring ahead to scout for any obstacles, while Birba trotted along below, her nose sniffing the air for interesting scents. Soon, they came across a bubbling brook. Anacletus carefully flew over it, but Birba hesitated. “Just a little jump!” Anacletus called out. With a deep breath, Birba leaped and landed safely on the other side. She purred proudly, and they continued on their way.
## Chapter 4: The Highland Cows and the Hidden Path
As they ventured deeper into the Highlands, they stumbled upon a herd of curious Highland cows with long, shaggy hair. The cows mooed softly, and one of them named Fergus approached. “Where are you two headed?” Fergus asked. “Were searching for the Loch of the Stars!” Anacletus replied. Fergus nodded knowingly and pointed his nose north. “Follow the path by the big stones, and it will lead you closer to the loch,” he said. Thanking Fergus, they set off again, Birba occasionally stopping to bat at the fluttering butterflies along the way.
## Chapter 5: The Mysterious Forest and the Deer Family
The day wore on, and they soon found themselves in a mysterious forest. Tall, ancient pine trees surrounded them, casting long shadows. “Stay close, Birba,” Anacletus whispered, his wise eyes scanning for any sign of danger. But Birba had already darted after a flicker of light, thinking it was a firefly. Anacletus sighed and followed her until they came to a hidden glade where a family of deer grazed quietly. The smallest fawn looked up and gave them a curious nod before they moved along.
## Chapter 6: The Loch of the Stars
After a while, the sun began to set, painting the sky in shades of pink and gold. Anacletus decided it was a good time to rest. They found a cozy hollow at the base of a tree, where they shared the snacks theyd packed. Birba munched on her fish treats while Anacletus nibbled on a biscuit. “Do you think well find the Loch of the Stars?” Birba asked, her eyes twinkling. “I think so,” Anacletus replied with a wise smile. “Were getting closer.”
## Chapter 7: The Shimmering Loch
As night fell, they finally reached the top of a hill where they could see a shimmering light in the distance. “Look, Birba!” Anacletus hooted excitedly. There, nestled among the hills, was the Loch of the Stars, gleaming like a sky full of stars. The two friends hurried down to the waters edge, marveling at how the loch sparkled under the moonlight, casting a gentle glow all around.
## Chapter 8: The Magic of the Loch
Birba dipped a curious paw into the water, causing ripples that sent stars dancing across the surface. “Its beautiful!” she gasped. Anacletus nodded, his heart filled with awe. They spent the night by the loch, watching the shimmering stars reflected in the water, feeling as though they were surrounded by magic.
## Chapter 9: The Journey Home
When dawn broke, the shimmering loch returned to its quiet, glassy calm. With a satisfied yawn, Birba stretched and said, “That was the best adventure yet.” Anacletus agreed, feeling a warmth in his feathers as they turned back toward home, carrying memories of the Loch of the Stars in their hearts.
## Chapter 10: The End of the Adventure
And as they made their way back to their cozy cottage, they already started dreaming of their next big adventure—because Anacletus and Birba knew that the Scottish Highlands held endless wonders for those who dared to explore.