AI App Development

Build a PDF Question-Answering App with RAG and ASP.NET Core

Design page-aware PDF ingestion, compatible embeddings, authorized retrieval, and answers with inspectable source citations.

PDF pages become chunks and vectors, then authorized evidence for a cited answer.

Upload a short text-based PDF to /pdf.html, ask a question, and the shared ASP.NET Core sample returns an answer with numbered source excerpts and physical PDF page numbers. Each request extracts and embeds that PDF, builds a temporary in-memory index, retrieves up to three chunks, and asks for a grounded response. Nothing in this example creates a permanent document library.

The runnable path is POST /api/pdf-answers in the same four-project archive used by the other Jovenzo articles. The browser submits multipart fields file and question, with X-Jovenzo-Client: tutorial. This guide follows that implementation, its resource limits and its offline fixtures. A response with valid citation IDs still needs human inspection: the application does not establish whether each claim is supported by the PDF.

Separate ingestion from answering

Within one request, PdfQuestionAnswering separates extraction, embedding, retrieval and generation into explicit steps. The file bytes and index belong to that request. After it ends, the service keeps no upload directory, shared vector cache or database entry. A second question submitted through the form uploads and processes the document again; the sample does not reuse an earlier index or conversation.

This arrangement makes the data path inspectable at a small scale, at the cost of repeated embedding work. The Microsoft .NET vector search quickstart provides context for more durable vector-store designs. Persistent ingestion, activation of an index version, and separately authorized querying would be production additions, not hidden features of this local API.

Ingestion extracts and embeds text, activates a complete index version, then serves retrieval and generation.
Production architecture illustration: version activation is not implemented in the request-local sample.

Run the complete ASP.NET Core example

Download the shared source archive and open the extracted Jovenzo.Samples directory. Keep Core, Api, Sdk and Tests together. Open src/Jovenzo.Samples.Core/Retrieval.cs, src/Jovenzo.Samples.Core/PdfQuestionAnswering.cs, src/Jovenzo.Samples.Core/OpenAiEmbeddingClient.cs and src/Jovenzo.Samples.Core/OpenAiResponsesClient.cs for the pipeline. The interfaces are in src/Jovenzo.Samples.Core/AiProviderException.cs. The endpoint is in src/Jovenzo.Samples.Api/Program.cs; its form is src/Jovenzo.Samples.Api/wwwroot/pdf.html. Under tests/Jovenzo.Samples.Tests, the relevant files are EmbeddingTests.cs, RetrievalTests.cs, PdfQuestionAnsweringTests.cs and PdfApiTests.cs.

Restore, build and run both offline suites before configuring credentials. The C# tests use real PDF extraction with synthetic PDFs and fake provider responses; the Node suite exercises the shared SSE browser module. Neither suite requires an OpenAI key. NuGet restore may use the network to obtain pinned packages, which is different from a model request.

dotnet restore Jovenzo.Samples.slnx --locked-mode
dotnet build Jovenzo.Samples.slnx --no-restore -c Release --disable-build-servers -m:1
dotnet test tests/Jovenzo.Samples.Tests/Jovenzo.Samples.Tests.csproj --no-build --no-restore -c Release
node --test tests/stream-browser.test.mjs
$env:ASPNETCORE_ENVIRONMENT = 'Development'
dotnet run --project src/Jovenzo.Samples.Api -c Release --no-build -- --urls http://127.0.0.1:5188

Starting the API and loading /pdf.html do not call a provider. To submit a real document, stop the API, configure an authorized key and enabled text/embedding models, and restart it from that shell. The following settings are placeholders; real values belong in environment variables or API-project User Secrets, never in the archive. The SDK console has its own documented OPENAI_* environment settings.

$env:Ai__ApiKey = '<your-own-key>'
$env:Ai__Model = '<your-enabled-text-model>'
$env:Ai__EmbeddingModel = '<your-enabled-embedding-model>'
dotnet run --project src/Jovenzo.Samples.Api -c Release --no-build -- --urls http://127.0.0.1:5188
# LIVE operation: run separately only with an authorized local PDF.
curl.exe -H "X-Jovenzo-Client: tutorial" -F "file=@manual.pdf;type=application/pdf" -F "question=What does the document say about cancellation?" http://127.0.0.1:5188/api/pdf-answers

Submitting the form or that POST sends extracted text and the question to the configured provider and can incur multiple embedding charges plus a generation charge. No live request was run for the verification below. The success object contains answer and sources; each source has a locally assigned id, page and excerpt. The page renders these as text, not generated HTML.

Validate uploads and extract page-aware text

The endpoint accepts one PDF of at most 5 MiB, at most 100 pages, and a nonblank question of up to 2,000 UTF-16 code units. Its multipart budget is 5 MiB plus 64 KiB for overhead. It requires the tutorial header, rejects an explicitly foreign Origin and grants no CORS preflight permission. These are local browser cross-site request defenses, not authentication: another program can supply the same header.

The endpoint reads accepted file bytes into request-local memory and invokes IPdfQuestionAnswering. A linked two-minute deadline reaches form reads, embeddings and answer generation. The request shares the existing four-operation concurrency limiter with no queue. Ordinary JSON endpoints keep their separate 16 KiB body budget; this PDF path does not relax those limits.

PdfPig extracts embedded words and physical page numbers. It does not perform OCR or guarantee natural reading order. PdfPig documentation describes the underlying page and word APIs. The chunker checks cancellation before opening the PDF and between pages and words, but opening/parsing and some library operations are synchronous. These checks cannot forcibly interrupt an in-progress parser call or isolate a malicious file.

Malformed or encrypted PDFs, empty extracted text and document limits produce controlled document errors. Provider response corruption is a separate boundary and returns a safe 502, not a misleading bad-PDF 400. Keep the sample on loopback and use trusted tutorial documents; public uploads require authentication, quotas and a hardened file-processing boundary.

Create chunks that preserve meaning and provenance

The actual record keeps document identity, physical page, sequential chunk index and extracted text. It does not contain a tenant, model version or embedding field. Vectors are stored beside chunks inside the temporary index. The page provenance is enough to map a retrieved excerpt back to the uploaded file, but is not an authorization policy.

public sealed record DocumentChunk(string DocumentId, int Page, int Index, string Text);

The default window contains 200 words with a 30-word overlap and starts again on each page. Overlap preserves some boundary context and repeats text; it does not repair reading order. The producer holds a bounded word window, checks cancellation during iteration, and checks the 20-chunk cap before constructing another chunk. It no longer builds an unrestricted list and counts it afterwards.

A chunk may contain at most 4,000 UTF-16 code units including spaces. A document that exceeds this policy is rejected rather than silently truncated or partially indexed. This is a character budget, not a model token count. It sits below the embedding input limit of 8,000 units and keeps three ordinary request-document excerpts plus their source headers below the 14,000-unit grounding budget. The service also checks the constructed context before generation.

For a synthetic example, imagine a two-page tutorial with a short cancellation paragraph on PDF page 1 and an unrelated cache paragraph on page 2. Each short page yields one chunk: indexes 0 and 1 retain page numbers 1 and 2. The offline fixtures use known numeric vectors to control ranking. They are not embeddings measured from those sentences. Longer pages can produce several overlapping chunks with the same physical page number.

Chunk metadata groups ownership, version and page location, and text with its embedding.
Production metadata illustration: tenant and version fields shown here are future extensions; the current record has DocumentId, Page, Index and Text.

Generate compatible embeddings

The shared contract is ITextEmbeddings, implemented by OpenAiEmbeddingClient. It requests the configured Ai:EmbeddingModel with one scalar text input and encoding_format: float. The OpenAI embeddings guide explains the vector response. The local 8,000-character bound does not substitute for a model-specific token limit or establish account access.

public interface ITextEmbeddings
{
    Task<float[]> EmbedAsync(string text, CancellationToken cancellationToken);
}

The client uses ResponseHeadersRead and an 8,192-byte read buffer. It checks a 1 MiB response-body budget before appending bytes to retained memory, then parses that bounded JSON. One scalar input must produce exactly one data item, with a nonempty embedding of at most 8,192 finite float components. The byte and dimension limits are tutorial resource policies, not claims about a provider maximum. One MiB leaves ample JSON space for that single bounded vector and metadata while preventing unrestricted buffering.

Missing or empty data, missing vectors, incorrect JSON types, overflow/nonfinite numbers, malformed JSON and oversized bodies become AiProviderException. A configured client rejects a different dimension count on a subsequent successful response. The RAG service independently checks vectors from its injectable interface, so a bad fake or alternative provider cannot turn a dimension mismatch into a user-PDF validation error. Cancellation propagates; raw provider content and secrets are not placed in the safe exception message.

Retrieve only authorized, useful evidence

Each AskAsync creates an InMemoryVectorIndex. Chunks are embedded and added under request-document; the question is embedded with the same model and dimension contract. Search computes exact cosine similarity, filters by that document ID, sorts by score with chunk index as a tie-breaker, and takes at most three hits. It is a small deterministic ranking mechanism, not a learned relevance guarantee.

var query = ValidateVector(await embeddings.EmbedAsync(question, cancellationToken));
var hits = index.Search("request-document", query, take: 3);
var context = Grounding.BuildContext(hits);
if (context.Length > 14000) throw new ArgumentException("Grounding context exceeds the tutorial limit.");
cancellationToken.ThrowIfCancellationRequested();
string answer;
try { answer = await answers.AnswerGroundedAsync(question, context, cancellationToken); }
catch (InvalidDataException)
{
    // This boundary is provider generation; PDF extraction errors above remain document errors.
    throw new AiProviderException(null);
}

The excerpt is selected from PdfQuestionAnswering.cs; its vector-validation helper and surrounding extraction remain in that file. Because every request owns a new index and only its uploaded document enters it, the fixed document ID cannot retrieve another request’s vectors. It is not a tenant identity. There is no authenticated document catalog, cross-request search or persistent access-control system.

In a production document service, ownership must be derived from authenticated membership and applied before retrieval. Searching globally and filtering afterwards can leak data and discard relevant authorized results. The diagram below illustrates that future boundary; the sample does not implement the depicted tenant system. Semantic retrieval may also miss exact identifiers or choose passages that do not answer the question. Those weaknesses need an evaluation corpus, not an assumed score threshold.

User authorization precedes a tenant-filtered search and bounded context construction.
Production authorization illustration, not an implemented tenant service. The sample searches only its request-local uploaded document.

Build grounded answers with verifiable citations

Grounding assigns identifiers by retrieved-hit order. The highest-ranked hit becomes [1], the next [2], regardless of their original page order. BuildContext includes each ID, document ID, physical page and excerpt. The generated answer uses the bracketed numbers assigned to this request’s retrieved sources.

public static string BuildContext(IReadOnlyList<SearchHit> hits) => string.Join("\n\n", hits.Select((hit, index) =>
    $"[{index + 1}] Document {hit.Chunk.DocumentId}, page {hit.Chunk.Page}\n{hit.Chunk.Text}"));

If the synthetic cancellation chunk ranks first, its context header is [1] Document request-document, page 1, and the returned source object maps ID 1 to PDF page 1 and its actual excerpt. An answer fixture such as Cancellation stops operations [1]. demonstrates the mapping only. It is not a recorded model response or evidence that a live system retrieved that passage correctly.

The shared Responses adapter instructs generation to use only the supplied excerpts, treats document text as untrusted data, and asks for source IDs. The service rejects empty/whitespace answers and citations outside the retrieved ID range. It constructs source objects locally rather than trusting model-generated links or page numbers. These instructions do not guarantee resistance to prompt injection or turn document text into authorization.

The identifier check uses a regular expression for numeric bracketed citations. With no matching citations, it returns true. Consequently, a nonblank “insufficient evidence” answer can be returned without citations, but so can an unsupported uncited assertion. Tests deliberately demonstrate both. The checker does not distinguish them semantically, require every claim to have a citation, or prove that a cited passage entails a statement. Inspect the excerpts and original PDF before relying on the answer.

Evaluate retrieval and generation separately

Verification follows separate boundaries: PDF extraction, chunk provenance and limits, provider-response validation, vector compatibility, retrieval order, citation ID checking and HTTP error mapping. Tests use synthetic PDF bytes, fixed vectors, fake HTTP responses and fake grounded answers. This allows a precise failure to be reproduced without paying for a model request or calling a fabricated response a live success.

For example, a provider response with an empty data array is a provider failure even though the uploaded PDF is valid. A query vector of the wrong dimension is also a provider failure. A PDF that produces more than 20 chunks is a document-limit error before any embedding request is needed. These distinctions are exercised separately, including safe 502 mapping through the real embedding adapter and endpoint.

A semantic evaluation would need authorized documents, expected source pages, answerable and unanswerable questions, and review of the generated claims. It should track extraction, model settings and retrieval configuration. No accuracy percentage, live retrieval quality, OCR quality or production benchmark was measured here. A correct ID and a green offline suite cannot supply that evidence.

Verification results

In the response-size follow-up verified on November 2, 2026, all four Release projects built with zero warnings or errors. All 186 shared C# tests passed, including the separately selected 62-test PDF/RAG subset, and all 45 Node tests passed. The new endpoint regression uses a valid PDF, successful fake embedding responses and the real Responses adapter with an oversized provider body: it returned 400 before the fix and safe 502 afterwards. A second regression confirms cancellation during answer generation. The conversion applies only at the generation boundary; document errors still return 400 and normal JSON/SSE behavior is unchanged. The rebuilt shared ZIP was extracted separately: locked restore and all four Release builds succeeded with zero warnings or errors, and the extracted package passed all 186 C# and 45 JavaScript tests. No live OpenAI request or real-browser test was performed.

For the initial PDF/RAG package revision verified on November 2, 2026, all four projects built in Release with zero warnings or errors. The full shared suite passed 184 C# tests, with none failed or skipped; a separately selected PDF/RAG subset passed 60 tests included in that total. All 45 JavaScript/Node tests also passed. The earlier 143/58 SSE results belong to the preceding package revision. The initial focused regressions recorded 23 failures and 20 passes before fixes; an additional changing-dimension regression also failed before its guard. These are local fixtures, not live provider results.

No live OpenAI embedding or generation call, real-browser form interaction, or semantic retrieval/answer evaluation was performed. The tests establish local limits, parsing, mapping and cancellation behavior; they do not establish account access, retrieval recall or generated-answer quality. The final shared ZIP was extracted into a separate directory: locked restore and all four Release builds succeeded with zero warnings or errors; all 184 C# tests and all 45 JavaScript tests passed there. The earlier 143 shared C# tests and selected 58 SSE tests remain results of the preceding SSE revision, not extra tests added to this count.

Evaluation compares expected source pages to retrieved chunks and supported generated claims.
Separate extraction, retrieval, and answer-quality failures in the evaluation record.

Operate document versions and deletion correctly

The request-local design has no index versions to activate or background deletion job to run. File bytes, chunks and vectors are held in memory for the request; the sample has no durable upload folder or shared vector database. Cancellation releases local work cooperatively, but does not undo information already sent to a provider or guarantee that billing stops immediately.

A future persistent service would need document ownership, embedding-model/version metadata, replacement-index activation, retention and deletion across storage, backups and caches. A source link would need authorization when opened as well as when an answer is generated. These are production design requirements, not features established by the supplied example or the diagrams.

Operational logs should avoid raw documents, questions and secret values unless there is an explicit data policy. Provider retention and handling must be reviewed for the account and API features in use. The absence of a local upload directory says nothing about external processing. Public deployment also requires real authentication, per-user quotas, parser isolation, TLS/proxy review and separate browser testing.

Key takeaways

  • Each request extracts, embeds and searches its own temporary document index.
  • Chunk and provider budgets fail explicitly before silent truncation or unlimited accumulation.
  • Provider corruption is a safe provider error, distinct from a bad PDF.
  • Numbered source IDs establish a mapping, not semantic support or tenant authorization.

Frequently asked questions

Does this sample support scanned PDFs?

No. It extracts embedded words through PdfPig and reports when no text is available. OCR and layout reconstruction would need separate tools and quality checks; the parser is not a hostile-file sandbox.

Is a vector database required?

The bounded tutorial uses a per-request in-memory index. Persistence would be useful for a durable multi-document library, but would introduce access control, lifecycle and deletion work that the current sample does not implement.

Can the answer be streamed?

The PDF endpoint returns one JSON object after generation and citation checking. The separate SSE streaming guide covers the text stream. Adding a PDF stream would need to preserve source mapping and distinguish partial output from a completed, checked answer.

Conclusion and next step

The runnable example now provides a bounded upload-to-answer path with physical page excerpts, request-local retrieval and explicit provider-error handling. Its tests make parsing and mapping failures observable without claiming live RAG quality. For the underlying JSON request lifecycle, use the bounded API integration pattern; before a public document service, validate the live models, access policy, extraction quality and semantic evidence separately.

KEEP EXPLORING

Sources & further reading

  1. Build a .NET AI vector search app
  2. OpenAI vector embeddings
  3. PdfPig documentation
Take the code with you.Download the example project, setup guide, and tests.

Share this article

Bora Yılmaz
ABOUT THE AUTHOR

Bora Yılmaz

Backend developer with a Computer Engineering degree and a professional software development background dating to 2015. His work spans C#, .NET, APIs, enterprise integrations, and software architecture.

FIND YOUR NEXT SOLUTION

What are you building?

Explore practical tutorials, developer workflows, and architecture guides.

AI App Development ↗AI Developer Tools ↗AI Agents ↗
YOUR PRIVACY, YOUR CHOICE