rag-doc-qa
Ask questions about your own documents in plain English. Answers are built only from passages retrieved out of the documents you upload, and every answer cites the passage it came from. When the answer is not in your documents, it says so instead of guessing.
Live: https://happy-mushroom-0162ee100.6.azurestaticapps.net (behind an access key)
Stack: React 19 + TypeScript + Vite 8 · ASP.NET Core (net10.0) · Azure AI Search · Azure SQL · Gemini API
How it works
Upload:
PDF/txt -> PdfPig extracts text -> 350-word chunks (50-word overlap)
-> Gemini embeds each chunk (768 dims, RETRIEVAL_DOCUMENT)
-> vectors to Azure AI Search, metadata to Azure SQL
Ask:
question -> Gemini embeds it (768 dims, RETRIEVAL_QUERY)
-> vector search returns top 5 passages
-> Gemini answers using only those passages, with [1][2] citations
Why it is built this way
350-word chunks with 50-word overlap. A passage has to be small enough that its embedding means one thing, and large enough to answer a question on its own. The overlap stops a sentence that straddles a boundary from being cut in half, leaving neither piece able to answer.
768 dimensions, not the 3072 default. The Azure AI Search free tier caps at
50 MB. Gemini only auto-normalises embeddings at full size, so GeminiClient
scales to unit length manually — without that, cosine similarity is wrong.
SearchSetup.Dimensions and the embed request must always match.
Asymmetric task types. RETRIEVAL_DOCUMENT when storing, RETRIEVAL_QUERY
when searching. A question and its answer are not semantically similar, so using
the matching type on each side measurably improves retrieval.
PreFilter, not PostFilter. Post-filtering takes the global top 5 and then discards other documents hits, so a filtered question can come back with two passages or none. Pre-filtering narrows to the chosen document first, then runs KNN over what is left.
Two stores, written in a fixed order. Vectors live in Azure AI Search, metadata in Azure SQL. Upload embeds first (the slow, quota-consuming step, so failing there writes nothing), then inserts the SQL row, then indexes the vectors — rolling the row back if indexing fails. Vectors without a SQL row are invisible in the filter dropdown and quietly pollute retrieval, so no ordering is allowed to produce them.
PDF text needs rebuilding. PdfPig Page.Text concatenates glyph runs with
no separators, fusing words and dropping line breaks entirely. Grouping words by
rounded Y position (PDF coordinates start bottom-left) and ordering each line by
X recovers the original lines. This affects chunk boundaries and every
embedding, not just readability.
Running it locally
Needs .NET 10 SDK and Node 20+.
Five secrets:
cd backend/RagDocQa.Api
dotnet user-secrets set "Llm:ApiKey" "..."
dotnet user-secrets set "Search:Endpoint" "https://<name>.search.windows.net"
dotnet user-secrets set "Search:ApiKey" "..."
dotnet user-secrets set "ConnectionStrings:Default" "..."
dotnet user-secrets set "Auth:ApiKey" "<any long random string>"
The SQL table:
CREATE TABLE Documents (
Id UNIQUEIDENTIFIER PRIMARY KEY,
FileName NVARCHAR(400) NOT NULL,
ChunkCount INT NOT NULL,
UploadedUtc DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
);
The search index is created on startup if it does not exist.
Terminal 1:
cd backend/RagDocQa.Api
dotnet run --launch-profile https
Terminal 2:
cd frontend/web
npm install
npm run dev
Tests (chunker and PDF line rebuilding):
dotnet test RagDocQa.slnx
Evaluation
eval/run.mjs measures retrieval and grounding against a running API. It
uploads eval/handbook.txt (a fictional 2,800-word staff handbook, 10
passages), asks 15 paraphrased questions scoped to it, checks 3 questions the
handbook cannot answer, then deletes the document:
API_KEY=... node eval/run.mjs # live API; API_URL=... for local
- Retrieval: where the passage holding the answer ranks in the top 5 (hit@1, hit@5, MRR). Questions avoid the handbook's wording, so matches have to come from meaning.
- Answers: whether the answer contains the expected fact.
- Refusals: whether unanswerable questions get "That is not covered in the uploaded documents."
Run it before and after changing chunk size, overlap, top-k or the prompt. It spends about 40 Gemini calls, 18 of them answers, and takes around three minutes, paced to stay under the per-IP ask limit. On the free tier that is almost the whole day's answer quota (see Known limits), so run it on a paid key or on a day the site is not in use.
Deployment
Every push to main runs CI, and if lint, build, format and tests pass, deploys
both halves:
| Part | Azure resource | How |
|---|---|---|
| API | App Service ragdocqa-api (Linux, F1) |
dotnet publish + publish profile |
| Frontend | Static Web App ragdocqa-web (Free) |
npm run build with VITE_API_URL baked in, then upload dist/ |
Both live in resource group rag-doc-qa. CI needs two repository secrets:
AZURE_WEBAPP_PUBLISH_PROFILE az webapp deployment list-publishing-profiles -g rag-doc-qa -n ragdocqa-api --xml
AZURE_STATIC_WEB_APPS_API_TOKEN az staticwebapp secrets list -g rag-doc-qa -n ragdocqa-web --query properties.apiKey -o tsv
The API's secrets are App Service application settings, with __ for nesting:
Llm__ApiKey, Search__Endpoint, Search__ApiKey, ConnectionStrings__Default,
Auth__ApiKey, plus Cors__AllowedOrigin set to the Static Web App URL.
Gotchas hit on the first deploy:
- Basic publishing auth must be on for the publish profile to work
(
basicPublishingCredentialsPolicies/scm,allow=true). A profile fetched while it was off has a blank password and fails with "Publish profile is invalid" — re-fetch it after turning it on. - The API will not start without
Auth__ApiKeyand returns 503 until it is set. To rotate the key, set a new value; the frontend clears the old key on its first 401 and asks again. staticwebapp.config.jsonrewrites unknown paths toindex.html, so refreshing/askdoes not 404.- The host appears in
index.html(og tags),public/sitemap.xml,public/robots.txtandsrc/seo/siteUrl.ts. Change all four if the domain changes.
API
Every route requires an X-Api-Key header matching the configured
Auth:ApiKey secret. A wrong or missing key gets a 401 before anything else
runs — before CORS-exempt preflight, but after rate limiting, so guessing the
key is also throttled. The frontend asks for the key once and keeps it in
localStorage.
| Method | Route | Notes |
|---|---|---|
| POST | /api/documents |
multipart upload, PDF or txt, 20 MB cap |
| GET | /api/documents |
list of uploaded documents |
| DELETE | /api/documents/{id} |
removes vectors and the SQL row |
| POST | /api/ask |
{ question, documentId? } |
Known limits
- One shared key, not per-user accounts. Anyone with the key can upload, list every filename, and spend the Gemini quota — it stops strangers, not a trusted-but-nosy holder of the link. Fine for a single-owner demo; would need real accounts to support distinct users.
- No deletion of uploaded text beyond the delete endpoint. Document text is stored recoverably in the search index and sent to Google's API for embedding and answering.
- Two-column PDFs interleave. Words sharing a Y position merge into one
line. Fine for prose and most CVs, wrong for academic papers.
DocstrumBoundingBoxesships with PdfPig if this becomes a real problem. - Scanned PDFs need OCR and are rejected as having no readable text.
- Free-tier Gemini quota is tiny for answers. The chat model allows 20
generateContentcalls per day per project, so the whole site can answer about 20 questions a day. Embeddings have a separate, larger quota (one call per chunk on upload, one per question). Quotas reset at midnight Pacific time. A 429 from Gemini is reported as "daily quota used up", though Gemini also returns 429 for per-minute limits. The API's ownAskPerDaycap (300) never comes into play on the free tier. SearchSetupusesCreateOrUpdateIndexAsync. Deleting the index in the Azure portal and restarting does not reliably reset it — if the delete has not propagated, the call finds the index still present and silently no-ops, leaving every old vector in place. Delete documents from the Upload page (orDELETE /api/documents/{id}) instead.- Chunking is O(n^2) in word count. Irrelevant at this scale.
Log in or sign up for Devpost to join the conversation.