How to Build an AI Document Q&A API in ASP.NET Core 10 Using C# and PDF Processing (2026)

A PDF question-answering API can produce a fluent answer and still fail its most important job: showing where the answer came from. Preserve page numbers before splitting the text. Then cite the passage you actually retrieved rather than a page guessed by a model.

This guide builds an ASP.NET Core 10 PDF Q&A API with PdfPig. It accepts one bounded PDF, extracts page text, converts overlapping chunks into normalized feature vectors, ranks passages and returns extractive answers with page citations. The local baseline uses deterministic lexical feature hashing. The tutorial explains how to replace it with semantic embeddings and grounded generation after the upload and citation path works.

What happens to the PDF after upload?

The API validates a bounded upload, extracts page text, ranks small chunks, and returns a passage with page references.
The API validates a bounded upload, extracts page text, ranks small chunks, and returns a passage with page references.
Stage Decision Why it matters
Upload Limit bytes and check header Prevent obviously invalid requests and oversized bodies
Extraction Keep each page number Preserve a source anchor
Chunking Keep overlap and avoid crossing pages Preserve nearby terms and make citations precise
Retrieval Score normalized vectors Return relevant chunks without sending text elsewhere
Answer Return the best passage and source IDs Keep the result grounded in actual extracted text

A PDF header check is only a preliminary validation step. It cannot establish that a PDF is safe. PDF parsing may allocate memory or consume CPU before you can check how many words a page contains. This code is a loopback lab and uses one concurrent parse per process. A public service should parse in a separate restricted worker with process-level memory, CPU and execution limits, no network egress, a quarantine policy, and patch management.

Build the project

Install .NET 10 and a PDF reader or editor that can create a selectable-text PDF. Create a new directory with the files below. The API accepts raw application/pdf, keeping the upload contract small. For a multipart upload, validate the form boundary and size before reading the file and never use a client filename as a storage path. See ASP.NET Core file uploads and PdfPig.

Lab.csproj
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup><TargetFramework>net10.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable></PropertyGroup>
  <ItemGroup><PackageReference Include="PdfPig" Version="0.1.16" /></ItemGroup>
</Project>
Program.cs
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using UglyToad.PdfPig;

const int MaxBytes = 2 * 1024 * 1024;
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(o => o.Limits.MaxRequestBodySize = MaxBytes);
builder.Services.AddSingleton(_ => new SemaphoreSlim(1, 1));
var app = builder.Build();
app.Use(async (ctx, next) =>
{
    if (ctx.Request.Host.Host != "127.0.0.1" || ctx.Request.Headers.ContainsKey("Origin"))
    { ctx.Response.StatusCode = 403; return; }
    await next(ctx);
});

app.MapPost("/pdf/ask", async (HttpRequest req, string question, SemaphoreSlim gate, CancellationToken ct) =>
{
    if (string.IsNullOrWhiteSpace(question) || question.Length > 256)
        return Results.BadRequest("Use 1–256 characters for the question.");
    if (!string.Equals(req.ContentType, "application/pdf", StringComparison.OrdinalIgnoreCase))
        return Results.StatusCode(415);

    if (!await gate.WaitAsync(0, ct)) return Results.StatusCode(429);
    try
    {
        using var buffer = new MemoryStream();
        await req.Body.CopyToAsync(buffer, ct);
        if (buffer.Length < 5 || !buffer.GetBuffer().AsSpan(0, 5).SequenceEqual("%PDF-"u8))
            return Results.BadRequest("Invalid PDF header.");

        var query = Embed(question);
        if (query.All(v => v == 0)) return Results.BadRequest("Use searchable terms.");
        var hits = new List<Passage>();
        try
        {
            using var doc = PdfDocument.Open(buffer.ToArray());
            if (doc.NumberOfPages > 30) return Results.BadRequest("Limit the PDF to 30 pages.");
            foreach (var page in doc.GetPages())
            {
                ct.ThrowIfCancellationRequested();
                var words = page.GetWords().Select(w => w.Text).Take(2401).ToArray();
                if (words.Length > 2400) return Results.BadRequest("Page text is too large.");
                for (var i = 0; i < words.Length; i += 96)
                {
                    var chunk = string.Join(" ", words.Skip(i).Take(120));
                    if (chunk.Length > 3000) return Results.BadRequest("Chunk text is too large.");
                    var vector = Embed(chunk);
                    var score = query.Zip(vector, (a, b) => a * b).Sum();
                    if (score > 0) hits.Add(new(page.Number, i / 96, chunk, score));
                    if (i + 120 >= words.Length) break;
                }
            }
        }
        catch (Exception ex) when (ex is not OperationCanceledException and not OutOfMemoryException)
        {
            return Results.BadRequest("The PDF could not be parsed.");
        }

        var best = hits.OrderByDescending(x => x.Score).ThenBy(x => x.Page)
            .ThenBy(x => x.Chunk).Take(3).ToArray();
        return Results.Ok(new
        {
            answer = best.FirstOrDefault()?.Text ?? "No matching passage found.",
            sources = best.Select(x => new { x.Page, x.Chunk, x.Score })
        });
    }
    finally { gate.Release(); }
});
await app.RunAsync();

static double[] Embed(string text)
{
    var vector = new double[512];
    foreach (Match term in Regex.Matches(text.ToLowerInvariant(), "[a-z0-9]{2,}"))
    {
        var hash = SHA256.HashData(Encoding.UTF8.GetBytes(term.Value));
        var bucket = (hash[0] << 1) | (hash[1] & 1);
        vector[bucket] += (hash[2] & 1) == 0 ? 1 : -1;
    }
    var length = Math.Sqrt(vector.Sum(x => x * x));
    if (length > 0)
        for (var i = 0; i < vector.Length; i++) vector[i] /= length;
    return vector;
}

record Passage(int Page, int Chunk, string Text, double Score);

The sample uses a normalized signed hash vector of 512 components. Similarity is the dot product of two unit vectors. This reflects term overlap, with possible collisions. It will miss paraphrases, and a positive score is not proof that a passage answers the question. The feature extractor accepts ASCII terms of at least two characters for reproducibility. Production retrieval may need language-aware tokenization and semantic embeddings. The response includes page and chunk so consumers can locate the source. For a persistent system, also store a document ID, immutable revision ID and, when appropriate, bounding boxes.

Run a reproducible PDF question

Create policy.pdf with the selectable text “Refunds are available within 30 days.” Keep it to one page. Run the server and send the raw bytes.

Run the API and ask about the sample policy
dotnet run --no-launch-profile -- --urls http://127.0.0.1:5080

# In another terminal:
curl -i 'http://127.0.0.1:5080/pdf/ask?question=refunds' \
  -H 'Content-Type: application/pdf' --data-binary @policy.pdf

The expected answer includes the policy sentence. The first citation should show page 1 and chunk 0. Also try a text file with the PDF content type, a wrong media type, an image-only page, and a second call while the worker is busy. Expect HTTP 400 for an invalid header, 415 for the wrong media type, a no-match response for an image-only fixture, and 429 if the concurrency limit is reached. The size limit applies before full buffering. Test the HTTP 413 path under both your application server and ingress.

The project compiled on .NET SDK 10.0.100. A one-page selectable-text fixture returned the expected refund passage and page 1 citation. A payload with an invalid PDF header returned HTTP 400. OCR and hostile parser workloads were not tested.

Add semantic embeddings and generation carefully

Replace the hashing function with the same embedding model for queries and document chunks. Persist model identifier and dimensionality with each revision. Batch provider calls outside database transactions, deduplicate chunks where safe, and limit retries. Run retrieval only against authorized document IDs. Retrieve a small set of source passages and pass each source label and text to the model as untrusted context. Validate that every returned citation belongs to the actual result set. If the model provides an unknown ID, reject or regenerate the answer without pretending that citation exists.

For a scanned PDF, use an OCR stage with a confidence threshold. Preserve page numbering and mark OCR text separately so a user can verify it. For tables and columns, basic word order may be unsuitable. Add layout-aware extraction and fixtures for multi-column pages, repeated footers, ligatures, and unusual reading order. The ASP.NET PB RAG chatbot guide covers provider embeddings and answer generation in more detail.

Symptom Check
Page returns no words Scanned image, unusual encoding, or encrypted PDF
Answer has jumbled columns Reading order and layout analysis
A paraphrase finds nothing Lexical baseline needs semantic embeddings
Source page seems wrong Preserve parser page number before chunking
Upload accepted but worker stalls Isolate parser and enforce process budgets

FAQ and references

Can the PDF instruct the model to call a tool? Extracted text is untrusted data. Keep tool authorization independent and do not give the Q&A model tools it does not need.

Does this handle OCR? The sample extracts embedded text. An image-only PDF needs a separate OCR stage.

Why return an extractive answer first? It makes citation errors visible before a generator can hide them in a fluent response.

Keep page provenance through extraction, chunking and ranking. Add a generative layer only after retrieval and citations pass representative document tests.

Comments