Build RAG in C# with ASP.NET Core and Qdrant
DEV Community

Build RAG in C# with ASP.NET Core and Qdrant

What RAG Does and What This Guide Builds

Retrieval-Augmented Generation (RAG) answers a question from your own documents: find the passages that cover it, give them to a language model, and have it answer from those passages only, citing them.

This guide builds RAG in C# with ASP.NET Core, Qdrant and a local model through Ollama, one step at a time, and shows what each step is for in a system that has been measured.

The code is condensed from SupportDesk AI, a customer-support knowledge base where staff upload policies and guides and customers or agents ask questions. The full source, with tests and the evaluation, is on GitHub under the MIT license: AfzaalLucky/SupportDesk-AI-Enterprise-RAG-Core-React.

The SupportDesk AI project page covers the whole application, including authentication, the React client and the design decisions. This page is only the RAG pipeline.

Measured Results

On its evaluation set (59 questions over an 8-document fictional support knowledge base, qwen2.5:7b and nomic-embed-text on a single 8 GB GPU, run of 2026-10-05):

  • Right document retrieved in the top 5 (answerable questions): 100%
  • Answer accuracy (49 answerable questions): 95.9%
  • Correct refusals (questions the documents don't cover, or the user may not see): 100%
  • Citation accuracy: 97.9%
  • Hallucination rate: 0%
  • Chunks from another organization or a forbidden visibility retrieved: 0
  • Median time per answer: 792 ms

Those numbers are for a small, clean corpus, and the refusal and access checks rest on few questions. Treat them as proof that the pipeline works, not as a promise for your documents. Step 10 shows how to measure your own.

What You Need

  • The .NET 10 SDK (the project targets .NET 10; the server-sent events helper in Step 8 is new in ASP.NET Core 10)
  • Docker for Qdrant
  • Ollama with two models pulled:
ollama pull qwen2.5:7b && ollama pull nomic-embed-text

If Ollama is new to you, run a local LLM with Ollama from C# first. It covers installation, model choice and the embeddings API this guide uses.

For the concepts behind each step (what an embedding is, why chunking matters, how RAG compares with fine-tuning), see What Is RAG? A Practical Guide for Developers.

The Pipeline

RAG is two pipelines that share one vector store.

Ingestion runs once per document, in the background: upload → extract text (one section per page or heading) → chunk each section (≤ 1,200 chars, 150 overlap) → embed "title - section + chunk text" → upsert into Qdrant with document, organization, visibility, page and section.

Answering runs on every question: question → embed → Qdrant search (top 5, cosine ≥ 0.3, access filter in the query) → nothing found? fixed "not found" answer, model never called → prompt with numbered passages [1]..[5] → stream the answer with citations.

The parts:

  • An ASP.NET Core minimal API.
  • An embedding model: nomic-embed-text (768 dimensions) through Ollama, or an OpenAI embedding model.
  • Qdrant for vector storage and filtered similarity search, through the Qdrant.Client package (gRPC).
  • A chat model for the answer: qwen2.5:7b through Ollama by default.
  • A background worker that ingests documents, so uploads return immediately.

SupportDesk calls Ollama and OpenAI through small typed HttpClient classes rather than an AI SDK. The pipeline only depends on two interfaces, IEmbeddingService and IChatService, so the same steps work with Microsoft.Extensions.AI or Semantic Kernel underneath.

Start Qdrant:

docker run -d --name qdrant -p 127.0.0.1:6333:6333 -p 127.0.0.1:6334:6334 -v qdrant-data:/qdrant/storage qdrant/qdrant:v1.18.3

Port 6333 is REST and the dashboard, 6334 is the gRPC port Qdrant.Client uses. Both are bound to 127.0.0.1 because Qdrant has no authentication unless you configure an API key. The repo's docker-compose.yml does the same and maps them to 6335 and 6336 on the host so they don't clash with another Qdrant.

Step 1: Extract Text by Page and Heading

Citations are only as precise as the text you keep track of. If extraction throws away page numbers and headings, no later step can put them back. So the extractors return sections, and each section knows where it came from:

  • PDF: one section per page, with PdfPig, text in reading order.
  • Word (.docx): one section per heading, tables as cell | cell rows (Open XML SDK).
  • Markdown: one section per # heading, ignoring # lines inside code fences.
  • Plain text: one section.

The PDF extractor, condensed from PdfTextExtractor.cs:

using UglyToad.PdfPig;
using UglyToad.PdfPig.DocumentLayoutAnalysis.TextExtractor;
// Condensed: error handling, the OCR helper and logging removed.
// Fewer visible characters than this and the page counts as scanned (page numbers, a stray header).
public const int ScannedPageMaxChars = 20;
using var pdf = PdfDocument.Open(bytes.ToArray());
var sections = new List<TextSection>(pdf.NumberOfPages);
foreach (var page in pdf.GetPages())
{
  var text = ContentOrderTextExtractor.GetText(page);
  if (text.Count(c => !char.IsWhiteSpace(c)) < ScannedPageMaxChars && await OcrAsync(page, cancellationToken) is { } scanned)
    text = scanned;
  sections.Add(new TextSection(text, PageNumber: page.Number));
}

Two details came from real documents:

Scanned pages. A page with almost no text but with images is a scan. SupportDesk reads its images with Tesseract when it's installed, and skips images under 200 × 200 pixels because those are logos and icons. Without this check a scanned PDF indexes as empty pages and every question about it is refused.

Reading order. ContentOrderTextExtractor returns text in reading order rather than the order it was drawn, which matters for two-column layouts.

If your source documents are PDFs and you'd rather work with headings than pages, convert the PDF to Markdown first and ingest the .md file. The Markdown extractor then splits it by heading, and every citation names a section.

Step 2: Chunk Inside Each Section, Never Across

An embedding represents a whole chunk as one vector, so a chunk should be about one thing. Chunking too coarsely loses precision; chunking too finely loses context. It's a tuning decision, not a fixed rule.

SupportDesk uses up to 1,200 characters with 150 characters of overlap, and one hard rule: a chunk never spans two sections. That's what makes "page 2, Returns" in a citation exact.

SectionChunker.cs is the whole rule:

public sealed class SectionChunker(IOptions<RagOptions> options) : IDocumentChunker
{
  public IReadOnlyList<DocumentChunk> Chunk(ExtractedDocument document)
  {
    var chunks = new List<DocumentChunk>();
    foreach (var section in document.Sections)
    {
      foreach (var text in TextChunker.Split(section.Text, options.Value.ChunkSize, options.Value.ChunkOverlap))
        chunks.Add(new DocumentChunk(chunks.Count, text, section.PageNumber, section.Heading));
    }
    return chunks;
  }
}

Inside a section, TextChunker packs whole paragraphs until the next one would overflow, then starts a new chunk with the tail of the previous one:

public static IReadOnlyList<string> Split(string text, int maxChars, int overlapChars)
{
  var paragraphs = text.Replace("\r\n", "\n", StringComparison.Ordinal)
    .Split("\n\n", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
    .SelectMany(p => HardSplit(p, maxChars - overlapChars)); // a paragraph longer than a chunk is cut at whitespace
  var chunks = new List<string>();
  var current = new StringBuilder();
  foreach (var paragraph in paragraphs)
  {
    if (current.Length > 0 && current.Length + 2 + paragraph.Length > maxChars)
    {
      var chunk = current.ToString();
      chunks.Add(chunk);
      current.Clear().Append(Tail(chunk, overlapChars)); // last overlapChars, starting at a word boundary
    }
    if (current.Length > 0) current.Append("\n\n");
    current.Append(paragraph);
  }
  if (current.Length > 0 && (chunks.Count == 0 || current.Length > overlapChars))
    chunks.Add(current.ToString());
  return chunks;
}

Compared with cutting every N characters, this never splits a sentence in the middle unless a single paragraph is longer than a chunk, and then it cuts at whitespace. Mid-sentence cuts hurt retrieval more than they seem like they should: the half-sentence at the end of one chunk and the half at the start of the next each embed as something vaguer than either. The overlap means a fact that sits on a boundary appears whole in at least one chunk.

Step 3: Embed Each Chunk With Its Title and Section

A chunk that says "Items must be returned within 30 days" doesn't say what items or which policy. Before embedding, SupportDesk puts the document title and the section heading in front of the text. From DocumentProcessor.cs:

// Title and section travel with every chunk's embedding, so a passage still says what it is about.
var embedded = await embeddings.GenerateEmbeddingsAsync(chunks.Select(c => $" {document.Title}{(c.Section is null ? "" : " - " + c.Section)} \n\n {c.Text} ").ToList(), cancellationToken);

Only the vector includes the prefix. The payload stores the plain chunk text, and the title and section go in their own fields, so the prompt in Step 7 can label passages cleanly.

Behind IEmbeddingService, the Ollama implementation posts batches of 32 texts to Ollama's /api/embed endpoint (OllamaEmbeddingService.cs). Use the same model, with the same settings, for documents and questions. A question embedded by one model and compared with chunks embedded by another returns confident-looking nonsense, not an error.

Step 4: Create the Qdrant Collection and Store the Payload

Two decisions in QdrantVectorSearchService.cs save you from the most confusing RAG bugs.

One collection per embedding space. The collection name is built from the provider, the model and the vector size, for example supportdesk__ollama__nomic-embed-text__768:

internal string CollectionName(EmbeddingSpace space) => $" {Slug(options.Value.CollectionPrefix)} __ {Slug(space.Provider)} __ {Slug(space.Model)} __ {space.Dimension} ";
await client.CreateCollectionAsync(collection, new VectorParams { Size = (ulong)space.Dimension, Distance = Distance.Cosine }, cancellationToken: cancellationToken);

Switch the embedding model and the new vectors go to a new collection. Vectors from different models can never be mixed in one index, or searched against each other, and the old index keeps working until you reprocess the documents. Every point also carries embedding_provider, embedding_model and embedding_dimension in its payload, so an index can always be audited.

A payload for citations and for access control. Each point stores what the answer needs to cite the chunk, and what a search needs to filter it:

// Condensed from ToPoint and AddAccessPayload; payload = record.Payload.
var point = new PointStruct { Id = record.Id, Vectors = record.Vector.ToArray() };
point.Payload["document_id"] = payload.DocumentId.ToString();
point.Payload["organization_id"] = payload.OrganizationId.ToString();
point.Payload["visibility"] = payload.Visibility.ToString().ToLowerInvariant(); // "internal" or "public"
point.Payload["title"] = payload.DocumentTitle;
point.Payload["chunk_index"] = payload.ChunkIndex;
point.Payload["text"] = payload.Text;
if (payload.PageNumber is { } page) point.Payload["page_number"] = page;
if (payload.Section is { } section) point.Payload["section"] = section;

The three fields every search or delete filters on get keyword payload indexes:

foreach (var field in new [] { "document_id", "organization_id", "visibility" })
  await client.CreatePayloadIndexAsync(collection, field, PayloadSchemaType.Keyword, cancellationToken: cancellationToken);

Reprocessing a document deletes its old points first (by document_id, in the old and the new embedding space), then upserts the new ones. Without that, an edited policy leaves its old chunks behind, and the model cites the version you replaced.

Ingestion runs in a hosted background service, one document at a time. The SQL Server database is the source of truth for document status (Pending, Processing, Completed, Failed), so a restart re-queues whatever was in flight.

Step 5: Search With the Access Filter Inside the Query

In a multi-user knowledge base, "which chunks may this user see?" is part of retrieval, not something to check afterwards. Filter after the search and two things go wrong: the top 5 can be all forbidden chunks, so an allowed answer is missed, and one forgotten check leaks a document into a prompt.

SupportDesk makes the unfiltered search impossible to write. The search method requires an AccessScope, built from the signed-in user, and there's no overload without one:

// What a user may retrieve: one organization's documents, at the visibilities their role allows.
public sealed record AccessScope(Guid OrganizationId, IReadOnlyList<DocumentVisibility> Visibilities, Guid? UserId = null);

The Qdrant implementation turns it into must conditions of the same query that does the similarity search:

private static Filter AccessFilter(AccessScop
Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.