Building a RAG Pipeline in .NET with Azure OpenAI and Azure AI Search

End-to-end retrieval-augmented generation in C#: chunking, embeddings, hybrid vector search, and grounding a chat completion so it cites its sources.

By Suthahar Jegatheesan 4 min read views

Retrieval-augmented generation is how you get an LLM to answer questions about data it was never trained on. The pattern is simple; the quality lives almost entirely in the retrieval half.

How does RAG actually work?

Three stages. Ingest: split documents into chunks, convert each to an embedding vector, store them in a searchable index. Retrieve: embed the user’s question and find the nearest chunks. Generate: put those chunks in the prompt and ask the model to answer using only that context.

The generation step is the easy part. Most bad RAG systems are bad because retrieval returned the wrong chunks, not because the model reasoned poorly.

Chunking

Chunk boundaries determine what can ever be retrieved together. Split on semantic boundaries — headings, paragraphs — rather than fixed character counts:

public static IEnumerable<Chunk> ChunkMarkdown(
    string content,
    int maxTokens = 700,
    double overlapRatio = 0.15)
{
    var sections = content.Split("\n## ", StringSplitOptions.RemoveEmptyEntries);
    var overlap = (int)(maxTokens * overlapRatio);

    foreach (var section in sections)
    {
        var tokens = Tokenizer.Encode(section);

        for (var start = 0; start < tokens.Count; start += maxTokens - overlap)
        {
            var window = tokens.Skip(start).Take(maxTokens).ToList();
            if (window.Count < 50) break;   // trailing scraps hurt retrieval

            yield return new Chunk(Tokenizer.Decode(window), start);
        }
    }
}

The overlap matters: without it, a sentence spanning a boundary is retrievable from neither chunk.

Generating embeddings

var client = new AzureOpenAIClient(
    new Uri(config["AzureOpenAI:Endpoint"]!),
    new DefaultAzureCredential());

var embeddingClient = client.GetEmbeddingClient("text-embedding-3-small");

var response = await embeddingClient.GenerateEmbeddingsAsync(
    chunks.Select(c => c.Text).ToList());

var vectors = response.Value
    .Select(e => e.ToFloats().ToArray())
    .ToArray();

Batch these. Embedding one chunk per call is slow and wastes your rate limit.

Use DefaultAzureCredential rather than API keys. It works with managed identity in Azure and your developer login locally, so no secret ever reaches the repo.

Hybrid retrieval

Pure vector search misses exact terms — product codes, error numbers, API names. Pure keyword search misses paraphrases. Hybrid runs both and fuses the rankings:

var searchOptions = new SearchOptions
{
    Size = 8,
    QueryType = SearchQueryType.Semantic,
    SemanticSearch = new()
    {
        SemanticConfigurationName = "default"
    },
    VectorSearch = new()
    {
        Queries =
        {
            new VectorizedQuery(questionEmbedding)
            {
                KNearestNeighborsCount = 20,
                Fields = { "contentVector" }
            }
        }
    }
};

var results = await searchClient.SearchAsync<DocChunk>(question, searchOptions);

Retrieve ~20 candidates by vector, let semantic reranking order them, then keep the top 8. Reranking is where most of the quality gain comes from.

Grounding the answer

The system prompt has one job: prevent the model from answering from its own memory.

var context = string.Join("\n\n---\n\n",
    topChunks.Select((c, i) => $"[{i + 1}] Source: {c.Title}\n{c.Content}"));

var messages = new ChatMessage[]
{
    new SystemChatMessage("""
        Answer the user's question using ONLY the numbered context below.
        Cite the sources you used as [1], [2], and so on.
        If the context does not contain the answer, say exactly:
        "I don't have that information in the provided documents."
        Do not use knowledge outside the context.
        """),
    new UserChatMessage($"Context:\n{context}\n\nQuestion: {question}")
};

var completion = await chatClient.CompleteChatAsync(messages);

The explicit refusal string matters. Without it, the model fills gaps with plausible invention, which is worse than no answer.

What to measure

MetricWhat it catches
Retrieval recall@kThe right chunk never reached the prompt
GroundednessThe answer contains claims absent from context
Citation accuracyCited sources do not support the claim
End-to-end latencyEmbedding + search + generation budget

Track retrieval separately from generation. When answers are wrong, this tells you which half to fix.

Key takeaways

  • Retrieval quality dominates; tune it before touching prompts.
  • Chunk on semantic boundaries at 500–800 tokens with ~15% overlap.
  • Hybrid search plus semantic reranking beats vector-only in nearly every case.
  • Instruct the model to refuse when context is insufficient, with an exact phrase.
  • Use managed identity, not API keys.

Was this useful?

Share

Found a mistake or an outdated step? Edit this page on GitHub

Frequently asked questions

What chunk size works best for RAG?
Start with 500 to 800 tokens per chunk with roughly 15 percent overlap. Smaller chunks retrieve more precisely but lose surrounding context; larger chunks dilute the embedding and push irrelevant text into the prompt.
Do I need a vector database for RAG in .NET?
Not necessarily. Azure AI Search supports vector and hybrid search directly, and for small corpora an in-memory index is enough. A dedicated vector database matters when you need millions of vectors or independent scaling of the index.
Why use hybrid search instead of pure vector search?
Pure vector search misses exact terms like product codes, error numbers and API names, while pure keyword search misses paraphrases. Hybrid runs both and fuses the rankings. Retrieve around 20 candidates by vector, let semantic reranking order them, then keep the top 8 — the reranking step is where most of the quality gain comes from.
How do you stop a RAG app answering from the model's own memory?
Constrain it in the system prompt: instruct the model to answer using only the numbered context supplied, cite the sources it used, and return a fixed refusal string when the context does not contain the answer. Grounding is a prompt-design problem, not only a retrieval problem.
A token is a subword fragment from a fixed vocabulary

Read next

11 min

What Is an AI Token? Why Every AI Cost Is Calculated in Them

Tokens are not words, and they are not characters. Here is what a token really is, why AI billing counts them, and how to work out the cost of a request.

Continue reading
PreviouslyHow to Secure a .NET Minimal API with JWT Bearer Authentication

Get new posts by email

New technical articles, Azure AI and GitHub Copilot updates, and upcoming events. No spam, unsubscribe anytime.

Comments

Your turn

How did Suthahar's articles help you?

If something here saved you time or unblocked a real project, I'd love to hear about it. Submissions are reviewed before they appear on the site.

0/1500 · minimum 10 characters

Never published — used only to verify your feedback.

Your name, company, and role appear publicly if published. Nothing else is collected.

navigate open