A multi-user RAG (Retrieval-Augmented Generation) application for PDF documents. Built with a React frontend and a FastAPI backend, featuring accounts, persistent per-document conversations, open-source document extraction and hybrid sparse-dense search.
One account β many chats β exactly one document each. A chat session's id is its Pinecone namespace, so chat β document β namespace is 1:1. Neon/Postgres owns identity, ownership and conversation history; Pinecone holds only vectors.
- Accounts & persistent chats: JWT auth over bcrypt, DB-backed login lockout, and conversations that survive a restart β including their source citations.
- Session rehydration: the in-process retriever is a pure cache. On a miss it rebuilds from Neon + Pinecone in ~11s instead of re-ingesting the document (~180s), by persisting the fitted BM25 encoder and recomputing centroids from the index.
- Asynchronous ingestion: upload returns
202and processes in the background with a pollable status, because extraction runs for minutes on a real packet. - Open-Source Extraction: Leverages Docling for high-fidelity, structure-aware PDF parsing.
- Hybrid Search Engine: A single Pinecone sparse-dense index holding
gemini-embedding-2embeddings (768-dim) alongside BM25 sparse vectors, fused by a tunablealpha(0.0 = pure keyword β 1.0 = pure semantic). - Conversational follow-ups: a follow-up like "and when does it lock?" is condensed into a standalone question before retrieval, because retrieval runs before any LLM sees a prompt. History is read server-side from Neon.
- Answer Generation: gemini-2.5-flash with thinking capped at 2048 β on an ambiguous multi-candidate question it enumerates candidates with sources instead of guessing. Thinking tokens bill at the output rate, so the cap bounds the tail (dynamic permits 24,576) without touching the ~300-token median. Modal/Gemma-2-9B remains as an opt-in fallback (
USE_MODAL_LLM=1). - Two models, split on measured need: classification and per-page boundary detection run on gemini-2.5-flash-lite (closed-set label, yes/no answer β and boundary detection fires once per page, making it the volume driver of ingest cost). Answers and query rewriting stay on flash.
- Semantic Routing: Automatic query routing to specific document sections via embedding centroids β no extra LLM call.
Note on reranking: a cross-encoder reranking stage (BAAI/bge-reranker-base on Modal) was built and evaluated on 250 questions, then removed β it changed answer quality by a statistically indistinguishable amount while costing a 3Γ over-fetch and a GPU round-trip per query. It remains a reasonable optional addition under conditions this corpus does not meet. See Design FAQ Q2 for the measurements.
Six layers, read top to bottom. Each arrow is a hand-off between layers; the ingest and retrieval pipelines flow left-to-right within their own band and the bands stack one below the other, while the shared services (data, external AI) are reached once per layer rather than by every stage, so the flow stays legible. Observability is cross-cutting.
graph TD
User(["π€ User"])
subgraph CLIENT ["1 Β· Client layer β React / Vite"]
Land["π¬ Landing page"]
UI["π¬ Chat UI Β· upload gate Β· live ingest stepper Β· streamed answers"]
end
subgraph APP ["2 Β· Application layer β FastAPI (main.py)"]
Auth["π Auth Β· bcrypt Β· access + refresh JWT Β· lockout Β· per-IP rate-limit"]
REST["ποΈ Chat endpoints Β· POST /message β SSE Β· 202 async upload + polling"]
end
subgraph INGEST ["3 Β· Ingest pipeline β background task"]
direction LR
Ext["π Extract Β· Docling / PyMuPDF"] --> Split["π·οΈ Classify + split Β· flash-lite"] --> Chunk["βοΈ Chunk Β· tables atomic"] --> Emb["𧬠Embed Β· 768d"] --> Up["π€ BM25 fit + Pinecone upsert"]
end
subgraph DATA ["4 Β· Data layer β Neon Postgres + Pinecone"]
PG[("π Postgres Β· accounts Β· chats Β· messages Β· bm25_params")]
Pine[("π² Pinecone Β· one namespace per user")]
end
subgraph QUERY ["5 Β· Retrieval + answer layer"]
direction LR
RW["π Rewrite follow-up β standalone"] --> Hyb["π Hybrid query Β· Ξ±Β·dense + (1βΞ±)Β·sparse"] --> Ans["π€ gemini-2.5-flash Β· streamed, cited"]
end
subgraph EXT ["6 Β· External AI services"]
Gem["βοΈ Google Gemini Β· flash / flash-lite / embeddings"]
Mod["βοΈ Modal Β· Docling GPU worker (L4)"]
end
OBS["π Observability Β· cross-cutting<br>Langfuse (LLM) + Grafana (HTTP Β· metrics Β· dashboard)"]
User --> CLIENT
CLIENT -->|HTTP + JWT| APP
APP -->|identity Β· ownership| DATA
APP -->|upload| INGEST
INGEST -->|vectors + bm25_params| DATA
DATA -->|hybrid search| QUERY
INGEST -->|extract Β· classify Β· embed| EXT
QUERY -->|rewrite Β· answer| EXT
APP -.->|HTTP traces Β· metrics| OBS
INGEST -.->|LLM traces| OBS
QUERY -.->|LLM traces| OBS
- Node.js: For the React frontend.
- Python 3.10+: For the FastAPI backend.
- Neon (or any Postgres): Accounts, chat sessions and messages.
- Pinecone: Serverless index,
dimension=768,metric=dotproduct. - Google AI API key:
gemini-embedding-2embeddings andgemini-2.5-flashanswers. - Modal account: Docling extraction (GPU). The Gemma-2 LLM server is optional.
- Navigate to the backend directory:
cd backend - Install dependencies:
pip install -r requirements.txt
Modal runs Docling extraction on a GPU. Answers come from the Gemini API directly, so no self-hosted LLM server is required.
- Initialize Modal:
pip install modal && modal setup. - Create Secrets: In the Modal dashboard, create a secret named
huggingface-secretcontaining yourHF_TOKEN. - Deploy the Stack:
# 1. LLM Server (Gemma-2 9B) modal run modal/modal_llm_server.py::download_model modal deploy modal/modal_llm_server.py # 2. Docling Worker (PDF Extraction) modal deploy modal/modal_docling_worker.py
- Finalize .env: Copy the deployment URLs into your backend
.env:LLM_URL=https://your-llm-server.modal.run DOCLING_URL=https://your-docling-worker.modal.run
Create a serverless index with dimension=768 and metric=dotproduct β dotproduct is required for sparse-dense hybrid queries, and the dimension must equal EMBED_DIM in llm/llm_router.py, which also drives the embedding call itself. The backend verifies both at startup and refuses to run on a mismatch, rather than failing minutes later at upsert.
Create a database and copy its pooled connection string (the -pooler host β PgBouncer multiplexes many client connections onto few backends, which a free-tier compute needs). Tables are prefixed drs_ so this schema can share a database with other projects.
Create them with either:
# from backend/
python -c "from db.database import engine, Base; import db.models; Base.metadata.create_all(engine)"
# ...or paste migrations.sql into the Neon SQL editor# Vector store
PINECONE_API_KEY=your_key
PINECONE_INDEX_NAME=your_index
PINECONE_HOST=https://your-index-xxxxx.svc.region.pinecone.io
# Embeddings + LLM
GEMINI_API_KEY=your_gemini_key
LLM_URL=https://your-llm-server.modal.run
DOCLING_URL=https://your-docling-worker.modal.run
# Database + auth
DATABASE_URL=postgresql://user:pass@ep-xxx-pooler.region.aws.neon.tech/dbname?sslmode=require
JWT_SECRET=<python -c "import secrets; print(secrets.token_urlsafe(48))">
# Optional
ALLOWED_ORIGINS=https://your-frontend.onrender.com,http://localhost:5173 # CORS allow-list (comma-separated)
MAX_UPLOAD_MB=3 # upload cap (MB), enforced while streaming
GEMINI_THINKING_BUDGET=2048 # fixed ceiling (default); 0 = off, -1 = dynamic
GEMINI_FAST_MODEL=gemini-2.5-flash-lite # classification + boundary detection
DOCLING_PIPELINE=classic # or "vlm" for granite-docling-258M (see below)
USE_MODAL_LLM=0 # 1 enables the Gemma-2 fallback
TOKEN_TTL_HOURS=24
# Observability (optional β all tracing stays off unless these are set)
LANGFUSE_PUBLIC_KEY=pk-lf-...
LANGFUSE_SECRET_KEY=sk-lf-...
LANGFUSE_HOST=https://us.cloud.langfuse.com
GRAFANA_OTLP_ENDPOINT=https://otlp-gateway-prod-<region>.grafana.net/otlp
GRAFANA_OTLP_AUTH=Basic <base64> # the full Authorization header value
OTEL_SERVICE_NAME=document-retrieval-system
Important
Deployment Workflow:
- First Time: Run
download_modelthendeploy. This ensures the Volume is populated before the server starts. - Subsequent Changes: Only run
modal deploy. You do NOT need to redownload unless you change theMODEL_NAMEin the script. - Why Deploy?:
modal rungives a temporary development URL.modal deploycreates the permanent production URL required for your.env.
- Navigate to the frontend directory:
cd frontend - Install dependencies:
npm install
- Run Dev Server:
npm run dev
- Open
http://localhost:5173, create an account, then start a chat and upload a PDF.
Point a build at a deployed backend with VITE_API_URL:
VITE_API_URL=https://your-backend.onrender.com npm run buildEvery endpoint except signup and login requires Authorization: Bearer <token>.
| Method | Path | Notes |
|---|---|---|
POST |
/api/auth/signup |
β { token, user_id, username } |
POST |
/api/auth/login |
5 failures / 15 min locks the username |
GET |
/api/chats |
Sidebar list, newest first |
POST |
/api/chats/new |
Reuses an existing empty chat |
GET |
/api/chats/{id} |
Chat + full message history |
POST |
/api/chats/{id}/document |
202 β ingests in the background |
GET |
/api/chats/{id}/status |
Poll while processing |
POST |
/api/chats/{id}/message |
Ask a question. Returns question_asked and question_searched so a rewritten follow-up is diagnosable |
PATCH |
/api/chats/{id} |
Rename |
DELETE |
/api/chats/{id} |
Drops the namespace and the rows |
Chat lifecycle: awaiting_document β processing β ready | failed
A chat that belongs to another user returns 404, not 403 β a 403 would confirm the id exists.
The system's performance is validated using the Ragas evaluation framework, focusing on faithfulness, relevancy, and retrieval quality.
| Metric | Hybrid (shipped) | Vector only |
|---|---|---|
| Faithfulness | 0.892 | 0.804 |
| Answer Correctness | 0.836 | 0.746 |
| Context Precision | 0.856 | 0.697 |
| Context Recall | 0.964 | 0.880 |
Hybrid sparse-dense retrieval beats pure vector search on every metric β which is what justifies the BM25 half of the index.
Note
Evaluation was performed on a diverse set of complex financial and legal documents to ensure robustness across different domains. Raw per-question output for all configurations lives in results/.
backend/load_test.py spawns the real app with only the retrieval + LLM boundary stubbed, so a run is free and takes seconds β it exercises the async endpoints, the connection pool, JWT auth and SSE, not the model. Idle-vs-saturated phases, a --ramp capacity sweep, and --calibrate for a few real messages.
Capacity (live Render instance, --ramp, read mix):
| Concurrent browse clients | p50 | p95 | errors |
|---|---|---|---|
| 5 | 485ms | 625ms | 0 |
| 25 | 781ms | 1313ms | 0 |
| 50 | 1578ms | 6828ms | 0 |
| 100 | 3031ms | 8640ms | 0 |
Healthy to ~25 concurrent browse clients, zero errors even at 100 (it degrades in latency, never fails). The ceiling is the DB connection pool: an A/B raising it from 15 β 30 (pool_size=10 + max_overflow=20) roughly doubled read throughput (~18 β ~33 req/s) and pushed the knee from ~50 to ~100. Streaming a /message competes for pooled connections with browse reads (_prepare / _save), so heavy answering degrades browsing ~1.4β2.3Γ β the pool is the lever.
OpenTelemetry over OTLP, wired programmatically (not the opentelemetry-instrument wrapper). openinference's GoogleGenAIInstrumentor auto-traces every Gemini call:
- LLM spans β Langfuse + Grafana. Each message is one
chat-messagetrace with the rewrite and answer generations nested under it, tagged with user + session. - HTTP spans β Grafana (a separate provider, so Langfuse stays LLM-only).
chat_messages_totalmetric β Grafana, with a paste-importable dashboard and a muted error-rate alert (backend/grafana/).
Everything is a no-op unless the env vars are set, and nothing raises β tracing must never break a request. Set LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY / LANGFUSE_HOST, and GRAFANA_OTLP_ENDPOINT / GRAFANA_OTLP_AUTH (the full Basic <base64> header) / OTEL_SERVICE_NAME.
.github/workflows/ci.yml runs on every push and PR:
- backend β
ruff,compileall,load_test.py --selftest(no network). - frontend β
npm ci,npm run lint,npm run build. - docker β build both images (no push), so a broken Dockerfile fails here, not at deploy.
- deploy β only after all three pass, only on push to
main: POSTs the Render deploy hooks (RENDER_DEPLOY_HOOK_*secrets), skipping gracefully if they're unset.
docker compose up --build runs the stack locally: backend/Dockerfile (python:3.12-slim + uvicorn, non-root, /api/health probe) and frontend/Dockerfile (Vite build β nginx). Docling runs on Modal, so the backend image needs no GPU/GL libraries.
The backend runs with --forwarded-allow-ips * (in the Dockerfile CMD) so slowapi's per-IP rate limits key on the real client (X-Forwarded-For) behind a proxy/balancer rather than the proxy's own IP β otherwise every user shares one rate-limit bucket. On a non-Docker deploy, set FORWARDED_ALLOW_IPS=* in the service env instead (uvicorn reads it).
Questions that come up when reading the retrieval code, answered from the implementation and the evaluation data rather than from general RAG folklore.
They are never shortlisted separately. This is the most common wrong mental model of this pipeline. There is no "top-N from dense, top-N from sparse, merge, then cut to k". There is one index, one query, one score, one ranking.
Each chunk is stored as a single Pinecone record carrying both a dense vector and a sparse vector (retriever.py β build_indices). At query time Pinecone computes one score per chunk, server-side:
score(chunk) = Ξ±Β·(q_dense Β· c_dense) + (1βΞ±)Β·(q_sparse Β· c_sparse)
top_k is applied to that number. Fusion happens before selection, inside the index β not after two separate retrievals.
Pinecone exposes no alpha parameter, because the server only knows how to dot-product. So the weighting is applied by pre-scaling the query vectors before they are sent (retriever.py β _scale_vectors):
scaled_dense = [v * alpha for v in dense_vec]
scaled_sparse = [v * (1 - alpha) for v in sparse_vec["values"]]This is exactly Pinecone's documented hybrid_score_norm convex-combination helper.
Two consequences worth internalising:
- Why
alphais structurally necessary. Dense vectors are L2-normalised on both write and query, so the dense term is effectively cosine, bounded in[-1, 1]. BM25 sparse weights are unbounded positive. Withoutalpha, the sparse term would simply dominate every query.alphais a scale-reconciliation device first and a user preference knob second. - Neither modality gets a guaranteed slot. A chunk that is the #1 BM25 hit but only mediocre semantically can fail to appear in the results at all, because it receives exactly one combined score. This is a real behavioural difference from Reciprocal Rank Fusion β see Q3.
This is also why the index must be created with metric=dotproduct: cosine and euclidean indexes cannot serve sparse-dense queries at all. The backend verifies this at startup.
A cross-encoder reranker was built, measured, and removed from this project. The obvious explanation β "the corpus is small, so there was nothing left to fix" β turns out to be wrong, and the real reason is more useful.
The corpus is ~67β87 chunks and recall@5 was 0.964, distributed almost binary: 241 of 250 questions at perfect recall, 9 at zero. So the apparent story is that there was no headroom. But testing what the reranker actually did to those 9:
| Outcome | Count |
|---|---|
| Questions where hybrid retrieved nothing relevant | 9 |
| β¦of those, rescued by the reranker | 8 |
| Rankings the reranker newly broke (perfect β zero) | 9 |
The reranker was doing substantial work in both directions. It rescued 8 of 9 genuine misses β its mechanism functioning exactly as designed, promoting a chunk from rank 6β15 into the top 5 β while simultaneously destroying 9 rankings that were already perfect. Net β1. The same churn shows up in the paired per-question comparison (answer correctness: 70 wins, 54 losses, 126 ties; overall delta β0.003, p=0.85).
The governing condition is therefore sharper than corpus size:
A reranker helps only if it is meaningfully more accurate than your first stage on your data. If the two are roughly equal in quality, over-fetching merely hands the reranker more opportunities to be wrong, and the result is churn rather than gain.
In this pipeline the first stage is strong and the reranker is not plausibly stronger: modern Gemini embeddings plus BM25 exact-term matching, against a corpus of exact-value lookups ("what is Total Loan Costs?"). BM25's lexical precision is near-ideal for that task, while bge-reranker-base is a ~278M general-domain cross-encoder with no exposure to mortgage documents.
Conditions under which a reranker does earn its keep:
| Condition | Why it matters |
|---|---|
recall@k βͺ recall@(kΓN) |
The entire mechanism is salvaging deep recall into shallow precision. Measure this first β it is a hard ceiling on any possible gain. |
| The reranker genuinely outranks your first stage on your domain | Non-negotiable. A general-purpose reranker layered over a strong domain-appropriate retriever frequently loses. |
| Queryβdocument term interaction matters | Bi-encoders embed query and document independently and cannot model interaction. Negation, "X but not Y", multi-hop conditions, comparatives. Simple field lookups do not need this. |
| Many near-duplicate candidates | Large corpora where dozens of chunks look near-identical to a bi-encoder. Nothing to disambiguate at 67 chunks. |
| The context window is the binding constraint | If only 3 chunks fit, they had better be the right 3. |
| Your embedding model is domain-mismatched | A cross-encoder can compensate for a weak first stage. |
The cheap diagnostic, before deploying one: compare recall@k against recall@(kΓ3). If they are close, a reranker cannot help β it only ever reorders what was already fetched.
Reciprocal Rank Fusion β score = Ξ£α΅’ 1/(60 + rankα΅’) β discards score magnitude and uses only position.
Use RRF when scores cannot be compared across systems. That is its entire purpose; it is scale-free by construction. Concretely:
- Retrievers are physically separate β e.g. Elasticsearch BM25 alongside a separate vector database. You genuinely have two ranked lists rather than one index, so there is no other option.
- You are fusing three or more heterogeneous sources β dense, sparse, graph, a second embedding model. RRF handles N lists uniformly; convex combination becomes awkward past two.
- You have no labelled data to tune
alpha. RRF's only knob is the constant (conventionally 60) and it is famously insensitive to it. - You want robustness to one retriever misbehaving. Each list contributes at most ~1/60, so no single system can dominate the fused ranking.
Use Ξ±-weighted score fusion (this project) when:
- A single index computes both signals natively β the combined score comes for free and there is no fusion step to implement.
- Magnitude carries signal. This is precisely why this project migrated off RRF. RRF treats rank 3 as rank 3 whether it scored 0.95 or 0.40; for exact-numeric lookups, "matched this figure exactly" versus "matched it weakly" is exactly the information worth keeping.
- You want a tunable knob exposed to the user (the
alphaslider).
No β they are different stages, not competing options.
- RRF / Ξ±-fusion is a merge strategy. It decides how to combine cheap relevance signals produced by models that never see query and document together.
- A reranker is a precision stage. A cross-encoder jointly encodes
(query, chunk)and can model term interaction. It is orders of magnitude slower per pair, which is exactly why it can only run over a shortlist.
The canonical production funnel is cheap retrievers β RRF merge β cross-encoder rerank β LLM, with each stage narrowing the candidate set.
There is a non-obvious corollary:
RRF increases the value of a reranker, relative to score fusion.
Because RRF deliberately discards magnitude, its ordering is coarse β it knows rank order but not by how much. A reranker restores fine-grained ordering at the top, so an RRF pipeline leaves more for a reranker to fix. The Ξ±-fusion used here preserves magnitude, so the top-k ordering is already fine-grained. That is a second, independent reason the reranker found little to improve in this project.
RRF alone suffices when recall@k is already high, latency matters, there is no GPU budget, or the reranker is not stronger than the first stage. Add a reranker on top when the corpus is large enough that recall@k is genuinely poor, over-fetching to 50β100 candidates is cheap, and you have verified on your own data that the cross-encoder actually outranks your first stage. That last clause is the one most often skipped β and it is the one that decided the outcome here.
Q4: How is multi-column form extraction handled, and why is DOCLING_PIPELINE=vlm (granite-docling-258M) available but not the default?
Extraction was this project's quality ceiling. A mortgage fee sheet is a two-column key-value form, and the classic pipeline returned the value detached from its label β Interest Rate: in one block, 4.250 % in another β so a question naming the field retrieved a chunk that did not contain the number.
The real cause was a discarded coordinate, not a weak model. The value was never lost: Interest Rate: (x=237) and 4.250 % (x=298) sit on the same visual row (y=842.9 vs 842.8), but the worker exposed only each block's y-coordinate, so pdf_processor sorted by y alone β dropping every label into one group and every value into another. The fix is one line in the Modal worker (emit x = bbox.l) plus pdf_processor._order_blocks(), which groups blocks into visual rows (y within a tolerance) and orders each row left-to-right by x. Interest Rate: 4.250 % is reassembled β verified end-to-end (the question now answers 4.250%), at classic's speed, with tables untouched.
Why not the VLM (granite-docling-258M) instead? It looked like the fix β IBM report TEDS 0.97 structure / 0.96 with-content on FinTabNet, a financial table benchmark β and it does keep a label with its value. But measured on the test packets it emitted the fee tables as empty blocks, taking ORIGINATION, Underwriting, 95,641.53 and every other fee figure with them:
| classic (was) | vlm | row-grouping (shipped) | |
|---|---|---|---|
Interest Rate value |
detached β | Interest Rate: 4.250 % β
|
Interest Rate: 4.250 % β
|
| fee / funds-to-close tables | kept β | emptied β | kept β |
| runtime | ~46s | ~2Γ (per-page VLM) | ~46s |
Losing the fee table is far worse than a detached field, and the fee sheet is the most-queried document in a packet. A published benchmark on a public dataset did not transfer to this layout β the same lesson Q2 records about the reranker. So the VLM stays behind the flag (its text-field win is real if you ever want it), but the row-grouping fix β a coordinate Docling already computed β supersedes the "classic tables + VLM text" hybrid it once pointed at.
document-retrieval-system/
βββ backend/
β βββ core/ # Processing & retrieval engine
β β βββ document_store.py # Orchestration + rehydrate()
β β βββ retriever.py # Hybrid search, routing, namespaces
β β βββ pdf_processor.py # Docling integration (classic | vlm)
β β βββ chunker.py # Structure-aware chunking
β β βββ document_classifier.py # Doc-type & boundary detection
β β βββ query_rewriter.py # Follow-up β standalone question
β β βββ answer_generator.py # Grounded answer prompt
β β βββ models.py # Core dataclasses
β βββ db/ # Neon / Postgres
β β βββ database.py # Engine + session factory
β β βββ models.py # accounts, chat_sessions, messages
β βββ llm/
β β βββ llm_router.py # Gemini answers + embeddings
β βββ modal/ # Cloud deployment scripts
β β βββ modal_llm_server.py # vLLM hosting (Gemma-2)
β β βββ modal_docling_worker.py # Serverless PDF extraction
β βββ eval/ # Measurement harnesses
β β βββ model_sweep.py # Generated questions, no judge (trust this)
β β βββ model_eval.py # flash vs flash-lite, LLM-judged
β β βββ prompt_eval.py # Prompt-change A/B
β β βββ extract_eval.py # classic vs granite-docling
β β βββ hybrid_extract_eval.py # classic vs vlm vs row-grouping
β βββ grafana/ # Grafana dashboard + alert provisioning
β βββ auth.py # JWT + bcrypt + refresh tokens
β βββ observability.py # OpenTelemetry β Langfuse + Grafana
β βββ main.py # API entry point
β βββ load_test.py # Capacity / responsiveness harness
β βββ Dockerfile # python:3.12-slim + uvicorn
β βββ migrations.sql # Schema + housekeeping queries
β βββ requirements.txt
β βββ .env # Keys, DB URL, worker URLs
βββ frontend/
β βββ src/
β β βββ api.js # API client, owns the JWT
β β βββ Landing.jsx # Animated landing page
β β βββ Login.jsx # Login / signup
β β βββ ChatPanel.jsx # Upload gate, live ingest stepper, messages
β β βββ App.jsx # Shell, auth gate, chat rail
β β βββ App.css # Design tokens & styles
β βββ Dockerfile # Vite build β nginx
β βββ nginx.conf # SPA routing
βββ .github/workflows/ci.yml # lint Β· build Β· docker Β· gated deploy
βββ docker-compose.yml # local api + frontend stack
βββ ruff.toml
βββ notebooks/ # R&D and evaluation (gitignored)
βββ results/ # Ragas metrics output
βββ README.md