Upload documents, ask questions, get answers grounded in what you uploaded — with the retrieved passages and their similarity scores shown next to every answer, so you can check the model's work.
React · FastAPI · ChromaDB · sentence-transformers · Claude
Browser FastAPI backend Claude
│ │ │
├── POST /upload ────────────▶│ extract text (pypdf for PDFs) │
│ │ chunk: 400 words, 20% overlap │
│ │ embed: all-MiniLM-L6-v2 │
│ │ store: ChromaDB (cosine) │
│ │ │
├── POST /query ─────────────▶│ embed query │
│ │ retrieve top-3 by similarity │
│ │ build grounded prompt ────────▶│
│◀── answer + sources + ──────│◀───────────────────────────────┤
retrieved chunks
The browser never calls the Claude API directly — the API key stays on the server. Retrieval is semantic (embedding similarity), not keyword matching, so a question phrased in different words than the source still finds the right passage.
Retrieval is the half of RAG that decides whether a correct answer is even possible: if the right chunk never reaches the model, no prompt recovers it. So it's measured on its own, against a golden set of questions deliberately worded to share no vocabulary with their answers.
python eval/eval_retrieval.pyDOCIQ retrieval eval: 10 questions over 9 chunks
[PASS] How do plants of the electrical kind turn daylight into po rank 1
[PASS] At what point does a turbine stop spinning to protect itse rank 1
[near] Which technology handles the evening demand spike once the rank 2
[PASS] What stores energy by lifting water to a higher elevation? rank 1
[MISS] Which renewable source keeps producing regardless of the w not in top 3
[PASS] How much does panel output fall off each year? rank 1
[PASS] Why is putting turbines out at sea more productive? rank 1
[PASS] How many charge cycles before capacity drops to eighty per rank 1
[PASS] What fraction of energy survives a round trip through the rank 1
[PASS] What makes deep underground heat expensive to exploit? rank 1
hit@1 80% hit@3 90% MRR 0.850
hit@1 is how often the top result is correct, hit@3 whether it reaches the
model at all, and MRR the mean reciprocal rank. The script exits non-zero below
a configurable hit@3 threshold, so a regression in chunking or embedding
fails CI instead of quietly degrading answers.
The one miss is instructive and left in deliberately. "Which renewable source keeps producing regardless of the weather?" should match the geothermal passage, which says output is "continuous and unaffected by weather" — but the passage names wind and solar explicitly, so the embedding pulls toward those documents instead. That's a chunking problem, not a model problem: the distinguishing claim and the distracting comparison sit in the same chunk. It's exactly the kind of failure the eval exists to surface.
Tune and re-measure:
python eval/eval_retrieval.py --chunk-size 100 --overlap 10 --top-k 5render.yaml defines both services as a Render Blueprint — connect the repo on
Render, and it provisions the
FastAPI backend and the static frontend build together. The only manual step
is entering ANTHROPIC_API_KEY when prompted; it's deliberately excluded from
the blueprint so it's never committed.
Free-tier web services have an ephemeral filesystem — indexed documents reset after 15 minutes idle or on redeploy. That's expected for a public demo where each visitor uploads their own file in one sitting; a paid plan with an attached disk removes the limit if persistence across idle periods matters.
Requires Python 3.11+, Node 20+, and an Anthropic API key.
cp .env.example .env # then add your key to .envdocker compose up --buildFrontend at http://localhost:5173, API at http://localhost:8000.
# Backend
cd backend
python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
uvicorn main:app --reload --port 8000
# Frontend (separate terminal)
cd frontend
npm install
npm run devThe first backend start downloads the embedding model (~90 MB).
Interactive docs at http://localhost:8000/docs.
| Method | Endpoint | Purpose |
|---|---|---|
GET |
/health |
Liveness plus indexed-document count |
POST |
/upload |
Ingest a .txt, .md, or .pdf (max 10 MB) |
GET |
/documents |
List indexed documents |
DELETE |
/documents/{doc_id} |
Remove a document and its chunks |
POST |
/query |
Ask a question; optional doc_ids filter |
curl -F "file=@notes.pdf" http://localhost:8000/upload
curl -X POST http://localhost:8000/query \
-H "Content-Type: application/json" \
-d '{"query": "What were the Q3 findings?"}'{
"answer": "Revenue grew 12% year over year, driven by...",
"sources": ["q3-report.pdf"],
"retrieved_chunks": [
{ "chunk": "Quarterly revenue grew by twelve...", "doc_name": "q3-report.pdf", "similarity": 0.87 }
]
}cd backend && pytestChunking and text extraction are tested in isolation. The API tests run real retrieval against a temporary ChromaDB store with only the Claude call stubbed, so retrieval behaviour is genuinely exercised rather than mocked away.
| Variable | Default | Purpose |
|---|---|---|
ANTHROPIC_API_KEY |
— | Required; the server won't start without it |
CHROMA_DIR |
./chroma_data |
Where vectors persist |
CORS_ORIGINS |
http://localhost:5173,http://localhost:3000 |
Comma-separated allowed origins |
VITE_API_BASE |
http://localhost:8000 |
Backend URL the frontend calls |
Chunk size — 400 words, 20% overlap. Large enough to keep a complete idea intact, small enough that a retrieved chunk is mostly signal. The overlap stops a sentence spanning a boundary from being lost to both neighbours.
Cosine over Euclidean distance. Sentence embeddings encode meaning in direction rather than magnitude, so angle is the metric that matters.
Thinking disabled on the generation call. Answering from supplied context is extraction, not reasoning — thinking tokens would add latency and cost without improving grounding.
Persistent Chroma, registry rebuilt on boot. Documents survive restarts; the in-process registry is reconstructed from stored metadata rather than being treated as the source of truth.
- Scanned/image-only PDFs are rejected rather than OCR'd.
- No authentication — intended for local and demo use, not multi-tenant deployment.
- The golden set is small (10 questions); it catches regressions, it doesn't establish absolute quality.
- Hybrid retrieval (BM25 + dense) and query rewriting
- Streaming responses via SSE
- Faithfulness scoring on the generation step, not just retrieval
MIT