Adding RAG to Microsoft Agent Framework is both straightforward and confusing. OK, that makes no sense, but I’ve been struggling to understand all the steps. Here’s a first approximation, more to come in future posts (and I have a video coming with Bruno Capuano and Jon Galloway on this very subject). There are also a bunch of RAG blog posts that I’ve already posted; you can find the list here.

TL;DR
- Learn the simple C# steps to wire Retrieval-Augmented Generation (RAG) into Microsoft Agent Framework (MAF).
- Includes a runnable, self-contained console stub that chunks documents, stores them in-memory with metadata, runs a demo similarity search, and returns answers plus source citations.
- Swap the demo token-overlap search for real embeddings + a vector DB in production and then pass the search delegate into MAF’s TextSearchProvider.
Overview
- Chunk documents and record metadata (Id, SourceName, SourceLink).
- Compute embeddings for each chunk and store (vector DB or in-memory for prototype).
- Implement a search delegate: query → embedding → nearest neighbors → map to TextSearchResult-like results.
- Attach the search delegate to MAF via TextSearchProvider. Choose between injecting passages (BeforeAIInvoke) or exposing search as a callable tool (OnDemandFunctionCalling).
- Run the agent; show model response and the underlying source metadata for traceability.
Notes on packages and API names
- The relevant MAF package is typically Microsoft.Agents.AI (check the exact package and namespace for your MAF version on NuGet and the MAF docs).
- Typical using directives you might need:
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.TextSearchProvider; - API names and enum names (e.g., TextSearchBehavior.BeforeAIInvoke) may vary by MAF version—verify your installed NuGet version and adjust namespaces accordingly.
Quick actionable steps
- Prepare documents
- Split long content into chunks (500–1000 chars recommended) with some overlap (50–100 chars) to preserve context.
- For each chunk, store:
- Id (unique)
- SourceName (file name)
- SourceLink (URL)
- Text (chunk text)
- Optional metadata (section title, date)
- Compute an embedding for each chunk (production) and store the embedding vector alongside metadata.
- Implement the search delegate
- Signature MAF expects (canonical shape you should adapt to your MAF version):
Func<string, CancellationToken, Task<IEnumerable<TextSearchProvider.TextSearchResult>>> - Implementation steps:
- Convert query → embedding (same embedding model used for docs).
- Query vector DB for top-K neighbors (approx. nearest neighbor).
- Map each hit to TextSearchResult (Text, SourceName, SourceLink, RawRepresentation).
- Create and configure a TextSearchProvider
- Instantiate a TextSearchProvider with your search delegate and options (behavior, top_k).
- Add to ChatClientAgentOptions.AIContextProviders so the agent can use it.
- Run the agent and surface metadata
- Decide the mode:
- BeforeAIInvoke—passages injected into prompt before model call (simpler, higher token usage).
- OnDemandFunctionCalling—model invokes search explicitly (more control/cost saving).
- Return the model response, and surface SourceName/SourceLink/RawRepresentation to the user for verification.
Scoring and thresholds
- Use cosine similarity on embeddings—normalized between -1 and 1. Typical positive ranges near 0.7–0.95 indicate high similarity depending on your model and vector dims.
- Prototype token-overlap scores are uncalibrated. Use a minScore cutoff (e.g., 0.05–0.2) to filter noise and avoid returning unrelated passages.
- Top-K: usually 3–5 passages works well to balance context and token cost.
Troubleshooting quick tips
- If queries return nothing: check chunking (too small chunks can lose context) and tokenization differences (embedding model vs. tokenization used in your search).
- If irrelevant results: increase overlap, reduce chunk size, or switch to a stronger embedding model.
- If costs are high: use OnDemandFunctionCalling and caching of query embeddings.
Runnable, self-contained C# console stub
- Purpose: demonstrate chunking, adding documents with metadata, a demo token-overlap similarity search, returning top-K hits, and showing source metadata.
- This demo is intentionally self-contained and uses a simple token-overlap scoring (NOT production quality). Replace the demo search and Tokenize with real embeddings + vector DB.
Create a new console app:
dotnet new console -n SimpleRagDemo
Replace Program.cs with the code below and run dotnet run.
Code (runnable stub)
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Text;
// Simple Document chunk container with metadata
record DocumentChunk(string Id, string SourceName, string SourceLink, string Text);
// Result shape similar to TextSearchProvider.TextSearchResult
record SearchResult(string Text, string SourceName, string SourceLink, double Score, string RawRepresentation);
class SimpleRagStore
{
private readonly List<DocumentChunk> _chunks = new();
// Add a chunk (in production you'd also persist an embedding vector here)
public void AddChunk(DocumentChunk chunk) => _chunks.Add(chunk);
// Demo-only: token-overlap similarity (replace with embeddings + vector DB in production)
// Returns topK search results (and ignores zero-score results by default)
public Task<IEnumerable<SearchResult>> SearchAsync(string query, int topK = 3, double minScore = 0.0, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
var qTokens = Tokenize(query);
if (qTokens.Count == 0) return Task.FromResult(Enumerable.Empty<SearchResult>());
var scored = _chunks.Select(c =>
{
var dTokens = Tokenize(c.Text);
var overlap = qTokens.Intersect(dTokens).Count();
// Simple normalized score for demo (not cosine)
var score = overlap == 0 ? 0.0 : overlap / Math.Sqrt(qTokens.Count * dTokens.Count);
return new SearchResult(
Text: c.Text,
SourceName: c.SourceName,
SourceLink: c.SourceLink,
Score: score,
RawRepresentation: $"chunk:{c.Id}"
);
})
.Where(r => r.Score > minScore)
.OrderByDescending(r => r.Score)
.Take(topK);
return Task.FromResult(scored.AsEnumerable());
}
// Demo tokenization: extract word-like tokens, lower-case, remove short stop-words.
// Production: keep consistent tokenization with embedding model or skip and use vector similarity.
private static HashSet<string> Tokenize(string text)
{
var matches = Regex.Matches(text.ToLowerInvariant(), @"\w+"); // letters/digits/underscore
var set = new HashSet<string>();
foreach (Match m in matches)
{
var tok = m.Value;
if (tok.Length <= 2) continue; // skip tiny tokens in this demo
set.Add(tok);
}
return set;
}
}
static class ChunkingHelper
{
// Split a long document into chunks with overlap.
// chunkSize characters and overlap characters.
public static IEnumerable<string> ChunkText(string text, int chunkSize = 800, int overlap = 100)
{
if (string.IsNullOrEmpty(text)) yield break;
if (chunkSize <= overlap) throw new ArgumentException("chunkSize must be greater than overlap");
int start = 0;
while (start < text.Length)
{
int len = Math.Min(chunkSize, text.Length - start);
yield return text.Substring(start, len);
start += (chunkSize - overlap);
}
}
}
class Program
{
// Placeholder: in production you'd call your embedding model here.
// Keep this method so it's clear where to swap in real embeddings.
static Task<double[]> GetEmbeddingAsync(string text, CancellationToken ct = default)
{
// Demo does not use embeddings. Return a dummy vector for shape.
return Task.FromResult(new double[0]);
}
static async Task Main(string[] args)
{
var store = new SimpleRagStore();
// Example long document (will be chunked)
var longDoc = @"This document explains how to reset your password. You can reset your password
by visiting /account/reset and following the instructions. Reset tokens last 1 hour.
If you don't receive an email, check your spam folder and contact support at support@example.com.";
// 1) Chunk the long document and add chunks with metadata
int chunkSize = 200; // small for demo visibility
int overlap = 40;
int chunkIndex = 0;
foreach (var chunkText in ChunkingHelper.ChunkText(longDoc, chunkSize, overlap))
{
var id = $"pwd-doc-{chunkIndex++}";
store.AddChunk(new DocumentChunk(
Id: id,
SourceName: "AccountFAQ.md",
SourceLink: "https://example/docs/account-faq",
Text: chunkText.Trim()
));
}
// Add a few short docs (already small)
store.AddChunk(new DocumentChunk("install-1", "GettingStarted.md", "https://example/docs/getting-started",
"To install the product, run dotnet tool install -g Example.Tool and then run example init."));
store.AddChunk(new DocumentChunk("rn-1", "ReleaseNotes.md", "https://example/docs/releases",
"Version 2.1 adds RAG support via a TextSearchProvider and includes an in-memory connector."));
Console.WriteLine("Simple RAG demo. Ask a question about the docs (type 'exit' to quit).");
while (true)
{
Console.Write("> ");
var question = Console.ReadLine();
if (string.IsNullOrWhiteSpace(question)) continue;
if (question.Trim().Equals("exit", StringComparison.OrdinalIgnoreCase)) break;
// 2) Search (this mimics the search delegate you'd pass into TextSearchProvider)
var results = await store.SearchAsync(question, topK: 3, minScore: 0.0, CancellationToken.None);
// 3) Synthesize a simple grounded answer (demo-only)
if (!results.Any())
{
Console.WriteLine("No grounded passages found. (In production you might still call your LLM but mark as 'no sources found')");
continue;
}
var best = results.First();
var outBuilder = new StringBuilder();
outBuilder.AppendLine("Answer (grounded):");
outBuilder.AppendLine($" - {best.Text}");
outBuilder.AppendLine();
outBuilder.AppendLine("Sources:");
foreach (var r in results)
{
outBuilder.AppendLine($" - {r.SourceName} ({r.SourceLink}) [score={r.Score:F3}] raw={r.RawRepresentation}");
}
Console.WriteLine(outBuilder.ToString());
}
}
}
How to swap the demo for real production components
- Replace Tokenize + token-overlap similarity with:
- Embeddings: call your embeddings model (Azure OpenAI or OpenAI), get float[] vectors for each chunk and for the query.
- Vector store: persist chunk vectors + metadata to Qdrant, Redis, Azure Cognitive Search (vector-capable) or pgvector and query via nearest-neighbor search (cosine or dot-product).
- Map DB hits to TextSearchProvider.TextSearchResult and return top-K.
- Ensure consistent embedding model between document and query vectors.
Mapping the search delegate into Microsoft Agent Framework (sketch)
- Search delegate signature (adapt to MAF version):
Func<string, CancellationToken, Task<IEnumerable<TextSearchProvider.TextSearchResult>>> - Example wiring (pseudo):
// Adapt namespaces & types for your MAF version
async Task<IEnumerable<TextSearchProvider.TextSearchResult>> MySearchAsync(string q, CancellationToken ct)
{
var qVec = await MyEmbeddingClient.GetEmbeddingAsync(q, ct);
var hits = await MyVectorDb.SearchAsync(qVec, topK: 4, ct);
return hits.Select(h => new TextSearchProvider.TextSearchResult
{
Text = h.Text,
SourceName = h.Metadata["sourceName"],
SourceLink = h.Metadata.TryGetValue("link", out var l) ? l : null,
RawRepresentation = h.Id
});
}
var textSearchProvider = new TextSearchProvider(MySearchAsync, new TextSearchProviderOptions
{
// Behavior = TextSearchBehavior.BeforeAIInvoke or OnDemandFunctionCalling
});
var agentOptions = new ChatClientAgentOptions
{
Name = "MyRagAgent",
AIContextProviders = new[] { textSearchProvider }
};
- Note: check MAF docs for exact type/namespace names for your NuGet version.
Practical tuning checklist
- Chunk size: 500–1000 chars is common. Use overlaps of 50–150 chars.
- Top-K: start with 3–5. Increase if model still hallucinates.
- Min score: filter out low similarity hits.
- Mode: BeforeAIInvoke for reliability; OnDemandFunctionCalling for cost control.
Security and cost considerations
- Embeddings and model calls cost money—cache embeddings and reuse them.
- Remove/obfuscate PII before storing embeddings if required by privacy rules.
- Log and surface source metadata for auditability.
Sample console output (what to expect)
- User: “How do I reset my password?”
- Output:
Answer (grounded):- This document explains how to reset your password. You can reset your password by visiting /account/reset and following the instructions.
Sources: - AccountFAQ.md (https://example/docs/account-faq) [score=0.62] raw=chunk:pwd-doc-0
- AccountFAQ.md (https://example/docs/account-faq) [score=0.45] raw=chunk:pwd-doc-1
- This document explains how to reset your password. You can reset your password by visiting /account/reset and following the instructions.
Note: The initial draft of this post was written by BlogWriter and then edited by Jesse Liberty.
Illustrations by Copilot. Caution: LLMs make mistakes; this post is offered as is.
