Erik Mostert

DocQuery: An AI processing pipeline for documents, with RAG and vector search

One .NET solution that carries a PDF all the way from upload to a cited, grounded answer. Built to exercise the concepts the AI-200 exam covers in a real system rather than in isolated tutorials.

AI-200

No hosted demo — the pipeline needs a real Azure OpenAI deployment. Clone it and dotnet run --project src/DocQuery.AppHost starts everything else on emulators.

What it is for

Exam prep material tends to teach retrieval-augmented generation one piece at a time. A chunking snippet here, an embeddings call there, a vector query in a notebook. DocQuery puts the whole path in a single solution you can clone and run. You upload a PDF in the browser, watch it move through chunking and embedding, then ask a question and read the answer with citations pointing back to the pages it came from. Because it is a real system rather than a sample, it also has to answer the questions a sample never does, such as what happens when Azure OpenAI is briefly unavailable, how a message gets published without ever drifting out of step with the database, and how the whole thing reaches a cluster.

What happens to a document

One PDF, five stages, each in its own host — so a slow or failing step never blocks the upload that started it.

  1. Upload

    Command API

    The React UI posts a PDF as multipart form data. The API streams it straight into Azure Blob Storage, records the document in PostgreSQL together with an outbox row, and returns 202 Accepted with a document id. The work happens after the response, not during it.

  2. Publish

    Outbox relay worker

    A separate worker polls the outbox table (FOR UPDATE SKIP LOCKED, so several instances can share the work) and publishes each row to a Service Bus topic. The message is only ever written in the same transaction as the document, so a crash mid-upload cannot leave the two out of step.

  3. Chunk

    Chunking worker

    Consumes DocumentUploaded events, extracts text page by page with PdfPig in reading order, and splits it into chunks under a token budget measured with the same o200k_base encoding Azure OpenAI uses. Paragraphs and lists are kept whole where they fit, sentences are the next boundary, and raw token windows are the last resort. Chunks never cross a page, so a citation can always name one.

  4. Embed

    Embedding worker

    Consumes DocumentChunked events and embeds every chunk with Azure OpenAI text-embedding-3-small (1536 dimensions) through Microsoft.Extensions.AI, batching chunks per call. The vectors go into a pgvector column with an HNSW cosine index, and the document is marked Embedded.

  5. Ask

    Query API

    Embeds the question with the same model that embedded the chunks, pulls the nearest passages out of pgvector by cosine distance, and asks the chat model to answer from those passages only. The response carries numbered citations with document, file name, page, position, excerpt and score, so every claim in the answer is traceable back to a page.

What it demonstrates

The RAG pipeline itself

  • Chunking is a design decision, not a string splitChunk size is bounded by real tokens rather than characters, structure is preserved where it fits, and the trade-offs and known limits of the heuristic are written down in an ADR instead of left implicit.
  • One embedding model on both sidesQuestions are embedded with the same deployment as the chunks. A mismatch there is the classic way a vector search quietly starts returning nonsense. This way, the model name lives in one configuration section used by both the worker and the query API.
  • Vector search in the database, not in memoryEmbeddings sit in a pgvector column with an HNSW cosine index next to the rows they describe, so retrieval is a query rather than a load-everything-and-compare loop.
  • Grounding and citationsThe chat model is told to answer from the retrieved passages only, and the answer's [n] markers map to citations naming the page they came from. With nothing embedded yet, the API says so and never calls the chat model at all.

Azure services are wired the way the exams describe

  • Blob Storage for the documentsUploads are streamed to a container rather than buffered in the API, and the storage calls run behind a resilience pipeline so a transient failure is a retry rather than a 500.
  • Service Bus topic with filtered subscriptionsOne topic carries every document event. Each worker's subscription uses a correlation filter on the message Subject so it only ever receives its own message type. Consumers dead-letter permanent failures with a reason and abandon everything else for redelivery.
  • Azure OpenAI through Microsoft.Extensions.AIEmbeddings and chat both go through the abstraction rather than a raw SDK client, with deployment names bound to validated options, so changing a deployment is configuration, not a code change.
  • Key Vault and workload identityLocally the OpenAI connection string can come from a vault through your own az login identity. In the cluster the pods use workload identity to reach Blob Storage, Service Bus and Azure OpenAI, and read only the database connection string from Key Vault.

The engineering around the AI parts

  • Clean Architecture with a CQRS splitWrite and read are separate hosts, with a command API that accepts uploads and a query API that answers questions. Domain, application, contracts and infrastructure are separate projects, each with its own test project.
  • Transactional outboxDomain events are raised by the aggregates and published through an outbox table rather than sent inline, which is what makes the message and the database row impossible to get out of sync.
  • Resilience on every outbound dependencyBlob Storage, Service Bus and Azure OpenAI each have their own Polly pipeline. They retry with exponential backoff and jitter, circuit breaker, per-attempt timeout, each configured under its own options section. An open circuit surfaces as a 503 with Retry-After, not an unhandled error.
  • Options validated at startupEvery configuration section is bound to a typed options class with range annotations and validated when the host starts, so a bad value stops the process with a clear message instead of failing the first request that happens to hit it.
  • Tests against real infrastructureInfrastructure tests run against PostgreSQL, Azurite and the Service Bus emulator in Testcontainers, and are reported as skipped rather than failed when Docker is not running. The React UI is tested with Vitest and Testing Library against a fake API client.
  • One command to run the whole thingAspire starts the emulators, the database, both APIs, all three workers and the Vite dev server together, with a dashboard for logs, traces and metrics. Deployment is Bicep for the Azure resources, Kustomize manifests for the cluster, and a GitHub Actions workflow that builds and applies them.
  • Decisions written downArchitecture decision records explain why each of the above is the way it is, including the parts that were deliberately kept simple.

What it deliberately does not do

  • No authentication or authorization. No endpoint is protected, and there is no per-user document access. A production deployment would sit behind Microsoft Entra ID or another identity provider.
  • Secrets management and network isolation are not hardened, and the rate and request-size limits are demo-friendly defaults rather than tuned per environment.
  • Azure OpenAI has no emulator, so the embedding worker and query API need a real deployment. Everything else runs locally against emulators.
  • The user interface is a simple React app with no styling or accessibility work. It is not a production-ready UI, but it is enough to exercise the pipeline end to end.