A demo that shows how to build a small Retrieval-Augmented Generation (RAG) assistant with modern .NET, and how to build it with an AI coding agent under explicit project rules.
Users import documents (Markdown, plain text, PDF). The system chunks them, embeds the chunks into PostgreSQL + pgvector, and a chat answers questions from the retrieved passages, in English or French. Every answer shows the passages it cites, and the app says so when the documents do not contain an answer.
Status: first release (spec 020). Documents, retrieval (vector, optional hybrid search and reranking), grounded chat with follow-ups, citations that show when their source changed, feedback, maintainers' diagnostics and an evaluation harness whose runs show in the admin area. See the roadmap.
This repository is a teaching artifact before it is a product. It aims to show:
- A RAG pipeline you can read end to end: ingestion, chunking, embeddings, vector and keyword search, grounded and cited answers, honest refusals. It uses the
Microsoft.Extensions.AIabstractions, with no provider SDK in the business code and no agent framework hiding the flow. - A modular monolith in .NET 10: independent modules with their own schema, DbContext and migrations, vertical slices inside each module, and communication only through small contracts.
- .NET Aspire 13.6 as the development orchestrator: one command starts PostgreSQL, Keycloak, Ollama, migrations, the API and the web app, with the dashboard for logs, traces and metrics.
- AI-assisted development done deliberately: specifications before code, architecture decisions recorded as ADRs, rules in
CLAUDE.md, and tests, reviews and CI as the safety net. The code is written with Claude and the dotnet-claude-kit skills, and the process is part of what the repo demonstrates. - Trust in the interface: streaming answers, visible citations, processing and failure states, a refusal path that needs no model, and measured quality (an evaluation harness with a golden dataset).
It is intentionally small and minimal in dependencies: every library must earn its place and has an ADR when it is not part of .NET, ASP.NET Core, EF Core or PostgreSQL.
| Area | Choice |
|---|---|
| Platform | .NET 10 (LTS), C# 14, ASP.NET Core Minimal APIs |
| Orchestration (dev) | .NET Aspire 13.6 (AppHost, ServiceDefaults, dashboard, Aspire CLI) |
| Data | PostgreSQL 18 + pgvector (HNSW, cosine distance), full-text search, EF Core + Npgsql, one schema per module |
| AI | Microsoft.Extensions.AI (IChatClient, IEmbeddingGenerator); local Ollama by default, an opt-in hosted OpenAI-compatible chat model, a deterministic demo chat client |
| Reranking (optional) | Text Embeddings Inference with BAAI/bge-reranker-v2-m3 |
| Streaming | Server-Sent Events (TypedResults.ServerSentEvents) |
| UI | Blazor Web App (WebAssembly) + Fluent UI Blazor, behind a BFF (YARP) |
| Identity | Keycloak (dev container), OIDC code + PKCE, JWT bearer, three roles |
| PdfPig (text layer only, no OCR) | |
| Tests | TUnit on Microsoft.Testing.Platform, WebApplicationFactory, Testcontainers, bUnit, Playwright smoke tests |
| Evaluation | Microsoft.Extensions.AI.Evaluation, a console harness, nightly in CI |
The reasoning behind each choice is in the ADRs.
Browser ──► RagAssistant.Web (Blazor + BFF: cookie session, YARP) ──► RagAssistant.Api
├─ Documents module (schema: documents)
├─ Chat module (schema: chat)
└─ Evaluation module (schema: evaluation)
Aspire AppHost: postgres (pgvector) · pgweb · keycloak · ollama · migrations · api · web [· reranker]
- Modular monolith: one deployable API, composed of modules that never reference each other. Cross-module capabilities are interfaces and DTOs in
RagAssistant.SharedKernel/Contracts. - Vertical slices: each feature is one file with its command or query, handler, response and validator.
- Background ingestion:
BackgroundService+Channel<T>, with the stage state persisted so unfinished runs resume after a restart. - Versioned documents: stable passage IDs, only the active indexed version is searchable, and each answer stores a snapshot of the passages it cited, so old answers keep their sources and say when a newer version changed them.
| Module | Responsibility |
|---|---|
Documents |
Import, extraction, chunking, embedding, versions, private and shared documents, retrieval (vector, hybrid, rerank), suggested questions |
Chat |
Conversations, follow-up rewriting, prompt building, streamed cited answers, feedback, request traces, export |
Evaluation |
Evaluation runs uploaded by the harness, for the admin area |
| Document | What it answers |
|---|---|
| Product overview | What is being built and why, principles, user journeys |
| Roadmap | Phases, order of delivery, what is done |
| Specs | One specification per feature, with acceptance criteria |
| ADRs | Stack, identity, model provider, versioning, kernel libraries, PDF import, browser tests, caching, visibility, reranker |
| CLAUDE.md | The rules the AI assistant follows in this repo |
Every feature follows the same loop: spec → plan → tests first → implement → verify → review → CI. Specs and ADRs are committed before or with the code, so the history reads as a design log.
- .NET SDK 10
- Docker (running), with about 10 GB free for images and models
- Optional: the Aspire CLI 13.6.1 (
aspire start,logs,stop);dotnet runworks without it - Optional, to work on the project the way it was built: Claude Code with the dotnet-claude-kit plugin
The default model provider is a local Ollama container started by the AppHost, so no API key is needed; the first run downloads the models (bge-m3 for embeddings, ministral-3:8b for answers). See ADR 0003.
GPU note. The AppHost's Ollama container only uses an NVIDIA GPU. On a machine without one (the reference laptop of spec 018 has an integrated Radeon the container cannot use) every model runs on the CPU: the first token of an answer then takes several seconds. For a quick demo, use the https-demo profile (no chat model) or a smaller model:
dotnet run --project src/RagAssistant.AppHost -- --Parameters:chat-model=llama3.2:3bdotnet run --project src/RagAssistant.AppHostOpen the dashboard via the login URL printed in the console, then the web link (https://localhost:7053).
| Launch profile | What it starts | Use it for |
|---|---|---|
https (default) |
Everything, with the Ollama chat model | The real thing |
https-demo |
Same, but answers come from a deterministic extractive demo client; no chat model is pulled, and no suggested questions are generated | A fast demo on any machine, the UI smoke tests |
https-rerank |
Same as https, plus the TEI reranker container (spec 019, ADR 0010); hybrid search and reranking are switched on |
Measuring retrieval; on a CPU raise the rerank timeout (see CLAUDE.md) |
dotnet run --project src/RagAssistant.AppHost --launch-profile https-demoThe AppHost starts:
| Resource | What | Where |
|---|---|---|
postgres |
PostgreSQL 18 + pgvector 0.8.7 (pgvector/pgvector:0.8.7-pg18-trixie) |
localhost:5433 |
pgweb |
Database browser | link in the dashboard |
ollama |
Local model server, models kept in the ragassistant-ollama-models volume |
— |
migrations |
Applies the EF Core migrations of every module, then exits | — |
keycloak |
Identity provider (dev only), realm imported from src/RagAssistant.AppHost/Realms/ragassistant-realm.json |
https://localhost:8180 |
api |
The API (starts after migrations and Keycloak) | https://localhost:7043 |
web |
The UI and its backend for frontend: sign-in, session cookie, /api/** forwarded to the API |
https://localhost:7053 |
reranker |
Only with https-rerank |
— |
The postgres and pgweb containers are persistent: they keep running after the AppHost stops, and data lives in the Docker volume ragassistant-postgres-data. Original PDF files live in .data/documents (git-ignored); back them up with the database.
User postgres; the password is generated by Aspire on first run and stored in the AppHost user secrets:
dotnet user-secrets list --project src/RagAssistant.AppHostIf that secret is lost or changed, the existing volume still expects the old password and the app can't connect. Either restore the secret, or reset the dev database (this deletes all local data):
docker rm -f $(docker ps -aq --filter "publish=5433")docker volume rm ragassistant-postgres-dataKeycloak has no data volume: the realm file is re-imported on every run, so changes made in the Keycloak admin console are lost. Edit the realm file instead.
Dev-only users (the realm file is never deployed):
| Username | Password | Role | What they can do |
|---|---|---|---|
seeker |
seeker-dev |
Knowledge Seeker | Ask questions, browse shared documents |
owner |
owner-dev |
Content Owner | Also import documents and manage the ones they imported (private or shared) |
maintainer |
maintainer-dev |
System Maintainer | Also the Admin page: requests, ingestion failures, feedback, evaluation runs, configuration |
Sign out first to switch users: sign-out also ends the Keycloak session. The UI is English only (the documents and answers may be French) and has a System / Light / Dark theme choice in the menu on the user name.
The browser only calls the Web, which forwards /api/** to the API with your access token (tokens never reach the browser). To call the API directly, get a token with the public dev client ragassistant-dev:
curl -sk -X POST https://localhost:8180/realms/ragassistant/protocol/openid-connect/token -d grant_type=password -d client_id=ragassistant-dev -d username=maintainer -d password=maintainer-devThe evaluation harness imports 17 sample documents (9 English, 8 French; Markdown and PDF) from samples/corpus, tagged sample. With the app running:
dotnet run --project tools/RagAssistant.Evaluation -- seedRunning it again only updates what changed.
With the sample corpus loaded, sign in as owner (about ten minutes):
- Home. The page counts the documents per language, lists the latest ones and (outside demo mode) suggests questions the documents answer. Click one: a new conversation opens and asks it.
- Ask in English. How long do I have to submit an expense claim after a trip? The answer streams with
[1]markers; open a citation to see the passage it comes from, and Activity to see what retrieval found. - Follow up. What is the hotel limit per night? then And in London?: the second question is rewritten into a standalone one (shown as "Searched for").
- Ask in French. Combien de jours de congés payés un salarié acquiert-il par mois ? The answer is in French, from the French policy.
- Cross-language. How many days of paid leave does an employee earn per month?: an English answer from the French document.
- Refusal. What is the capital of Australia? The app says the documents do not answer it, without calling the model.
- A changed source. Open the cited document, upload a new version or reprocess it, and come back to the conversation: the citation now says Newer version or Changed in newer version, and its preview offers the current version.
- Keep it. Rename the conversation, search for it in the list (accents and case do not matter), Export as Markdown, or Copy answer with sources.
- Maintainer. Sign in as
maintainer, open Admin: the request inspector shows each retrieval stage; Evaluation lists the harness runs.
dotnet test --solution RagAssistant.slnxThe Web tests (tests/RagAssistant.Web.Tests) need no Docker: bUnit component tests and the BFF against an in-process stub API. Integration tests start their own PostgreSQL container through Testcontainers, so Docker must be running. No test calls a real model.
Playwright tests in tests/RagAssistant.Ui.Smoke drive a running app and are not part of the solution. Start the app in demo mode, install Chromium once, then run them:
dotnet run -c Release --project src/RagAssistant.AppHost --launch-profile https-demopowershell -NoProfile -File tests/RagAssistant.Ui.Smoke/bin/Release/net10.0/playwright.ps1 install chromiumdotnet test --project tests/RagAssistant.Ui.Smoke -c Releasetools/RagAssistant.Evaluation measures retrieval and answers against the golden questions in samples/golden (spec 018). With the app running:
dotnet run --project tools/RagAssistant.Evaluation -- runIt writes report.json and report.md to .data/evaluation/<run>/ and uploads the report to the app, where maintainers see it under Admin › Evaluation (--no-upload keeps it local; upload <report> sends an older one). compare, sweep and judge work on reports offline; see CLAUDE.md for every command. The same run happens nightly in CI with a small model (.github/workflows/evaluation.yml).