diff --git a/SqlDatabaseVectorSearch/Models/Response.cs b/SqlDatabaseVectorSearch/Models/Response.cs index 62bde55..20e6e55 100644 --- a/SqlDatabaseVectorSearch/Models/Response.cs +++ b/SqlDatabaseVectorSearch/Models/Response.cs @@ -7,4 +7,6 @@ public record class Response(string? OriginalQuestion, string? ReformulatedQuest : this(null, null, token, streamState, tokenUsageResponse, citations) { } -} \ No newline at end of file +} + +public record class RagResponse(Guid ConversationId, string OriginalQuestion, string ReformulatedQuestion, string Answer); \ No newline at end of file diff --git a/SqlDatabaseVectorSearch/Program.cs b/SqlDatabaseVectorSearch/Program.cs index 61f0553..e88cd6c 100644 --- a/SqlDatabaseVectorSearch/Program.cs +++ b/SqlDatabaseVectorSearch/Program.cs @@ -1,12 +1,16 @@ using System.ClientModel; +using System.ClientModel.Primitives; using System.Net.Mime; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; using System.Text.Json.Serialization; using FluentValidation; +using Microsoft.Agents.AI; using Microsoft.Agents.AI.Hosting; using Microsoft.Agents.AI.Workflows; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.AI; -using Microsoft.SemanticKernel; using OpenAI; using OpenAI.Responses; using SqlDatabaseVectorSearch.Components; @@ -44,14 +48,6 @@ builder.Services.AddSqlServer(builder.Configuration.GetCon options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); }); -builder.Services.AddHybridCache(options => -{ - options.DefaultEntryOptions = new() - { - LocalCacheExpiration = appSettings.MessageExpiration - }; -}); - builder.Services.ConfigureHttpClientDefaults(configure => { configure.AddStandardResilienceHandler(options => @@ -76,16 +72,12 @@ builder.Services.AddChatClient(_ => var chatClient = new OpenAIClient(new ApiKeyCredential(aiSettings.ChatCompletion.ApiKey), new() { Endpoint = new(aiSettings.ChatCompletion.Endpoint), + Transport = new HttpClientPipelineTransport(new HttpClient(new TraceHttpClientHandler())) }).GetResponsesClient().AsIChatClientWithStoredOutputDisabled(aiSettings.ChatCompletion.Deployment); return chatClient; }); -// Semantic Kernel is used to generate embeddings and to reformulate questions taking into account all the previous interactions, -// so that embeddings themselves can be generated more accurately. -builder.Services.AddKernel() - .AddAzureOpenAIChatCompletion(aiSettings.ChatCompletion.Deployment, aiSettings.ChatCompletion.Endpoint, aiSettings.ChatCompletion.ApiKey, modelId: aiSettings.ChatCompletion.ModelId); - builder.Services.AddKeyedSingleton(MediaTypeNames.Application.Pdf); builder.Services.AddKeyedSingleton("application/vnd.openxmlformats-officedocument.wordprocessingml.document"); builder.Services.AddKeyedSingleton(MediaTypeNames.Text.Plain); @@ -95,10 +87,10 @@ builder.Services.AddKeyedSingleton(KeyedServic builder.Services.AddKeyedSingleton(MediaTypeNames.Text.Markdown); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -119,6 +111,148 @@ builder.AddWorkflow("EmbeddingWorkflow", (services, key) => return workflow; }, ServiceLifetime.Scoped); +builder.Services.AddAIAgent("ReformulationAgent", (services, key) => +{ + var chatClient = services.GetRequiredService(); + + return chatClient.AsAIAgent(new ChatClientAgentOptions() + { + Name = key, + ChatOptions = new() + { + Instructions = """ + You are a helpful assistant that reformulates questions to perform embeddings search. + Your task is to reformulate the question taking into account the context of the chat. + The reformulated question must always explicitly contain the subject of the question. + + You MUST reformulate the question in the SAME language as the user's question. + For example, if the user asks a question in English, the reformulated question MUST be in English. If the user asks in Italian, the reformulated question MUST be in Italian. + + Never add "in this chat", "in the context of this chat", "in the context of our conversation", "search for" or something like that in your answer. + Your answer must contain only the reformulated question and nothing else. + Never add follow-up messages, clarifications, notes, disclaimers, or requests for more information such as "if you give me more information, I can be more precise". + """, + Reasoning = new() + { + Effort = ReasoningEffort.None, + Output = ReasoningOutput.None + } + }, + ChatHistoryProvider = new InMemoryChatHistoryProvider(new() + { + StorageInputRequestMessageFilter = _ => [], + StorageInputResponseMessageFilter = _ => [] + }) + }, + loggerFactory: services.GetRequiredService(), + services: services); +}); + +var textSearchOptions = new TextSearchProviderOptions() +{ + ContextFormatter = results => + { + var sb = new StringBuilder(); + + sb.AppendLine("## Additional Context"); + sb.AppendLine("Use the excerpts below to answer the user."); + sb.AppendLine("Citation rules:"); + sb.AppendLine("- Do NOT add inline citations."); + sb.AppendLine("- At the END of your answer, add a single line exactly like:"); + sb.AppendLine(" Sources: [SourceName](SourceLink), [SourceName](SourceLink)"); + sb.AppendLine("- Include ONLY sources you actually used. No duplicates."); + sb.AppendLine(); + + sb.AppendLine("### Sources (copy/paste-ready)"); + foreach (var (i, r) in results.Index()) + { + var name = string.IsNullOrWhiteSpace(r.SourceName) ? $"Source {i + 1}" : r.SourceName; + + if (!string.IsNullOrWhiteSpace(r.SourceLink)) + { + sb.AppendLine($"- [{name}]({r.SourceLink})"); + } + else + { + sb.AppendLine($"- {name}"); + } + } + + sb.AppendLine(); + + sb.AppendLine("### Excerpts"); + foreach (var (i, r) in results.Index()) + { + var name = string.IsNullOrWhiteSpace(r.SourceName) ? $"Source {i + 1}" : r.SourceName; + + sb.AppendLine($"[{i + 1}] {name}"); + sb.AppendLine(r.Text); + sb.AppendLine(); + } + + return sb.ToString(); + } +}; + +builder.Services.AddHybridCache(options => +{ + options.DefaultEntryOptions = new() + { + LocalCacheExpiration = appSettings.MessageExpiration + }; +}); +builder.Services.AddSingleton(); + +builder.Services.AddAIAgent("RagAgent", (services, key) => +{ + var chatClient = services.GetRequiredService(); + + return chatClient.AsAIAgent(new ChatClientAgentOptions + { + Name = key, + ChatOptions = new() + { + Instructions = """ + You are a helpful assistant. Answer questions using the provided context and cite the source document when available. + You can use only the information provided in this chat to answer questions. If you don't know the answer, reply suggesting to refine the question. + + For example, if the user asks "What is the capital of Italy?" and in this chat there isn't information about Italy, you should reply something like: + - This information isn't available in the given context. + - I'm sorry, I don't know the answer to that question. + - I don't have that information. + - I don't know. + - Given the context, I can't answer that question. + - I'm sorry, I don't have enough information to answer that question. + + Never answer questions that are not related to this chat. + """, + Reasoning = new() + { + Effort = ReasoningEffort.Low, + Output = ReasoningOutput.None + } + }, + ChatHistoryProvider = new InMemoryChatHistoryProvider(new() + { + ChatReducer = new MessageCountingChatReducer(appSettings.MessageLimit), + ReducerTriggerEvent = InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded, + StorageInputRequestMessageFilter = messages => + { + return messages.Where(m => m.GetAgentRequestMessageSourceType() != AgentRequestMessageSourceType.ChatHistory + && m.GetAgentRequestMessageSourceType() != AgentRequestMessageSourceType.AIContextProvider); + } + }), + AIContextProviders = [new TextSearchProvider(services.GetRequiredService().SearchAsync, textSearchOptions)] + }, + loggerFactory: services.GetRequiredService(), + services: services); +}, ServiceLifetime.Scoped) +.WithSessionStore((services, _) => +{ + var sessionStore = services.GetRequiredService(); + return sessionStore; +}, withIsolation: false); + builder.Services.AddOpenApi(options => { options.RemoveServerList(); @@ -154,6 +288,7 @@ app.UseWhen(context => context.IsApiRequest(), builder => { app.UseExceptionHandler(new ExceptionHandlerOptions { + SuppressDiagnosticsCallback = _ => false, StatusCodeSelector = exception => exception switch { NotSupportedException => StatusCodes.Status501NotImplemented, @@ -189,4 +324,48 @@ static async Task ConfigureDatabaseAsync(IServiceProvider serviceProvider) var dbContext = scope.ServiceProvider.GetRequiredService(); await dbContext.Database.MigrateAsync(); +} + +public class TraceHttpClientHandler : HttpClientHandler +{ + private static readonly JsonSerializerOptions jsonSerializerOptions = new() + { + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var requestString = request.Content is null ? "(no request body)" : await request.Content.ReadAsStringAsync(cancellationToken); + + PrintText($"Raw Request ({request.RequestUri})", ConsoleColor.Green); + PrintText(FormatJson(requestString), ConsoleColor.DarkGray); + PrintSeparator(); + + var response = await base.SendAsync(request, cancellationToken); + + return response; + + static void PrintText(string message, ConsoleColor color) + { + Console.ForegroundColor = color; + Console.WriteLine(message); + Console.ResetColor(); + } + + static void PrintSeparator() => Console.WriteLine(new string('-', 50)); + } + + private static string FormatJson(string input) + { + try + { + var jsonElement = JsonSerializer.Deserialize(input); + return JsonSerializer.Serialize(jsonElement, jsonSerializerOptions); + } + catch + { + return input; + } + } } \ No newline at end of file diff --git a/SqlDatabaseVectorSearch/Services/ChatService.cs b/SqlDatabaseVectorSearch/Services/ChatService.cs deleted file mode 100644 index d0c68b3..0000000 --- a/SqlDatabaseVectorSearch/Services/ChatService.cs +++ /dev/null @@ -1,247 +0,0 @@ -using System.Runtime.CompilerServices; -using System.Text; -using Microsoft.Extensions.Caching.Hybrid; -using Microsoft.Extensions.Options; -using Microsoft.SemanticKernel.ChatCompletion; -using Microsoft.SemanticKernel.Connectors.AzureOpenAI; -using OpenAI.Chat; -using SqlDatabaseVectorSearch.Models; -using SqlDatabaseVectorSearch.Settings; -using Entities = SqlDatabaseVectorSearch.Data.Entities; - -namespace SqlDatabaseVectorSearch.Services; - -public class ChatService(IChatCompletionService chatCompletionService, TokenizerService tokenizerService, HybridCache cache, IOptions appSettingsOptions, ILogger logger) -{ - private readonly AppSettings appSettings = appSettingsOptions.Value; - - private static readonly string systemPromptForReformulation = """ - You are a helpful assistant that reformulates questions to perform embeddings search. - Your task is to reformulate the question taking into account the context of the chat. - The reformulated question must always explicitly contain the subject of the question. - You MUST reformulate the question in the SAME language as the user's question. For example, if the user asks a question in English, the reformulated question MUST be in English. If the user asks in Italian, the reformulated question MUST be in Italian. - - If asking a clarifying question to the user would help, ask the question. - Never add "in this chat", "in the context of this chat", "in the context of our conversation", "search for" or something like that in your answer. - """; - - private static readonly string systemPromptForAnswering = """ - You can use only the information provided in this chat to answer questions. If you don't know the answer, reply suggesting to refine the question. - - For example, if the user asks "What is the capital of Italy?" and in this chat there isn't information about Italy, you should reply something like: - - This information isn't available in the given context. - - I'm sorry, I don't know the answer to that question. - - I don't have that information. - - I don't know. - - Given the context, I can't answer that question. - - I'm sorry, I don't have enough information to answer that question. - - Never answer questions that are not related to this chat. - - LANGUAGE RULE: You MUST ALWAYS answer in the SAME language as the user's question. For example, if the user asks a question in English, the answer MUST be in English. If the user asks in Italian, the answer MUST be in Italian. This rule applies NO MATTER what language the documents are written in. The language of your response must match the language of the question, NOT the language of the documents. - - FORMATTING REQUIREMENT: Your answer MUST ALWAYS end with a period followed by a space before the citations block. - If your answer doesn't naturally end with a period, you MUST add one followed by a space. - - After the answer, you need to include citations following the XML format below ONLY IF you know the answer and are providing information from the context. If you do NOT know the answer, DO NOT include the citations section at all. - - 【exact quote here - exact quote here】 - - The entire list of XML citations MUST be enclosed between 【 and 】 (U+3010 and U+3011) and must exactly match the above format. - The quote in each MUST be MAXIMUM 5 words, taken word-for-word from the search result. - - IMPORTANT CITATION RULES: - 1. NEVER put citations inside your answer text. - 2. ALWAYS provide your complete answer FIRST. - 3. ONLY AFTER completing your answer, add ALL citations in a block at the very end. - 4. The citations block MUST be the last thing in your response, with absolutely nothing (no text, no spaces, no newlines, no punctuation, no comments) after it. - 5. NEVER reference citations by number or mention them in your answer text. - 6. The citations MUST ALWAYS follow the XML format exactly as shown below. Any other format is NOT ACCEPTED. - 7. If you add anything after the citations block, your answer will be considered invalid. - 8. If you do NOT know the answer, DO NOT include the citations block at all. - 9. ALWAYS check that your answer ends with a period followed by a space before adding citations. - - --- - Example of a correct answer: - The capital of Italy is Rome. - 【capital of Italy is Rome】 - - Example of a correct answer when you do NOT know the answer: - I'm sorry, I don't know the answer to that question. - - Example of an incorrect answer (NOT ACCEPTED): - The capital of Italy is Rome - 【capital of Italy is Rome】 - Thank you for your question. - - Another incorrect example (NOT ACCEPTED): - The capital of Italy is Rome. - 【capital of Italy is Rome】 - [1] italy.pdf, page 1 - --- - - Only the correct format is accepted. If you do not follow the XML format exactly, or if you add anything after the citations block, your answer will be considered invalid. - If you do NOT know the answer, DO NOT include the citations block at all. - Remember to ALWAYS end your answer with a period followed by a space before adding citations. - """; - - public async Task CreateReformulateQuestionAsync(Guid conversationId, string question, CancellationToken cancellationToken = default) - { - var chat = await GetChatHistoryAsync(conversationId, cancellationToken); - - var settings = new AzureOpenAIPromptExecutionSettings - { - ChatSystemPrompt = systemPromptForReformulation - }; - - var embeddingQuestion = $""" - Reformulate the following question: - --- - {question} - """; - - chat.AddUserMessage(embeddingQuestion); - - var reformulatedQuestion = await chatCompletionService.GetChatMessageContentAsync(chat, settings, cancellationToken: cancellationToken); - - chat.AddAssistantMessage(reformulatedQuestion.Content!); - - await UpdateCacheAsync(conversationId, chat, cancellationToken); - - var tokenUsage = GetTokenUsage(reformulatedQuestion); - logger.LogDebug("Reformulation: {TokenUsage}", tokenUsage); - - return new(reformulatedQuestion.Content!, tokenUsage); - } - - public async Task AskQuestionAsync(Guid conversationId, IEnumerable chunks, string question, CancellationToken cancellationToken = default) - { - var (chat, settings) = CreateChatAsync(chunks, question); - - var answer = await chatCompletionService.GetChatMessageContentAsync(chat, settings, cancellationToken: cancellationToken); - - // Add question and answer to the chat history. - await SetChatHistoryAsync(conversationId, question, answer.Content!, cancellationToken); - - var tokenUsage = GetTokenUsage(answer); - logger.LogDebug("Ask question: {TokenUsage}", tokenUsage); - - return new(answer.Content!, tokenUsage); - } - - public async IAsyncEnumerable AskStreamingAsync(Guid conversationId, IEnumerable chunks, string question, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - var (chat, settings) = CreateChatAsync(chunks, question); - - var answer = new StringBuilder(); - await foreach (var token in chatCompletionService.GetStreamingChatMessageContentsAsync(chat, settings, cancellationToken: cancellationToken)) - { - if (!string.IsNullOrEmpty(token.Content)) - { - yield return new(token.Content); - answer.Append(token.Content); - } - else if (token.Content is null) - { - // Token usage is returned in the last message, when the Content is null. - var tokenUsage = GetTokenUsage(token); - if (tokenUsage is not null) - { - logger.LogDebug("Ask streaming: {TokenUsage}", tokenUsage); - yield return new(null, tokenUsage); - } - } - } - - // Add question and answer to the chat history. - await SetChatHistoryAsync(conversationId, question, answer.ToString(), cancellationToken).ConfigureAwait(false); - } - - private static TokenUsage? GetTokenUsage(Microsoft.SemanticKernel.ChatMessageContent message) => - message.InnerContent is ChatCompletion content && content.Usage is not null - ? new(content.Usage.InputTokenCount, content.Usage.OutputTokenCount) : null; - - private static TokenUsage? GetTokenUsage(Microsoft.SemanticKernel.StreamingChatMessageContent message) => - message.InnerContent is StreamingChatCompletionUpdate content && content.Usage is not null - ? new(content.Usage.InputTokenCount, content.Usage.OutputTokenCount) : null; - - private (ChatHistory Chat, AzureOpenAIPromptExecutionSettings Settings) CreateChatAsync(IEnumerable chunks, string question) - { - var settings = new AzureOpenAIPromptExecutionSettings - { - MaxTokens = appSettings.MaxOutputTokens, - ChatSystemPrompt = systemPromptForAnswering - }; - - var prompt = new StringBuilder($""" - Answer the following question: - --- - {question} - ===== - Using the following information: - - """); - - var availableTokens = appSettings.MaxInputTokens - - tokenizerService.CountChatCompletionTokens(systemPromptForAnswering) // System prompt. - - tokenizerService.CountChatCompletionTokens(prompt.ToString()) // Initial user prompt. - - appSettings.MaxOutputTokens; // To ensure there is enough space for the answer. - - foreach (var chunk in chunks) - { - var text = $"--- {chunk.Document.Name} (Document ID: {chunk.Document.Id} | Chunk ID: {chunk.Id} | Page Number: {chunk.PageNumber} | Index on Page: {chunk.IndexOnPage}) {Environment.NewLine}{chunk.Content}{Environment.NewLine}"; - - var tokenCount = tokenizerService.CountChatCompletionTokens(text); - if (tokenCount > availableTokens) - { - // There isn't enough space to add the current chunk. - break; - } - - prompt.Append(text); - - availableTokens -= tokenCount; - if (availableTokens <= 0) - { - // There isn't enough space to add more chunks. - break; - } - } - - var chat = new ChatHistory(); - chat.AddUserMessage(prompt.ToString()); - - return (chat, settings); - } - - private async Task UpdateCacheAsync(Guid conversationId, ChatHistory chat, CancellationToken cancellationToken) - { - if (chat.Count > appSettings.MessageLimit) - { - chat.RemoveRange(0, chat.Count - appSettings.MessageLimit); - } - - await cache.SetAsync(conversationId.ToString(), chat, cancellationToken: cancellationToken); - } - - private async Task GetChatHistoryAsync(Guid conversationId, CancellationToken cancellationToken) - { - var chat = await cache.GetOrCreateAsync(conversationId.ToString(), (cancellationToken) => - { - return ValueTask.FromResult([]); - }, cancellationToken: cancellationToken); - - return chat; - } - - private async Task SetChatHistoryAsync(Guid conversationId, string question, string answer, CancellationToken cancellationToken) - { - var chat = await GetChatHistoryAsync(conversationId, cancellationToken); - - chat.AddUserMessage(question); - chat.AddAssistantMessage(answer); - - await UpdateCacheAsync(conversationId, chat, cancellationToken); - } -} diff --git a/SqlDatabaseVectorSearch/Services/HybridCacheSessionStoreService.cs b/SqlDatabaseVectorSearch/Services/HybridCacheSessionStoreService.cs new file mode 100644 index 0000000..2ae9b7b --- /dev/null +++ b/SqlDatabaseVectorSearch/Services/HybridCacheSessionStoreService.cs @@ -0,0 +1,30 @@ +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting; +using Microsoft.Extensions.Caching.Hybrid; + +namespace SqlDatabaseVectorSearch.Services; + +public class HybridCacheSessionStoreService(HybridCache cache) : AgentSessionStore +{ + public override async ValueTask GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default) + { + var sessionContent = await cache.GetOrCreateAsync( + GetCacheKey(conversationId), + async ct => + { + var session = await agent.CreateSessionAsync(ct); + return await agent.SerializeSessionAsync(session, cancellationToken: ct); + }, + cancellationToken: cancellationToken); + + return await agent.DeserializeSessionAsync(sessionContent, cancellationToken: cancellationToken); + } + + public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default) + { + var sessionContent = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken); + await cache.SetAsync(GetCacheKey(conversationId), sessionContent, cancellationToken: cancellationToken); + } + + private static string GetCacheKey(string conversationId) => $"agent-session:{conversationId}"; +} diff --git a/SqlDatabaseVectorSearch/Services/VectorSearchService.cs b/SqlDatabaseVectorSearch/Services/VectorSearchService.cs index 8f5dbd7..d3b2c92 100644 --- a/SqlDatabaseVectorSearch/Services/VectorSearchService.cs +++ b/SqlDatabaseVectorSearch/Services/VectorSearchService.cs @@ -1,7 +1,7 @@ using System.Data; using System.Runtime.CompilerServices; -using System.Text; -using System.Text.RegularExpressions; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting; using Microsoft.Agents.AI.Workflows; using Microsoft.Data.SqlTypes; using Microsoft.EntityFrameworkCore; @@ -11,12 +11,11 @@ using SqlDatabaseVectorSearch.Data; using SqlDatabaseVectorSearch.Models; using SqlDatabaseVectorSearch.Settings; using SqlDatabaseVectorSearch.Workflows; -using ChatResponse = SqlDatabaseVectorSearch.Models.ChatResponse; -using Entities = SqlDatabaseVectorSearch.Data.Entities; namespace SqlDatabaseVectorSearch.Services; -public partial class VectorSearchService([FromKeyedServices("EmbeddingWorkflow")] Workflow workflow, ApplicationDbContext dbContext, IEmbeddingGenerator> embeddingGenerator, TokenizerService tokenizerService, ChatService chatService, TimeProvider timeProvider, IOptions appSettingsOptions, ILogger logger) +public partial class VectorSearchService([FromKeyedServices("EmbeddingWorkflow")] Workflow workflow, [FromKeyedServices("ReformulationAgent")] AIAgent reformulationAgent, [FromKeyedServices("RagAgent")] AIAgent ragAgent, + [FromKeyedServices("RagAgent")] AgentSessionStore sessionStore, IOptions appSettingsOptions) { private readonly AppSettings appSettings = appSettingsOptions.Value; @@ -35,119 +34,95 @@ public partial class VectorSearchService([FromKeyedServices("EmbeddingWorkflow") return result; } - public async Task AskQuestionAsync(Question question, bool reformulate = true, CancellationToken cancellationToken = default) + public async Task AskQuestionAsync(Question question, bool reformulate = true, CancellationToken cancellationToken = default) { - // It the user doesn't want to reforulate the question, CreateContextAsync returns the original one. - var (reformulatedQuestion, embeddingTokenCount, chunks) = await CreateContextAsync(question, reformulate, cancellationToken); + var reformulatedQuestion = question.Text; + var session = await sessionStore.GetSessionAsync(ragAgent, question.ConversationId.ToString(), cancellationToken); - var (fullAnswer, tokenUsage) = await chatService.AskQuestionAsync(question.ConversationId, chunks, reformulatedQuestion.Text!, cancellationToken); + if (reformulate) + { + // Reformulates the question taking into account the context of the chat to perform keyword search and embeddings. + var reformulationResponse = await reformulationAgent.RunAsync(question.Text, session, cancellationToken: cancellationToken); + reformulatedQuestion = reformulationResponse.Text; + } - // Extract citations from the answer. - var (answer, citations) = ExtractCitations(fullAnswer); + var response = await ragAgent.RunAsync(reformulatedQuestion, session, cancellationToken: cancellationToken); - return new(question.Text, reformulatedQuestion.Text!, answer, StreamState.End, new(reformulatedQuestion.TokenUsage, embeddingTokenCount, tokenUsage), citations); + await sessionStore.SaveSessionAsync(ragAgent, question.ConversationId.ToString(), session, cancellationToken); + + session.TryGetInMemoryChatHistory(out var chatHistory); + + return new(question.ConversationId, question.Text, reformulatedQuestion, response.Text); } public async IAsyncEnumerable AskStreamingAsync(Question question, bool reformulate = true, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - // It the user doesn't want to reforulate the question, CreateContextAsync returns the original one. - var (reformulatedQuestion, embeddingTokenCount, chunks) = await CreateContextAsync(question, reformulate, cancellationToken); + yield return null!; - var answerStream = chatService.AskStreamingAsync(question.ConversationId, chunks, reformulatedQuestion.Text!, cancellationToken: cancellationToken); + //// It the user doesn't want to reforulate the question, CreateContextAsync returns the original one. + //var (reformulatedQuestion, embeddingTokenCount, chunks) = await CreateContextAsync(question, reformulate, cancellationToken); - // The first message contains the question and the corresponding token usage (if reformulated). - yield return new(question.Text, reformulatedQuestion.Text!, null, StreamState.Start, new(reformulatedQuestion.TokenUsage, embeddingTokenCount, null)); + //var answerStream = chatService.AskStreamingAsync(question.ConversationId, chunks, reformulatedQuestion.Text!, cancellationToken: cancellationToken); - TokenUsageResponse? tokenUsageResponse = null; - var fullAnswer = new StringBuilder(); - var citationsStarted = false; + //// The first message contains the question and the corresponding token usage (if reformulated). + //yield return new(question.Text, reformulatedQuestion.Text!, null, StreamState.Start, new(reformulatedQuestion.TokenUsage, embeddingTokenCount, null)); - // Returns each token as a partial response. - await foreach (var (token, tokenUsage) in answerStream) - { - if (token is not null) // token can be null when the stream ends. - { - fullAnswer.Append(token); + //TokenUsageResponse? tokenUsageResponse = null; + //var fullAnswer = new StringBuilder(); + //var citationsStarted = false; - if (token.Contains('【')) - { - // Citations start when we encounter a token containing a 【 character. - // We need to track it because we don't want to return the citations in the actual response. - citationsStarted = true; - } + //// Returns each token as a partial response. + //await foreach (var (token, tokenUsage) in answerStream) + //{ + // if (token is not null) // token can be null when the stream ends. + // { + // fullAnswer.Append(token); - if (!citationsStarted) - { - yield return new(token, StreamState.Append); - } - } - else - { - // Token usage is expected in the last message, when token is null. - tokenUsageResponse ??= tokenUsage is not null ? new(tokenUsage) : null; - } - } + // if (token.Contains('【')) + // { + // // Citations start when we encounter a token containing a 【 character. + // // We need to track it because we don't want to return the citations in the actual response. + // citationsStarted = true; + // } - // Extract citations at the end of streaming. - var (_, citations) = ExtractCitations(fullAnswer.ToString()); - yield return new(null, StreamState.End, tokenUsageResponse, citations); + // if (!citationsStarted) + // { + // yield return new(token, StreamState.Append); + // } + // } + // else + // { + // // Token usage is expected in the last message, when token is null. + // tokenUsageResponse ??= tokenUsage is not null ? new(tokenUsage) : null; + // } + //} + + //// Extract citations at the end of streaming. + //var (_, citations) = ExtractCitations(fullAnswer.ToString()); + //yield return new(null, StreamState.End, tokenUsageResponse, citations); } +} - private async Task<(ChatResponse ReformulatedQuestion, int EmbeddingTokenCount, IEnumerable Chunks)> CreateContextAsync(Question question, bool reformulate, CancellationToken cancellationToken) +public class ContextProvider(ApplicationDbContext dbContext, IEmbeddingGenerator> embeddingGenerator, IOptions appSettingsOptions) +{ + private readonly AppSettings appSettings = appSettingsOptions.Value; + + public async Task> SearchAsync(string query, CancellationToken cancellationToken) { - // Reformulate the question taking into account the context of the chat to perform keyword search and embeddings. - var reformulatedQuestion = reformulate ? await chatService.CreateReformulateQuestionAsync(question.ConversationId, question.Text, cancellationToken) : new(question.Text); - - var embeddingTokenCount = tokenizerService.CountEmbeddingTokens(reformulatedQuestion.Text!); - logger.LogDebug("Embedding Token Count: {EmbeddingTokenCount}", embeddingTokenCount); - // Perform Vector Search on SQL Database. - var questionEmbedding = await embeddingGenerator.GenerateVectorAsync(reformulatedQuestion.Text!, cancellationToken: cancellationToken); + var questionEmbedding = await embeddingGenerator.GenerateVectorAsync(query, cancellationToken: cancellationToken); var embeddingVector = new SqlVector(questionEmbedding); var chunks = await dbContext.DocumentChunks.Include(c => c.Document) .OrderBy(c => EF.Functions.VectorDistance("cosine", c.Embedding, embeddingVector)) - .Take(appSettings.MaxRelevantChunks) + .Take(appSettings.MaxRelevantChunks).Select(c => new TextSearchProvider.TextSearchResult + { + SourceLink = c.Id.ToString().ToLowerInvariant(), + SourceName = c.Document.Name, + Text = c.Content, + }) .ToListAsync(cancellationToken); - return (reformulatedQuestion, embeddingTokenCount, chunks); + return chunks; } - - private static (string, IEnumerable) ExtractCitations(string? text) - { - var citations = new List(); - - if (string.IsNullOrEmpty(text)) - { - return (text ?? string.Empty, citations); - } - - var matches = CitationRegEx.Matches(text); - - foreach (Match match in matches) - { - if (match.Success) - { - citations.Add(new Citation - { - DocumentId = Guid.Parse(match.Groups["documentId"].Value), - ChunkId = Guid.Parse(match.Groups["chunkId"].Value), - FileName = match.Groups["filename"].Value, - PageNumber = int.TryParse(match.Groups["pageNumber"].Value, out var pageNumber) && pageNumber > 0 ? pageNumber : null, - IndexOnPage = int.TryParse(match.Groups["indexOnPage"].Value, out var indexOnPage) ? indexOnPage : 0, - Quote = match.Groups["quote"].Value - }); - } - } - - // Remove all content between 【 and 】. - var cleanText = RemoveCitationsRegEx.Replace(text, string.Empty).TrimEnd(); - return (cleanText, citations.OrderBy(c => c.FileName).ThenBy(c => c.PageNumber)); - } - - [GeneratedRegex(@"[^""']*)(?:""|'|)\s+chunk-id=(?:""|'|)(?[^""']*)(?:""|'|)\s+filename=(?:""|'|)(?[^""']*)(?:""|'|)\s+page-number=(?:""|'|)(?[^""']*)(?:""|'|)\s+index-on-page=(?:""|'|)(?[^""']*)(?:""|'|)>\s*(?.*?)\s*", RegexOptions.Singleline)] - private static partial Regex CitationRegEx { get; } - - [GeneratedRegex(@"【.*?】", RegexOptions.Singleline)] - private static partial Regex RemoveCitationsRegEx { get; } } \ No newline at end of file diff --git a/SqlDatabaseVectorSearch/SqlDatabaseVectorSearch.csproj b/SqlDatabaseVectorSearch/SqlDatabaseVectorSearch.csproj index 47ca899..b0062e0 100644 --- a/SqlDatabaseVectorSearch/SqlDatabaseVectorSearch.csproj +++ b/SqlDatabaseVectorSearch/SqlDatabaseVectorSearch.csproj @@ -4,7 +4,7 @@ net10.0 enable enable - $(NoWarn);SKEXP0010;SKEXP0050;OPENAI001;MAAI001 + $(NoWarn);SKEXP0010;SKEXP0050;OPENAI001;MAAI001;MEAI001 @@ -34,7 +34,6 @@ - diff --git a/SqlDatabaseVectorSearch/Workflows/GenerateEmbeddingExecutor.cs b/SqlDatabaseVectorSearch/Workflows/GenerateEmbeddingExecutor.cs index a365855..e413fa3 100644 --- a/SqlDatabaseVectorSearch/Workflows/GenerateEmbeddingExecutor.cs +++ b/SqlDatabaseVectorSearch/Workflows/GenerateEmbeddingExecutor.cs @@ -2,13 +2,12 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.Options; using SqlDatabaseVectorSearch.ContentDecoders; -using SqlDatabaseVectorSearch.Models; using SqlDatabaseVectorSearch.Services; using SqlDatabaseVectorSearch.Settings; namespace SqlDatabaseVectorSearch.Workflows; -public partial class GenerateEmbeddingExecutor(IServiceProvider serviceProvider, IEmbeddingGenerator> embeddingGenerator, TokenizerService tokenizerService, IOptions appSettingsOptions, ILogger logger) : Executor(nameof(GenerateEmbeddingExecutor)) +public partial class GenerateEmbeddingExecutor(IServiceProvider serviceProvider, IEmbeddingGenerator> embeddingGenerator, TokenizerService tokenizerService, IOptions appSettingsOptions, ILogger logger) : Executor(nameof(GenerateEmbeddingExecutor)) { private readonly AppSettings appSettings = appSettingsOptions.Value; diff --git a/SqlDatabaseVectorSearch/Workflows/StoreEmbeddingExecutor.cs b/SqlDatabaseVectorSearch/Workflows/StoreEmbeddingExecutor.cs index c0f0155..aaec48d 100644 --- a/SqlDatabaseVectorSearch/Workflows/StoreEmbeddingExecutor.cs +++ b/SqlDatabaseVectorSearch/Workflows/StoreEmbeddingExecutor.cs @@ -7,7 +7,7 @@ using Entities = SqlDatabaseVectorSearch.Data.Entities; namespace SqlDatabaseVectorSearch.Workflows; -public partial class StoreEmbeddingExecutor(ApplicationDbContext dbContext, DocumentService documentService, TokenizerService tokenizerService, TimeProvider timeProvider, ILogger logger) : Executor(nameof(StoreEmbeddingExecutor)) +public partial class StoreEmbeddingExecutor(ApplicationDbContext dbContext, DocumentService documentService, TokenizerService tokenizerService, TimeProvider timeProvider, ILogger logger) : Executor(nameof(StoreEmbeddingExecutor)) { [MessageHandler] private async ValueTask HandleAsync(EmbeddingResponse embeddingData, IWorkflowContext context, CancellationToken cancellationToken) diff --git a/SqlDatabaseVectorSearch/appsettings.json b/SqlDatabaseVectorSearch/appsettings.json index 3c23b98..46d3a96 100644 --- a/SqlDatabaseVectorSearch/appsettings.json +++ b/SqlDatabaseVectorSearch/appsettings.json @@ -24,7 +24,7 @@ "MaxTokensPerLine": 300, "MaxTokensPerParagraph": 1000, "OverlapTokens": 100, - "MaxRelevantChunks": 50, + "MaxRelevantChunks": 30, "MaxInputTokens": 32768, "MaxOutputTokens": 800, "MessageExpiration": "00:05:00",