Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

150 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

πŸ“„ Advanced Document Retrieval System

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.


πŸš€ Key Features

  • 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 202 and 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-2 embeddings (768-dim) alongside BM25 sparse vectors, fused by a tunable alpha (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.


πŸ—οΈ Architecture

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
Loading

πŸ› οΈ Setup & Execution

1. Requirements

  • 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-2 embeddings and gemini-2.5-flash answers.
  • Modal account: Docling extraction (GPU). The Gemma-2 LLM server is optional.

2. Backend Setup

  1. Navigate to the backend directory:
    cd backend
  2. Install dependencies:
    pip install -r requirements.txt

3. Worker Setup (Modal)

Modal runs Docling extraction on a GPU. Answers come from the Gemini API directly, so no self-hosted LLM server is required.

A. Modal Deployment (Docling, and optionally the LLM)

  1. Initialize Modal: pip install modal && modal setup.
  2. Create Secrets: In the Modal dashboard, create a secret named huggingface-secret containing your HF_TOKEN.
  3. 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
  4. 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
    

C. Pinecone

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.

D. Neon (Postgres)

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

E. Complete backend/.env

# 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_model then deploy. 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 the MODEL_NAME in the script.
  • Why Deploy?: modal run gives a temporary development URL. modal deploy creates the permanent production URL required for your .env.

4. Running the Frontend

  1. Navigate to the frontend directory:
    cd frontend
  2. Install dependencies:
    npm install
  3. Run Dev Server:
    npm run dev
  4. 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 build

πŸ”Œ API

Every 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.


πŸ“ˆ Performance & Evaluation

The system's performance is validated using the Ragas evaluation framework, focusing on faithfulness, relevancy, and retrieval quality.

Evaluation Metrics (k=5, 250 questions, results/ragas_results_5_hybrid.csv)

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/.


πŸ”₯ Load testing & capacity

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.


πŸ“ˆ Observability

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-message trace 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_total metric β†’ 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.


🐳 CI/CD & Docker

.github/workflows/ci.yml runs on every push and PR:

  1. backend β€” ruff, compileall, load_test.py --selftest (no network).
  2. frontend β€” npm ci, npm run lint, npm run build.
  3. docker β€” build both images (no push), so a broken Dockerfile fails here, not at deploy.
  4. 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).


❓ Design FAQ

Questions that come up when reading the retrieval code, answered from the implementation and the evaluation data rather than from general RAG folklore.

Q1: After the semantic and BM25 candidates are shortlisted, how are they combined?

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 alpha is 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. Without alpha, the sparse term would simply dominate every query. alpha is 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.


Q2: When is a reranker actually worth adding?

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.


Q3: When should RRF be used, and is a reranker still needed alongside it?

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 alpha slider).

Does RRF remove the need for a reranker?

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.


πŸ“‚ Project Structure

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

About

Advanced RAG system for PDF documents with React, FastAPI, hybrid retrieval (FAISS + BM25), cross-encoder reranking, Docling extraction, and AI-powered question answering.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages