From 3cbda5acd89dd17e32cdc276db067737553c1645 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Tue, 18 Jun 2024 14:32:32 +0200 Subject: [PATCH] Enable dynamic filter expressions for QuestionAnswerAdvisor - Add FILTER_EXPRESSION advisor context parameter to update filter expressions per call/stream - Implement dynamic filter expression handling in QuestionAnswerAdvisor - Add unit tests for dynamic filter expression functionality - Update documentation with usage examples Resolves #887 --- .../client/advisor/QuestionAnswerAdvisor.java | 25 +++- .../client/QuestionAnswerAdvisorTests.java | 114 ++++++++++++++++++ .../modules/ROOT/pages/api/chatclient.adoc | 23 +++- 3 files changed, 157 insertions(+), 5 deletions(-) create mode 100644 spring-ai-core/src/test/java/org/springframework/ai/chat/client/QuestionAnswerAdvisorTests.java diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/advisor/QuestionAnswerAdvisor.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/advisor/QuestionAnswerAdvisor.java index a8e7a9f40..3e2862b39 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/advisor/QuestionAnswerAdvisor.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/advisor/QuestionAnswerAdvisor.java @@ -28,7 +28,11 @@ import org.springframework.ai.document.Document; import org.springframework.ai.model.Content; import org.springframework.ai.vectorstore.SearchRequest; import org.springframework.ai.vectorstore.VectorStore; +import org.springframework.ai.vectorstore.filter.Filter; +import org.springframework.ai.vectorstore.filter.FilterExpressionTextParser; import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + import reactor.core.publisher.Flux; /** @@ -36,7 +40,7 @@ import reactor.core.publisher.Flux; * user text. * * @author Christian Tzolov - * @since 1.0.0 M1 + * @since 1.0.0 */ public class QuestionAnswerAdvisor implements RequestResponseAdvisor { @@ -56,7 +60,9 @@ public class QuestionAnswerAdvisor implements RequestResponseAdvisor { private final SearchRequest searchRequest; - public static String RETRIEVED_DOCUMENTS = "qa_retrieved_documents"; + public static final String RETRIEVED_DOCUMENTS = "qa_retrieved_documents"; + + public static final String FILTER_EXRESSION = "qa_filter_expression"; public QuestionAnswerAdvisor(VectorStore vectorStore) { this(vectorStore, SearchRequest.defaults(), DEFAULT_USER_TEXT_ADVISE); @@ -93,8 +99,12 @@ public class QuestionAnswerAdvisor implements RequestResponseAdvisor { // 1. Advise the system text. String advisedUserText = request.userText() + System.lineSeparator() + this.userTextAdvise; + var searchRequestToUse = SearchRequest.from(this.searchRequest) + .withQuery(request.userText()) + .withFilterExpression(doGetFilterExpression(context)); + // 2. Search for similar documents in the vector store. - List documents = vectorStore.similaritySearch(searchRequest.withQuery(request.userText())); + List documents = this.vectorStore.similaritySearch(searchRequestToUse); context.put(RETRIEVED_DOCUMENTS, documents); @@ -129,4 +139,13 @@ public class QuestionAnswerAdvisor implements RequestResponseAdvisor { }); } + protected Filter.Expression doGetFilterExpression(Map context) { + + if (!context.containsKey(FILTER_EXRESSION) || !StringUtils.hasText(context.get(FILTER_EXRESSION).toString())) { + return this.searchRequest.getFilterExpression(); + } + return new FilterExpressionTextParser().parse(context.get(FILTER_EXRESSION).toString()); + + } + } diff --git a/spring-ai-core/src/test/java/org/springframework/ai/chat/client/QuestionAnswerAdvisorTests.java b/spring-ai-core/src/test/java/org/springframework/ai/chat/client/QuestionAnswerAdvisorTests.java new file mode 100644 index 000000000..44a9c797a --- /dev/null +++ b/spring-ai-core/src/test/java/org/springframework/ai/chat/client/QuestionAnswerAdvisorTests.java @@ -0,0 +1,114 @@ +/* + * Copyright 2024-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.chat.client; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.ai.chat.client.advisor.QuestionAnswerAdvisor; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.MessageType; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.document.Document; +import org.springframework.ai.vectorstore.SearchRequest; +import org.springframework.ai.vectorstore.VectorStore; +import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder; + +/** + * @author Christian Tzolov + */ +@ExtendWith(MockitoExtension.class) +public class QuestionAnswerAdvisorTests { + + @Mock + ChatModel chatModel; + + @Captor + ArgumentCaptor promptCaptor; + + @Captor + ArgumentCaptor vectorSearchCaptor; + + @Mock + VectorStore vectorStore; + + @Test + public void qaAdvisorWithDynamicFilterExpressions() { + + when(chatModel.call(promptCaptor.capture())) + .thenReturn(new ChatResponse(List.of(new Generation("Your answer is ZXY")))); + + when(vectorStore.similaritySearch(vectorSearchCaptor.capture())) + .thenReturn(List.of(new Document("doc1"), new Document("doc2"))); + + var qaAdvisor = new QuestionAnswerAdvisor(vectorStore, + SearchRequest.defaults().withSimilarityThreshold(0.99d).withTopK(6)); + + var chatClient = ChatClient.builder(chatModel) + .defaultSystem("Default system text.") + .defaultAdvisors(qaAdvisor) + .build(); + + // @formatter:off + var content = chatClient.prompt() + .user("Please answer my question XYZ") + .advisors(a -> a.param(QuestionAnswerAdvisor.FILTER_EXRESSION, "type == 'Spring'")) + .call() + .content(); + //formatter:on + + assertThat(content).isEqualTo("Your answer is ZXY"); + + Message systemMessage = promptCaptor.getValue().getInstructions().get(0); + + System.out.println(systemMessage.getContent()); + + assertThat(systemMessage.getContent()).isEqualToIgnoringWhitespace(""" + Default system text. + """); + assertThat(systemMessage.getMessageType()).isEqualTo(MessageType.SYSTEM); + + Message userMessage = promptCaptor.getValue().getInstructions().get(1); + + assertThat(userMessage.getContent()).isEqualToIgnoringWhitespace(""" + Please answer my question XYZ + Context information is below. + --------------------- + doc1 + doc2 + --------------------- + Given the context and provided history information and not prior knowledge, + reply to the user comment. If the answer is not in the context, inform + the user that you can't answer the question. + """); + + assertThat(vectorSearchCaptor.getValue().getFilterExpression()).isEqualTo(new FilterExpressionBuilder().eq("type", "Spring").build()); + assertThat(vectorSearchCaptor.getValue().getSimilarityThreshold()).isEqualTo(0.99d); + assertThat(vectorSearchCaptor.getValue().getTopK()).isEqualTo(6); + } +} diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chatclient.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chatclient.adoc index 67a16ee48..63772bb13 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chatclient.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chatclient.adoc @@ -183,8 +183,6 @@ After specifying the `stream` method on `ChatClient`, there are a few options fo Creating a ChatClient with default system text in an `@Configuration` class simplifies runtime code. By setting defaults, you only need to specify user text when calling `ChatClient`, eliminating the need to set system text for each request in your runtime code path. - - === Default System Text In the following example, we will configure the system text to always reply in a pirate's voice. @@ -346,6 +344,27 @@ ChatResponse response = ChatClient.builder(chatModel) Is this example, the `SearchRequest.defaults()` will perform a similarity search over all documents in the Vector Database. To restrict the types of documents that are searched, the `SearchRequest` takes a SQL like filter expression that is portable across all `VectorStores`. +==== Rutntime filter expressions + +You can update the default `SearchRequest` filter expression at run time using the `FILTER_EXRESSION` advisor context parameter: + +[source,java] +---- +var chatClient = ChatClient.builder(chatModel) + .defaultSystem("Default system text.") + .defaultAdvisors(new QuestionAnswerAdvisor(vectorStore, + SearchRequest.defaults().withSimilarityThreshold(0.99d).withTopK(6))) + .build(); + +// and at runtime use the `FILTER_EXRESSION` advisor context parameter to update the filter expression + +var content = chatClient.prompt() + .user("Please answer my question XYZ") + .advisors(a -> a.param(QuestionAnswerAdvisor.FILTER_EXRESSION, "type == 'Spring'")) + .call() + .content(); +---- + === Chat Memory The interface `ChatMemory` represents a storage for chat conversation history. It provides methods to add messages to a