ASP.NET Hosting

RAG Architecture Patterns in.NET: From Basic to High-Level

Your AI model is excellent. It is capable of data analysis, essay writing, and human-like dialogue. However, it fails when you ask it about your company’s internal records, sales figures from the previous quarter, or the return policy you changed yesterday. Not because it’s foolish. as it is unsure.

Only the information contained in their training data is known by large language models. Your personal databases, sensitive documents, and most recent changes are nonexistent in the realm of the model. Fine-tuning is costly, time-consuming, and rigid. You would have to retrain each time your data changed.

This is resolved via Retrieval-Augmented Generation. You retrieve the pertinent data at query time and give it to the model along with the question, rather than teaching it your data. Based on actual, up-to-date data from your systems, the model produces a response.

A framework is not what RAG is. It’s not a library. It’s a pattern in architecture. Additionally, everything you need to implement it at commercial scale is available in the.NET ecosystem in 2026.

The full journey is described in this post. We begin by defining RAG. We construct a simplistic implementation. The chunking techniques, embedding creation, vector storage, hybrid search, and architectural patterns that make RAG manageable in enterprise.NET applications are then covered in order to make it production-grade.

What RAG Actually Is

RAG has three letters and five stages. Here is what happens when a user asks a question:

1. The user asks a question. “What is our refund policy for digital products?”

2. The question gets embedded. An embedding model converts the question into a vector: a high-dimensional array of numbers that represents the meaning of the question.

3. Relevant documents are retrieved. The system searches a vector database for documents whose embeddings are similar to the question’s embedding. It returns the top 5 or 10 most relevant chunks.

4. The context is injected into the prompt. The retrieved chunks are added to the prompt alongside the user’s question. The model now has the original question plus the relevant context.

5. The model generates a grounded response. Instead of guessing or hallucinating, the model answers based on the retrieved context. It can cite sources, quote specific passages, and admit when the context does not contain the answer.

That is the entire pattern. Embed, retrieve, inject, generate. Everything else is optimization.

The Naive Implementation

Let us build the simplest possible RAG pipeline in .NET. No frameworks, no magic. Just the raw pattern.

// The simplest RAG pipeline you can write

var embedder = new OpenAIEmbeddingGenerator(
    "text-embedding-3-small", apiKey);
var chatClient = new OpenAIChatClient("gpt-4o", apiKey);

// 1. Your "database" of documents
var documents = new List<(string Text, float[] Vector)>();

// 2. Index a document
var docText = "Digital products are non-refundable after " +
    "download. Customers may request a refund within " +
    "24 hours of purchase if the product has not been " +
    "downloaded.";
var docVector = await embedder.GenerateVectorAsync(docText);
documents.Add((docText, docVector));

// 3. User asks a question
var question = "Can I get a refund on a digital purchase?";
var questionVector = await embedder
    .GenerateVectorAsync(question);

// 4. Find the most similar document (cosine similarity)
var bestMatch = documents
    .OrderBy(d => CosineDistance(questionVector, d.Vector))
    .First();

// 5. Build the prompt with context
var prompt = $"""
    Answer the question based on the context below.
    If the context does not contain the answer, say
    "I don't know."

    Context:
    {bestMatch.Text}

    Question: {question}
    """;

// 6. Generate response
var response = await chatClient.GetResponseAsync(prompt);
Console.WriteLine(response.Text);

This works. The model gets the refund policy document as context and answers accurately. But this naive approach has problems that become obvious at scale.

Problem 1: Documents are too big. A 50-page PDF does not fit in the model’s context window. You need to split documents into smaller chunks.

Problem 2: In-memory storage. A list of tuples does not survive a restart, does not scale, and does not support metadata filtering.

Problem 3: No relevance threshold. The system always returns something, even when nothing is relevant.

Problem 4: Single embedding model. If the provider changes pricing or goes down, everything breaks.

Let us fix all of these.

Stage 1: Chunking — Splitting Documents Intelligently

Before you can embed documents, you need to split them into chunks that fit in the model’s context window and are semantically coherent. This is the single most impactful decision in a RAG pipeline.

Chunking Strategies

Fixed-size chunking. Split every N tokens. Simple, predictable, but can break mid-sentence or mid-paragraph. A chunk about refund policy might end with “customers may request a” and the next chunk starts with “refund within 24 hours.” Neither chunk is useful alone.

Paragraph-based chunking. Split on double newlines. Preserves natural semantic units, but paragraphs vary wildly in size. A one-sentence paragraph wastes embedding capacity. A five-page paragraph defeats the purpose.

Recursive chunking. Try to split on paragraphs first. If a paragraph is too big, split on sentences. If a sentence is too big, split on tokens. This is the best default for most use cases.

Semantic chunking. Use embedding similarity to detect topic boundaries. When the embedding of consecutive sentences diverges sharply, that is a chunk boundary. Produces the most coherent chunks but is computationally expensive.

Implementation with Semantic Kernel’s TextChunker

using Microsoft.SemanticKernel.Text;

// Load your document
var documentText = await File.ReadAllTextAsync(
    "refund-policy.md");

// Recursive chunking: paragraphs first, then lines
var lines = TextChunker.SplitPlainTextLines(
    documentText, maxTokensPerLine: 128);

var chunks = TextChunker.SplitPlainTextParagraphs(
    lines,
    maxTokensPerParagraph: 256,
    overlapTokens: 32);

// Each chunk is now 128-256 tokens with 32-token
// overlap between consecutive chunks

The overlap parameter is important. A 32-token overlap means consecutive chunks share context at their boundaries, so information that spans a chunk boundary is not lost entirely. Typical overlap is 10-15% of the chunk size.

How Big Should Chunks Be

There is no universal answer, but here is what works in practice:

128-256 tokens for factual Q&A (support docs, FAQs, policies). Small chunks mean more precise retrieval.

256-512 tokens for analytical content (reports, articles, research). Larger chunks preserve more context for complex reasoning.

512-1024 tokens for code documentation and tutorials. Code blocks need surrounding explanation to make sense.

Start with 256 tokens and adjust based on retrieval quality.

Stage 2: Embedding — Converting Text to Vectors

Each chunk needs to be converted to a vector that captures its semantic meaning. The choice of embedding model determines the quality of your retrieval.

Using Microsoft.Extensions.AI

using Microsoft.Extensions.AI;

// Register in DI (provider-agnostic)
builder.Services.AddEmbeddingGenerator(
    new OpenAIClient(apiKey)
    .GetEmbeddingClient("text-embedding-3-small")
    .AsIEmbeddingGenerator());

The IEmbeddingGenerator<string, Embedding<float>> abstraction means you can switch providers without changing your RAG code.

Embedding Models Compared

OpenAI text-embedding-3-small. 1536 dimensions. Good quality, low cost. Best default for most .NET applications.

OpenAI text-embedding-3-large. 3072 dimensions. Higher quality, higher cost and storage. Use when precision matters more than cost.

Local models via Ollama (mxbai-embed-large). 1024 dimensions. Runs on your machine. Zero API cost. Good for development and privacy-sensitive deployments.

Azure OpenAI. Same models as OpenAI, but deployed in your Azure tenant. Best for enterprise compliance requirements.

Batch Embedding

Never embed one chunk at a time in production. Batch calls reduce latency and cost:

public async Task<List<(string Text, float[] Vector)>>
    EmbedChunksAsync(
        List<string> chunks,
        IEmbeddingGenerator<string,
            Embedding<float>> embedder)
{
    var results = new List<(string, float[])>();

    // Process in batches of 100
    foreach (var batch in chunks.Chunk(100))
    {
        var embeddings = await embedder
            .GenerateAsync(batch.ToList());

        for (int i = 0; i < batch.Length; i++)
        {
            results.Add((
                batch[i],
                embeddings[i].Vector.ToArray()));
        }
    }

    return results;
}

Stage 3: Storage — Where Your Vectors Live

You have three options in the .NET ecosystem, each with different trade-offs.

Option A: EF Core 10 with SQL Server 2025

If you already use SQL Server, this is the simplest path. No new infrastructure.

public class DocumentChunk
{
    public int Id { get; set; }
    public string Text { get; set; } = "";
    public string Source { get; set; } = "";
    public string Category { get; set; } = "";
    public DateTime IndexedAt { get; set; }

    [Column(TypeName = "vector(1536)")]
    public SqlVector<float> Embedding { get; set; }
}

// Query
var results = await db.Chunks
    .Where(c => c.Category == "policies")
    .OrderBy(c => EF.Functions.VectorDistance(
        "cosine", c.Embedding, queryVector))
    .Take(5)
    .ToListAsync();

Best for: datasets under 10 million vectors, existing SQL Server infrastructure, applications that need joins and transactions alongside vector search.

Option B: Microsoft.Extensions.VectorData

A provider-agnostic abstraction that works with Azure AI Search, Qdrant, Cosmos DB, Redis, Weaviate, and an in-memory implementation for testing.

using Microsoft.Extensions.VectorData;
using Microsoft.SemanticKernel.Connectors.InMemory;

// Define your record
public class DocChunk
{
    [VectorStoreRecordKey]
    public string Id { get; set; } = "";

    [VectorStoreRecordData]
    public string Text { get; set; } = "";

    [VectorStoreRecordData]
    public string Source { get; set; } = "";

    [VectorStoreRecordVector(1536)]
    public ReadOnlyMemory<float> Embedding { get; set; }
}

// Create a collection
var vectorStore = new InMemoryVectorStore();
var collection = vectorStore
    .GetCollection<string, DocChunk>("documents");
await collection.CreateCollectionIfNotExistsAsync();

// Upsert a record
await collection.UpsertAsync(new DocChunk
{
    Id = Guid.NewGuid().ToString(),
    Text = chunkText,
    Source = "refund-policy.md",
    Embedding = embeddingVector
});

// Search
var searchResults = await collection
    .VectorizedSearchAsync(queryVector,
        new VectorSearchOptions { Top = 5 });

await foreach (var result in searchResults.Results)
{
    Console.WriteLine(
        $"{result.Score}: {result.Record.Text}");
}

Best for: applications that may switch vector backends, teams using Semantic Kernel, multi-backend architectures.

Option C: Kernel Memory

Microsoft’s out-of-the-box RAG solution. Handles the entire pipeline: document ingestion, chunking, embedding, storage, and retrieval through a single service.

using Microsoft.KernelMemory;

builder.Services.AddKernelMemory<MemoryServerless>(
    memoryBuilder =>
{
    memoryBuilder
        .WithPostgresMemoryDb(postgresConfig)
        .WithOpenAITextGeneration(openAiConfig)
        .WithOpenAITextEmbeddingGeneration(openAiConfig);
});

// Ingest a document (handles chunking + embedding)
await memory.ImportDocumentAsync(
    "refund-policy.pdf", documentId: "policy-001");

// Ask a question (handles retrieval + generation)
var answer = await memory.AskAsync(
    "What is the refund policy for digital products?");

Console.WriteLine(answer.Result);

Best for: rapid prototyping, applications where you want RAG with minimal custom code, teams that prefer convention over configuration.

Stage 4: Retrieval — Getting the Right Chunks

Retrieval quality determines RAG quality. If you retrieve irrelevant chunks, no amount of prompt engineering fixes the output.

Basic Vector Retrieval

var queryVector = await embedder
    .GenerateVectorAsync(userQuestion);

var chunks = await db.Chunks
    .OrderBy(c => EF.Functions.VectorDistance(
        "cosine", c.Embedding,
        new SqlVector<float>(queryVector)))
    .Take(5)
    .Select(c => new { c.Text, c.Source })
    .ToListAsync();

Filtered Retrieval

Always filter before searching when you can. It reduces the search space and improves relevance:

var chunks = await db.Chunks
    .Where(c => c.Category == "policies")
    .Where(c => c.IndexedAt >= DateTime.UtcNow
        .AddMonths(-3))
    .OrderBy(c => EF.Functions.VectorDistance(
        "cosine", c.Embedding,
        new SqlVector<float>(queryVector)))
    .Take(5)
    .ToListAsync();

Hybrid Retrieval

Combine vector similarity with keyword matching for the best results. On Cosmos DB, EF Core 10 provides native hybrid search:

var results = await db.Chunks
    .OrderBy(c => EF.Functions.Rrf(
        EF.Functions.FullTextScore(
            c.Text, "refund digital"),
        EF.Functions.VectorDistance(
            c.Embedding, queryVector)))
    .Take(5)
    .ToListAsync();

Relevance Thresholds

Not every query has a relevant answer in your database. Set a distance threshold to avoid returning garbage:

var results = await db.Chunks
    .Select(c => new
    {
        c.Text,
        c.Source,
        Distance = EF.Functions.VectorDistance(
            "cosine", c.Embedding,
            new SqlVector<float>(queryVector))
    })
    .Where(r => r.Distance < 0.35)
    .OrderBy(r => r.Distance)
    .Take(5)
    .ToListAsync();

if (!results.Any())
    return "I don't have information about that topic.";

A cosine distance below 0.35 typically indicates meaningful relevance. Above 0.5, the results are likely noise. Tune based on your data.

Stage 5: Generation — Building the Prompt

The prompt is where retrieval and generation meet. A well-structured prompt makes the difference between a useful answer and a hallucinated one.

The Basic RAG Prompt

public async Task<string> GenerateAnswerAsync(
    string question,
    List<RetrievedChunk> chunks,
    IChatClient chat)
{
    var context = string.Join("\n\n",
        chunks.Select((c, i) =>
            $"[Source {i + 1}: {c.Source}]\n{c.Text}"));

    var prompt = $"""
        You are a helpful assistant that answers
        questions based on the provided context.

        Rules:
        1. Only use information from the context below.
        2. If the context does not contain the answer,
           say "I don't have information about that."
        3. Cite your sources using [Source N] format.
        4. Be concise and direct.

        Context:
        {context}

        Question: {question}
        """;

    var response = await chat.GetResponseAsync(prompt);
    return response.Text;
}

Prompt Design Principles

Be explicit about grounding. Tell the model to only use the provided context. Without this instruction, models happily mix retrieved context with training knowledge, which defeats the purpose of RAG.

Include source attribution. By numbering sources and instructing the model to cite them, you get verifiable answers. Users can check the cited source to confirm accuracy.

Handle the “no answer” case. Explicitly instruct the model what to do when the context does not contain the answer. Without this, models confabulate convincing but incorrect responses.

Keep the system prompt short. The system message should establish behavior, not provide content. Put all retrieved context in the user message or a dedicated context section.

The Complete Production RAG Service

Here is everything combined into a clean, injectable service:

public class RagService
{
    private readonly AppDbContext _db;
    private readonly IEmbeddingGenerator<string,
        Embedding<float>> _embedder;
    private readonly IChatClient _chat;
    private readonly ILogger<RagService> _logger;

    public RagService(
        AppDbContext db,
        IEmbeddingGenerator<string,
            Embedding<float>> embedder,
        IChatClient chat,
        ILogger<RagService> logger)
    {
        _db = db;
        _embedder = embedder;
        _chat = chat;
        _logger = logger;
    }

    public async Task<RagResponse> AskAsync(
        string question,
        string? category = null,
        int topK = 5,
        double maxDistance = 0.35)
    {
        // 1. Embed the question
        var queryVector = new SqlVector<float>(
            await _embedder
                .GenerateVectorAsync(question));

        // 2. Retrieve relevant chunks
        var query = _db.Chunks.AsQueryable();

        if (category is not null)
            query = query
                .Where(c => c.Category == category);

        var chunks = await query
            .Select(c => new
            {
                c.Text,
                c.Source,
                Distance = EF.Functions.VectorDistance(
                    "cosine", c.Embedding, queryVector)
            })
            .Where(c => c.Distance < maxDistance)
            .OrderBy(c => c.Distance)
            .Take(topK)
            .ToListAsync();

        _logger.LogInformation(
            "Retrieved {Count} chunks for question: {Q}",
            chunks.Count, question);

        if (!chunks.Any())
        {
            return new RagResponse
            {
                Answer = "I don't have information " +
                    "about that topic in my knowledge base.",
                Sources = [],
                ChunksUsed = 0
            };
        }

        // 3. Build prompt with context
        var context = string.Join("\n\n",
            chunks.Select((c, i) =>
                $"[Source {i + 1}: {c.Source}]\n{c.Text}"));

        var prompt = $"""
            Answer the question based only on the
            context below. Cite sources using
            [Source N] format. If the context does not
            contain the answer, say so.

            Context:
            {context}

            Question: {question}
            """;

        // 4. Generate response
        var response = await _chat
            .GetResponseAsync(prompt);

        return new RagResponse
        {
            Answer = response.Text,
            Sources = chunks
                .Select(c => c.Source)
                .Distinct()
                .ToList(),
            ChunksUsed = chunks.Count
        };
    }
}

public class RagResponse
{
    public string Answer { get; set; } = "";
    public List<string> Sources { get; set; } = [];
    public int ChunksUsed { get; set; }
}

This service is provider-agnostic (IEmbeddingGenerator + IChatClient), supports category filtering, enforces relevance thresholds, includes source attribution, handles the “no answer” case gracefully, and logs retrieval metrics.

Where RAG Fits in Clean Architecture

RAG is not a separate system. It is a pattern within your application architecture.

Your Application
    Domain          → Entities, business rules
    Application     → RagService, IndexingService,
                      commands, queries
    Infrastructure  → EF Core (vector storage),
                      embedding providers,
                      LLM providers
    Presentation    → REST endpoints, MCP tools

The RagService lives in the Application layer. It depends on abstractions (IEmbeddingGenerator, IChatClient, DbContext) defined or referenced in that layer. Infrastructure provides the implementations. Presentation exposes the service through REST endpoints, MCP tools, or both.

This means your RAG pipeline follows the same architectural rules as the rest of your application. Testing is straightforward: mock the embedding generator and chat client, provide a test database, and verify retrieval quality in isolation.

RAG Through MCP

RAG and MCP complement each other naturally. An MCP server can expose RAG as a tool that any AI agent can call:

[McpToolType]
public class KnowledgeBaseTools
{
    private readonly RagService _rag;

    public KnowledgeBaseTools(RagService rag)
        => _rag = rag;

    [McpTool("search_knowledge_base")]
    [Description(
        "Search the company knowledge base using " +
        "semantic search. Returns relevant documents " +
        "with source citations. Use when the user " +
        "asks about company policies, procedures, " +
        "products, or internal documentation.")]
    public async Task<ToolResult> SearchKnowledgeBase(
        [Description("The user's question")]
        string question,
        [Description("Category filter (optional)")]
        string? category = null)
    {
        var response = await _rag.AskAsync(
            question, category);

        var result = $"{response.Answer}\n\n" +
            $"Sources: {string.Join(", ", response.Sources)}\n" +
            $"Chunks used: {response.ChunksUsed}";

        return ToolResult.Success(result);
    }
}

Now any MCP-compatible AI client, Claude, Copilot, Semantic Kernel agents, can search your knowledge base through a standardized protocol. The RAG pipeline is an implementation detail behind the tool. The AI agent does not need to know about embeddings, vectors, or chunking. It just calls a tool and gets grounded answers.

Common Pitfalls and How to Avoid Them

Chunks too large (over 512 tokens). Wastes context window space and dilutes relevance. The retrieved chunk contains the answer somewhere in 500 tokens of surrounding text. The model has to find the needle.

Chunks too small (under 64 tokens). Loses context. A 50-token chunk rarely contains enough information to be useful on its own. “The refund period is 24 hours” without knowing what product or under what conditions is incomplete.

No metadata filtering. Vector similarity alone retrieves the most semantically similar chunks, but “similar” is not always “relevant.” A question about refund policies might retrieve chunks about return shipping procedures because they are semantically close. Category and date filters narrow the search space.

Embedding the wrong text. If your documents have titles, embed “Title: Refund Policy. Content: Digital products are non-refundable…” not just the content. The title carries significant semantic signal.

Ignoring the “no answer” case. Without a relevance threshold, RAG returns the “least irrelevant” chunk for every question, and the model generates a convincing answer from irrelevant context. Always set a distance threshold and handle the case where nothing is relevant.

Not measuring retrieval quality. You can have a perfect LLM and a perfect prompt, but if retrieval returns wrong chunks, the output is wrong. Measure retrieval precision and recall separately from generation quality. Build a test set of questions with known relevant chunks and evaluate your retrieval pipeline against it.

Performance Tips

Cache embeddings for common queries. If users frequently ask similar questions, cache the query embedding and retrieval results. A semantic cache (search cached queries by vector similarity) can serve repeated questions without hitting the embedding API.

Pre-compute embeddings during ingestion. Never compute embeddings at query time for documents. All document embeddings should be generated during the indexing phase and stored alongside the text.

Use appropriate dimensions. OpenAI’s text-embedding-3-small supports dimension reduction. If 1536 dimensions is too expensive for storage, you can generate 512-dimension embeddings with modest accuracy loss. Test with your data to find the sweet spot.

Index your vectors. For datasets over 100,000 rows, vector search without an index is a full table scan. Use DiskANN indexes on SQL Server 2025 or HNSW indexes on dedicated vector databases.

Batch everything. Batch embedding generation during indexing. Batch retrieval if handling multiple queries. Batch database operations when ingesting large document sets.

Conclusion

RAG is not difficult. Chunk, embed, store, retrieve, and generate are the five steps. For every level, the.NET ecosystem offers tools that are ready for production. Text of the Semantic KernelSplitting is handled via Chunker. Microsoft.extensions.AI offers abstractions for embedding. Microsoft or EF Core 10.extensions.Storage and retrieval are handled via VectorData. Generation is handled by IChatClient.

The architecture is tidy. The Application layer is where the RagService resides. Implementations are provided via infrastructure. Presentation exposes it via MCP or REST. Testing is simple. One type of configuration update is provider switching.

The pattern is not what distinguishes a production-grade RAG implementation from a naive one. It’s the specifics. chunk size. overlap. filters for metadata. criterion for relevance. prompt organization. Attribution of sources. handling errors in the “no answer” scenario.

If you get those details correct, RAG will change your.NET application from a system that can only respond to inquiries about public knowledge to one that can respond to inquiries about your data. Your rules. Your goods. Your records. Your expertise.

That’s the whole idea.

ASP.NET Core 10.0 Hosting Recommendation

HostForLIFE.eu
HostForLIFE.eu is a popular recommendation that offers various hosting choices. Starting from shared hosting to dedicated servers, you will find options fit for beginners and popular websites. It offers various hosting choices if you want to scale up. Also, you get flexible billing plans where you can choose to purchase a subscription even for one or six months.