From 5624f73640cced1aebdfef6c8f4fba9fb20b0f34 Mon Sep 17 00:00:00 2001
From: Marco Minerva
Date: Mon, 15 Jun 2026 17:58:30 +0200
Subject: [PATCH 01/32] Refactor: replace SemanticKernel with Agents.AI.OpenAI
Removed Microsoft.SemanticKernel dependencies in favor of Microsoft.Agents.AI.OpenAI for embedding and chat services. Updated DI registrations in Program.cs to use OpenAIClient. Reimplemented text chunking with a new PlainTextChunker class, updating DefaultTextChunker and MarkdownTextChunker accordingly. Updated .csproj to add new package references and suppress related analyzer warnings.
---
SqlDatabaseVectorSearch/Program.cs | 29 +-
.../SqlDatabaseVectorSearch.csproj | 8 +-
.../TextChunkers/DefaultTextChunker.cs | 6 +-
.../Implementations/PlainTextChunker.cs | 347 ++++++++++++++++++
.../TextChunkers/MarkdownTextChunker.cs | 6 +-
5 files changed, 383 insertions(+), 13 deletions(-)
create mode 100644 SqlDatabaseVectorSearch/TextChunkers/Implementations/PlainTextChunker.cs
diff --git a/SqlDatabaseVectorSearch/Program.cs b/SqlDatabaseVectorSearch/Program.cs
index 655e93a..e99238c 100644
--- a/SqlDatabaseVectorSearch/Program.cs
+++ b/SqlDatabaseVectorSearch/Program.cs
@@ -1,8 +1,11 @@
+using System.ClientModel;
using System.Net.Mime;
using System.Text.Json.Serialization;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
-using Microsoft.SemanticKernel;
+using Microsoft.Extensions.AI;
+using OpenAI;
+using OpenAI.Responses;
using SqlDatabaseVectorSearch.Components;
using SqlDatabaseVectorSearch.ContentDecoders;
using SqlDatabaseVectorSearch.Data;
@@ -54,11 +57,25 @@ builder.Services.ConfigureHttpClientDefaults(configure =>
});
});
-// 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()
- .AddAzureOpenAIEmbeddingGenerator(aiSettings.Embedding.Deployment, aiSettings.Embedding.Endpoint, aiSettings.Embedding.ApiKey, modelId: aiSettings.Embedding.ModelId, dimensions: aiSettings.Embedding.Dimensions)
- .AddAzureOpenAIChatCompletion(aiSettings.ChatCompletion.Deployment, aiSettings.ChatCompletion.Endpoint, aiSettings.ChatCompletion.ApiKey, modelId: aiSettings.ChatCompletion.ModelId);
+builder.Services.AddSingleton(_ =>
+{
+ var embeddingClient = new OpenAIClient(new ApiKeyCredential(aiSettings.Embedding.ApiKey), new()
+ {
+ Endpoint = new(aiSettings.Embedding.Endpoint),
+ }).GetEmbeddingClient(aiSettings.Embedding.Deployment).AsIEmbeddingGenerator(aiSettings.Embedding.Dimensions);
+
+ return embeddingClient;
+});
+
+builder.Services.AddChatClient(_ =>
+{
+ var chatClient = new OpenAIClient(new ApiKeyCredential(aiSettings.ChatCompletion.ApiKey), new()
+ {
+ Endpoint = new(aiSettings.ChatCompletion.Endpoint),
+ }).GetResponsesClient().AsIChatClientWithStoredOutputDisabled(aiSettings.ChatCompletion.Deployment);
+
+ return chatClient;
+});
builder.Services.AddKeyedSingleton(MediaTypeNames.Application.Pdf);
builder.Services.AddKeyedSingleton("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
diff --git a/SqlDatabaseVectorSearch/SqlDatabaseVectorSearch.csproj b/SqlDatabaseVectorSearch/SqlDatabaseVectorSearch.csproj
index c82f9d0..42361e4 100644
--- a/SqlDatabaseVectorSearch/SqlDatabaseVectorSearch.csproj
+++ b/SqlDatabaseVectorSearch/SqlDatabaseVectorSearch.csproj
@@ -4,7 +4,7 @@
net10.0enableenable
- $(NoWarn);SKEXP0010;SKEXP0050
+ $(NoWarn);SKEXP0010;SKEXP0050;OPENAI001;MAAI001
@@ -12,7 +12,13 @@
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
diff --git a/SqlDatabaseVectorSearch/TextChunkers/DefaultTextChunker.cs b/SqlDatabaseVectorSearch/TextChunkers/DefaultTextChunker.cs
index a5a6854..777f48f 100644
--- a/SqlDatabaseVectorSearch/TextChunkers/DefaultTextChunker.cs
+++ b/SqlDatabaseVectorSearch/TextChunkers/DefaultTextChunker.cs
@@ -1,7 +1,7 @@
using Microsoft.Extensions.Options;
-using Microsoft.SemanticKernel.Text;
using SqlDatabaseVectorSearch.Services;
using SqlDatabaseVectorSearch.Settings;
+using SqlDatabaseVectorSearch.TextChunkers.Implementations;
namespace SqlDatabaseVectorSearch.TextChunkers;
@@ -11,8 +11,8 @@ public class DefaultTextChunker(TokenizerService tokenizerService, IOptions Split(string text)
{
- var lines = TextChunker.SplitPlainTextLines(text, appSettings.MaxTokensPerLine, tokenizerService.CountEmbeddingTokens);
- var paragraphs = TextChunker.SplitPlainTextParagraphs(lines, appSettings.MaxTokensPerParagraph, appSettings.OverlapTokens, tokenCounter: tokenizerService.CountEmbeddingTokens);
+ var lines = PlainTextChunker.SplitPlainTextLines(text, appSettings.MaxTokensPerLine, tokenizerService.CountEmbeddingTokens);
+ var paragraphs = PlainTextChunker.SplitPlainTextParagraphs(lines, appSettings.MaxTokensPerParagraph, appSettings.OverlapTokens, tokenCounter: tokenizerService.CountEmbeddingTokens);
return paragraphs;
}
diff --git a/SqlDatabaseVectorSearch/TextChunkers/Implementations/PlainTextChunker.cs b/SqlDatabaseVectorSearch/TextChunkers/Implementations/PlainTextChunker.cs
new file mode 100644
index 0000000..be81fd9
--- /dev/null
+++ b/SqlDatabaseVectorSearch/TextChunkers/Implementations/PlainTextChunker.cs
@@ -0,0 +1,347 @@
+using System.Diagnostics;
+using System.Text;
+
+namespace SqlDatabaseVectorSearch.TextChunkers.Implementations;
+
+///
+/// Split text in chunks, attempting to leave meaning intact.
+/// For plain text, split looking at new lines first, then periods, and so on.
+/// For markdown, split looking at punctuation first, and so on.
+///
+internal static class PlainTextChunker
+{
+ ///
+ /// Represents a list of strings with token count.
+ /// Used to reduce the number of calls to the tokenizer.
+ ///
+ private sealed class StringListWithTokenCount(TokenCounter? tokenCounter)
+ {
+ private readonly TokenCounter? tokenCounter = tokenCounter;
+
+ public void Add(string value) => Values.Add((value, tokenCounter is null ? GetDefaultTokenCount(value.Length) : tokenCounter(value)));
+
+ public void Add(string value, int tokenCount) => Values.Add((value, tokenCount));
+
+ public void AddRange(StringListWithTokenCount range) => Values.AddRange(range.Values);
+
+ public void RemoveRange(int index, int count) => Values.RemoveRange(index, count);
+
+ public int Count => Values.Count;
+
+ public List ToStringList() => Values.Select(v => v.Value).ToList();
+
+ private List<(string Value, int TokenCount)> Values { get; } = [];
+
+ public string ValueAt(int i) => Values[i].Value;
+
+ public int TokenCountAt(int i) => Values[i].TokenCount;
+ }
+
+ ///
+ /// Delegate for counting tokens in a string.
+ ///
+ /// The input string to count tokens in.
+ /// The number of tokens in the input string.
+ public delegate int TokenCounter(string input);
+
+ private static readonly char[] spaceChar = [' '];
+ private static readonly string?[] plainTextSplitOptions = ["\n", ".。.", "?!", ";", ":", ",,、", ")]}", " ", "-", null];
+ private static readonly string?[] markdownSplitOptions = [".\u3002\uFF0E", "?!", ";", ":", ",\uFF0C\u3001", ")]}", " ", "-", "\n\r", null];
+
+ ///
+ /// Split plain text into lines.
+ ///
+ /// Text to split
+ /// Maximum number of tokens per line.
+ /// Function to count tokens in a string. If not supplied, the default counter will be used.
+ /// List of lines.
+ public static List SplitPlainTextLines(string text, int maxTokensPerLine, TokenCounter? tokenCounter = null) =>
+ InternalSplitLines(text, maxTokensPerLine, trim: true, plainTextSplitOptions, tokenCounter);
+
+ ///
+ /// Split markdown text into lines.
+ ///
+ /// Text to split
+ /// Maximum number of tokens per line.
+ /// Function to count tokens in a string. If not supplied, the default counter will be used.
+ /// List of lines.
+ public static List SplitMarkDownLines(string text, int maxTokensPerLine, TokenCounter? tokenCounter = null) =>
+ InternalSplitLines(text, maxTokensPerLine, trim: true, markdownSplitOptions, tokenCounter);
+
+ ///
+ /// Split plain text into paragraphs.
+ ///
+ /// Lines of text.
+ /// Maximum number of tokens per paragraph.
+ /// Number of tokens to overlap between paragraphs.
+ /// Text to be prepended to each individual chunk.
+ /// Function to count tokens in a string. If not supplied, the default counter will be used.
+ /// List of paragraphs.
+ public static List SplitPlainTextParagraphs(IEnumerable lines, int maxTokensPerParagraph, int overlapTokens = 0, string? chunkHeader = null, TokenCounter? tokenCounter = null)
+ => InternalSplitTextParagraphs(lines.Select(line => line.Replace("\r\n", "\n").Replace('\r', '\n')), maxTokensPerParagraph, overlapTokens, chunkHeader,
+ static (text, maxTokens, tokenCounter) => InternalSplitLines(text, maxTokens, trim: false, plainTextSplitOptions, tokenCounter), tokenCounter);
+
+ ///
+ /// Split markdown text into paragraphs.
+ ///
+ /// Lines of text.
+ /// Maximum number of tokens per paragraph.
+ /// Number of tokens to overlap between paragraphs.
+ /// Text to be prepended to each individual chunk.
+ /// Function to count tokens in a string. If not supplied, the default counter will be used.
+ /// List of paragraphs.
+ public static List SplitMarkdownParagraphs(IEnumerable lines, int maxTokensPerParagraph, int overlapTokens = 0, string? chunkHeader = null, TokenCounter? tokenCounter = null)
+ => InternalSplitTextParagraphs(lines, maxTokensPerParagraph, overlapTokens, chunkHeader,
+ static (text, maxTokens, tokenCounter) => InternalSplitLines(text, maxTokens, trim: false, markdownSplitOptions, tokenCounter), tokenCounter);
+
+ private static List InternalSplitTextParagraphs(IEnumerable lines, int maxTokensPerParagraph, int overlapTokens, string? chunkHeader, Func> longLinesSplitter, TokenCounter? tokenCounter)
+ {
+ if (maxTokensPerParagraph <= 0)
+ {
+ throw new ArgumentException("maxTokensPerParagraph should be a positive number", nameof(maxTokensPerParagraph));
+ }
+
+ if (maxTokensPerParagraph <= overlapTokens)
+ {
+ throw new ArgumentException("overlapTokens cannot be larger than maxTokensPerParagraph", nameof(maxTokensPerParagraph));
+ }
+
+ // Optimize empty inputs if we can efficiently determine the're empty
+ if (lines is ICollection c && c.Count == 0)
+ {
+ return [];
+ }
+
+ var chunkHeaderTokens = chunkHeader is { Length: > 0 } ? GetTokenCount(chunkHeader, tokenCounter) : 0;
+ var adjustedMaxTokensPerParagraph = maxTokensPerParagraph - overlapTokens - chunkHeaderTokens;
+
+ // Split long lines first
+ var truncatedLines = lines.SelectMany(line => longLinesSplitter(line, adjustedMaxTokensPerParagraph, tokenCounter));
+
+ var paragraphs = BuildParagraph(truncatedLines, adjustedMaxTokensPerParagraph, tokenCounter);
+ var processedParagraphs = ProcessParagraphs(paragraphs, adjustedMaxTokensPerParagraph, overlapTokens, chunkHeader, longLinesSplitter, tokenCounter);
+
+ return processedParagraphs;
+ }
+
+ private static List BuildParagraph(IEnumerable truncatedLines, int maxTokensPerParagraph, TokenCounter? tokenCounter)
+ {
+ StringBuilder paragraphBuilder = new();
+ List paragraphs = [];
+
+ foreach (var line in truncatedLines)
+ {
+ if (paragraphBuilder.Length > 0)
+ {
+ string? paragraph = null;
+
+ var currentCount = GetTokenCount(line, tokenCounter) + 1;
+ if (currentCount < maxTokensPerParagraph)
+ {
+ currentCount += tokenCounter is null ?
+ GetDefaultTokenCount(paragraphBuilder.Length) :
+ tokenCounter(paragraph = paragraphBuilder.ToString());
+ }
+
+ if (currentCount >= maxTokensPerParagraph)
+ {
+ // Complete the paragraph and prepare for the next
+ paragraph ??= paragraphBuilder.ToString();
+ paragraphs.Add(paragraph.Trim());
+ paragraphBuilder.Clear();
+ }
+ }
+
+ paragraphBuilder.AppendLine(line);
+ }
+
+ if (paragraphBuilder.Length > 0)
+ {
+ // Add the final paragraph if there's anything remaining
+ paragraphs.Add(paragraphBuilder.ToString().Trim());
+ }
+
+ return paragraphs;
+ }
+
+ private static List ProcessParagraphs(List paragraphs, int adjustedMaxTokensPerParagraph, int overlapTokens, string? chunkHeader, Func> longLinesSplitter, TokenCounter? tokenCounter)
+ {
+ // distribute text more evenly in the last paragraphs when the last paragraph is too short.
+ if (paragraphs.Count > 1)
+ {
+ var lastParagraph = paragraphs[^1];
+ var secondLastParagraph = paragraphs[^2];
+
+ if (GetTokenCount(lastParagraph, tokenCounter) < adjustedMaxTokensPerParagraph / 4)
+ {
+ var lastParagraphTokens = lastParagraph.Split(spaceChar, StringSplitOptions.RemoveEmptyEntries);
+ var secondLastParagraphTokens = secondLastParagraph.Split(spaceChar, StringSplitOptions.RemoveEmptyEntries);
+
+ var lastParagraphTokensCount = lastParagraphTokens.Length;
+ var secondLastParagraphTokensCount = secondLastParagraphTokens.Length;
+
+ if (lastParagraphTokensCount + secondLastParagraphTokensCount <= adjustedMaxTokensPerParagraph)
+ {
+ var newSecondLastParagraph = string.Join(" ", secondLastParagraphTokens);
+ var newLastParagraph = string.Join(" ", lastParagraphTokens);
+
+ paragraphs[^2] = $"{newSecondLastParagraph} {newLastParagraph}";
+ paragraphs.RemoveAt(paragraphs.Count - 1);
+ }
+ }
+ }
+
+ var processedParagraphs = new List();
+ var paragraphStringBuilder = new StringBuilder();
+
+ for (var i = 0; i < paragraphs.Count; i++)
+ {
+ paragraphStringBuilder.Clear();
+
+ if (chunkHeader is not null)
+ {
+ paragraphStringBuilder.Append(chunkHeader);
+ }
+
+ var paragraph = paragraphs[i];
+
+ if (overlapTokens > 0 && i < paragraphs.Count - 1)
+ {
+ var nextParagraph = paragraphs[i + 1];
+ var split = longLinesSplitter(nextParagraph, overlapTokens, tokenCounter);
+
+ paragraphStringBuilder.Append(paragraph);
+
+ if (split.Count != 0)
+ {
+ paragraphStringBuilder.Append(' ').Append(split[0]);
+ }
+ }
+ else
+ {
+ paragraphStringBuilder.Append(paragraph);
+ }
+
+ processedParagraphs.Add(paragraphStringBuilder.ToString());
+ }
+
+ return processedParagraphs;
+ }
+
+ private static List InternalSplitLines(string text, int maxTokensPerLine, bool trim, string?[] splitOptions, TokenCounter? tokenCounter)
+ {
+ var result = new StringListWithTokenCount(tokenCounter);
+
+ text = text.Replace("\r\n", "\n"); // normalize line endings
+ result.Add(text);
+ for (var i = 0; i < splitOptions.Length; i++)
+ {
+ var count = result.Count; // track where the original input left off
+ var (splits2, inputWasSplit2) = Split(result, maxTokensPerLine, splitOptions[i].AsSpan(), trim, tokenCounter);
+ result.AddRange(splits2);
+ result.RemoveRange(0, count); // remove the original input
+ if (!inputWasSplit2)
+ {
+ break;
+ }
+ }
+
+ return result.ToStringList();
+ }
+
+ private static (StringListWithTokenCount, bool) Split(StringListWithTokenCount input, int maxTokens, ReadOnlySpan separators, bool trim, TokenCounter? tokenCounter)
+ {
+ var inputWasSplit = false;
+ StringListWithTokenCount result = new(tokenCounter);
+ var count = input.Count;
+ for (var i = 0; i < count; i++)
+ {
+ var (splits, split) = Split(input.ValueAt(i).AsSpan(), input.ValueAt(i), maxTokens, separators, trim, tokenCounter, input.TokenCountAt(i));
+ result.AddRange(splits);
+ inputWasSplit |= split;
+ }
+
+ return (result, inputWasSplit);
+ }
+
+ private static (StringListWithTokenCount, bool) Split(ReadOnlySpan input, string? inputString, int maxTokens, ReadOnlySpan separators, bool trim, TokenCounter? tokenCounter, int inputTokenCount)
+ {
+ Debug.Assert(inputString is null || input.SequenceEqual(inputString.AsSpan()));
+ StringListWithTokenCount result = new(tokenCounter);
+ var inputWasSplit = false;
+
+ if (inputTokenCount > maxTokens)
+ {
+ inputWasSplit = true;
+
+ var half = input.Length / 2;
+ var cutPoint = -1;
+
+ if (separators.IsEmpty)
+ {
+ cutPoint = half;
+ }
+ else if (input.Length > 2)
+ {
+ var pos = 0;
+ while (true)
+ {
+ var index = input[pos..^1].IndexOfAny(separators);
+ if (index < 0)
+ {
+ break;
+ }
+
+ index += pos;
+
+ if (Math.Abs(half - index) < Math.Abs(half - cutPoint))
+ {
+ cutPoint = index + 1;
+ }
+
+ pos = index + 1;
+ }
+ }
+
+ if (cutPoint > 0)
+ {
+ var firstHalf = input[..cutPoint];
+ var secondHalf = input[cutPoint..];
+ if (trim)
+ {
+ firstHalf = firstHalf.Trim();
+ secondHalf = secondHalf.Trim();
+ }
+
+ // Recursion
+ var (splits1, split1) = Split(firstHalf, null, maxTokens, separators, trim, tokenCounter, GetTokenCount(firstHalf.ToString(), tokenCounter));
+ result.AddRange(splits1);
+ var (splits2, split2) = Split(secondHalf, null, maxTokens, separators, trim, tokenCounter, GetTokenCount(secondHalf.ToString(), tokenCounter));
+ result.AddRange(splits2);
+
+ inputWasSplit = split1 || split2;
+ return (result, inputWasSplit);
+ }
+ }
+
+ var resultString = inputString ?? input.ToString();
+ var resultTokenCount = inputTokenCount;
+ if (trim && !resultString.Trim().Equals(resultString, StringComparison.Ordinal))
+ {
+ resultString = resultString.Trim();
+ resultTokenCount = GetTokenCount(resultString, tokenCounter);
+ }
+
+ result.Add(resultString, resultTokenCount);
+
+ return (result, inputWasSplit);
+ }
+
+ private static int GetTokenCount(string input, TokenCounter? tokenCounter) => tokenCounter is null ? GetDefaultTokenCount(input.Length) : tokenCounter(input);
+
+ private static int GetDefaultTokenCount(int length)
+ {
+ Debug.Assert(length >= 0);
+ return length >> 2;
+ }
+}
\ No newline at end of file
diff --git a/SqlDatabaseVectorSearch/TextChunkers/MarkdownTextChunker.cs b/SqlDatabaseVectorSearch/TextChunkers/MarkdownTextChunker.cs
index cba6679..37a06e7 100644
--- a/SqlDatabaseVectorSearch/TextChunkers/MarkdownTextChunker.cs
+++ b/SqlDatabaseVectorSearch/TextChunkers/MarkdownTextChunker.cs
@@ -1,7 +1,7 @@
using Microsoft.Extensions.Options;
-using Microsoft.SemanticKernel.Text;
using SqlDatabaseVectorSearch.Services;
using SqlDatabaseVectorSearch.Settings;
+using SqlDatabaseVectorSearch.TextChunkers.Implementations;
namespace SqlDatabaseVectorSearch.TextChunkers;
@@ -11,8 +11,8 @@ public class MarkdownTextChunker(TokenizerService tokenizerService, IOptions Split(string text)
{
- var lines = TextChunker.SplitMarkDownLines(text, appSettings.MaxTokensPerLine, tokenizerService.CountEmbeddingTokens);
- var paragraphs = TextChunker.SplitMarkdownParagraphs(lines, appSettings.MaxTokensPerParagraph, appSettings.OverlapTokens, tokenCounter: tokenizerService.CountEmbeddingTokens);
+ var lines = PlainTextChunker.SplitMarkDownLines(text, appSettings.MaxTokensPerLine, tokenizerService.CountEmbeddingTokens);
+ var paragraphs = PlainTextChunker.SplitMarkdownParagraphs(lines, appSettings.MaxTokensPerParagraph, appSettings.OverlapTokens, tokenCounter: tokenizerService.CountEmbeddingTokens);
return paragraphs;
}
From 14f983307e185416fd8316341cd828a216dc1287 Mon Sep 17 00:00:00 2001
From: Marco Minerva
Date: Tue, 16 Jun 2026 15:55:11 +0200
Subject: [PATCH 02/32] Improve validation and naming in PlainTextChunker
Refactored PlainTextChunker to add input validation, null checks, and argument validation in public methods. Renamed SplitMarkDownLines to SplitMarkdownLines for consistency and updated all references. Centralized line ending normalization and token count validation into dedicated methods. Enhanced error handling with clearer messages and exception types. Simplified paragraph merging logic and ensured GetDefaultTokenCount returns at least 1 for non-empty input. Updated MarkdownTextChunker to use the corrected method name.
---
.../Implementations/PlainTextChunker.cs | 81 ++++++++++++-------
.../TextChunkers/MarkdownTextChunker.cs | 2 +-
2 files changed, 54 insertions(+), 29 deletions(-)
diff --git a/SqlDatabaseVectorSearch/TextChunkers/Implementations/PlainTextChunker.cs b/SqlDatabaseVectorSearch/TextChunkers/Implementations/PlainTextChunker.cs
index be81fd9..80c25e0 100644
--- a/SqlDatabaseVectorSearch/TextChunkers/Implementations/PlainTextChunker.cs
+++ b/SqlDatabaseVectorSearch/TextChunkers/Implementations/PlainTextChunker.cs
@@ -44,7 +44,6 @@ internal static class PlainTextChunker
/// The number of tokens in the input string.
public delegate int TokenCounter(string input);
- private static readonly char[] spaceChar = [' '];
private static readonly string?[] plainTextSplitOptions = ["\n", ".。.", "?!", ";", ":", ",,、", ")]}", " ", "-", null];
private static readonly string?[] markdownSplitOptions = [".\u3002\uFF0E", "?!", ";", ":", ",\uFF0C\u3001", ")]}", " ", "-", "\n\r", null];
@@ -55,8 +54,13 @@ internal static class PlainTextChunker
/// Maximum number of tokens per line.
/// Function to count tokens in a string. If not supplied, the default counter will be used.
/// List of lines.
- public static List SplitPlainTextLines(string text, int maxTokensPerLine, TokenCounter? tokenCounter = null) =>
- InternalSplitLines(text, maxTokensPerLine, trim: true, plainTextSplitOptions, tokenCounter);
+ public static List SplitPlainTextLines(string text, int maxTokensPerLine, TokenCounter? tokenCounter = null)
+ {
+ ArgumentNullException.ThrowIfNull(text);
+ ValidateMaxTokens(maxTokensPerLine, nameof(maxTokensPerLine));
+
+ return InternalSplitLines(text, maxTokensPerLine, trim: true, plainTextSplitOptions, tokenCounter);
+ }
///
/// Split markdown text into lines.
@@ -65,8 +69,13 @@ internal static class PlainTextChunker
/// Maximum number of tokens per line.
/// Function to count tokens in a string. If not supplied, the default counter will be used.
/// List of lines.
- public static List SplitMarkDownLines(string text, int maxTokensPerLine, TokenCounter? tokenCounter = null) =>
- InternalSplitLines(text, maxTokensPerLine, trim: true, markdownSplitOptions, tokenCounter);
+ public static List SplitMarkdownLines(string text, int maxTokensPerLine, TokenCounter? tokenCounter = null)
+ {
+ ArgumentNullException.ThrowIfNull(text);
+ ValidateMaxTokens(maxTokensPerLine, nameof(maxTokensPerLine));
+
+ return InternalSplitLines(text, maxTokensPerLine, trim: true, markdownSplitOptions, tokenCounter);
+ }
///
/// Split plain text into paragraphs.
@@ -78,7 +87,7 @@ internal static class PlainTextChunker
/// Function to count tokens in a string. If not supplied, the default counter will be used.
/// List of paragraphs.
public static List SplitPlainTextParagraphs(IEnumerable lines, int maxTokensPerParagraph, int overlapTokens = 0, string? chunkHeader = null, TokenCounter? tokenCounter = null)
- => InternalSplitTextParagraphs(lines.Select(line => line.Replace("\r\n", "\n").Replace('\r', '\n')), maxTokensPerParagraph, overlapTokens, chunkHeader,
+ => InternalSplitTextParagraphs(lines, maxTokensPerParagraph, overlapTokens, chunkHeader,
static (text, maxTokens, tokenCounter) => InternalSplitLines(text, maxTokens, trim: false, plainTextSplitOptions, tokenCounter), tokenCounter);
///
@@ -96,17 +105,20 @@ internal static class PlainTextChunker
private static List InternalSplitTextParagraphs(IEnumerable lines, int maxTokensPerParagraph, int overlapTokens, string? chunkHeader, Func> longLinesSplitter, TokenCounter? tokenCounter)
{
- if (maxTokensPerParagraph <= 0)
+ ArgumentNullException.ThrowIfNull(lines);
+ ValidateMaxTokens(maxTokensPerParagraph, nameof(maxTokensPerParagraph));
+
+ if (overlapTokens < 0)
{
- throw new ArgumentException("maxTokensPerParagraph should be a positive number", nameof(maxTokensPerParagraph));
+ throw new ArgumentOutOfRangeException(nameof(overlapTokens), "overlapTokens cannot be negative.");
}
if (maxTokensPerParagraph <= overlapTokens)
{
- throw new ArgumentException("overlapTokens cannot be larger than maxTokensPerParagraph", nameof(maxTokensPerParagraph));
+ throw new ArgumentException("overlapTokens cannot be larger than or equal to maxTokensPerParagraph.", nameof(overlapTokens));
}
- // Optimize empty inputs if we can efficiently determine the're empty
+ // Optimize empty inputs if we can efficiently determine they're empty.
if (lines is ICollection c && c.Count == 0)
{
return [];
@@ -114,9 +126,13 @@ internal static class PlainTextChunker
var chunkHeaderTokens = chunkHeader is { Length: > 0 } ? GetTokenCount(chunkHeader, tokenCounter) : 0;
var adjustedMaxTokensPerParagraph = maxTokensPerParagraph - overlapTokens - chunkHeaderTokens;
+ if (adjustedMaxTokensPerParagraph <= 0)
+ {
+ throw new ArgumentException("chunkHeader and overlapTokens must leave room for paragraph content.", nameof(chunkHeader));
+ }
// Split long lines first
- var truncatedLines = lines.SelectMany(line => longLinesSplitter(line, adjustedMaxTokensPerParagraph, tokenCounter));
+ var truncatedLines = lines.SelectMany(line => longLinesSplitter(NormalizeLineEndings(line), adjustedMaxTokensPerParagraph, tokenCounter));
var paragraphs = BuildParagraph(truncatedLines, adjustedMaxTokensPerParagraph, tokenCounter);
var processedParagraphs = ProcessParagraphs(paragraphs, adjustedMaxTokensPerParagraph, overlapTokens, chunkHeader, longLinesSplitter, tokenCounter);
@@ -174,18 +190,11 @@ internal static class PlainTextChunker
if (GetTokenCount(lastParagraph, tokenCounter) < adjustedMaxTokensPerParagraph / 4)
{
- var lastParagraphTokens = lastParagraph.Split(spaceChar, StringSplitOptions.RemoveEmptyEntries);
- var secondLastParagraphTokens = secondLastParagraph.Split(spaceChar, StringSplitOptions.RemoveEmptyEntries);
+ var mergedParagraph = $"{secondLastParagraph} {lastParagraph}";
- var lastParagraphTokensCount = lastParagraphTokens.Length;
- var secondLastParagraphTokensCount = secondLastParagraphTokens.Length;
-
- if (lastParagraphTokensCount + secondLastParagraphTokensCount <= adjustedMaxTokensPerParagraph)
+ if (GetTokenCount(mergedParagraph, tokenCounter) <= adjustedMaxTokensPerParagraph)
{
- var newSecondLastParagraph = string.Join(" ", secondLastParagraphTokens);
- var newLastParagraph = string.Join(" ", lastParagraphTokens);
-
- paragraphs[^2] = $"{newSecondLastParagraph} {newLastParagraph}";
+ paragraphs[^2] = mergedParagraph;
paragraphs.RemoveAt(paragraphs.Count - 1);
}
}
@@ -232,7 +241,7 @@ internal static class PlainTextChunker
{
var result = new StringListWithTokenCount(tokenCounter);
- text = text.Replace("\r\n", "\n"); // normalize line endings
+ text = NormalizeLineEndings(text);
result.Add(text);
for (var i = 0; i < splitOptions.Length; i++)
{
@@ -314,9 +323,9 @@ internal static class PlainTextChunker
}
// Recursion
- var (splits1, split1) = Split(firstHalf, null, maxTokens, separators, trim, tokenCounter, GetTokenCount(firstHalf.ToString(), tokenCounter));
+ var (splits1, split1) = Split(firstHalf, null, maxTokens, separators, trim, tokenCounter, GetTokenCount(firstHalf, tokenCounter));
result.AddRange(splits1);
- var (splits2, split2) = Split(secondHalf, null, maxTokens, separators, trim, tokenCounter, GetTokenCount(secondHalf.ToString(), tokenCounter));
+ var (splits2, split2) = Split(secondHalf, null, maxTokens, separators, trim, tokenCounter, GetTokenCount(secondHalf, tokenCounter));
result.AddRange(splits2);
inputWasSplit = split1 || split2;
@@ -326,10 +335,14 @@ internal static class PlainTextChunker
var resultString = inputString ?? input.ToString();
var resultTokenCount = inputTokenCount;
- if (trim && !resultString.Trim().Equals(resultString, StringComparison.Ordinal))
+ if (trim)
{
- resultString = resultString.Trim();
- resultTokenCount = GetTokenCount(resultString, tokenCounter);
+ var trimmedResult = resultString.Trim();
+ if (!trimmedResult.Equals(resultString, StringComparison.Ordinal))
+ {
+ resultString = trimmedResult;
+ resultTokenCount = GetTokenCount(resultString, tokenCounter);
+ }
}
result.Add(resultString, resultTokenCount);
@@ -339,9 +352,21 @@ internal static class PlainTextChunker
private static int GetTokenCount(string input, TokenCounter? tokenCounter) => tokenCounter is null ? GetDefaultTokenCount(input.Length) : tokenCounter(input);
+ private static int GetTokenCount(ReadOnlySpan input, TokenCounter? tokenCounter) => tokenCounter is null ? GetDefaultTokenCount(input.Length) : tokenCounter(input.ToString());
+
+ private static string NormalizeLineEndings(string text) => text.Replace("\r\n", "\n").Replace('\r', '\n');
+
+ private static void ValidateMaxTokens(int maxTokens, string parameterName)
+ {
+ if (maxTokens <= 0)
+ {
+ throw new ArgumentOutOfRangeException(parameterName, "The maximum token count must be a positive number.");
+ }
+ }
+
private static int GetDefaultTokenCount(int length)
{
Debug.Assert(length >= 0);
- return length >> 2;
+ return length == 0 ? 0 : Math.Max(1, length >> 2);
}
}
\ No newline at end of file
diff --git a/SqlDatabaseVectorSearch/TextChunkers/MarkdownTextChunker.cs b/SqlDatabaseVectorSearch/TextChunkers/MarkdownTextChunker.cs
index 37a06e7..ebea231 100644
--- a/SqlDatabaseVectorSearch/TextChunkers/MarkdownTextChunker.cs
+++ b/SqlDatabaseVectorSearch/TextChunkers/MarkdownTextChunker.cs
@@ -11,7 +11,7 @@ public class MarkdownTextChunker(TokenizerService tokenizerService, IOptions Split(string text)
{
- var lines = PlainTextChunker.SplitMarkDownLines(text, appSettings.MaxTokensPerLine, tokenizerService.CountEmbeddingTokens);
+ var lines = PlainTextChunker.SplitMarkdownLines(text, appSettings.MaxTokensPerLine, tokenizerService.CountEmbeddingTokens);
var paragraphs = PlainTextChunker.SplitMarkdownParagraphs(lines, appSettings.MaxTokensPerParagraph, appSettings.OverlapTokens, tokenCounter: tokenizerService.CountEmbeddingTokens);
return paragraphs;
From 8a761ddcd7183ac06ac60a8e5656020f857982e8 Mon Sep 17 00:00:00 2001
From: Marco Minerva
Date: Wed, 17 Jun 2026 12:35:30 +0200
Subject: [PATCH 03/32] Refactor document import to workflow-based architecture
Replaces the direct import logic with a workflow using Microsoft.Agents.AI.Workflows. Adds executors for file conversion, embedding generation, and storage. Updates dependency injection, API endpoint, and UI to use the new workflow. Replaces ImportDocumentResponse with StoreEmbeddingResponse. Adds required NuGet packages.
---
.../Components/Pages/Documents.razor | 2 +-
.../Endpoints/DocumentEndpoints.cs | 10 +--
.../Models/ImportDocumentResponse.cs | 3 -
SqlDatabaseVectorSearch/Program.cs | 28 ++++++++
.../Services/VectorSearchService.cs | 71 ++++---------------
.../SqlDatabaseVectorSearch.csproj | 5 ++
.../FormFileToEmbeddingRequestExecutor.cs | 18 +++++
.../Workflows/GenerateEmbeddingExecutor.cs | 41 +++++++++++
.../Workflows/StoreEmbeddingExecutor.cs | 58 +++++++++++++++
9 files changed, 166 insertions(+), 70 deletions(-)
delete mode 100644 SqlDatabaseVectorSearch/Models/ImportDocumentResponse.cs
create mode 100644 SqlDatabaseVectorSearch/Workflows/FormFileToEmbeddingRequestExecutor.cs
create mode 100644 SqlDatabaseVectorSearch/Workflows/GenerateEmbeddingExecutor.cs
create mode 100644 SqlDatabaseVectorSearch/Workflows/StoreEmbeddingExecutor.cs
diff --git a/SqlDatabaseVectorSearch/Components/Pages/Documents.razor b/SqlDatabaseVectorSearch/Components/Pages/Documents.razor
index eec9704..2eca5f6 100644
--- a/SqlDatabaseVectorSearch/Components/Pages/Documents.razor
+++ b/SqlDatabaseVectorSearch/Components/Pages/Documents.razor
@@ -177,7 +177,7 @@ else
var vectorSearchService = scope.ServiceProvider.GetRequiredService();
var documentId = string.IsNullOrWhiteSpace(Model.DocumentId) ? null : (Guid?)Guid.Parse(Model.DocumentId);
- await vectorSearchService.ImportAsync(stream, fileName, MimeUtility.GetMimeMapping(fileName), documentId);
+ //await vectorSearchService.ImportAsync(stream, fileName, MimeUtility.GetMimeMapping(fileName), documentId);
ToastService.Notify(await CreateToastMessageAsync(ToastType.Success, "Upload document", $"The document {fileName} has been successfully uploaded and indexed."));
diff --git a/SqlDatabaseVectorSearch/Endpoints/DocumentEndpoints.cs b/SqlDatabaseVectorSearch/Endpoints/DocumentEndpoints.cs
index 6862f09..f42caeb 100644
--- a/SqlDatabaseVectorSearch/Endpoints/DocumentEndpoints.cs
+++ b/SqlDatabaseVectorSearch/Endpoints/DocumentEndpoints.cs
@@ -1,9 +1,9 @@
using System.ComponentModel;
using Microsoft.AspNetCore.Http.HttpResults;
-using MimeMapping;
using SqlDatabaseVectorSearch.Models;
using SqlDatabaseVectorSearch.Services;
+using SqlDatabaseVectorSearch.Workflows;
namespace SqlDatabaseVectorSearch.Endpoints;
@@ -23,12 +23,8 @@ public class DocumentEndpoints : IEndpointRouteHandlerBuilder
documentsApiGroup.MapPost(string.Empty, async (IFormFile file, VectorSearchService vectorSearchService, CancellationToken cancellationToken,
[Description("The unique identifier of the document. If not provided, a new one will be generated. If you specify an existing documentId, the corresponding document will be overwritten.")] Guid? documentId = null) =>
{
- using var stream = file.OpenReadStream();
-
- // Note: file.ContentType is not 100% reliable (for example, for markdown file).
- var response = await vectorSearchService.ImportAsync(stream, file.FileName, MimeUtility.GetMimeMapping(file.FileName), documentId, cancellationToken);
-
- return TypedResults.Ok(response);
+ var result = await vectorSearchService.ImportAsync(new FormFileEmbeddingRequest(file, documentId), cancellationToken);
+ return TypedResults.Ok(result);
})
.DisableAntiforgery()
.ProducesProblem(StatusCodes.Status400BadRequest)
diff --git a/SqlDatabaseVectorSearch/Models/ImportDocumentResponse.cs b/SqlDatabaseVectorSearch/Models/ImportDocumentResponse.cs
deleted file mode 100644
index 252018a..0000000
--- a/SqlDatabaseVectorSearch/Models/ImportDocumentResponse.cs
+++ /dev/null
@@ -1,3 +0,0 @@
-namespace SqlDatabaseVectorSearch.Models;
-
-public record class ImportDocumentResponse(Guid DocumentId, int EmbeddingTokenCount);
diff --git a/SqlDatabaseVectorSearch/Program.cs b/SqlDatabaseVectorSearch/Program.cs
index e99238c..61f0553 100644
--- a/SqlDatabaseVectorSearch/Program.cs
+++ b/SqlDatabaseVectorSearch/Program.cs
@@ -2,8 +2,11 @@ using System.ClientModel;
using System.Net.Mime;
using System.Text.Json.Serialization;
using FluentValidation;
+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;
@@ -13,6 +16,7 @@ using SqlDatabaseVectorSearch.Extensions;
using SqlDatabaseVectorSearch.Services;
using SqlDatabaseVectorSearch.Settings;
using SqlDatabaseVectorSearch.TextChunkers;
+using SqlDatabaseVectorSearch.Workflows;
using TinyHelpers.AspNetCore.Extensions;
using TinyHelpers.AspNetCore.OpenApi;
@@ -77,6 +81,11 @@ builder.Services.AddChatClient(_ =>
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);
@@ -91,6 +100,25 @@ builder.Services.AddSingleton();
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddScoped(); // This executor is registered as scoped because it uses the DbContext, which is also scoped.
+
+builder.AddWorkflow("EmbeddingWorkflow", (services, key) =>
+{
+ var formfileToConversionRequestExecutor = services.GetRequiredService();
+ var generateEmbeddingExecutor = services.GetRequiredService();
+ var storeEmbeddingExecutor = services.GetRequiredService();
+
+ var workflow = new WorkflowBuilder(formfileToConversionRequestExecutor).WithName(key)
+ .AddEdge(formfileToConversionRequestExecutor, generateEmbeddingExecutor)
+ .AddEdge(generateEmbeddingExecutor, storeEmbeddingExecutor)
+ .WithOutputFrom(storeEmbeddingExecutor)
+ .Build(validateOrphans: true);
+
+ return workflow;
+}, ServiceLifetime.Scoped);
+
builder.Services.AddOpenApi(options =>
{
options.RemoveServerList();
diff --git a/SqlDatabaseVectorSearch/Services/VectorSearchService.cs b/SqlDatabaseVectorSearch/Services/VectorSearchService.cs
index f18d39e..8f5dbd7 100644
--- a/SqlDatabaseVectorSearch/Services/VectorSearchService.cs
+++ b/SqlDatabaseVectorSearch/Services/VectorSearchService.cs
@@ -2,84 +2,37 @@
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.RegularExpressions;
+using Microsoft.Agents.AI.Workflows;
using Microsoft.Data.SqlTypes;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Options;
-using SqlDatabaseVectorSearch.ContentDecoders;
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(IServiceProvider serviceProvider, ApplicationDbContext dbContext, DocumentService documentService, IEmbeddingGenerator> embeddingGenerator, TokenizerService tokenizerService, ChatService chatService, TimeProvider timeProvider, IOptions appSettingsOptions, ILogger logger)
+public partial class VectorSearchService([FromKeyedServices("EmbeddingWorkflow")] Workflow workflow, ApplicationDbContext dbContext, IEmbeddingGenerator> embeddingGenerator, TokenizerService tokenizerService, ChatService chatService, TimeProvider timeProvider, IOptions appSettingsOptions, ILogger logger)
{
private readonly AppSettings appSettings = appSettingsOptions.Value;
- public async Task ImportAsync(Stream stream, string name, string contentType, Guid? documentId, CancellationToken cancellationToken = default)
+ public async Task ImportAsync(FormFileEmbeddingRequest request, CancellationToken cancellationToken = default)
{
- // Extract the contents of the file.
- var decoder = serviceProvider.GetKeyedService(contentType) ?? throw new NotSupportedException($"Content type '{contentType}' is not supported.");
- var chunks = await decoder.DecodeAsync(stream, contentType, cancellationToken);
- var chunkContents = chunks.Select(p => p.Content).ToList();
+ await using var run = await InProcessExecution.RunAsync(workflow, request, cancellationToken: cancellationToken);
+ var events = run.NewEvents.ToList();
- // We get the token count of the whole document because it is the total number of token used by embedding (it may be necessary, for example, for cost analysis).
- var tokenCount = tokenizerService.CountEmbeddingTokens(string.Join(" ", chunkContents));
-
- var strategy = dbContext.Database.CreateExecutionStrategy();
- var document = await strategy.ExecuteAsync(async (cancellationToken) =>
+ var exception = events.OfType().Select(e => e.Exception).FirstOrDefault();
+ if (exception is not null)
{
- await dbContext.Database.BeginTransactionAsync(cancellationToken);
+ throw exception;
+ }
- if (documentId.HasValue)
- {
- // If the user is importing a document that already exists, delete the previous one.
- await documentService.DeleteAsync(documentId.Value, cancellationToken);
- }
-
- var document = new Entities.Document { Id = documentId.GetValueOrDefault(), Name = name, CreationDate = timeProvider.GetUtcNow() };
- dbContext.Documents.Add(document);
-
- // Process paragraphs in batches.
- var embeddings = new List>();
- foreach (var batch in chunkContents.Chunk(appSettings.EmbeddingBatchSize))
- {
- logger.LogDebug("Processing batch of {Count} chunks for embedding generation...", batch.Length);
-
- // Generate embeddings for this batch.
- var batchEmbeddings = await embeddingGenerator.GenerateAsync(batch, cancellationToken: cancellationToken);
- embeddings.AddRange(batchEmbeddings);
- }
-
- // Save the document chunks and the corresponding embedding in the database.
- foreach (var (index, embedding) in embeddings.Index())
- {
- var chunk = chunks.ElementAt(index);
- logger.LogDebug("Storing a chunk of {TokenCount} tokens.", tokenizerService.CountEmbeddingTokens(chunk.Content));
-
- var documentChunk = new Entities.DocumentChunk
- {
- Document = document,
- Index = index,
- PageNumber = chunk.PageNumber,
- IndexOnPage = chunk.IndexOnPage,
- Content = chunk.Content,
- Embedding = new SqlVector(embedding.Vector)
- };
-
- dbContext.DocumentChunks.Add(documentChunk);
- }
-
- await dbContext.SaveChangesAsync(cancellationToken);
- await dbContext.Database.CommitTransactionAsync(cancellationToken);
-
- return document;
- }, cancellationToken);
-
- return new(document.Id, tokenCount);
+ var result = events.OfType().Select(e => e.Data).OfType().First();
+ return result;
}
public async Task AskQuestionAsync(Question question, bool reformulate = true, CancellationToken cancellationToken = default)
diff --git a/SqlDatabaseVectorSearch/SqlDatabaseVectorSearch.csproj b/SqlDatabaseVectorSearch/SqlDatabaseVectorSearch.csproj
index 42361e4..47ca899 100644
--- a/SqlDatabaseVectorSearch/SqlDatabaseVectorSearch.csproj
+++ b/SqlDatabaseVectorSearch/SqlDatabaseVectorSearch.csproj
@@ -14,6 +14,11 @@
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
diff --git a/SqlDatabaseVectorSearch/Workflows/FormFileToEmbeddingRequestExecutor.cs b/SqlDatabaseVectorSearch/Workflows/FormFileToEmbeddingRequestExecutor.cs
new file mode 100644
index 0000000..4e93aa2
--- /dev/null
+++ b/SqlDatabaseVectorSearch/Workflows/FormFileToEmbeddingRequestExecutor.cs
@@ -0,0 +1,18 @@
+using Microsoft.Agents.AI.Workflows;
+
+namespace SqlDatabaseVectorSearch.Workflows;
+
+public partial class FormFileToEmbeddingRequestExecutor() : Executor(nameof(FormFileToEmbeddingRequestExecutor))
+{
+ [MessageHandler]
+ private ValueTask HandleAsync(FormFileEmbeddingRequest request, IWorkflowContext context, CancellationToken cancellationToken)
+ {
+ // Note: file.ContentType is not 100% reliable (for example, for markdown file).
+ var embeddingRequest = new EmbeddingRequest(request.File.OpenReadStream(), Path.GetFileName(request.File.FileName), MimeMapping.MimeUtility.GetMimeMapping(request.File.FileName), request.DocumentId);
+ return ValueTask.FromResult(embeddingRequest);
+ }
+}
+
+public record class FormFileEmbeddingRequest(IFormFile File, Guid? DocumentId);
+
+public record class EmbeddingRequest(Stream Content, string FileName, string ContentType, Guid? DocumentId);
\ No newline at end of file
diff --git a/SqlDatabaseVectorSearch/Workflows/GenerateEmbeddingExecutor.cs b/SqlDatabaseVectorSearch/Workflows/GenerateEmbeddingExecutor.cs
new file mode 100644
index 0000000..a365855
--- /dev/null
+++ b/SqlDatabaseVectorSearch/Workflows/GenerateEmbeddingExecutor.cs
@@ -0,0 +1,41 @@
+using Microsoft.Agents.AI.Workflows;
+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))
+{
+ private readonly AppSettings appSettings = appSettingsOptions.Value;
+
+ [MessageHandler]
+ private async ValueTask HandleAsync(EmbeddingRequest request, IWorkflowContext context, CancellationToken cancellationToken)
+ {
+ // Extract the contents of the file.
+ var decoder = serviceProvider.GetKeyedService(request.ContentType) ?? throw new NotSupportedException($"Content type '{request.ContentType}' is not supported.");
+ var chunks = await decoder.DecodeAsync(request.Content, request.ContentType, cancellationToken);
+ var chunkContents = chunks.Select(p => p.Content).ToList();
+
+ // We get the token count of the whole document because it is the total number of token used by embedding (it may be necessary, for example, for cost analysis).
+ var tokenCount = tokenizerService.CountEmbeddingTokens(string.Join(" ", chunkContents));
+
+ // Process paragraphs in batches.
+ var embeddings = new List>();
+ foreach (var batch in chunkContents.Chunk(appSettings.EmbeddingBatchSize))
+ {
+ logger.LogDebug("Processing batch of {Count} chunks for embedding generation...", batch.Length);
+
+ // Generate embeddings for this batch.
+ var batchEmbeddings = await embeddingGenerator.GenerateAsync(batch, cancellationToken: cancellationToken);
+ embeddings.AddRange(batchEmbeddings);
+ }
+
+ return new EmbeddingResponse(request, chunks, embeddings, tokenCount);
+ }
+}
+
+public record class EmbeddingResponse(EmbeddingRequest Request, IEnumerable Chunks, IEnumerable> Embeddings, int TokenCount);
\ No newline at end of file
diff --git a/SqlDatabaseVectorSearch/Workflows/StoreEmbeddingExecutor.cs b/SqlDatabaseVectorSearch/Workflows/StoreEmbeddingExecutor.cs
new file mode 100644
index 0000000..c0f0155
--- /dev/null
+++ b/SqlDatabaseVectorSearch/Workflows/StoreEmbeddingExecutor.cs
@@ -0,0 +1,58 @@
+using Microsoft.Agents.AI.Workflows;
+using Microsoft.Data.SqlTypes;
+using Microsoft.EntityFrameworkCore;
+using SqlDatabaseVectorSearch.Data;
+using SqlDatabaseVectorSearch.Services;
+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))
+{
+ [MessageHandler]
+ private async ValueTask HandleAsync(EmbeddingResponse embeddingData, IWorkflowContext context, CancellationToken cancellationToken)
+ {
+ var strategy = dbContext.Database.CreateExecutionStrategy();
+ var document = await strategy.ExecuteAsync(async (cancellationToken) =>
+ {
+ await dbContext.Database.BeginTransactionAsync(cancellationToken);
+
+ if (embeddingData.Request.DocumentId.HasValue)
+ {
+ // If the user is importing a document that already exists, delete the previous one.
+ await documentService.DeleteAsync(embeddingData.Request.DocumentId.Value, cancellationToken);
+ }
+
+ var document = new Entities.Document { Id = embeddingData.Request.DocumentId.GetValueOrDefault(), Name = embeddingData.Request.FileName, CreationDate = timeProvider.GetUtcNow() };
+ dbContext.Documents.Add(document);
+
+ // Save the document chunks and the corresponding embedding in the database.
+ foreach (var (index, embedding) in embeddingData.Embeddings.Index())
+ {
+ var chunk = embeddingData.Chunks.ElementAt(index);
+ logger.LogDebug("Storing a chunk of {TokenCount} tokens.", tokenizerService.CountEmbeddingTokens(chunk.Content));
+
+ var documentChunk = new Entities.DocumentChunk
+ {
+ Document = document,
+ Index = index,
+ PageNumber = chunk.PageNumber,
+ IndexOnPage = chunk.IndexOnPage,
+ Content = chunk.Content,
+ Embedding = new SqlVector(embedding.Vector)
+ };
+
+ dbContext.DocumentChunks.Add(documentChunk);
+ }
+
+ await dbContext.SaveChangesAsync(cancellationToken);
+ await dbContext.Database.CommitTransactionAsync(cancellationToken);
+
+ return document;
+ }, cancellationToken);
+
+ return new(document.Id, embeddingData.TokenCount);
+ }
+}
+
+public record class StoreEmbeddingResponse(Guid DocumentId, int TokenCount);
\ No newline at end of file
From a39a81166ac169ec4bd76aaa116beccf2db53dcc Mon Sep 17 00:00:00 2001
From: Marco Minerva
Date: Wed, 17 Jun 2026 16:25:09 +0200
Subject: [PATCH 04/32] Refactor: migrate to Microsoft.Agents.AI for RAG/chat
Replaced SemanticKernel with Microsoft.Agents.AI and agent-based abstractions for chat and RAG workflows. Removed ChatService in favor of AIAgent instances for question reformulation and answering. Added agent registration/configuration in Program.cs, new RagResponse record, and ContextProvider for context search. Introduced HybridCacheSessionStoreService for session persistence. Switched to direct OpenAIClient usage with request tracing. Reduced max relevant context chunks to 30. Removed SemanticKernel from project references and suppressed MEAI001 warning. Refactored code for improved style and DI alignment.
---
SqlDatabaseVectorSearch/Models/Response.cs | 4 +-
SqlDatabaseVectorSearch/Program.cs | 209 +++++++++++++--
.../Services/ChatService.cs | 247 ------------------
.../HybridCacheSessionStoreService.cs | 30 +++
.../Services/VectorSearchService.cs | 163 +++++-------
.../SqlDatabaseVectorSearch.csproj | 3 +-
.../Workflows/GenerateEmbeddingExecutor.cs | 3 +-
.../Workflows/StoreEmbeddingExecutor.cs | 2 +-
SqlDatabaseVectorSearch/appsettings.json | 2 +-
9 files changed, 300 insertions(+), 363 deletions(-)
delete mode 100644 SqlDatabaseVectorSearch/Services/ChatService.cs
create mode 100644 SqlDatabaseVectorSearch/Services/HybridCacheSessionStoreService.cs
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.0enableenable
- $(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",
From 7af4214d6daa94ed606a52cb3048f4dc14727dd7 Mon Sep 17 00:00:00 2001
From: Marco Minerva
Date: Fri, 24 Jul 2026 17:27:52 +0200
Subject: [PATCH 05/32] Refactor models, update usage tracking, and clean
citations
- Enabled additional C# code style suggestions in .editorconfig.
- Removed citation rendering and extraction from Ask.razor.
- Deleted Citation, ChatResponse, and TokenUsage models and references.
- Updated TokenUsageResponse to use UsageDetails from Microsoft.Extensions.AI.
- Refactored Response model: removed citations, added ConversationId, and adopted new token usage structure.
- Updated HybridCacheSessionStoreService with new cache key format and DeleteSessionAsync method.
- Refactored VectorSearchService to remove AppSettings dependency, update method signatures, and use UsageDetails for token tracking.
- Updated NuGet package references in SqlDatabaseVectorSearch.csproj.
---
.editorconfig | 3 +
.../Components/Pages/Ask.razor | 60 ++-------------
.../Models/ChatResponse.cs | 3 -
SqlDatabaseVectorSearch/Models/Citation.cs | 16 ----
SqlDatabaseVectorSearch/Models/Response.cs | 10 +--
SqlDatabaseVectorSearch/Models/TokenUsage.cs | 6 --
.../Models/TokenUsageResponse.cs | 12 +--
.../HybridCacheSessionStoreService.cs | 29 +++++---
.../Services/VectorSearchService.cs | 73 ++++++++-----------
.../SqlDatabaseVectorSearch.csproj | 26 +++----
10 files changed, 76 insertions(+), 162 deletions(-)
delete mode 100644 SqlDatabaseVectorSearch/Models/ChatResponse.cs
delete mode 100644 SqlDatabaseVectorSearch/Models/Citation.cs
delete mode 100644 SqlDatabaseVectorSearch/Models/TokenUsage.cs
diff --git a/.editorconfig b/.editorconfig
index 8f8512a..430f71b 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -124,6 +124,7 @@ csharp_style_prefer_null_check_over_type_check = true:suggestion
# Modifier preferences
csharp_prefer_static_local_function = true:suggestion
+csharp_prefer_static_anonymous_function = true:suggestion
csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:silent
# Code-block preferences
@@ -139,9 +140,11 @@ csharp_prefer_system_threading_lock = true:suggestion
csharp_prefer_simple_default_expression = true:suggestion
csharp_style_deconstructed_variable_declaration = false:suggestion
csharp_style_inlined_variable_declaration = true:suggestion
+csharp_style_prefer_implicitly_typed_lambda_expression = true:suggestion
csharp_style_pattern_local_over_anonymous_function = true:suggestion
csharp_style_prefer_index_operator = true:suggestion
csharp_style_prefer_range_operator = true:suggestion
+csharp_style_prefer_unbound_generic_type_in_nameof = true:suggestion
csharp_style_throw_expression = true:suggestion
csharp_style_unused_value_assignment_preference = discard_variable:none
csharp_style_unused_value_expression_statement_preference = discard_variable:none
diff --git a/SqlDatabaseVectorSearch/Components/Pages/Ask.razor b/SqlDatabaseVectorSearch/Components/Pages/Ask.razor
index b18fa18..9cf8cb6 100644
--- a/SqlDatabaseVectorSearch/Components/Pages/Ask.razor
+++ b/SqlDatabaseVectorSearch/Components/Pages/Ask.razor
@@ -83,23 +83,6 @@
- @if (message.Citations is not null && message.Citations.Count() > 0)
- {
-
- @foreach (var citation in message.Citations)
- {
-
"
: string.Empty;
- return $"{reformulation}{embeddingTokenCount}{question}";
+ return $"{reformulation}{question}";
- static string FormatTokenUsageDetails(TokenUsage? tokenUsage)
+ static string FormatTokenUsageDetails(Microsoft.Extensions.AI.UsageDetails? tokenUsage)
{
if (tokenUsage is null)
{
return string.Empty;
}
- return $"Prompt tokens: {tokenUsage.PromptTokens} " +
- $"Completion tokens: {tokenUsage.CompletionTokens} " +
- $"Total tokens: {tokenUsage.TotalTokens}";
+ return $"Input tokens: {tokenUsage.InputTokenCount} " +
+ $"Output tokens: {tokenUsage.OutputTokenCount} " +
+ $"Total tokens: {tokenUsage.TotalTokenCount}";
}
}
@@ -326,23 +294,5 @@
public MessageStatus Status { get; set; } = MessageStatus.New;
public string? TokenUsage { get; set; }
-
- // List of citations extracted from the answer.
- public IEnumerable? Citations { get; set; }
- }
-
- public class Citation
- {
- public Guid DocumentId { get; set; }
-
- public Guid ChunkId { get; set; }
-
- public string FileName { get; set; } = null!;
-
- public string Quote { get; set; } = null!;
-
- public int? PageNumber { get; set; }
-
- public int IndexOnPage { get; set; }
}
}
\ No newline at end of file
diff --git a/SqlDatabaseVectorSearch/Models/ChatResponse.cs b/SqlDatabaseVectorSearch/Models/ChatResponse.cs
deleted file mode 100644
index 1f67ca9..0000000
--- a/SqlDatabaseVectorSearch/Models/ChatResponse.cs
+++ /dev/null
@@ -1,3 +0,0 @@
-namespace SqlDatabaseVectorSearch.Models;
-
-public record class ChatResponse(string? Text, TokenUsage? TokenUsage = null);
\ No newline at end of file
diff --git a/SqlDatabaseVectorSearch/Models/Citation.cs b/SqlDatabaseVectorSearch/Models/Citation.cs
deleted file mode 100644
index 04fb64b..0000000
--- a/SqlDatabaseVectorSearch/Models/Citation.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-namespace SqlDatabaseVectorSearch.Models;
-
-public class Citation
-{
- public Guid DocumentId { get; set; }
-
- public Guid ChunkId { get; set; }
-
- public string FileName { get; set; } = null!;
-
- public string Quote { get; set; } = null!;
-
- public int? PageNumber { get; set; }
-
- public int IndexOnPage { get; set; }
-}
\ No newline at end of file
diff --git a/SqlDatabaseVectorSearch/Models/Response.cs b/SqlDatabaseVectorSearch/Models/Response.cs
index 20e6e55..3aed02f 100644
--- a/SqlDatabaseVectorSearch/Models/Response.cs
+++ b/SqlDatabaseVectorSearch/Models/Response.cs
@@ -1,12 +1,10 @@
namespace SqlDatabaseVectorSearch.Models;
// Question and Answer can be null when using response streaming.
-public record class Response(string? OriginalQuestion, string? ReformulatedQuestion, string? Answer, StreamState? StreamState = null, TokenUsageResponse? TokenUsage = null, IEnumerable? Citations = null)
+public record class Response(Guid ConversationId, string? OriginalQuestion, string? ReformulatedQuestion, string? Answer, StreamState? StreamState = null, TokenUsageResponse? TokenUsage = null)
{
- public Response(string? token, StreamState streamState, TokenUsageResponse? tokenUsageResponse = null, IEnumerable? citations = null)
- : this(null, null, token, streamState, tokenUsageResponse, citations)
+ public Response(Guid conversationId, string? token, StreamState streamState, TokenUsageResponse? tokenUsageResponse = null)
+ : this(conversationId, null, null, token, streamState, tokenUsageResponse)
{
}
-}
-
-public record class RagResponse(Guid ConversationId, string OriginalQuestion, string ReformulatedQuestion, string Answer);
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/SqlDatabaseVectorSearch/Models/TokenUsage.cs b/SqlDatabaseVectorSearch/Models/TokenUsage.cs
deleted file mode 100644
index 9a39649..0000000
--- a/SqlDatabaseVectorSearch/Models/TokenUsage.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-namespace SqlDatabaseVectorSearch.Models;
-
-public record class TokenUsage(int PromptTokens, int CompletionTokens)
-{
- public int TotalTokens => PromptTokens + CompletionTokens;
-}
diff --git a/SqlDatabaseVectorSearch/Models/TokenUsageResponse.cs b/SqlDatabaseVectorSearch/Models/TokenUsageResponse.cs
index 500a72e..1dae2e0 100644
--- a/SqlDatabaseVectorSearch/Models/TokenUsageResponse.cs
+++ b/SqlDatabaseVectorSearch/Models/TokenUsageResponse.cs
@@ -1,9 +1,5 @@
-namespace SqlDatabaseVectorSearch.Models;
+using Microsoft.Extensions.AI;
-public record class TokenUsageResponse(TokenUsage? Reformulation, int? EmbeddingTokenCount, TokenUsage? Question)
-{
- public TokenUsageResponse(TokenUsage? question)
- : this(null, null, question)
- {
- }
-}
+namespace SqlDatabaseVectorSearch.Models;
+
+public record class TokenUsageResponse(UsageDetails? Reformulation, UsageDetails? Question);
diff --git a/SqlDatabaseVectorSearch/Services/HybridCacheSessionStoreService.cs b/SqlDatabaseVectorSearch/Services/HybridCacheSessionStoreService.cs
index 2ae9b7b..e66a48f 100644
--- a/SqlDatabaseVectorSearch/Services/HybridCacheSessionStoreService.cs
+++ b/SqlDatabaseVectorSearch/Services/HybridCacheSessionStoreService.cs
@@ -8,23 +8,30 @@ public class HybridCacheSessionStoreService(HybridCache cache) : AgentSessionSto
{
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);
+ var key = GetKey(agent, conversationId);
+ var sessionContent = await cache.GetOrCreateAsync(key, 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 key = GetKey(agent, conversationId);
var sessionContent = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken);
- await cache.SetAsync(GetCacheKey(conversationId), sessionContent, cancellationToken: cancellationToken);
+
+ await cache.SetAsync(key, sessionContent, cancellationToken: cancellationToken);
}
- private static string GetCacheKey(string conversationId) => $"agent-session:{conversationId}";
-}
+ public override async ValueTask DeleteSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
+ {
+ var key = GetKey(agent, conversationId);
+ await cache.RemoveAsync(key, cancellationToken);
+ }
+
+ private static string GetKey(AIAgent agent, string conversationId)
+ => $"{agent.Id}:{conversationId}";
+}
\ No newline at end of file
diff --git a/SqlDatabaseVectorSearch/Services/VectorSearchService.cs b/SqlDatabaseVectorSearch/Services/VectorSearchService.cs
index d3b2c92..1668fe7 100644
--- a/SqlDatabaseVectorSearch/Services/VectorSearchService.cs
+++ b/SqlDatabaseVectorSearch/Services/VectorSearchService.cs
@@ -15,10 +15,8 @@ using SqlDatabaseVectorSearch.Workflows;
namespace SqlDatabaseVectorSearch.Services;
public partial class VectorSearchService([FromKeyedServices("EmbeddingWorkflow")] Workflow workflow, [FromKeyedServices("ReformulationAgent")] AIAgent reformulationAgent, [FromKeyedServices("RagAgent")] AIAgent ragAgent,
- [FromKeyedServices("RagAgent")] AgentSessionStore sessionStore, IOptions appSettingsOptions)
+ [FromKeyedServices("RagAgent")] AgentSessionStore sessionStore)
{
- private readonly AppSettings appSettings = appSettingsOptions.Value;
-
public async Task ImportAsync(FormFileEmbeddingRequest request, CancellationToken cancellationToken = default)
{
await using var run = await InProcessExecution.RunAsync(workflow, request, cancellationToken: cancellationToken);
@@ -34,8 +32,9 @@ 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)
{
+ UsageDetails? reformulationUsage = null;
var reformulatedQuestion = question.Text;
var session = await sessionStore.GetSessionAsync(ragAgent, question.ConversationId.ToString(), cancellationToken);
@@ -44,62 +43,48 @@ public partial class VectorSearchService([FromKeyedServices("EmbeddingWorkflow")
// 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;
+ reformulationUsage = reformulationResponse.Usage;
}
var response = await ragAgent.RunAsync(reformulatedQuestion, session, cancellationToken: cancellationToken);
await sessionStore.SaveSessionAsync(ragAgent, question.ConversationId.ToString(), session, cancellationToken);
- session.TryGetInMemoryChatHistory(out var chatHistory);
-
- return new(question.ConversationId, question.Text, reformulatedQuestion, response.Text);
+ return new(question.ConversationId, question.Text, reformulatedQuestion, response.Text, null, new TokenUsageResponse(reformulationUsage, response.Usage));
}
public async IAsyncEnumerable AskStreamingAsync(Question question, bool reformulate = true, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
- yield return null!;
+ UsageDetails? reformulationUsage = null;
+ var reformulatedQuestion = question.Text;
+ var session = await sessionStore.GetSessionAsync(ragAgent, question.ConversationId.ToString(), 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);
+ 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;
+ reformulationUsage = reformulationResponse.Usage;
+ }
- //var answerStream = chatService.AskStreamingAsync(question.ConversationId, chunks, reformulatedQuestion.Text!, cancellationToken: cancellationToken);
+ // The first message contains the question and the corresponding token usage (if reformulated).
+ yield return new(question.ConversationId, question.Text, reformulatedQuestion, null, StreamState.Start, new(reformulationUsage, null));
- //// 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 updates = new List();
- //TokenUsageResponse? tokenUsageResponse = null;
- //var fullAnswer = new StringBuilder();
- //var citationsStarted = false;
+ await foreach (var update in ragAgent.RunStreamingAsync(reformulatedQuestion, session, cancellationToken: cancellationToken))
+ {
+ updates.Add(update);
+ if (!string.IsNullOrEmpty(update.Text))
+ {
+ yield return new(question.ConversationId, update.Text, StreamState.Append);
+ }
+ }
- //// 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);
+ await sessionStore.SaveSessionAsync(ragAgent, question.ConversationId.ToString(), session, cancellationToken);
+ var response = updates.ToAgentResponse();
- // 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;
- // }
-
- // 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);
+ yield return new(question.ConversationId, null, null, response.Text, StreamState.End, new TokenUsageResponse(null, response.Usage));
}
}
diff --git a/SqlDatabaseVectorSearch/SqlDatabaseVectorSearch.csproj b/SqlDatabaseVectorSearch/SqlDatabaseVectorSearch.csproj
index b0062e0..fd24438 100644
--- a/SqlDatabaseVectorSearch/SqlDatabaseVectorSearch.csproj
+++ b/SqlDatabaseVectorSearch/SqlDatabaseVectorSearch.csproj
@@ -12,34 +12,34 @@
-
-
-
-
+
+
+
+ allruntime; build; native; contentfiles; analyzers; buildtransitive
-
-
+
+ allruntime; build; native; contentfiles; analyzers; buildtransitive
-
-
+
+ allruntime; build; native; contentfiles; analyzers; buildtransitive
-
-
+
+
-
-
-
+
+
+
From 6c3e5392727c31026e5a99231f6490cbd93c270a Mon Sep 17 00:00:00 2001
From: Marco Minerva
Date: Fri, 24 Jul 2026 17:32:08 +0200
Subject: [PATCH 06/32] Refactor scope creation and update namespace imports
Replaced `IServiceProvider` with `IServiceScopeFactory` for async scope creation, updated injected services and usages, switched namespace import from `System.Text.RegularExpressions` to `Microsoft.Extensions.AI`, and simplified the `FormatTokenUsageDetails` method signature to use the unqualified `UsageDetails` type.
---
SqlDatabaseVectorSearch/Components/Pages/Ask.razor | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/SqlDatabaseVectorSearch/Components/Pages/Ask.razor b/SqlDatabaseVectorSearch/Components/Pages/Ask.razor
index 9cf8cb6..fee2b4c 100644
--- a/SqlDatabaseVectorSearch/Components/Pages/Ask.razor
+++ b/SqlDatabaseVectorSearch/Components/Pages/Ask.razor
@@ -1,7 +1,7 @@
@page "/ask"
-@using System.Text.RegularExpressions
+@using Microsoft.Extensions.AI
-@inject IServiceProvider ServiceProvider
+@inject IServiceScopeFactory ServiceScopeFactory
@inject IJSRuntime JSRuntime
Chat with your data
@@ -176,7 +176,7 @@
try
{
- await using var scope = ServiceProvider.CreateAsyncScope();
+ await using var scope = ServiceScopeFactory.CreateAsyncScope();
var vectorSearchService = scope.ServiceProvider.GetRequiredService();
var response = vectorSearchService.AskStreamingAsync(userQuestion);
@@ -260,7 +260,7 @@
return $"{reformulation}{question}";
- static string FormatTokenUsageDetails(Microsoft.Extensions.AI.UsageDetails? tokenUsage)
+ static string FormatTokenUsageDetails(UsageDetails? tokenUsage)
{
if (tokenUsage is null)
{
From f474d8fe82303fdd9c1452b76bf210e4a23c5b89 Mon Sep 17 00:00:00 2001
From: Marco Minerva
Date: Fri, 24 Jul 2026 17:38:12 +0200
Subject: [PATCH 07/32] Add Response ctor overload, agent Ids, and metadata
fields
- Added a new Response constructor overload for simpler instantiation with conversationId, streamState, and optional tokenUsageResponse.
- Updated AddAIAgent registrations for "ReformulationAgent" and "RagAgent" to set Id to the lower-cased key.
- Modified VectorSearchService to use the new Response constructor, omitting now-optional parameters.
- Enhanced ContextProvider: TextSearchResult now includes a RawRepresentation property with chunk metadata (Id, DocumentId, PageNumber, IndexOnPage, Content).
---
SqlDatabaseVectorSearch/Models/Response.cs | 5 +++++
SqlDatabaseVectorSearch/Program.cs | 2 ++
SqlDatabaseVectorSearch/Services/VectorSearchService.cs | 3 ++-
3 files changed, 9 insertions(+), 1 deletion(-)
diff --git a/SqlDatabaseVectorSearch/Models/Response.cs b/SqlDatabaseVectorSearch/Models/Response.cs
index 3aed02f..a80b2ec 100644
--- a/SqlDatabaseVectorSearch/Models/Response.cs
+++ b/SqlDatabaseVectorSearch/Models/Response.cs
@@ -7,4 +7,9 @@ public record class Response(Guid ConversationId, string? OriginalQuestion, stri
: this(conversationId, null, null, token, streamState, tokenUsageResponse)
{
}
+
+ public Response(Guid conversationId, StreamState streamState, TokenUsageResponse? tokenUsageResponse = null)
+ : this(conversationId, null, null, null, streamState, tokenUsageResponse)
+ {
+ }
}
\ No newline at end of file
diff --git a/SqlDatabaseVectorSearch/Program.cs b/SqlDatabaseVectorSearch/Program.cs
index e88cd6c..7217fe8 100644
--- a/SqlDatabaseVectorSearch/Program.cs
+++ b/SqlDatabaseVectorSearch/Program.cs
@@ -117,6 +117,7 @@ builder.Services.AddAIAgent("ReformulationAgent", (services, key) =>
return chatClient.AsAIAgent(new ChatClientAgentOptions()
{
+ Id = key.ToLowerInvariant(),
Name = key,
ChatOptions = new()
{
@@ -209,6 +210,7 @@ builder.Services.AddAIAgent("RagAgent", (services, key) =>
return chatClient.AsAIAgent(new ChatClientAgentOptions
{
+ Id = key.ToLowerInvariant(),
Name = key,
ChatOptions = new()
{
diff --git a/SqlDatabaseVectorSearch/Services/VectorSearchService.cs b/SqlDatabaseVectorSearch/Services/VectorSearchService.cs
index 1668fe7..2c19f40 100644
--- a/SqlDatabaseVectorSearch/Services/VectorSearchService.cs
+++ b/SqlDatabaseVectorSearch/Services/VectorSearchService.cs
@@ -84,7 +84,7 @@ public partial class VectorSearchService([FromKeyedServices("EmbeddingWorkflow")
await sessionStore.SaveSessionAsync(ragAgent, question.ConversationId.ToString(), session, cancellationToken);
var response = updates.ToAgentResponse();
- yield return new(question.ConversationId, null, null, response.Text, StreamState.End, new TokenUsageResponse(null, response.Usage));
+ yield return new(question.ConversationId, StreamState.End, new TokenUsageResponse(null, response.Usage));
}
}
@@ -105,6 +105,7 @@ public class ContextProvider(ApplicationDbContext dbContext, IEmbeddingGenerator
SourceLink = c.Id.ToString().ToLowerInvariant(),
SourceName = c.Document.Name,
Text = c.Content,
+ RawRepresentation = new { c.Id, c.DocumentId, c.PageNumber, c.IndexOnPage, c.Content }
})
.ToListAsync(cancellationToken);
From b2394208cc5597d049270cc4f0b186c702728094 Mon Sep 17 00:00:00 2001
From: Marco Minerva
Date: Fri, 24 Jul 2026 18:03:53 +0200
Subject: [PATCH 08/32] Improve DI, citation formatting, and search result
output
- Inject IServiceScopeFactory in Documents.razor for better DI and async scope creation.
- Update citation formatting in Program.cs: sources now appear as a localized, numbered Markdown list with block quotes.
- Use a helper for consistent source/page formatting in TextSearchProviderOptions.
- Simplify RawRepresentation in VectorSearchService to just the page number.
---
.../Components/Pages/Documents.razor | 8 ++--
SqlDatabaseVectorSearch/Program.cs | 38 +++++++++++--------
.../Services/VectorSearchService.cs | 2 +-
3 files changed, 27 insertions(+), 21 deletions(-)
diff --git a/SqlDatabaseVectorSearch/Components/Pages/Documents.razor b/SqlDatabaseVectorSearch/Components/Pages/Documents.razor
index 2eca5f6..64bd2b4 100644
--- a/SqlDatabaseVectorSearch/Components/Pages/Documents.razor
+++ b/SqlDatabaseVectorSearch/Components/Pages/Documents.razor
@@ -1,7 +1,7 @@
@page "/documents"
@using MimeMapping
-@inject IServiceProvider ServiceProvider
+@inject IServiceScopeFactory ServiceScopeFactory
@inject IJSRuntime JSRuntime
@@ -127,7 +127,7 @@ else
return;
}
- await using var scope = ServiceProvider.CreateAsyncScope();
+ await using var scope = ServiceScopeFactory.CreateAsyncScope();
await LoadDocumentsAsync(scope.ServiceProvider);
StateHasChanged();
@@ -173,7 +173,7 @@ else
await using var inputStream = Model.File.OpenReadStream(20 * 1024 * 1024); // 20 MB
await using var stream = await inputStream.GetMemoryStreamAsync();
- await using var scope = ServiceProvider.CreateAsyncScope();
+ await using var scope = ServiceScopeFactory.CreateAsyncScope();
var vectorSearchService = scope.ServiceProvider.GetRequiredService();
var documentId = string.IsNullOrWhiteSpace(Model.DocumentId) ? null : (Guid?)Guid.Parse(Model.DocumentId);
@@ -223,7 +223,7 @@ else
{
deleteButton.ShowLoading();
- await using var scope = ServiceProvider.CreateAsyncScope();
+ await using var scope = ServiceScopeFactory.CreateAsyncScope();
var documentService = scope.ServiceProvider.GetRequiredService();
await documentService.DeleteAsync(selectedDocumentIds);
diff --git a/SqlDatabaseVectorSearch/Program.cs b/SqlDatabaseVectorSearch/Program.cs
index 7217fe8..4b09813 100644
--- a/SqlDatabaseVectorSearch/Program.cs
+++ b/SqlDatabaseVectorSearch/Program.cs
@@ -159,24 +159,23 @@ var textSearchOptions = new TextSearchProviderOptions()
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("- At the END of your answer, add a sources label translated in the same language as the user's question.");
+ sb.AppendLine("- The sources label MUST be standard-size italic Markdown text, not a heading and not bold. For example: *Sources* or *Fonti*.");
+ sb.AppendLine("- The citation list MUST be a numbered Markdown list.");
+ sb.AppendLine("- Format each citation with the source name and the localized page label, followed by a Markdown block quote of about 20-30 words from the excerpt that supports the answer.");
+ sb.AppendLine("- The block quote MUST start on a new line with the '>' Markdown character.");
+ sb.AppendLine("- Format each source exactly like:");
+ sb.AppendLine(" *Sources*");
+ sb.AppendLine(" 1. SourceName, localized-page-label PageNumber");
+ sb.AppendLine(" > Supporting excerpt quote of about 20-30 words.");
+ sb.AppendLine("- Do NOT format source names as links.");
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($"- {GetSourceName(r, i)}");
}
sb.AppendLine();
@@ -184,14 +183,21 @@ var textSearchOptions = new TextSearchProviderOptions()
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($"[{i + 1}] {GetSourceName(r, i)}");
sb.AppendLine(r.Text);
- sb.AppendLine();
+ sb.AppendLine("---");
}
return sb.ToString();
+
+ static string GetSourceName(TextSearchProvider.TextSearchResult result, int index)
+ {
+ var name = string.IsNullOrWhiteSpace(result.SourceName) ? $"Source {index + 1}" : result.SourceName;
+ var pageNumber = result.RawRepresentation is int number ? number : (int?)null;
+ var pageText = pageNumber.HasValue ? $", page {pageNumber}" : string.Empty;
+
+ return $"{name}{pageText}";
+ }
}
};
diff --git a/SqlDatabaseVectorSearch/Services/VectorSearchService.cs b/SqlDatabaseVectorSearch/Services/VectorSearchService.cs
index 2c19f40..9333f45 100644
--- a/SqlDatabaseVectorSearch/Services/VectorSearchService.cs
+++ b/SqlDatabaseVectorSearch/Services/VectorSearchService.cs
@@ -105,7 +105,7 @@ public class ContextProvider(ApplicationDbContext dbContext, IEmbeddingGenerator
SourceLink = c.Id.ToString().ToLowerInvariant(),
SourceName = c.Document.Name,
Text = c.Content,
- RawRepresentation = new { c.Id, c.DocumentId, c.PageNumber, c.IndexOnPage, c.Content }
+ RawRepresentation = c.PageNumber
})
.ToListAsync(cancellationToken);
From 57ba80bae4e082f4fd5f1d5596718f56f60dd8b1 Mon Sep 17 00:00:00 2001
From: Marco Minerva
Date: Mon, 27 Jul 2026 17:16:25 +0200
Subject: [PATCH 09/32] Update citation formatting rules for Markdown output
Clarifies citation formatting: the sources label must be localized and in standard-size italic text (not a heading or bold). Citations now require the source name in bold, followed by the localized page label and page number if available, then a colon and 15-20 italicized words from the excerpt. Source names are not links, and only actually used, non-duplicate sources are included. Example formats are updated accordingly.
---
SqlDatabaseVectorSearch/Program.cs | 13 +++++++------
1 file changed, 7 insertions(+), 6 deletions(-)
diff --git a/SqlDatabaseVectorSearch/Program.cs b/SqlDatabaseVectorSearch/Program.cs
index 4b09813..18fec2b 100644
--- a/SqlDatabaseVectorSearch/Program.cs
+++ b/SqlDatabaseVectorSearch/Program.cs
@@ -160,14 +160,15 @@ var textSearchOptions = new TextSearchProviderOptions()
sb.AppendLine("Citation rules:");
sb.AppendLine("- Do NOT add inline citations.");
sb.AppendLine("- At the END of your answer, add a sources label translated in the same language as the user's question.");
- sb.AppendLine("- The sources label MUST be standard-size italic Markdown text, not a heading and not bold. For example: *Sources* or *Fonti*.");
+ sb.AppendLine("- The sources label MUST be standard-size italic Markdown text, not a heading and not bold, and MUST be localized in the same language as the user's question.");
sb.AppendLine("- The citation list MUST be a numbered Markdown list.");
- sb.AppendLine("- Format each citation with the source name and the localized page label, followed by a Markdown block quote of about 20-30 words from the excerpt that supports the answer.");
- sb.AppendLine("- The block quote MUST start on a new line with the '>' Markdown character.");
+ sb.AppendLine("- The source name MUST be bold, followed by the localized page label and the page number ONLY when the page number is available, then a colon.");
+ sb.AppendLine("- After the colon, add about 15-20 words in italic taken from the excerpt that supports the answer.");
sb.AppendLine("- Format each source exactly like:");
- sb.AppendLine(" *Sources*");
- sb.AppendLine(" 1. SourceName, localized-page-label PageNumber");
- sb.AppendLine(" > Supporting excerpt quote of about 20-30 words.");
+ sb.AppendLine(" *localized-sources-label*");
+ sb.AppendLine(" 1. **SourceName**, localized-page-label PageNumber: *supporting excerpt of about 15-20 words*");
+ sb.AppendLine(" If the page number is not available, omit it and the page label, like:");
+ sb.AppendLine(" 1. **SourceName**: *supporting excerpt of about 15-20 words*");
sb.AppendLine("- Do NOT format source names as links.");
sb.AppendLine("- Include ONLY sources you actually used. No duplicates.");
sb.AppendLine();
From 3b06b0e0f9e284d1aa932066aac9360dc59a4026 Mon Sep 17 00:00:00 2001
From: Marco Minerva
Date: Mon, 27 Jul 2026 17:19:34 +0200
Subject: [PATCH 10/32] Refactor Sources section formatting in output
Updated the "Sources" section header and changed the source list from bullets to a numbered format (e.g., "[1] SourceName"). Each source now includes its excerpt and a separator line ("---"). Removed the "### Excerpts" section and extra blank lines, consolidating sources and excerpts for improved clarity.
---
SqlDatabaseVectorSearch/Program.cs | 10 +---------
1 file changed, 1 insertion(+), 9 deletions(-)
diff --git a/SqlDatabaseVectorSearch/Program.cs b/SqlDatabaseVectorSearch/Program.cs
index 18fec2b..6dfc438 100644
--- a/SqlDatabaseVectorSearch/Program.cs
+++ b/SqlDatabaseVectorSearch/Program.cs
@@ -173,15 +173,7 @@ var textSearchOptions = new TextSearchProviderOptions()
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())
- {
- sb.AppendLine($"- {GetSourceName(r, i)}");
- }
-
- sb.AppendLine();
-
- sb.AppendLine("### Excerpts");
+ sb.AppendLine("### Sources");
foreach (var (i, r) in results.Index())
{
sb.AppendLine($"[{i + 1}] {GetSourceName(r, i)}");
From d5b0e1606f285c4de8a8a5cf1ccf36aba45456a5 Mon Sep 17 00:00:00 2001
From: Marco Minerva
Date: Mon, 27 Jul 2026 17:22:09 +0200
Subject: [PATCH 11/32] Clarify and update sources section formatting rules
The instructions for formatting the sources section at the end of answers were revised for clarity and precision. Instead of multiple separate citation rules, the new guidance requires using a provided template, with the sources and page labels localized to the user's language. The updated rules specify omitting the page label and number if not available, and explicitly prohibit headings or links in the sources section. The formatting example was also updated to match these changes.
---
SqlDatabaseVectorSearch/Program.cs | 12 +++---------
1 file changed, 3 insertions(+), 9 deletions(-)
diff --git a/SqlDatabaseVectorSearch/Program.cs b/SqlDatabaseVectorSearch/Program.cs
index 6dfc438..716dd7e 100644
--- a/SqlDatabaseVectorSearch/Program.cs
+++ b/SqlDatabaseVectorSearch/Program.cs
@@ -159,17 +159,11 @@ var textSearchOptions = new TextSearchProviderOptions()
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 sources label translated in the same language as the user's question.");
- sb.AppendLine("- The sources label MUST be standard-size italic Markdown text, not a heading and not bold, and MUST be localized in the same language as the user's question.");
- sb.AppendLine("- The citation list MUST be a numbered Markdown list.");
- sb.AppendLine("- The source name MUST be bold, followed by the localized page label and the page number ONLY when the page number is available, then a colon.");
- sb.AppendLine("- After the colon, add about 15-20 words in italic taken from the excerpt that supports the answer.");
- sb.AppendLine("- Format each source exactly like:");
+ sb.AppendLine("- At the END of your answer, add a sources section that follows this template exactly, where the sources label and the page label are localized in the same language as the user's question:");
sb.AppendLine(" *localized-sources-label*");
sb.AppendLine(" 1. **SourceName**, localized-page-label PageNumber: *supporting excerpt of about 15-20 words*");
- sb.AppendLine(" If the page number is not available, omit it and the page label, like:");
- sb.AppendLine(" 1. **SourceName**: *supporting excerpt of about 15-20 words*");
- sb.AppendLine("- Do NOT format source names as links.");
+ sb.AppendLine("- Omit the page label and the page number when the page number is not available.");
+ sb.AppendLine("- Do NOT use headings or links in the sources section.");
sb.AppendLine("- Include ONLY sources you actually used. No duplicates.");
sb.AppendLine();
From c7138d571c2ae3e798aaa77a8ce98523432c6fed Mon Sep 17 00:00:00 2001
From: Marco Minerva
Date: Mon, 27 Jul 2026 17:44:23 +0200
Subject: [PATCH 12/32] Switch to Agent Framework, update SSE protocol and docs
Updated README.md and Home.razor to document migration from Semantic Kernel to Microsoft Agent Framework, detailing new orchestration, document import, question reformulation, and RAG agent workflows. Revised API and streaming response examples to use Server-Sent Events (SSE) with event names Start, Delta, and End. Refactored AskEndpoints.cs to use .NET 8 ServerSentEvents API and yield SseItem with correct event names. Updated Ask.razor, Documents.razor, and VectorSearchService.cs to use StreamState.Delta instead of Append, and replaced StateHasChanged() with await InvokeAsync(StateHasChanged) for async UI updates. Removed MaxInputTokens and MaxOutputTokens from AppSettings.cs and appsettings.json.
---
README.md | 263 ++++--------------
.../Components/Pages/Ask.razor | 25 +-
.../Components/Pages/Documents.razor | 2 +-
.../Components/Pages/Home.razor | 16 +-
.../Endpoints/AskEndpoints.cs | 10 +-
SqlDatabaseVectorSearch/Models/StreamState.cs | 2 +-
.../Services/VectorSearchService.cs | 2 +-
.../Settings/AppSettings.cs | 4 -
SqlDatabaseVectorSearch/appsettings.json | 2 -
9 files changed, 84 insertions(+), 242 deletions(-)
diff --git a/README.md b/README.md
index 79ac6a7..d47e85b 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
[](https://dotnet.microsoft.com/apps/aspnet/apis)
[](https://dotnet.microsoft.com/apps/aspnet/web-apps/blazor)
-A Blazor Web App and Minimal API for performing RAG (Retrieval Augmented Generation) and vector search using the native VECTOR type in Azure SQL Database and Azure OpenAI.
+A Blazor Web App and Minimal API for performing RAG (Retrieval Augmented Generation) and vector search using the native VECTOR type in Azure SQL Database, Azure OpenAI, and [Microsoft Agent Framework](https://github.com/microsoft/agent-framework).
## Table of Contents
- [Overview](#overview)
@@ -24,10 +24,10 @@ A Blazor Web App and Minimal API for performing RAG (Retrieval Augmented Generat
This application allows you to:
- Load documents (PDF, DOCX, TXT, MD)
- Generate embeddings and save them as vectors in Azure SQL Database
-- Perform semantic search and RAG using Azure OpenAI
+- Perform semantic search and RAG using Azure OpenAI and Microsoft Agent Framework agents
- Interact via a Blazor Web App or programmatically via Minimal API
-Embeddings and chat completion are powered by [Semantic Kernel](https://github.com/microsoft/semantic-kernel).
+Embeddings and chat completion are orchestrated with [Microsoft Agent Framework](https://github.com/microsoft/agent-framework). The application uses an embedding workflow to import documents, a reformulation agent to rewrite follow-up questions with conversation context, and a RAG agent connected to a SQL vector-search context provider.
## Screenshots
@@ -49,6 +49,7 @@ Embeddings and chat completion are powered by [Semantic Kernel](https://github.c
- `Endpoints/` - Minimal API endpoints
- `Services/` - Business logic and integration services
- `TextChunkers/` - Text splitting utilities
+ - `Workflows/` - Microsoft Agent Framework workflow executors for document import and embedding generation
- `Settings/` - Configuration classes
## Setup
@@ -76,16 +77,26 @@ Embeddings and chat completion are powered by [Semantic Kernel](https://github.c
## Supported features
-- **Conversation History with Question Reformulation**: This feature allows users to view the history of their conversations, including the ability to reformulate questions for better clarity and understanding. This ensures that users can track their interactions and refine their queries as needed.
-- **Information about Token Usage**: Users can access detailed information about token usage, which helps in understanding the consumption of tokens during interactions. This feature provides transparency and helps users manage their token usage effectively.
-- **Response Streaming**: This feature enables real-time streaming of responses, allowing users to receive information as it is being processed. This ensures a seamless and efficient flow of information, enhancing the overall user experience.
-- **Citations**: The application provides citations for the sources used to justify each answer. This allows users to verify the information and understand the origin of the content provided by the system.
+- **Microsoft Agent Framework orchestration**: Document import is implemented as a workflow, while question reformulation and RAG are implemented as agents.
+- **Conversation history with question reformulation**: The reformulation agent rewrites each question using the conversation context before vector search is performed.
+- **SQL vector-search context provider**: The RAG agent receives relevant chunks from Azure SQL Database through a `TextSearchProvider` backed by native VECTOR search.
+- **Information about token usage**: The Blazor chat page and API responses expose token usage for reformulation and final answer generation.
+- **Response streaming**: The Blazor chat page uses streaming responses, appending answer tokens as they arrive.
+- **Markdown source citations**: Citations are included directly in the generated Markdown answer as a localized sources section with source name, page number when available, and a short supporting excerpt.
## How to Use
-- **Web App**: Use the Blazor interface to upload documents, search, and chat with RAG.
+- **Web App**: Use the Blazor interface to manage documents and chat with your indexed content. The chat page streams answers, shows token usage, supports conversation reset, and renders source citations as part of the Markdown answer.
- **API**: Import documents via `POST /api/documents` and ask questions via `POST /api/ask` or `POST /api/ask-streaming`.
+### How it works
+
+1. Documents are uploaded through the API and processed by the `EmbeddingWorkflow`.
+2. The workflow converts the uploaded file into text, chunks it, generates embeddings, and stores documents, chunks, and VECTOR embeddings in Azure SQL Database.
+3. When a question is asked, the `ReformulationAgent` can rewrite it using the current conversation context.
+4. The `RagAgent` receives relevant SQL vector-search results through a `TextSearchProvider` and answers using only the provided context.
+5. Sources are not returned as a separate JSON collection. They are formatted directly in the Markdown answer.
+
#### Example API Request
```
POST /api/ask
@@ -101,224 +112,58 @@ Content-Type: application/json
```json
{
+ "conversationId": "3d0bd178-499d-433a-b2bc-c35e488d9e2c",
"originalQuestion": "why is mars called the red planet?",
"reformulatedQuestion": "Why is the planet Mars called the red planet?",
- "answer": "Mars is called the Red Planet because its surface has an orange-red color due to being covered in iron(III) oxide dust, also known as rust. This iron oxide gives Mars its distinctive reddish appearance when observed from Earth and is the origin of its well-known nickname",
- "streamState": "End",
+ "answer": "Mars is called the Red Planet because its surface has an orange-red color caused by iron oxide dust.\n\n*Sources*\n1. **Mars.pdf**, page 1: *surface of Mars is orange-red because it is covered in iron oxide dust*",
+ "streamState": null,
"tokenUsage": {
"reformulation": {
- "promptTokens": 812,
- "completionTokens": 11,
- "totalTokens": 823
+ "inputTokenCount": 812,
+ "outputTokenCount": 11,
+ "totalTokenCount": 823
},
- "embeddingTokenCount": 10,
"question": {
- "promptTokens": 31708,
- "completionTokens": 227,
- "totalTokens": 31935
+ "inputTokenCount": 31708,
+ "outputTokenCount": 227,
+ "totalTokenCount": 31935
}
- },
- "citations": [
- {
- "documentId": "b1870ad7-4685-42a3-576a-08ddb01159d5",
- "chunkId": "749aba1e-0db5-4033-cfa6-08ddb0115da3",
- "fileName": "Mars.pdf",
- "quote": "surface of Mars is orange-red because it is covered in iron(III) oxide",
- "pageNumber": 1,
- "indexOnPage": 0
- },
- {
- "documentId": "b1870ad7-4685-42a3-576a-08ddb01159d5",
- "chunkId": "215e7197-513f-4fbe-cfa8-08ddb0115da3",
- "fileName": "Mars.pdf",
- "quote": "Martian surface is caused by ferric oxide, or rust",
- "pageNumber": 3,
- "indexOnPage": 0
- }
- ]
+ }
}
```
### How response streaming works
-When using the `/api/ask-streaming` endpoint, answers will be streamed as with the typical response from OpenAI. The format of the response is as follows:
+When using the `/api/ask-streaming` endpoint, answers are streamed as [Server-Sent Events](https://developer.mozilla.org/docs/Web/API/Server-sent_events). Each event has a name matching the `streamState` value and a JSON `Response` payload in the `data` field. The format is as follows:
-```json
-[
- {
- "originalQuestion": "why is mars called the red planet?",
- "reformulatedQuestion": "Why is the planet Mars known as the red planet?",
- "answer": null,
- "streamState": "Start",
- "tokenUsage": {
- "reformulation": {
- "promptTokens": 541,
- "completionTokens": 12,
- "totalTokens": 553
- },
- "embeddingTokenCount": 11,
- "question": null
- },
- "citations": null
- },
- {
- "originalQuestion": null,
- "reformulatedQuestion": null,
- "answer": "Mars",
- "streamState": "Append",
- "tokenUsage": null,
- "citations": null
- },
- {
- "originalQuestion": null,
- "reformulatedQuestion": null,
- "answer": " is",
- "streamState": "Append",
- "tokenUsage": null,
- "citations": null
- },
- {
- "originalQuestion": null,
- "reformulatedQuestion": null,
- "answer": " known",
- "streamState": "Append",
- "tokenUsage": null,
- "citations": null
- },
- {
- "originalQuestion": null,
- "reformulatedQuestion": null,
- "answer": " as",
- "streamState": "Append",
- "tokenUsage": null,
- "citations": null
- },
- {
- "originalQuestion": null,
- "reformulatedQuestion": null,
- "answer": " the",
- "streamState": "Append",
- "tokenUsage": null,
- "citations": null
- },
- {
- "originalQuestion": null,
- "reformulatedQuestion": null,
- "answer": " red",
- "streamState": "Append",
- "tokenUsage": null,
- "citations": null
- },
- {
- "originalQuestion": null,
- "reformulatedQuestion": null,
- "answer": " planet",
- "streamState": "Append",
- "tokenUsage": null,
- "citations": null
- },
- {
- "originalQuestion": null,
- "reformulatedQuestion": null,
- "answer": " because",
- "streamState": "Append",
- "tokenUsage": null,
- "citations": null
- },
- {
- "originalQuestion": null,
- "reformulatedQuestion": null,
- "answer": " its",
- "streamState": "Append",
- "tokenUsage": null,
- "citations": null
- },
- {
- "originalQuestion": null,
- "reformulatedQuestion": null,
- "answer": " surface",
- "streamState": "Append",
- "tokenUsage": null,
- "citations": null
- },
- {
- "originalQuestion": null,
- "reformulatedQuestion": null,
- "answer": " is",
- "streamState": "Append",
- "tokenUsage": null,
- "citations": null
- },
- {
- "originalQuestion": null,
- "reformulatedQuestion": null,
- "answer": " covered",
- "streamState": "Append",
- "tokenUsage": null,
- "citations": null
- },
- {
- "originalQuestion": null,
- "reformulatedQuestion": null,
- "answer": " in",
- "streamState": "Append",
- "tokenUsage": null,
- "citations": null
- },
- {
- "originalQuestion": null,
- "reformulatedQuestion": null,
- "answer": " iron",
- "streamState": "Append",
- "tokenUsage": null,
- "citations": null
- },
- /// ...
- {
- "originalQuestion": null,
- "reformulatedQuestion": null,
- "answer": null,
- "streamState": "End",
- "tokenUsage": {
- "reformulation": null,
- "embeddingTokenCount": null,
- "question": {
- "promptTokens": 30949,
- "completionTokens": 221,
- "totalTokens": 31170
- }
- },
- "citations": [
- {
- "documentId": "b1870ad7-4685-42a3-576a-08ddb01159d5",
- "chunkId": "749aba1e-0db5-4033-cfa6-08ddb0115da3",
- "fileName": "Mars.pdf",
- "quote": "surface of Mars is orange-red",
- "pageNumber": 1,
- "indexOnPage": 0
- },
- {
- "documentId": "b1870ad7-4685-42a3-576a-08ddb01159d5",
- "chunkId": "215e7197-513f-4fbe-cfa8-08ddb0115da3",
- "fileName": "Mars.pdf",
- "quote": "red-orange appearance of the Martian surface is caused by ferric oxide, or rust",
- "pageNumber": 3,
- "indexOnPage": 0
- }
- ]
- }
-]
+```text
+event: Start
+data: {"conversationId":"3d0bd178-499d-433a-b2bc-c35e488d9e2c","originalQuestion":"why is mars called the red planet?","reformulatedQuestion":"Why is the planet Mars known as the red planet?","answer":null,"streamState":"Start","tokenUsage":{"reformulation":{"inputTokenCount":541,"outputTokenCount":12,"totalTokenCount":553,"cachedInputTokenCount":0,"reasoningTokenCount":0,"inputAudioTokenCount":null,"inputTextTokenCount":null,"outputAudioTokenCount":null,"outputTextTokenCount":null,"additionalCounts":null},"question":null}}
+
+event: Delta
+data: {"conversationId":"3d0bd178-499d-433a-b2bc-c35e488d9e2c","originalQuestion":null,"reformulatedQuestion":null,"answer":"Mars","streamState":"Delta","tokenUsage":null}
+
+event: Delta
+data: {"conversationId":"3d0bd178-499d-433a-b2bc-c35e488d9e2c","originalQuestion":null,"reformulatedQuestion":null,"answer":" is known as the red planet because its surface is rich in iron oxide dust.\n\n","streamState":"Delta","tokenUsage":null}
+
+event: Delta
+data: {"conversationId":"3d0bd178-499d-433a-b2bc-c35e488d9e2c","originalQuestion":null,"reformulatedQuestion":null,"answer":"Sources\n1. **Mars.pdf**, page 1: *surface of Mars is orange-red because it is covered in iron oxide dust*","streamState":"Delta","tokenUsage":null}
+
+event: End
+data: {"conversationId":"3d0bd178-499d-433a-b2bc-c35e488d9e2c","originalQuestion":null,"reformulatedQuestion":null,"answer":null,"streamState":"End","tokenUsage":{"reformulation":null,"question":{"inputTokenCount":30949,"outputTokenCount":221,"totalTokenCount":31170,"cachedInputTokenCount":3840,"reasoningTokenCount":0,"inputAudioTokenCount":null,"inputTextTokenCount":null,"outputAudioTokenCount":null,"outputTextTokenCount":null,"additionalCounts":null}}}
```
-- The first piece of the response has the following characteristics:
+- The first event has the following characteristics:
+ - The SSE event name is `Start`.
- The *streamState* property is set to `Start`.
- It contains the question and its reformulation (if not requested, *reformulatedQuestion* will be equal to *originalQuestion*).
- - The *tokenUsage* section holds information about tokens used for reformulation (if done) and for the embedding of the question.
-- Then, there are as many elements for the actual answer as necessary:
- - Each one contains a token.
- - The *streamState* property is set to `Append`.
- - *originalQuestion*, *reformulatedQuestion*, *tokenUsage* and *citations* are always `null`.
-- The stream ends when an element with *streamState* equals `End` is received. This element contains token usage information for the question and the whole answer, and the list of citations.
+ - The *tokenUsage* section holds information about tokens used for reformulation, if done.
+- Then, there are as many `Delta` events as necessary for the actual answer:
+ - Each event contains a token or chunk of generated text in the *answer* property.
+ - The *streamState* property is set to `Delta`.
+ - *originalQuestion*, *reformulatedQuestion* and *tokenUsage* are always `null`.
+- The stream ends when an `End` event is received. This event contains token usage information for the final answer.
+- Sources are included in the Markdown answer text.
## Limitations & FAQ
diff --git a/SqlDatabaseVectorSearch/Components/Pages/Ask.razor b/SqlDatabaseVectorSearch/Components/Pages/Ask.razor
index fee2b4c..b8f7091 100644
--- a/SqlDatabaseVectorSearch/Components/Pages/Ask.razor
+++ b/SqlDatabaseVectorSearch/Components/Pages/Ask.razor
@@ -170,7 +170,7 @@
messages.Add(assistantMessage);
question = null;
- await Task.Yield();
+ await InvokeAsync(StateHasChanged);
await EnsureMessageIsVisibleAsync();
@@ -180,27 +180,26 @@
var vectorSearchService = scope.ServiceProvider.GetRequiredService();
var response = vectorSearchService.AskStreamingAsync(userQuestion);
- await foreach (var delta in response)
+ await foreach (var update in response)
{
- if (delta.StreamState == StreamState.Start)
+ if (update.StreamState == StreamState.Start)
{
- userMessage.Text = delta.ReformulatedQuestion;
- assistantMessage.TokenUsage = FormatTokenUsage(delta.TokenUsage);
+ userMessage.Text = update.ReformulatedQuestion;
+ assistantMessage.TokenUsage = FormatTokenUsage(update.TokenUsage);
assistantMessage.Status = MessageStatus.Streaming;
}
- else if (delta.StreamState == StreamState.Append)
+ else if (update.StreamState == StreamState.Delta)
{
// Adds tokens to the assistant message as they are received.
- assistantMessage.Text += delta.Answer;
+ assistantMessage.Text += update.Answer;
}
- else if (delta.StreamState == StreamState.End)
+ else if (update.StreamState == StreamState.End)
{
assistantMessage.Status = MessageStatus.Completed;
- assistantMessage.TokenUsage += FormatTokenUsage(delta.TokenUsage);
+ assistantMessage.TokenUsage += FormatTokenUsage(update.TokenUsage);
}
- await Task.Yield();
- StateHasChanged();
+ await InvokeAsync(StateHasChanged);
await EnsureMessageIsVisibleAsync();
}
@@ -234,13 +233,13 @@
showCopyConfirmation = true;
toolTipText = "Copied!";
- StateHasChanged();
+ await InvokeAsync(StateHasChanged);
await Task.Delay(3000); // Shows the checkmark for 3 seconds
toolTipText = "Copy to Clipboard";
showCopyConfirmation = false;
- StateHasChanged();
+ await InvokeAsync(StateHasChanged);
}
private static string FormatTokenUsage(TokenUsageResponse? tokenUsageResponse)
diff --git a/SqlDatabaseVectorSearch/Components/Pages/Documents.razor b/SqlDatabaseVectorSearch/Components/Pages/Documents.razor
index 64bd2b4..486826c 100644
--- a/SqlDatabaseVectorSearch/Components/Pages/Documents.razor
+++ b/SqlDatabaseVectorSearch/Components/Pages/Documents.razor
@@ -130,7 +130,7 @@ else
await using var scope = ServiceScopeFactory.CreateAsyncScope();
await LoadDocumentsAsync(scope.ServiceProvider);
- StateHasChanged();
+ await InvokeAsync(StateHasChanged);
}
private async Task LoadDocumentsAsync(IServiceProvider services)
diff --git a/SqlDatabaseVectorSearch/Components/Pages/Home.razor b/SqlDatabaseVectorSearch/Components/Pages/Home.razor
index a120b40..8ce2e55 100644
--- a/SqlDatabaseVectorSearch/Components/Pages/Home.razor
+++ b/SqlDatabaseVectorSearch/Components/Pages/Home.razor
@@ -6,7 +6,7 @@
SQL Database Vector Search
- A Blazor Web App and Minimal API for Retrieval Augmented Generation (RAG) and vector search using the native VECTOR type in Azure SQL Database with Azure OpenAI.
+ A Blazor Web App and Minimal API for Retrieval Augmented Generation (RAG) and vector search using the native VECTOR type in Azure SQL Database with Azure OpenAI and Microsoft Agent Framework.
@@ -14,18 +14,20 @@
Load documents (PDF, DOCX, TXT, MD)
Generate embeddings and save them as vectors in Azure SQL Database
-
Perform semantic search and RAG using Azure OpenAI
+
Perform semantic search and RAG using Azure OpenAI agents
Interact via a Blazor Web App or programmatically via Minimal API
Conversation History with Question Reformulation: View and reformulate your conversation history for better clarity and understanding.
-
Information about Token Usage: Access detailed information about token usage for transparency and management.
-
Response Streaming: Receive real-time streaming of responses for a seamless and efficient user experience.
-
Citations: Get citations for the sources used to justify each answer, allowing you to verify and understand the origin of the content.
+
Microsoft Agent Framework orchestration: Import documents through an embedding workflow and answer questions with dedicated reformulation and RAG agents.
+
Conversation history with question reformulation: Rewrite follow-up questions with the current conversation context before vector search.
+
SQL vector-search context: Retrieve relevant chunks from Azure SQL Database through native VECTOR search.
+
Information about token usage: Access token usage for reformulation and final answer generation.
+
Response streaming: Receive answer tokens in real time in the chat page and streaming API.
+
Markdown source citations: Get citations directly in the answer text with source name, page number when available, and a short supporting excerpt.
diff --git a/SqlDatabaseVectorSearch/Endpoints/AskEndpoints.cs b/SqlDatabaseVectorSearch/Endpoints/AskEndpoints.cs
index 5b236ff..d1b2a80 100644
--- a/SqlDatabaseVectorSearch/Endpoints/AskEndpoints.cs
+++ b/SqlDatabaseVectorSearch/Endpoints/AskEndpoints.cs
@@ -1,4 +1,6 @@
using System.ComponentModel;
+using System.Net.ServerSentEvents;
+using System.Runtime.CompilerServices;
using MinimalHelpers.FluentValidation;
using SqlDatabaseVectorSearch.Models;
using SqlDatabaseVectorSearch.Services;
@@ -23,18 +25,18 @@ public class AskEndpoints : IEndpointRouteHandlerBuilder
endpoints.MapPost("/api/ask-streaming", (Question question, VectorSearchService vectorSearchService, CancellationToken cancellationToken,
[Description("If true, the question will be reformulated taking into account the context of the chat identified by the given ConversationId.")] bool reformulate = true) =>
{
- async IAsyncEnumerable Stream()
+ async IAsyncEnumerable> StreamAsync([EnumeratorCancellation] CancellationToken innerCancellationToken)
{
// Requests a streaming response.
- var responseStream = vectorSearchService.AskStreamingAsync(question, reformulate, cancellationToken);
+ var responseStream = vectorSearchService.AskStreamingAsync(question, reformulate, innerCancellationToken);
await foreach (var delta in responseStream)
{
- yield return delta;
+ yield return new(delta, delta.StreamState?.ToString());
}
}
- return Stream();
+ return TypedResults.ServerSentEvents(StreamAsync(cancellationToken));
})
.WithValidation()
.WithSummary("Asks a question and gets the response as streaming")
diff --git a/SqlDatabaseVectorSearch/Models/StreamState.cs b/SqlDatabaseVectorSearch/Models/StreamState.cs
index 2bb25ad..0846911 100644
--- a/SqlDatabaseVectorSearch/Models/StreamState.cs
+++ b/SqlDatabaseVectorSearch/Models/StreamState.cs
@@ -3,6 +3,6 @@
public enum StreamState
{
Start,
- Append,
+ Delta,
End
}
\ No newline at end of file
diff --git a/SqlDatabaseVectorSearch/Services/VectorSearchService.cs b/SqlDatabaseVectorSearch/Services/VectorSearchService.cs
index 9333f45..71f6045 100644
--- a/SqlDatabaseVectorSearch/Services/VectorSearchService.cs
+++ b/SqlDatabaseVectorSearch/Services/VectorSearchService.cs
@@ -77,7 +77,7 @@ public partial class VectorSearchService([FromKeyedServices("EmbeddingWorkflow")
updates.Add(update);
if (!string.IsNullOrEmpty(update.Text))
{
- yield return new(question.ConversationId, update.Text, StreamState.Append);
+ yield return new(question.ConversationId, update.Text, StreamState.Delta);
}
}
diff --git a/SqlDatabaseVectorSearch/Settings/AppSettings.cs b/SqlDatabaseVectorSearch/Settings/AppSettings.cs
index 538fed8..fed324e 100644
--- a/SqlDatabaseVectorSearch/Settings/AppSettings.cs
+++ b/SqlDatabaseVectorSearch/Settings/AppSettings.cs
@@ -12,10 +12,6 @@ public class AppSettings
public int MaxRelevantChunks { get; init; } = 5;
- public int MaxInputTokens { get; init; } = 16385;
-
- public int MaxOutputTokens { get; init; } = 800;
-
public TimeSpan MessageExpiration { get; init; }
public int MessageLimit { get; set; } = 20;
diff --git a/SqlDatabaseVectorSearch/appsettings.json b/SqlDatabaseVectorSearch/appsettings.json
index 46d3a96..76e57e5 100644
--- a/SqlDatabaseVectorSearch/appsettings.json
+++ b/SqlDatabaseVectorSearch/appsettings.json
@@ -25,8 +25,6 @@
"MaxTokensPerParagraph": 1000,
"OverlapTokens": 100,
"MaxRelevantChunks": 30,
- "MaxInputTokens": 32768,
- "MaxOutputTokens": 800,
"MessageExpiration": "00:05:00",
"MessageLimit": 20
},
From 96fd8adf0fe6dfd87665d5174bb16ff4b99c3c26 Mon Sep 17 00:00:00 2001
From: Marco Minerva
Date: Mon, 27 Jul 2026 17:57:30 +0200
Subject: [PATCH 13/32] Refactor embedding request creation and processing
Consolidate embedding request logic into a new EmbeddingRequest record with static factory methods for IFormFile and stream input. Remove FormFileToEmbeddingRequestExecutor and FormFileEmbeddingRequest, updating all usages and dependency injection accordingly. Clean up related code in Program.cs, VectorSearchService, and _Imports.razor. Enhance XML documentation for EmbeddingRequest to clarify intent and usage.
---
.../Components/Pages/Documents.razor | 2 +-
.../Components/_Imports.razor | 1 +
.../Endpoints/DocumentEndpoints.cs | 2 +-
SqlDatabaseVectorSearch/Program.cs | 5 +---
.../Services/VectorSearchService.cs | 2 +-
.../Workflows/EmbeddingRequest.cs | 26 +++++++++++++++++++
.../FormFileToEmbeddingRequestExecutor.cs | 18 -------------
7 files changed, 31 insertions(+), 25 deletions(-)
create mode 100644 SqlDatabaseVectorSearch/Workflows/EmbeddingRequest.cs
delete mode 100644 SqlDatabaseVectorSearch/Workflows/FormFileToEmbeddingRequestExecutor.cs
diff --git a/SqlDatabaseVectorSearch/Components/Pages/Documents.razor b/SqlDatabaseVectorSearch/Components/Pages/Documents.razor
index 486826c..165064a 100644
--- a/SqlDatabaseVectorSearch/Components/Pages/Documents.razor
+++ b/SqlDatabaseVectorSearch/Components/Pages/Documents.razor
@@ -177,7 +177,7 @@ else
var vectorSearchService = scope.ServiceProvider.GetRequiredService();
var documentId = string.IsNullOrWhiteSpace(Model.DocumentId) ? null : (Guid?)Guid.Parse(Model.DocumentId);
- //await vectorSearchService.ImportAsync(stream, fileName, MimeUtility.GetMimeMapping(fileName), documentId);
+ await vectorSearchService.ImportAsync(EmbeddingRequest.Create(stream, fileName, documentId));
ToastService.Notify(await CreateToastMessageAsync(ToastType.Success, "Upload document", $"The document {fileName} has been successfully uploaded and indexed."));
diff --git a/SqlDatabaseVectorSearch/Components/_Imports.razor b/SqlDatabaseVectorSearch/Components/_Imports.razor
index ef23f97..6ef6fce 100644
--- a/SqlDatabaseVectorSearch/Components/_Imports.razor
+++ b/SqlDatabaseVectorSearch/Components/_Imports.razor
@@ -13,4 +13,5 @@
@using SqlDatabaseVectorSearch.Extensions
@using SqlDatabaseVectorSearch.Models
@using SqlDatabaseVectorSearch.Services
+@using SqlDatabaseVectorSearch.Workflows
@using BlazorBootstrap
\ No newline at end of file
diff --git a/SqlDatabaseVectorSearch/Endpoints/DocumentEndpoints.cs b/SqlDatabaseVectorSearch/Endpoints/DocumentEndpoints.cs
index f42caeb..a167f3d 100644
--- a/SqlDatabaseVectorSearch/Endpoints/DocumentEndpoints.cs
+++ b/SqlDatabaseVectorSearch/Endpoints/DocumentEndpoints.cs
@@ -23,7 +23,7 @@ public class DocumentEndpoints : IEndpointRouteHandlerBuilder
documentsApiGroup.MapPost(string.Empty, async (IFormFile file, VectorSearchService vectorSearchService, CancellationToken cancellationToken,
[Description("The unique identifier of the document. If not provided, a new one will be generated. If you specify an existing documentId, the corresponding document will be overwritten.")] Guid? documentId = null) =>
{
- var result = await vectorSearchService.ImportAsync(new FormFileEmbeddingRequest(file, documentId), cancellationToken);
+ var result = await vectorSearchService.ImportAsync(EmbeddingRequest.FromFormFile(file, documentId), cancellationToken);
return TypedResults.Ok(result);
})
.DisableAntiforgery()
diff --git a/SqlDatabaseVectorSearch/Program.cs b/SqlDatabaseVectorSearch/Program.cs
index 716dd7e..afdbb93 100644
--- a/SqlDatabaseVectorSearch/Program.cs
+++ b/SqlDatabaseVectorSearch/Program.cs
@@ -92,18 +92,15 @@ builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
-builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddScoped(); // This executor is registered as scoped because it uses the DbContext, which is also scoped.
builder.AddWorkflow("EmbeddingWorkflow", (services, key) =>
{
- var formfileToConversionRequestExecutor = services.GetRequiredService();
var generateEmbeddingExecutor = services.GetRequiredService();
var storeEmbeddingExecutor = services.GetRequiredService();
- var workflow = new WorkflowBuilder(formfileToConversionRequestExecutor).WithName(key)
- .AddEdge(formfileToConversionRequestExecutor, generateEmbeddingExecutor)
+ var workflow = new WorkflowBuilder(generateEmbeddingExecutor).WithName(key)
.AddEdge(generateEmbeddingExecutor, storeEmbeddingExecutor)
.WithOutputFrom(storeEmbeddingExecutor)
.Build(validateOrphans: true);
diff --git a/SqlDatabaseVectorSearch/Services/VectorSearchService.cs b/SqlDatabaseVectorSearch/Services/VectorSearchService.cs
index 71f6045..e6edd4e 100644
--- a/SqlDatabaseVectorSearch/Services/VectorSearchService.cs
+++ b/SqlDatabaseVectorSearch/Services/VectorSearchService.cs
@@ -17,7 +17,7 @@ namespace SqlDatabaseVectorSearch.Services;
public partial class VectorSearchService([FromKeyedServices("EmbeddingWorkflow")] Workflow workflow, [FromKeyedServices("ReformulationAgent")] AIAgent reformulationAgent, [FromKeyedServices("RagAgent")] AIAgent ragAgent,
[FromKeyedServices("RagAgent")] AgentSessionStore sessionStore)
{
- public async Task ImportAsync(FormFileEmbeddingRequest request, CancellationToken cancellationToken = default)
+ public async Task ImportAsync(EmbeddingRequest request, CancellationToken cancellationToken = default)
{
await using var run = await InProcessExecution.RunAsync(workflow, request, cancellationToken: cancellationToken);
var events = run.NewEvents.ToList();
diff --git a/SqlDatabaseVectorSearch/Workflows/EmbeddingRequest.cs b/SqlDatabaseVectorSearch/Workflows/EmbeddingRequest.cs
new file mode 100644
index 0000000..9837162
--- /dev/null
+++ b/SqlDatabaseVectorSearch/Workflows/EmbeddingRequest.cs
@@ -0,0 +1,26 @@
+namespace SqlDatabaseVectorSearch.Workflows;
+
+public record class EmbeddingRequest(Stream Content, string FileName, string ContentType, Guid? DocumentId)
+{
+ ///
+ /// Creates an from an uploaded .
+ ///
+ /// The uploaded file.
+ /// The optional identifier of the document to overwrite.
+ public static EmbeddingRequest FromFormFile(IFormFile file, Guid? documentId = null) => Create(file.OpenReadStream(), Path.GetFileName(file.FileName), documentId);
+
+ ///
+ /// Creates an from a content stream, inferring the content type from the file name.
+ ///
+ /// The stream that contains the document content.
+ /// The name of the document.
+ /// The optional identifier of the document to overwrite.
+ ///
+ /// The content type is inferred from the file name because the content type declared by the client is not always reliable (for example, for Markdown files).
+ ///
+ public static EmbeddingRequest Create(Stream content, string fileName, Guid? documentId = null)
+ {
+ var name = Path.GetFileName(fileName);
+ return new EmbeddingRequest(content, name, MimeMapping.MimeUtility.GetMimeMapping(name), documentId);
+ }
+}
\ No newline at end of file
diff --git a/SqlDatabaseVectorSearch/Workflows/FormFileToEmbeddingRequestExecutor.cs b/SqlDatabaseVectorSearch/Workflows/FormFileToEmbeddingRequestExecutor.cs
deleted file mode 100644
index 4e93aa2..0000000
--- a/SqlDatabaseVectorSearch/Workflows/FormFileToEmbeddingRequestExecutor.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-using Microsoft.Agents.AI.Workflows;
-
-namespace SqlDatabaseVectorSearch.Workflows;
-
-public partial class FormFileToEmbeddingRequestExecutor() : Executor(nameof(FormFileToEmbeddingRequestExecutor))
-{
- [MessageHandler]
- private ValueTask HandleAsync(FormFileEmbeddingRequest request, IWorkflowContext context, CancellationToken cancellationToken)
- {
- // Note: file.ContentType is not 100% reliable (for example, for markdown file).
- var embeddingRequest = new EmbeddingRequest(request.File.OpenReadStream(), Path.GetFileName(request.File.FileName), MimeMapping.MimeUtility.GetMimeMapping(request.File.FileName), request.DocumentId);
- return ValueTask.FromResult(embeddingRequest);
- }
-}
-
-public record class FormFileEmbeddingRequest(IFormFile File, Guid? DocumentId);
-
-public record class EmbeddingRequest(Stream Content, string FileName, string ContentType, Guid? DocumentId);
\ No newline at end of file
From ded1fba4988d62d2b93c331ad845bf3b34bd8e81 Mon Sep 17 00:00:00 2001
From: Marco Minerva
Date: Tue, 28 Jul 2026 12:37:11 +0200
Subject: [PATCH 14/32] Flexible DbContext SQL provider selection and config
Replaced AddSqlServer with AddDbContext for ApplicationDbContext, enabling dynamic provider selection based on the connection string. Now uses UseAzureSql for Azure SQL targets and UseSqlServer with retry logic otherwise. Configured all queries to use NoTracking for improved performance.
---
SqlDatabaseVectorSearch/Program.cs | 16 +++++++++++++++-
1 file changed, 15 insertions(+), 1 deletion(-)
diff --git a/SqlDatabaseVectorSearch/Program.cs b/SqlDatabaseVectorSearch/Program.cs
index afdbb93..e80fea6 100644
--- a/SqlDatabaseVectorSearch/Program.cs
+++ b/SqlDatabaseVectorSearch/Program.cs
@@ -43,8 +43,22 @@ builder.Services.ConfigureHttpJsonOptions(options =>
builder.Services.AddSingleton(TimeProvider.System);
-builder.Services.AddSqlServer(builder.Configuration.GetConnectionString("SqlConnection"), optionsAction: options =>
+builder.Services.AddDbContext(options =>
{
+ var connectionString = builder.Configuration.GetConnectionString("SqlConnection")!;
+
+ if (connectionString.Contains("database.windows.net"))
+ {
+ options.UseAzureSql(connectionString);
+ }
+ else
+ {
+ options.UseSqlServer(connectionString, sqlOptions =>
+ {
+ sqlOptions.EnableRetryOnFailure(maxRetryCount: 5, maxRetryDelay: TimeSpan.FromSeconds(10), errorNumbersToAdd: null);
+ });
+ }
+
options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
});
From 9f0d4f640219d85c87caf42f1ac3cb9b0ce66892 Mon Sep 17 00:00:00 2001
From: Marco Minerva
Date: Tue, 28 Jul 2026 12:56:21 +0200
Subject: [PATCH 15/32] Refine icons, colors, and UI feedback for clarity
Update icon choices and colors across the Blazor app for improved visual clarity and consistency. Use explicit InteractiveServerRenderMode in App.razor. Replace sidebar, header, and navigation icons with filled or colorized variants in MainLayout.razor. Update Ask.razor and Documents.razor with more descriptive icons. Enhance reconnect modal messaging and button visibility. Adjust app.css for individual sidebar icon colors and brand icon styling. Remove EFCore.SqlServer.VectorSearch from the home page feature list to focus on Agent Framework. These changes improve UI feedback, accessibility, and maintainability.
---
SqlDatabaseVectorSearch/Components/App.razor | 4 ++--
.../Components/Layout/MainLayout.razor | 8 ++++----
.../Components/Layout/ReconnectModal.razor | 10 +++++-----
.../Components/Pages/Ask.razor | 4 ++--
.../Components/Pages/Documents.razor | 8 ++++----
.../Components/Pages/Home.razor | 2 +-
SqlDatabaseVectorSearch/wwwroot/css/app.css | 15 ++++++++++++++-
7 files changed, 32 insertions(+), 19 deletions(-)
diff --git a/SqlDatabaseVectorSearch/Components/App.razor b/SqlDatabaseVectorSearch/Components/App.razor
index 389a89c..1b22215 100644
--- a/SqlDatabaseVectorSearch/Components/App.razor
+++ b/SqlDatabaseVectorSearch/Components/App.razor
@@ -14,11 +14,11 @@
-
+
-
+
diff --git a/SqlDatabaseVectorSearch/Components/Layout/MainLayout.razor b/SqlDatabaseVectorSearch/Components/Layout/MainLayout.razor
index 14c0325..3806144 100644
--- a/SqlDatabaseVectorSearch/Components/Layout/MainLayout.razor
+++ b/SqlDatabaseVectorSearch/Components/Layout/MainLayout.razor
@@ -5,10 +5,10 @@
-
+
-
+
@@ -44,8 +44,8 @@
{
navItems = [
new() { Id = "1", Href = "/", IconName = IconName.HouseDoorFill, Text = "Home", Match = NavLinkMatch.All},
- new() { Id = "2", Href= "/documents", IconName = IconName.FileText, Text = "Documents" },
- new() { Id = "3", Href = "/ask", IconName = IconName.ChatDots, Text = "Ask"}
+ new() { Id = "2", Href= "/documents", IconName = IconName.FileEarmarkTextFill, Text = "Documents" },
+ new() { Id = "3", Href = "/ask", IconName = IconName.ChatDotsFill, Text = "Ask"}
];
return navItems;
diff --git a/SqlDatabaseVectorSearch/Components/Layout/ReconnectModal.razor b/SqlDatabaseVectorSearch/Components/Layout/ReconnectModal.razor
index a55bcc1..e740b0c 100644
--- a/SqlDatabaseVectorSearch/Components/Layout/ReconnectModal.razor
+++ b/SqlDatabaseVectorSearch/Components/Layout/ReconnectModal.razor
@@ -10,7 +10,7 @@
Rejoining the server...
- Rejoin failed... Trying again in seconds.
+ Rejoin failed... trying again in seconds.
Failed to rejoin. Please retry or reload the page.
@@ -21,11 +21,11 @@