feat: Add RAGAS eval, score passthrough, docs, and hybrid retrieval config
Browse files- chain.py: pass similarity/bm25/rrf/rerank scores from metadata to API response
- routes/chat.py: store contexts in eval_log for RAGAS; surface retrieval_method
- routes/eval.py: wire POST /api/eval/ragas endpoint with RagasRequest schema
- server/eval/ragas_eval.py: RAGAS faithfulness + answer_relevancy via LangchainLLMWrapper
- main.py: add GET /health endpoint (status + version)
- config.yaml: add hybrid retrieval params (dense_weight, sparse_weight, retrieve_k, rerank_k)
- requirements.txt: add rank_bm25, sentence-transformers, ragas, datasets
- docs/: migrate architecture, decisions, api-spec, structure from finrag.md
- .gitignore: exclude CLAUDE.md and .claude/ (personal workstation files)
- finrag.md: deleted (content moved to docs/)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- .gitignore +4 -0
- config.yaml +4 -1
- docs/api-spec.md +109 -0
- docs/architecture.md +65 -0
- docs/decisions.md +24 -0
- docs/structure.md +89 -0
- finrag.md +0 -778
- requirements.txt +4 -0
- server/chain.py +5 -0
- server/eval/ragas_eval.py +116 -0
- server/main.py +5 -0
- server/routes/chat.py +3 -1
- server/routes/eval.py +17 -0
|
@@ -34,3 +34,7 @@ frontend/dist/
|
|
| 34 |
|
| 35 |
# Eval results
|
| 36 |
eval_results_*.json
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
# Eval results
|
| 36 |
eval_results_*.json
|
| 37 |
+
|
| 38 |
+
# Claude Code — personal workstation files (career data, memory)
|
| 39 |
+
CLAUDE.md
|
| 40 |
+
.claude/
|
|
@@ -3,8 +3,11 @@ chunking:
|
|
| 3 |
chunk_overlap: 50
|
| 4 |
|
| 5 |
retrieval:
|
| 6 |
-
k: 5
|
| 7 |
collection_name: "finrag"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
memory:
|
| 10 |
max_token_limit: 2000
|
|
|
|
| 3 |
chunk_overlap: 50
|
| 4 |
|
| 5 |
retrieval:
|
|
|
|
| 6 |
collection_name: "finrag"
|
| 7 |
+
dense_weight: 0.7
|
| 8 |
+
sparse_weight: 0.3
|
| 9 |
+
retrieve_k: 20
|
| 10 |
+
rerank_k: 5
|
| 11 |
|
| 12 |
memory:
|
| 13 |
max_token_limit: 2000
|
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# API Specification — FinRAG v2
|
| 2 |
+
|
| 3 |
+
## Base URL
|
| 4 |
+
- Local: `http://localhost:8000`
|
| 5 |
+
- Production: `https://finrag-v2.onrender.com` (set after deploy)
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## Chat
|
| 10 |
+
|
| 11 |
+
### POST /api/chat
|
| 12 |
+
**Purpose:** Submit a question; returns answer with sources and eval scores.
|
| 13 |
+
|
| 14 |
+
**Request:**
|
| 15 |
+
```json
|
| 16 |
+
{ "question": "What was UPI transaction volume in FY24?" }
|
| 17 |
+
```
|
| 18 |
+
**Response:**
|
| 19 |
+
```json
|
| 20 |
+
{
|
| 21 |
+
"answer": "India processed over 100 billion UPI transactions in FY2024...",
|
| 22 |
+
"sources": [
|
| 23 |
+
{
|
| 24 |
+
"content": "chunk text...",
|
| 25 |
+
"source": "npci_upi_report_2024.pdf",
|
| 26 |
+
"page": 12,
|
| 27 |
+
"similarity_score": 0.87,
|
| 28 |
+
"bm25_score": 3.42,
|
| 29 |
+
"rrf_score": 0.021,
|
| 30 |
+
"rerank_score": 4.91
|
| 31 |
+
}
|
| 32 |
+
],
|
| 33 |
+
"faithfulness": { "score": 4, "reason": "Answer well-grounded in context." },
|
| 34 |
+
"retrieval_method": "hybrid+rerank"
|
| 35 |
+
}
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
### DELETE /api/chat/memory
|
| 39 |
+
**Purpose:** Clear conversation history (new conversation).
|
| 40 |
+
**Response:** `{ "status": "cleared" }`
|
| 41 |
+
|
| 42 |
+
---
|
| 43 |
+
|
| 44 |
+
## Upload
|
| 45 |
+
|
| 46 |
+
### POST /api/upload
|
| 47 |
+
**Purpose:** Upload a document and re-ingest into the corpus.
|
| 48 |
+
**Content-Type:** `multipart/form-data` | Field: `file`
|
| 49 |
+
**Allowed types:** `.pdf`, `.txt`, `.csv` | Max size: 20MB
|
| 50 |
+
|
| 51 |
+
**Response:**
|
| 52 |
+
```json
|
| 53 |
+
{
|
| 54 |
+
"filename": "new_document.pdf",
|
| 55 |
+
"chunks_added": 143,
|
| 56 |
+
"total_chunks": 990,
|
| 57 |
+
"status": "ingested"
|
| 58 |
+
}
|
| 59 |
+
```
|
| 60 |
+
**Error:** 422 if file type unsupported.
|
| 61 |
+
|
| 62 |
+
---
|
| 63 |
+
|
| 64 |
+
## Eval
|
| 65 |
+
|
| 66 |
+
### GET /api/eval/session
|
| 67 |
+
**Purpose:** Get per-turn faithfulness log for current session.
|
| 68 |
+
**Response:**
|
| 69 |
+
```json
|
| 70 |
+
[
|
| 71 |
+
{
|
| 72 |
+
"query": "...",
|
| 73 |
+
"answer": "...",
|
| 74 |
+
"faithfulness_score": 4,
|
| 75 |
+
"reason": "..."
|
| 76 |
+
}
|
| 77 |
+
]
|
| 78 |
+
```
|
| 79 |
+
|
| 80 |
+
### POST /api/eval/precision
|
| 81 |
+
**Purpose:** Run batch Precision@K against ground truth pairs.
|
| 82 |
+
**Response:**
|
| 83 |
+
```json
|
| 84 |
+
{
|
| 85 |
+
"mean_precision_at_k": 0.74,
|
| 86 |
+
"per_query_results": [...]
|
| 87 |
+
}
|
| 88 |
+
```
|
| 89 |
+
|
| 90 |
+
### POST /api/eval/ragas
|
| 91 |
+
**Purpose:** Run RAGAS evaluation on last N session QA pairs.
|
| 92 |
+
**Request:** `{ "n_pairs": 10 }`
|
| 93 |
+
**Response:**
|
| 94 |
+
```json
|
| 95 |
+
{
|
| 96 |
+
"faithfulness": 0.87,
|
| 97 |
+
"answer_relevancy": 0.91,
|
| 98 |
+
"context_precision": 0.74,
|
| 99 |
+
"context_recall": 0.68,
|
| 100 |
+
"per_query": [...]
|
| 101 |
+
}
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
---
|
| 105 |
+
|
| 106 |
+
## Health
|
| 107 |
+
|
| 108 |
+
### GET /health
|
| 109 |
+
**Response:** `{ "status": "ok", "version": "2.0.0" }`
|
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Architecture — FinRAG v2
|
| 2 |
+
|
| 3 |
+
## Problem
|
| 4 |
+
Fintech analysts spend hours manually reading RBI circulars, NPCI reports, and earnings transcripts. Standard dense-only RAG fails silently and misses exact keyword matches in regulatory text (section numbers, policy codes).
|
| 5 |
+
|
| 6 |
+
## Architecture overview
|
| 7 |
+
Query → hybrid retrieval (ChromaDB dense + BM25 sparse) → weighted RRF fusion → cross-encoder rerank (top-20 → top-5) → LLM answer → faithfulness eval + LangSmith trace. ParentDocumentRetriever stores 200-char child chunks for retrieval but returns 800-char parent chunks to LLM.
|
| 8 |
+
|
| 9 |
+
## Component breakdown
|
| 10 |
+
|
| 11 |
+
| Component | Technology | Purpose |
|
| 12 |
+
|-----------|------------|---------|
|
| 13 |
+
| Vector store | ChromaDB (persistent) | Dense embedding storage and retrieval |
|
| 14 |
+
| Sparse retrieval | rank_bm25 (BM25Okapi) | Keyword-match retrieval for regulatory text |
|
| 15 |
+
| Hybrid fusion | Weighted RRF (dense 0.7 + sparse 0.3) | Merge dense + sparse result lists |
|
| 16 |
+
| Reranker | cross-encoder/ms-marco-MiniLM-L-6-v2 | Re-score top-20 → return top-5 |
|
| 17 |
+
| Embeddings | Euron API (text-embedding-3-small) | API-based; avoids OOM on Render free tier |
|
| 18 |
+
| LLM | Euron API (gpt-4.1-mini) via OpenAI SDK | Answer generation |
|
| 19 |
+
| Chunking | LangChain ParentDocumentRetriever | Child 200-char indexed, parent 800-char sent to LLM |
|
| 20 |
+
| Memory | ConversationBufferWindowMemory (k=10) | Last 10 conversation turns |
|
| 21 |
+
| Chain | ConversationalRetrievalChain | LangChain orchestration |
|
| 22 |
+
| Eval (primary) | RAGAS | faithfulness, answer_relevancy, context_precision, context_recall |
|
| 23 |
+
| Eval (secondary) | Custom LLM-as-Judge | 1–5 faithfulness score per turn (retained from v1) |
|
| 24 |
+
| Eval (retrieval) | Precision@K | Ground-truth chunk matching |
|
| 25 |
+
| Observability | LangSmith | Traces all LLM + retrieval calls via LANGCHAIN_TRACING_V2=true |
|
| 26 |
+
| Document parsing | LlamaParse (primary), pypdf (fallback) | PDF extraction |
|
| 27 |
+
| Backend | FastAPI + Uvicorn | REST API |
|
| 28 |
+
| Frontend | React 19 + Vite + Tailwind CSS v4 | Chat / Eval / Upload tabs |
|
| 29 |
+
| Deployment | Render (Docker backend) + Vercel (frontend) | Production |
|
| 30 |
+
|
| 31 |
+
## Data flow
|
| 32 |
+
|
| 33 |
+
### Ingestion
|
| 34 |
+
1. `run_ingest.py` or `POST /api/upload` → load PDFs/TXT/CSV
|
| 35 |
+
2. `ParentDocumentRetriever`: split into 800-char parent + 200-char child chunks
|
| 36 |
+
3. Embed child chunks via Euron API → store in ChromaDB
|
| 37 |
+
4. Store parent chunks in `InMemoryStore`
|
| 38 |
+
5. Build BM25 index over child chunk corpus
|
| 39 |
+
|
| 40 |
+
### Query
|
| 41 |
+
1. `POST /api/chat` receives question
|
| 42 |
+
2. `dense_retrieve`: ChromaDB top-20 by cosine similarity
|
| 43 |
+
3. `sparse_retrieve`: BM25 top-20 by keyword score
|
| 44 |
+
4. `reciprocal_rank_fusion`: merge → deduplicate → RRF score
|
| 45 |
+
5. `Reranker.rerank`: cross-encoder score → return top-5 parent chunks
|
| 46 |
+
6. `ConversationalRetrievalChain`: LLM answers with context + memory
|
| 47 |
+
7. `score_faithfulness`: LLM-as-Judge scores answer 1–5
|
| 48 |
+
8. Response includes: answer, sources (with scores), faithfulness, retrieval_method
|
| 49 |
+
|
| 50 |
+
## Key design decisions
|
| 51 |
+
- **API embeddings over local**: sentence-transformers ~400MB OOMs on Render 512MB free tier; Euron API ~0MB
|
| 52 |
+
- **ParentDocumentRetriever**: small chunks improve retrieval precision; large parent chunks improve answer faithfulness
|
| 53 |
+
- **Cross-encoder reranker**: bi-encoder (ChromaDB) is fast but approximate; cross-encoder is slower but more accurate on top-20 pool
|
| 54 |
+
- **BM25 weight 0.3**: regulatory text has exact keyword matches (section numbers); sparse retrieval catches what dense misses
|
| 55 |
+
- **RAGAS as primary eval**: 4 named metrics that interviewers recognise; custom scorer retained as supplementary
|
| 56 |
+
|
| 57 |
+
## Known limitations
|
| 58 |
+
- InMemoryStore for parent chunks: does not survive server restart (re-ingest required)
|
| 59 |
+
- BM25 index rebuilt in memory on each startup (not persisted to disk)
|
| 60 |
+
- Render free tier: 512MB RAM, cold starts after inactivity
|
| 61 |
+
|
| 62 |
+
## Future improvements
|
| 63 |
+
- Persist BM25 index to disk (pickle)
|
| 64 |
+
- Multi-collection ChromaDB (one per document set)
|
| 65 |
+
- Streaming responses from LLM
|
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Technical Decisions — FinRAG v2
|
| 2 |
+
|
| 3 |
+
## Decision log
|
| 4 |
+
|
| 5 |
+
| Date | Decision | Rationale | Status |
|
| 6 |
+
|------|----------|-----------|--------|
|
| 7 |
+
| 2026-05 | Euron API for embeddings (not local sentence-transformers) | Local model ~400MB → OOM on Render 512MB free tier | Active |
|
| 8 |
+
| 2026-05 | Render (Docker) over Railway for backend | Free tier RAM fit confirmed: embeddings ~150MB + reranker ~85MB = ~250MB total | Active |
|
| 9 |
+
| 2026-05 | ParentDocumentRetriever (child 200 / parent 800) | Better faithfulness: small chunks retrieved precisely, large chunks give LLM full context | Active |
|
| 10 |
+
| 2026-05 | BM25 weight 0.3 in RRF fusion | Regulatory text has exact keyword matches; sparse recall is complementary, not dominant | Active |
|
| 11 |
+
| 2026-05 | RAGAS as primary eval (custom scorer as secondary) | RAGAS provides 4 named metrics (faithfulness, answer_relevancy, context_precision, context_recall) that interviewers recognise; v1 custom scorer retained for per-turn display | Active |
|
| 12 |
+
| 2026-05 | LangSmith tracing via env var (no code changes) | LangChain reads LANGCHAIN_TRACING_V2 automatically; zero instrumentation cost | Active |
|
| 13 |
+
| 2026-05 | Cross-encoder reranker pre-downloaded at Docker build time | Avoids cold-start latency on first request in production | Active |
|
| 14 |
+
| 2026-05 | Idempotent ingestion via md5(source+page+text) chunk IDs | Re-running ingest does not duplicate chunks in ChromaDB | Active |
|
| 15 |
+
|
| 16 |
+
## Rejected alternatives
|
| 17 |
+
|
| 18 |
+
| Alternative | Why rejected |
|
| 19 |
+
|-------------|-------------|
|
| 20 |
+
| sentence-transformers local embeddings | ~400MB RAM → OOM on Render free tier |
|
| 21 |
+
| Pinecone / Weaviate vector store | Adds external dependency and cost; ChromaDB sufficient for demo scale |
|
| 22 |
+
| LlamaIndex instead of LangChain | LangChain has better ParentDocumentRetriever and ConversationalRetrievalChain support |
|
| 23 |
+
| Streaming LLM responses | Adds frontend complexity; acceptable latency at demo scale |
|
| 24 |
+
| BM25 persisted to disk | Not required for demo; rebuild on startup is fast enough (~1s for sample corpus) |
|
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Project Structure — FinRAG v2
|
| 2 |
+
|
| 3 |
+
```
|
| 4 |
+
finrag/
|
| 5 |
+
├── CLAUDE.md
|
| 6 |
+
├── README.md
|
| 7 |
+
├── requirements.txt
|
| 8 |
+
├── .env.example
|
| 9 |
+
├── config.yaml # All tunable params (chunk sizes, weights, eval flags)
|
| 10 |
+
├── Dockerfile
|
| 11 |
+
├── render.yaml
|
| 12 |
+
├── .gitignore
|
| 13 |
+
│
|
| 14 |
+
├── .claude/
|
| 15 |
+
│ ├── settings.json
|
| 16 |
+
│ ├── hooks/
|
| 17 |
+
│ │ └── session-reflect.sh
|
| 18 |
+
│ └── rules/
|
| 19 |
+
│ ├── memory-profile.md # Facts about Ben
|
| 20 |
+
│ ├── memory-preferences.md # How Ben likes things done
|
| 21 |
+
│ ├── memory-decisions.md # Technical decisions log
|
| 22 |
+
│ ├── memory-sessions.md # Session log
|
| 23 |
+
│ └── coding-standards.md # Code style rules
|
| 24 |
+
│
|
| 25 |
+
├── docs/
|
| 26 |
+
│ ├── architecture.md
|
| 27 |
+
│ ├── decisions.md
|
| 28 |
+
│ ├── api-spec.md
|
| 29 |
+
│ └── structure.md # This file
|
| 30 |
+
│
|
| 31 |
+
├── server/
|
| 32 |
+
│ ├── __init__.py
|
| 33 |
+
│ ├── main.py # FastAPI app, lifespan startup
|
| 34 |
+
│ ├── ingest.py # Load → ParentDocumentRetriever → embed → store
|
| 35 |
+
│ ├── retriever.py # Hybrid: dense + BM25 + RRF + reranker
|
| 36 |
+
│ ├── bm25_index.py # BM25 index singleton
|
| 37 |
+
│ ├── reranker.py # Cross-encoder reranker singleton
|
| 38 |
+
│ ├── memory.py # ConversationBufferWindowMemory
|
| 39 |
+
│ ├── chain.py # ConversationalRetrievalChain
|
| 40 |
+
│ ├── utils.py # Config loader, logger, token counter
|
| 41 |
+
│ ├── routes/
|
| 42 |
+
│ │ ├── __init__.py
|
| 43 |
+
│ │ ├── chat.py # POST /api/chat, DELETE /api/chat/memory
|
| 44 |
+
│ │ ├── upload.py # POST /api/upload
|
| 45 |
+
│ │ └── eval.py # GET /api/eval/session, POST /api/eval/precision|ragas
|
| 46 |
+
│ └── eval/
|
| 47 |
+
│ ├── __init__.py
|
| 48 |
+
│ ├── precision.py # Precision@K
|
| 49 |
+
│ ├── faithfulness.py # LLM-as-Judge (1–5 score)
|
| 50 |
+
│ └── ragas_eval.py # RAGAS (4 metrics)
|
| 51 |
+
│
|
| 52 |
+
├── frontend/
|
| 53 |
+
│ ├── package.json
|
| 54 |
+
│ ├── vite.config.js
|
| 55 |
+
│ ├── tailwind.config.js
|
| 56 |
+
│ ├── index.html
|
| 57 |
+
│ └── src/
|
| 58 |
+
│ ├── main.jsx
|
| 59 |
+
│ ├── App.jsx # Tab nav: Chat | Eval | Upload
|
| 60 |
+
│ ├── api.js # Axios client
|
| 61 |
+
│ └── components/
|
| 62 |
+
│ ├── ChatTab.jsx
|
| 63 |
+
│ ├── EvalDashboard.jsx # RAGAS scorecard + Precision@K + LangSmith link
|
| 64 |
+
│ ├── UploadTab.jsx # Drag-and-drop upload
|
| 65 |
+
│ ├── MessageBubble.jsx
|
| 66 |
+
│ └── SourceExpander.jsx
|
| 67 |
+
│
|
| 68 |
+
├── scripts/
|
| 69 |
+
│ ├── run_ingest.py # CLI ingestion
|
| 70 |
+
│ ├── run_eval.py # CLI Precision@K eval
|
| 71 |
+
│ ├── run_ragas_eval.py # CLI RAGAS eval
|
| 72 |
+
│ └── benchmark_chunks.py # Sweep chunk sizes, plot Precision@K
|
| 73 |
+
│
|
| 74 |
+
├── tests/
|
| 75 |
+
│ ├── test_ingest.py
|
| 76 |
+
│ ├── test_retriever.py
|
| 77 |
+
│ ├── test_reranker.py
|
| 78 |
+
│ ├── test_chain.py
|
| 79 |
+
│ ├── test_eval.py
|
| 80 |
+
│ └── test_ragas.py
|
| 81 |
+
│
|
| 82 |
+
├── data/
|
| 83 |
+
│ ├── raw/ # Drop PDFs here (gitignored)
|
| 84 |
+
│ └── ground_truth/
|
| 85 |
+
│ └── eval_pairs.json # 20 query/chunk pairs + ground_truth field
|
| 86 |
+
│
|
| 87 |
+
├── sample_data/ # Seeded sample PDFs for demo
|
| 88 |
+
└── chroma_db/ # Auto-created, gitignored
|
| 89 |
+
```
|
|
@@ -1,778 +0,0 @@
|
|
| 1 |
-
# FinRAG — Fintech Research Agent with Persistent Memory
|
| 2 |
-
## Product Requirements Document (PRD) for Claude Code
|
| 3 |
-
|
| 4 |
-
**Version:** 1.1
|
| 5 |
-
**Author:** Ben Roshan D
|
| 6 |
-
**Date:** April 2026
|
| 7 |
-
**Status:** Built & deploying
|
| 8 |
-
|
| 9 |
-
---
|
| 10 |
-
|
| 11 |
-
## 1. Project Overview
|
| 12 |
-
|
| 13 |
-
### 1.1 Problem Statement
|
| 14 |
-
Fintech professionals and analysts spend hours manually reading RBI circulars, earnings call transcripts, and NPCI reports to answer domain questions. Existing RAG systems retrieve context but provide no signal on whether retrieval is actually working — they fail silently.
|
| 15 |
-
|
| 16 |
-
### 1.2 Solution
|
| 17 |
-
FinRAG is a production-grade conversational research agent that:
|
| 18 |
-
- Ingests fintech documents (PDFs, text) into a persistent vector store
|
| 19 |
-
- Answers multi-turn questions with conversation memory
|
| 20 |
-
- Evaluates its own retrieval quality (Precision@K, Faithfulness score) per query
|
| 21 |
-
- Surfaces eval metrics in a dedicated dashboard tab
|
| 22 |
-
|
| 23 |
-
### 1.3 What Makes It Different
|
| 24 |
-
Most RAG portfolios ship without retrieval evaluation. FinRAG adds a self-scoring eval layer — Precision@K and LLM-as-Judge faithfulness scoring — so retrieval degradation is visible before users notice it.
|
| 25 |
-
|
| 26 |
-
### 1.4 Target Audience (for README framing)
|
| 27 |
-
- Fintech analysts querying RBI policy, UPI stats, earnings data
|
| 28 |
-
- DS/AI interviewers evaluating production RAG architecture understanding
|
| 29 |
-
|
| 30 |
-
---
|
| 31 |
-
|
| 32 |
-
## 2. Tech Stack
|
| 33 |
-
|
| 34 |
-
| Layer | Technology |
|
| 35 |
-
|---|---|
|
| 36 |
-
| Vector store | ChromaDB (persistent, local) |
|
| 37 |
-
| Embeddings | `sentence-transformers/all-MiniLM-L6-v2` (free, no API cost) |
|
| 38 |
-
| LLM | Euron API (`gpt-4.1-mini`) via `openai` SDK (base_url: `https://api.euron.one/api/v1/euri`) |
|
| 39 |
-
| Memory | LangChain `ConversationBufferWindowMemory` (k=10 turns) |
|
| 40 |
-
| Orchestration | LangChain `ConversationalRetrievalChain` |
|
| 41 |
-
| Document loading | `pypdf`, `langchain_community.document_loaders` (PDF, TXT, CSV) |
|
| 42 |
-
| Chunking | `RecursiveCharacterTextSplitter` |
|
| 43 |
-
| Eval | Custom Python module — no external eval library |
|
| 44 |
-
| Backend API | FastAPI + Uvicorn |
|
| 45 |
-
| Frontend | React 19 (Vite + Tailwind CSS v4) |
|
| 46 |
-
| Deployment | Backend on Render (Docker), Frontend on Vercel. Repo: https://github.com/BenRoshan100/fin-rag.git |
|
| 47 |
-
| Config | `.env` for API keys, `config.yaml` for chunking/retrieval params |
|
| 48 |
-
|
| 49 |
-
---
|
| 50 |
-
|
| 51 |
-
## 3. Directory Structure
|
| 52 |
-
|
| 53 |
-
```
|
| 54 |
-
finrag/
|
| 55 |
-
├── data/
|
| 56 |
-
│ ├── raw/ # Drop PDFs here
|
| 57 |
-
│ │ ├── rbi_annual_report_2024.pdf
|
| 58 |
-
│ │ ├── npci_upi_report_2024.pdf
|
| 59 |
-
│ │ └── bajaj_finance_q3_2024_transcript.txt
|
| 60 |
-
│ └── ground_truth/
|
| 61 |
-
│ └── eval_pairs.json # 20 query/relevant-chunk pairs for Precision@K
|
| 62 |
-
│
|
| 63 |
-
├── server/
|
| 64 |
-
│ ├── __init__.py
|
| 65 |
-
│ ├── main.py # FastAPI app entrypoint
|
| 66 |
-
│ ├── routes/
|
| 67 |
-
│ │ ├── __init__.py
|
| 68 |
-
│ │ ├── chat.py # POST /api/chat, DELETE /api/chat/memory
|
| 69 |
-
│ │ └── eval.py # GET /api/eval/session, POST /api/eval/precision
|
| 70 |
-
│ ├── ingest.py # Document loading, chunking, embedding, ChromaDB storage
|
| 71 |
-
│ ├── retriever.py # Query ChromaDB, return top-K chunks with metadata
|
| 72 |
-
│ ├── memory.py # ConversationBufferMemory setup and management
|
| 73 |
-
│ ├── chain.py # LangChain QA chain combining retriever + memory + LLM
|
| 74 |
-
│ ├── eval/
|
| 75 |
-
│ │ ├── __init__.py
|
| 76 |
-
│ │ ├── precision.py # Precision@K computation against ground truth
|
| 77 |
-
│ │ └── faithfulness.py # LLM-as-Judge faithfulness scorer
|
| 78 |
-
│ └── utils.py # Logging, config loader, token counter
|
| 79 |
-
│
|
| 80 |
-
├── frontend/
|
| 81 |
-
│ ├── package.json
|
| 82 |
-
│ ├── vite.config.js
|
| 83 |
-
│ ├── tailwind.config.js
|
| 84 |
-
│ ├── postcss.config.js
|
| 85 |
-
│ ├── index.html
|
| 86 |
-
│ └── src/
|
| 87 |
-
│ ├── main.jsx # React entrypoint
|
| 88 |
-
│ ├── App.jsx # Root component with tab navigation
|
| 89 |
-
│ ├── api.js # Axios client for FastAPI backend
|
| 90 |
-
│ ├── components/
|
| 91 |
-
│ │ ├── ChatTab.jsx # Chat interface
|
| 92 |
-
│ │ ├── EvalDashboard.jsx # Eval metrics dashboard
|
| 93 |
-
│ │ ├── MessageBubble.jsx # Chat message component
|
| 94 |
-
│ │ └── SourceExpander.jsx # Source chunk expander component
|
| 95 |
-
│ └── index.css # Tailwind imports
|
| 96 |
-
│
|
| 97 |
-
├── scripts/
|
| 98 |
-
│ ├── run_ingest.py # CLI: python scripts/run_ingest.py --data-dir data/raw
|
| 99 |
-
│ └── run_eval.py # CLI: python scripts/run_eval.py --queries data/ground_truth/eval_pairs.json
|
| 100 |
-
│
|
| 101 |
-
├── tests/
|
| 102 |
-
│ ├── test_ingest.py
|
| 103 |
-
│ ├── test_retriever.py
|
| 104 |
-
│ ├── test_chain.py
|
| 105 |
-
│ └── test_eval.py
|
| 106 |
-
│
|
| 107 |
-
├── requirements.txt # Python backend dependencies
|
| 108 |
-
├── .env.example
|
| 109 |
-
├── config.yaml
|
| 110 |
-
├── Dockerfile
|
| 111 |
-
├── .gitignore
|
| 112 |
-
│
|
| 113 |
-
└── chroma_db/ # Auto-created by ChromaDB, gitignored
|
| 114 |
-
```
|
| 115 |
-
|
| 116 |
-
---
|
| 117 |
-
|
| 118 |
-
## 4. Module Specifications
|
| 119 |
-
|
| 120 |
-
### 4.1 `server/ingest.py`
|
| 121 |
-
|
| 122 |
-
**Purpose:** Load documents from `data/raw/`, chunk them, embed them, store in ChromaDB.
|
| 123 |
-
|
| 124 |
-
**Functions to implement:**
|
| 125 |
-
|
| 126 |
-
```python
|
| 127 |
-
def load_documents(data_dir: str) -> list[Document]:
|
| 128 |
-
"""
|
| 129 |
-
Load all PDFs and .txt files from data_dir.
|
| 130 |
-
Use PyPDFLoader for PDFs, TextLoader for .txt.
|
| 131 |
-
Return list of LangChain Document objects with metadata:
|
| 132 |
-
- source: filename
|
| 133 |
-
- page: page number (PDFs only)
|
| 134 |
-
"""
|
| 135 |
-
|
| 136 |
-
def chunk_documents(documents: list[Document], chunk_size: int = 500, chunk_overlap: int = 50) -> list[Document]:
|
| 137 |
-
"""
|
| 138 |
-
Split documents using RecursiveCharacterTextSplitter.
|
| 139 |
-
chunk_size and chunk_overlap from config.yaml.
|
| 140 |
-
Preserve metadata from parent document.
|
| 141 |
-
Add chunk_index to metadata.
|
| 142 |
-
"""
|
| 143 |
-
|
| 144 |
-
def embed_and_store(chunks: list[Document], collection_name: str = "finrag") -> Chroma:
|
| 145 |
-
"""
|
| 146 |
-
Embed chunks using HuggingFaceEmbeddings (all-MiniLM-L6-v2).
|
| 147 |
-
Store in ChromaDB at ./chroma_db.
|
| 148 |
-
If collection already exists, skip re-embedding (idempotent).
|
| 149 |
-
Return Chroma retriever object.
|
| 150 |
-
"""
|
| 151 |
-
|
| 152 |
-
def run_ingestion_pipeline(data_dir: str) -> Chroma:
|
| 153 |
-
"""
|
| 154 |
-
Orchestrates: load → chunk → embed → store.
|
| 155 |
-
Print progress: N docs loaded, N chunks created, stored in ChromaDB.
|
| 156 |
-
"""
|
| 157 |
-
```
|
| 158 |
-
|
| 159 |
-
**Important:** Ingestion must be idempotent. Running twice should not duplicate chunks. Use document hash as ChromaDB document ID.
|
| 160 |
-
|
| 161 |
-
---
|
| 162 |
-
|
| 163 |
-
### 4.2 `server/retriever.py`
|
| 164 |
-
|
| 165 |
-
**Purpose:** Query ChromaDB and return top-K chunks with similarity scores and metadata.
|
| 166 |
-
|
| 167 |
-
```python
|
| 168 |
-
def get_retriever(collection_name: str = "finrag", k: int = 5) -> VectorStoreRetriever:
|
| 169 |
-
"""
|
| 170 |
-
Load existing ChromaDB collection.
|
| 171 |
-
Return LangChain retriever with k=5 (from config.yaml).
|
| 172 |
-
"""
|
| 173 |
-
|
| 174 |
-
def retrieve_with_scores(query: str, k: int = 5) -> list[dict]:
|
| 175 |
-
"""
|
| 176 |
-
Return list of dicts:
|
| 177 |
-
[
|
| 178 |
-
{
|
| 179 |
-
"content": "chunk text...",
|
| 180 |
-
"source": "rbi_annual_report_2024.pdf",
|
| 181 |
-
"page": 12,
|
| 182 |
-
"chunk_index": 34,
|
| 183 |
-
"similarity_score": 0.87
|
| 184 |
-
},
|
| 185 |
-
...
|
| 186 |
-
]
|
| 187 |
-
"""
|
| 188 |
-
```
|
| 189 |
-
|
| 190 |
-
---
|
| 191 |
-
|
| 192 |
-
### 4.3 `server/memory.py`
|
| 193 |
-
|
| 194 |
-
**Purpose:** Manage conversation memory across turns.
|
| 195 |
-
|
| 196 |
-
```python
|
| 197 |
-
def create_memory(memory_key: str = "chat_history", max_token_limit: int = 2000) -> ConversationBufferMemory:
|
| 198 |
-
"""
|
| 199 |
-
Create LangChain ConversationBufferMemory.
|
| 200 |
-
memory_key = "chat_history"
|
| 201 |
-
return_messages = True
|
| 202 |
-
max_token_limit = 2000 (truncate oldest messages when exceeded)
|
| 203 |
-
"""
|
| 204 |
-
|
| 205 |
-
def get_memory_as_string(memory: ConversationBufferMemory) -> str:
|
| 206 |
-
"""
|
| 207 |
-
Return conversation history as formatted string for display in UI.
|
| 208 |
-
"""
|
| 209 |
-
|
| 210 |
-
def clear_memory(memory: ConversationBufferMemory) -> None:
|
| 211 |
-
"""
|
| 212 |
-
Clear all messages. Called on "New Conversation" button.
|
| 213 |
-
"""
|
| 214 |
-
```
|
| 215 |
-
|
| 216 |
-
---
|
| 217 |
-
|
| 218 |
-
### 4.4 `server/chain.py`
|
| 219 |
-
|
| 220 |
-
**Purpose:** Assemble the full RAG + memory chain. Core of the application.
|
| 221 |
-
|
| 222 |
-
```python
|
| 223 |
-
def build_qa_chain(retriever, memory) -> ConversationalRetrievalChain:
|
| 224 |
-
"""
|
| 225 |
-
Build LangChain ConversationalRetrievalChain:
|
| 226 |
-
- LLM: Euron API (gpt-4.1-mini) via ChatOpenAI with base_url="https://api.euron.one/api/v1/euri"
|
| 227 |
-
- Retriever: from retriever.py
|
| 228 |
-
- Memory: from memory.py
|
| 229 |
-
- return_source_documents: True
|
| 230 |
-
- verbose: False
|
| 231 |
-
|
| 232 |
-
System prompt to inject:
|
| 233 |
-
"You are FinRAG, a fintech research assistant. Answer questions using
|
| 234 |
-
only the provided context. If the answer is not in the context, say
|
| 235 |
-
'I could not find this in the loaded documents.' Do not hallucinate.
|
| 236 |
-
Be concise and cite your source document."
|
| 237 |
-
"""
|
| 238 |
-
|
| 239 |
-
def run_query(chain, question: str) -> dict:
|
| 240 |
-
"""
|
| 241 |
-
Run chain on question.
|
| 242 |
-
Return:
|
| 243 |
-
{
|
| 244 |
-
"answer": "...",
|
| 245 |
-
"source_documents": [...],
|
| 246 |
-
"question": "..."
|
| 247 |
-
}
|
| 248 |
-
"""
|
| 249 |
-
```
|
| 250 |
-
|
| 251 |
-
---
|
| 252 |
-
|
| 253 |
-
### 4.5 `server/eval/precision.py`
|
| 254 |
-
|
| 255 |
-
**Purpose:** Compute Precision@K against a ground truth set.
|
| 256 |
-
|
| 257 |
-
**Ground truth format (`data/ground_truth/eval_pairs.json`):**
|
| 258 |
-
```json
|
| 259 |
-
[
|
| 260 |
-
{
|
| 261 |
-
"query": "What was India's UPI transaction volume in FY24?",
|
| 262 |
-
"relevant_sources": ["npci_upi_report_2024.pdf"],
|
| 263 |
-
"relevant_chunk_keywords": ["billion transactions", "FY2024", "NPCI"]
|
| 264 |
-
},
|
| 265 |
-
...
|
| 266 |
-
]
|
| 267 |
-
```
|
| 268 |
-
|
| 269 |
-
```python
|
| 270 |
-
def compute_precision_at_k(query: str, retrieved_chunks: list[dict], ground_truth: dict, k: int = 5) -> float:
|
| 271 |
-
"""
|
| 272 |
-
Precision@K = (relevant chunks in top-K) / K
|
| 273 |
-
|
| 274 |
-
A chunk is "relevant" if:
|
| 275 |
-
- Its source matches ground_truth["relevant_sources"], OR
|
| 276 |
-
- Its content contains any keyword from ground_truth["relevant_chunk_keywords"]
|
| 277 |
-
|
| 278 |
-
Return float between 0 and 1.
|
| 279 |
-
"""
|
| 280 |
-
|
| 281 |
-
def run_batch_precision_eval(eval_pairs_path: str, k: int = 5) -> dict:
|
| 282 |
-
"""
|
| 283 |
-
Run precision@K for all queries in eval_pairs.json.
|
| 284 |
-
Return:
|
| 285 |
-
{
|
| 286 |
-
"mean_precision_at_k": 0.74,
|
| 287 |
-
"per_query_results": [
|
| 288 |
-
{"query": "...", "precision_at_k": 0.8, "retrieved_sources": [...]},
|
| 289 |
-
...
|
| 290 |
-
]
|
| 291 |
-
}
|
| 292 |
-
"""
|
| 293 |
-
```
|
| 294 |
-
|
| 295 |
-
---
|
| 296 |
-
|
| 297 |
-
### 4.6 `server/eval/faithfulness.py`
|
| 298 |
-
|
| 299 |
-
**Purpose:** Score whether the generated answer is faithful to the retrieved context using LLM-as-Judge.
|
| 300 |
-
|
| 301 |
-
```python
|
| 302 |
-
FAITHFULNESS_PROMPT = """
|
| 303 |
-
You are an evaluation judge. Given a context and an answer, score how faithful
|
| 304 |
-
the answer is to the context on a scale of 1-5.
|
| 305 |
-
|
| 306 |
-
1 = Answer contradicts or ignores the context entirely
|
| 307 |
-
2 = Answer uses context minimally, adds significant unsupported claims
|
| 308 |
-
3 = Answer mostly uses context with minor unsupported additions
|
| 309 |
-
4 = Answer is well-grounded in context with trivial additions only
|
| 310 |
-
5 = Answer is entirely and accurately derived from the context
|
| 311 |
-
|
| 312 |
-
Context:
|
| 313 |
-
{context}
|
| 314 |
-
|
| 315 |
-
Answer:
|
| 316 |
-
{answer}
|
| 317 |
-
|
| 318 |
-
Respond ONLY with valid JSON: {{"score": <int>, "reason": "<one sentence>"}}
|
| 319 |
-
"""
|
| 320 |
-
|
| 321 |
-
def score_faithfulness(answer: str, source_chunks: list[dict]) -> dict:
|
| 322 |
-
"""
|
| 323 |
-
Call Euron API (gpt-4.1-mini) with FAITHFULNESS_PROMPT.
|
| 324 |
-
Parse JSON response.
|
| 325 |
-
Return:
|
| 326 |
-
{
|
| 327 |
-
"score": 4,
|
| 328 |
-
"reason": "Answer accurately summarizes the retrieved UPI statistics.",
|
| 329 |
-
"raw_response": "..."
|
| 330 |
-
}
|
| 331 |
-
Handle JSON parse errors gracefully — return score: -1 on failure.
|
| 332 |
-
"""
|
| 333 |
-
```
|
| 334 |
-
|
| 335 |
-
---
|
| 336 |
-
|
| 337 |
-
### 4.7 `server/utils.py`
|
| 338 |
-
|
| 339 |
-
```python
|
| 340 |
-
def load_config(config_path: str = "config.yaml") -> dict:
|
| 341 |
-
"""Load config.yaml and return as dict."""
|
| 342 |
-
|
| 343 |
-
def count_tokens(text: str) -> int:
|
| 344 |
-
"""Approximate token count: len(text.split()) * 1.3"""
|
| 345 |
-
|
| 346 |
-
def setup_logger(name: str) -> logging.Logger:
|
| 347 |
-
"""Return configured logger with timestamp format."""
|
| 348 |
-
```
|
| 349 |
-
|
| 350 |
-
---
|
| 351 |
-
|
| 352 |
-
### 4.8 `config.yaml`
|
| 353 |
-
|
| 354 |
-
```yaml
|
| 355 |
-
chunking:
|
| 356 |
-
chunk_size: 500
|
| 357 |
-
chunk_overlap: 50
|
| 358 |
-
|
| 359 |
-
retrieval:
|
| 360 |
-
k: 5
|
| 361 |
-
collection_name: "finrag"
|
| 362 |
-
|
| 363 |
-
memory:
|
| 364 |
-
max_token_limit: 2000
|
| 365 |
-
|
| 366 |
-
llm:
|
| 367 |
-
model: "gpt-5.3-instant"
|
| 368 |
-
base_url: "https://api.euron.one/api/v1/euri"
|
| 369 |
-
max_tokens: 1000
|
| 370 |
-
temperature: 0.1
|
| 371 |
-
|
| 372 |
-
eval:
|
| 373 |
-
ground_truth_path: "data/ground_truth/eval_pairs.json"
|
| 374 |
-
precision_k: 5
|
| 375 |
-
```
|
| 376 |
-
|
| 377 |
-
---
|
| 378 |
-
|
| 379 |
-
## 5. FastAPI Backend + React Frontend
|
| 380 |
-
|
| 381 |
-
### 5.1 `server/main.py` — FastAPI Application
|
| 382 |
-
|
| 383 |
-
**Entry point.** Initializes FastAPI app, CORS middleware, and includes route modules.
|
| 384 |
-
|
| 385 |
-
```python
|
| 386 |
-
from fastapi import FastAPI
|
| 387 |
-
from fastapi.middleware.cors import CORSMiddleware
|
| 388 |
-
from server.routes import chat, eval
|
| 389 |
-
|
| 390 |
-
app = FastAPI(title="FinRAG API")
|
| 391 |
-
|
| 392 |
-
app.add_middleware(
|
| 393 |
-
CORSMiddleware,
|
| 394 |
-
allow_origins=["http://localhost:5173"], # Vite dev server
|
| 395 |
-
allow_methods=["*"],
|
| 396 |
-
allow_headers=["*"],
|
| 397 |
-
)
|
| 398 |
-
|
| 399 |
-
app.include_router(chat.router, prefix="/api")
|
| 400 |
-
app.include_router(eval.router, prefix="/api")
|
| 401 |
-
|
| 402 |
-
# On startup: initialize chain, memory, retriever as app state
|
| 403 |
-
@app.on_event("startup")
|
| 404 |
-
def startup():
|
| 405 |
-
app.state.memory = create_memory()
|
| 406 |
-
retriever = get_retriever()
|
| 407 |
-
app.state.chain = build_qa_chain(retriever, app.state.memory)
|
| 408 |
-
app.state.eval_log = []
|
| 409 |
-
```
|
| 410 |
-
|
| 411 |
-
---
|
| 412 |
-
|
| 413 |
-
### 5.2 `server/routes/chat.py` — Chat API
|
| 414 |
-
|
| 415 |
-
```python
|
| 416 |
-
# POST /api/chat
|
| 417 |
-
# Request: { "question": "What was UPI volume in FY24?" }
|
| 418 |
-
# Response: {
|
| 419 |
-
# "answer": "...",
|
| 420 |
-
# "sources": [{ "content", "source", "page", "chunk_index", "similarity_score" }],
|
| 421 |
-
# "faithfulness": { "score": 4, "reason": "..." }
|
| 422 |
-
# }
|
| 423 |
-
|
| 424 |
-
# DELETE /api/chat/memory
|
| 425 |
-
# Clears conversation memory. Returns { "status": "cleared" }
|
| 426 |
-
```
|
| 427 |
-
|
| 428 |
-
---
|
| 429 |
-
|
| 430 |
-
### 5.3 `server/routes/eval.py` — Eval API
|
| 431 |
-
|
| 432 |
-
```python
|
| 433 |
-
# GET /api/eval/session
|
| 434 |
-
# Returns the session eval log: list of { query, answer, faithfulness_score, reason }
|
| 435 |
-
|
| 436 |
-
# POST /api/eval/precision
|
| 437 |
-
# Runs batch Precision@K eval against ground truth.
|
| 438 |
-
# Response: {
|
| 439 |
-
# "mean_precision_at_k": 0.74,
|
| 440 |
-
# "per_query_results": [{ "query", "precision_at_k", "retrieved_sources" }]
|
| 441 |
-
# }
|
| 442 |
-
```
|
| 443 |
-
|
| 444 |
-
---
|
| 445 |
-
|
| 446 |
-
### 5.4 React Frontend (`frontend/`)
|
| 447 |
-
|
| 448 |
-
**Built with:** Vite + React + Tailwind CSS
|
| 449 |
-
|
| 450 |
-
**Two-tab layout via tab navigation in `App.jsx`:**
|
| 451 |
-
|
| 452 |
-
**Chat Tab (`ChatTab.jsx`):**
|
| 453 |
-
- Sidebar: loaded documents list, chunk count, "New Conversation" button, eval summary (last 5)
|
| 454 |
-
- Main panel: chat message history, input box at bottom
|
| 455 |
-
- Each assistant message: collapsible source expander showing source filename, page, similarity score, chunk preview (first 200 chars)
|
| 456 |
-
- Faithfulness badge inline after each answer: green (4-5/5), yellow (3/5), red (1-2/5)
|
| 457 |
-
- "New Conversation" calls `DELETE /api/chat/memory` and clears local message state
|
| 458 |
-
|
| 459 |
-
**Eval Dashboard (`EvalDashboard.jsx`):**
|
| 460 |
-
- Section 1 — Session eval log table (fetched from `GET /api/eval/session`)
|
| 461 |
-
- Section 2 — "Run Precision@K Eval" button → calls `POST /api/eval/precision` → shows mean score + per-query breakdown table + bar chart
|
| 462 |
-
- Section 3 — Retrieval health traffic light: green if both > 0.7, yellow if either 0.5-0.7, red if either < 0.5
|
| 463 |
-
|
| 464 |
-
---
|
| 465 |
-
|
| 466 |
-
## 6. Ground Truth Setup (`data/ground_truth/eval_pairs.json`)
|
| 467 |
-
|
| 468 |
-
Create 20 eval pairs covering the 3 loaded documents. Sample structure — Claude Code should generate all 20:
|
| 469 |
-
|
| 470 |
-
```json
|
| 471 |
-
[
|
| 472 |
-
{
|
| 473 |
-
"query": "What was the total UPI transaction volume in FY2024?",
|
| 474 |
-
"relevant_sources": ["npci_upi_report_2024.pdf"],
|
| 475 |
-
"relevant_chunk_keywords": ["billion", "FY2024", "transaction volume", "NPCI"]
|
| 476 |
-
},
|
| 477 |
-
{
|
| 478 |
-
"query": "What is RBI's stance on digital lending regulations?",
|
| 479 |
-
"relevant_sources": ["rbi_annual_report_2024.pdf"],
|
| 480 |
-
"relevant_chunk_keywords": ["digital lending", "NBFC", "regulation", "guidelines"]
|
| 481 |
-
},
|
| 482 |
-
{
|
| 483 |
-
"query": "What were Bajaj Finance's AUM figures in Q3 FY24?",
|
| 484 |
-
"relevant_sources": ["bajaj_finance_q3_2024_transcript.txt"],
|
| 485 |
-
"relevant_chunk_keywords": ["AUM", "assets under management", "Q3", "crore"]
|
| 486 |
-
}
|
| 487 |
-
]
|
| 488 |
-
```
|
| 489 |
-
|
| 490 |
-
**Note for Claude Code:** Generate 20 realistic fintech eval pairs in this format. Do not make up specific numbers — use keyword-based matching only.
|
| 491 |
-
|
| 492 |
-
---
|
| 493 |
-
|
| 494 |
-
## 7. CLI Scripts
|
| 495 |
-
|
| 496 |
-
### `scripts/run_ingest.py`
|
| 497 |
-
```
|
| 498 |
-
Usage: python scripts/run_ingest.py --data-dir data/raw [--reset]
|
| 499 |
-
--reset: wipe ChromaDB and re-ingest from scratch
|
| 500 |
-
Output:
|
| 501 |
-
Loading documents from data/raw...
|
| 502 |
-
Loaded 3 documents (127 pages total)
|
| 503 |
-
Chunking... 847 chunks created
|
| 504 |
-
Embedding and storing in ChromaDB... done
|
| 505 |
-
Collection 'finrag': 847 chunks ready
|
| 506 |
-
```
|
| 507 |
-
|
| 508 |
-
### `scripts/run_eval.py`
|
| 509 |
-
```
|
| 510 |
-
Usage: python scripts/run_eval.py --queries data/ground_truth/eval_pairs.json [--k 5]
|
| 511 |
-
Output:
|
| 512 |
-
Running Precision@5 eval on 20 queries...
|
| 513 |
-
Mean Precision@5: 0.74
|
| 514 |
-
Results saved to: eval_results_<timestamp>.json
|
| 515 |
-
```
|
| 516 |
-
|
| 517 |
-
---
|
| 518 |
-
|
| 519 |
-
## 8. Tests
|
| 520 |
-
|
| 521 |
-
### `tests/test_ingest.py`
|
| 522 |
-
- Test `chunk_documents` returns chunks with correct metadata
|
| 523 |
-
- Test `embed_and_store` is idempotent (run twice, chunk count stays same)
|
| 524 |
-
|
| 525 |
-
### `tests/test_retriever.py`
|
| 526 |
-
- Test `retrieve_with_scores` returns exactly K results
|
| 527 |
-
- Test each result has required keys: content, source, similarity_score
|
| 528 |
-
|
| 529 |
-
### `tests/test_chain.py`
|
| 530 |
-
- Test `run_query` returns dict with keys: answer, source_documents, question
|
| 531 |
-
- Test answer is non-empty string
|
| 532 |
-
|
| 533 |
-
### `tests/test_eval.py`
|
| 534 |
-
- Test `compute_precision_at_k` returns float between 0 and 1
|
| 535 |
-
- Test `score_faithfulness` returns dict with score key
|
| 536 |
-
- Test faithfulness handles JSON parse error (returns score: -1)
|
| 537 |
-
|
| 538 |
-
---
|
| 539 |
-
|
| 540 |
-
## 9. `requirements.txt` (Python backend)
|
| 541 |
-
|
| 542 |
-
```
|
| 543 |
-
openai>=1.0.0
|
| 544 |
-
langchain>=0.1.0
|
| 545 |
-
langchain-openai>=0.1.0
|
| 546 |
-
langchain-community>=0.0.20
|
| 547 |
-
langchain-chroma>=0.1.0
|
| 548 |
-
langchain-huggingface>=0.1.0
|
| 549 |
-
langchain-text-splitters>=0.1.0
|
| 550 |
-
langchain-classic>=0.1.0
|
| 551 |
-
chromadb>=0.4.0
|
| 552 |
-
sentence-transformers>=2.2.0
|
| 553 |
-
pypdf>=3.0.0
|
| 554 |
-
fastapi>=0.110.0
|
| 555 |
-
uvicorn>=0.27.0
|
| 556 |
-
python-multipart>=0.0.6
|
| 557 |
-
pyyaml>=6.0
|
| 558 |
-
python-dotenv>=1.0.0
|
| 559 |
-
pytest>=7.0.0
|
| 560 |
-
```
|
| 561 |
-
|
| 562 |
-
---
|
| 563 |
-
|
| 564 |
-
## 10. Environment Variables
|
| 565 |
-
|
| 566 |
-
### Backend (`.env` locally, or Render dashboard in prod)
|
| 567 |
-
```
|
| 568 |
-
EURON_API_KEY=your_key_here
|
| 569 |
-
FRONTEND_URL=https://your-app.vercel.app # production only — for CORS
|
| 570 |
-
```
|
| 571 |
-
|
| 572 |
-
### Frontend (`.env` locally, or Vercel dashboard in prod)
|
| 573 |
-
```
|
| 574 |
-
VITE_API_URL=https://your-backend.onrender.com/api
|
| 575 |
-
```
|
| 576 |
-
When unset, the frontend falls back to `/api` (used in local dev via Vite proxy).
|
| 577 |
-
|
| 578 |
-
---
|
| 579 |
-
|
| 580 |
-
## 11. `Dockerfile` (backend-only)
|
| 581 |
-
|
| 582 |
-
The frontend is deployed separately on Vercel, so the Dockerfile only builds the Python backend.
|
| 583 |
-
|
| 584 |
-
```dockerfile
|
| 585 |
-
FROM python:3.11-slim
|
| 586 |
-
WORKDIR /app
|
| 587 |
-
|
| 588 |
-
COPY requirements.txt .
|
| 589 |
-
RUN pip install --no-cache-dir -r requirements.txt
|
| 590 |
-
|
| 591 |
-
COPY server/ server/
|
| 592 |
-
COPY config.yaml .
|
| 593 |
-
COPY data/ground_truth/ data/ground_truth/
|
| 594 |
-
|
| 595 |
-
EXPOSE 8000
|
| 596 |
-
|
| 597 |
-
CMD ["uvicorn", "server.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
| 598 |
-
```
|
| 599 |
-
|
| 600 |
-
### `render.yaml` (Render Blueprint)
|
| 601 |
-
```yaml
|
| 602 |
-
services:
|
| 603 |
-
- type: web
|
| 604 |
-
name: finrag
|
| 605 |
-
runtime: docker
|
| 606 |
-
plan: free
|
| 607 |
-
envVars:
|
| 608 |
-
- key: EURON_API_KEY
|
| 609 |
-
sync: false
|
| 610 |
-
```
|
| 611 |
-
|
| 612 |
-
### Deployment flow
|
| 613 |
-
1. Push repo to GitHub
|
| 614 |
-
2. **Render** → New Web Service → connect repo → set `EURON_API_KEY` env var → deploy
|
| 615 |
-
3. **Vercel** → Import repo → Root Directory: `frontend` → set `VITE_API_URL` to the Render URL → deploy
|
| 616 |
-
4. Back on Render → set `FRONTEND_URL` to the Vercel URL (for CORS)
|
| 617 |
-
|
| 618 |
-
**Note on Render free tier:** 512MB RAM is insufficient for `sentence-transformers` (loads a ~400MB PyTorch model). Either upgrade to the Standard plan (2GB RAM, $25/mo) or swap to API-based embeddings via Euron.
|
| 619 |
-
|
| 620 |
-
---
|
| 621 |
-
|
| 622 |
-
## 12. README Structure (write after ship)
|
| 623 |
-
|
| 624 |
-
```markdown
|
| 625 |
-
# FinRAG — Fintech Research Agent with Persistent Memory
|
| 626 |
-
|
| 627 |
-
## Problem
|
| 628 |
-
[2 paragraphs — fintech analysts manually reading PDFs]
|
| 629 |
-
|
| 630 |
-
## What makes it different
|
| 631 |
-
[The eval layer — most RAG ships without retrieval quality signals]
|
| 632 |
-
|
| 633 |
-
## Architecture diagram
|
| 634 |
-
[ASCII or image]
|
| 635 |
-
|
| 636 |
-
## Demo GIF
|
| 637 |
-
[Screen recording of chat + eval dashboard]
|
| 638 |
-
|
| 639 |
-
## Eval results
|
| 640 |
-
| Metric | Score |
|
| 641 |
-
|---|---|
|
| 642 |
-
| Mean Precision@5 | 0.XX |
|
| 643 |
-
| Mean Faithfulness | X.X/5 |
|
| 644 |
-
|
| 645 |
-
## How to run locally
|
| 646 |
-
[5 steps]
|
| 647 |
-
|
| 648 |
-
## Tech stack
|
| 649 |
-
[Table]
|
| 650 |
-
```
|
| 651 |
-
|
| 652 |
-
---
|
| 653 |
-
|
| 654 |
-
## 13. Build Order — Phased Implementation
|
| 655 |
-
|
| 656 |
-
### Phase 1: Project Setup & Configuration
|
| 657 |
-
> **Goal:** Scaffold the project (backend + frontend), set up config, and install dependencies.
|
| 658 |
-
|
| 659 |
-
- [ ] 1.1 Scaffold full directory structure with empty files (server/, frontend/, scripts/, tests/, data/)
|
| 660 |
-
- [ ] 1.2 Write `requirements.txt` (Python backend deps)
|
| 661 |
-
- [ ] 1.3 Initialize React frontend with Vite + Tailwind CSS (`frontend/`)
|
| 662 |
-
- [ ] 1.4 Implement `config.yaml` and `.env.example`
|
| 663 |
-
- [ ] 1.5 Implement `server/utils.py` (config loader, logger, token counter)
|
| 664 |
-
- [ ] 1.6 Set up `.gitignore` (chroma_db/, .env, __pycache__, node_modules/, dist/, etc.)
|
| 665 |
-
|
| 666 |
-
**Milestone:** `pip install -r requirements.txt` succeeds, `cd frontend && npm install` succeeds, config loads without error.
|
| 667 |
-
|
| 668 |
-
---
|
| 669 |
-
|
| 670 |
-
### Phase 2: Ingestion & Retrieval Pipeline
|
| 671 |
-
> **Goal:** Build the core data pipeline — load documents, chunk, embed, store, and retrieve.
|
| 672 |
-
|
| 673 |
-
- [ ] 2.1 Implement `server/ingest.py` — all 4 functions (load, chunk, embed, orchestrate)
|
| 674 |
-
- [ ] 2.2 Implement `server/retriever.py` — both functions (get_retriever, retrieve_with_scores)
|
| 675 |
-
- [ ] 2.3 Implement `scripts/run_ingest.py` (CLI for ingestion)
|
| 676 |
-
- [ ] 2.4 Add sample documents to `data/raw/`
|
| 677 |
-
- [ ] 2.5 Verify: `python scripts/run_ingest.py --data-dir data/raw` processes documents and reports chunk count
|
| 678 |
-
|
| 679 |
-
**Milestone:** Documents are ingested into ChromaDB, retrieval returns top-K chunks with scores.
|
| 680 |
-
|
| 681 |
-
---
|
| 682 |
-
|
| 683 |
-
### Phase 3: Memory & RAG Chain
|
| 684 |
-
> **Goal:** Wire up conversation memory and the full RAG chain with Claude.
|
| 685 |
-
|
| 686 |
-
- [ ] 3.1 Implement `server/memory.py` — all 3 functions (create, get_as_string, clear)
|
| 687 |
-
- [ ] 3.2 Implement `server/chain.py` — both functions (build_qa_chain, run_query)
|
| 688 |
-
- [ ] 3.3 Verify: chain answers a fintech question from CLI and returns source documents
|
| 689 |
-
|
| 690 |
-
**Milestone:** End-to-end RAG pipeline works — query → retrieve → generate answer with sources.
|
| 691 |
-
|
| 692 |
-
---
|
| 693 |
-
|
| 694 |
-
### Phase 4: Evaluation Layer
|
| 695 |
-
> **Goal:** Add self-scoring retrieval eval — Precision@K and faithfulness.
|
| 696 |
-
|
| 697 |
-
- [ ] 4.1 Generate `data/ground_truth/eval_pairs.json` — 20 query/relevant-chunk pairs
|
| 698 |
-
- [ ] 4.2 Implement `server/eval/precision.py` — both functions (compute_precision_at_k, run_batch)
|
| 699 |
-
- [ ] 4.3 Implement `server/eval/faithfulness.py` — LLM-as-Judge scorer
|
| 700 |
-
- [ ] 4.4 Implement `scripts/run_eval.py` (CLI for batch eval)
|
| 701 |
-
- [ ] 4.5 Verify: `python scripts/run_eval.py` outputs mean Precision@5 and per-query scores
|
| 702 |
-
|
| 703 |
-
**Milestone:** Eval pipeline produces Precision@K and faithfulness scores for all ground truth queries.
|
| 704 |
-
|
| 705 |
-
---
|
| 706 |
-
|
| 707 |
-
### Phase 5a: FastAPI Backend API
|
| 708 |
-
> **Goal:** Build the REST API that serves the RAG chain and eval endpoints.
|
| 709 |
-
|
| 710 |
-
- [ ] 5a.1 Implement `server/main.py` (FastAPI app, CORS, startup event, static file serving)
|
| 711 |
-
- [ ] 5a.2 Implement `server/routes/chat.py` (POST /api/chat, DELETE /api/chat/memory)
|
| 712 |
-
- [ ] 5a.3 Implement `server/routes/eval.py` (GET /api/eval/session, POST /api/eval/precision)
|
| 713 |
-
- [ ] 5a.4 Verify: `uvicorn server.main:app` starts and API endpoints respond correctly
|
| 714 |
-
|
| 715 |
-
**Milestone:** All API endpoints work — chat returns answers with sources + faithfulness, eval returns scores.
|
| 716 |
-
|
| 717 |
-
---
|
| 718 |
-
|
| 719 |
-
### Phase 5b: React Frontend
|
| 720 |
-
> **Goal:** Build the React UI — Chat + Eval Dashboard tabs.
|
| 721 |
-
|
| 722 |
-
- [ ] 5b.1 Implement `frontend/src/api.js` (Axios client for backend)
|
| 723 |
-
- [ ] 5b.2 Implement `frontend/src/App.jsx` (tab navigation between Chat and Eval)
|
| 724 |
-
- [ ] 5b.3 Implement `frontend/src/components/ChatTab.jsx` (chat UI, sidebar, message input)
|
| 725 |
-
- [ ] 5b.4 Implement `frontend/src/components/MessageBubble.jsx` and `SourceExpander.jsx`
|
| 726 |
-
- [ ] 5b.5 Implement `frontend/src/components/EvalDashboard.jsx` (session log, batch Precision@K, retrieval health)
|
| 727 |
-
- [ ] 5b.6 Verify: `npm run dev` launches frontend, chat works end-to-end with backend
|
| 728 |
-
|
| 729 |
-
**Milestone:** Full UI is functional — chat with sources, inline faithfulness badges, eval dashboard with bar chart.
|
| 730 |
-
|
| 731 |
-
---
|
| 732 |
-
|
| 733 |
-
### Phase 6: Testing
|
| 734 |
-
> **Goal:** Write and pass all unit tests.
|
| 735 |
-
|
| 736 |
-
- [ ] 6.1 Implement `tests/test_ingest.py` (chunking metadata, idempotency)
|
| 737 |
-
- [ ] 6.2 Implement `tests/test_retriever.py` (K results, required keys)
|
| 738 |
-
- [ ] 6.3 Implement `tests/test_chain.py` (response structure, non-empty answer)
|
| 739 |
-
- [ ] 6.4 Implement `tests/test_eval.py` (precision range, faithfulness structure, error handling)
|
| 740 |
-
- [ ] 6.5 Verify: `pytest` passes all tests
|
| 741 |
-
|
| 742 |
-
**Milestone:** All 4 test files pass with `pytest`.
|
| 743 |
-
|
| 744 |
-
---
|
| 745 |
-
|
| 746 |
-
### Phase 7: Deployment & Polish
|
| 747 |
-
> **Goal:** Containerize, deploy, and finalize the project.
|
| 748 |
-
|
| 749 |
-
- [x] 7.1 Write backend `Dockerfile` (Python-only; frontend deploys to Vercel)
|
| 750 |
-
- [x] 7.2 Write `render.yaml` blueprint
|
| 751 |
-
- [x] 7.3 Add `VITE_API_URL` env support in `frontend/src/api.js`
|
| 752 |
-
- [x] 7.4 Configure CORS with `FRONTEND_URL` env var in `server/main.py`
|
| 753 |
-
- [x] 7.5 Push to GitHub (https://github.com/BenRoshan100/fin-rag.git)
|
| 754 |
-
- [ ] 7.6 Deploy backend to Render (blocked: free tier OOM — need Standard plan or API embeddings)
|
| 755 |
-
- [ ] 7.7 Deploy frontend to Vercel
|
| 756 |
-
- [ ] 7.8 Verify live URLs are accessible and functional
|
| 757 |
-
- [ ] 7.9 Write `README.md` (problem, architecture, demo GIF, eval results, setup steps)
|
| 758 |
-
|
| 759 |
-
**Milestone:** Backend live on Render, frontend live on Vercel, README complete, project portfolio-ready.
|
| 760 |
-
|
| 761 |
-
---
|
| 762 |
-
|
| 763 |
-
## 14. Acceptance Criteria
|
| 764 |
-
|
| 765 |
-
- [ ] `run_ingest.py` processes 3 documents and confirms chunk count in terminal
|
| 766 |
-
- [ ] Chat tab answers a fintech question and shows source chunks in expander
|
| 767 |
-
- [ ] Follow-up question uses prior context (memory working)
|
| 768 |
-
- [ ] Each answer shows faithfulness badge (🟢/🟡/🔴)
|
| 769 |
-
- [ ] Eval dashboard batch run shows Precision@5 score and bar chart
|
| 770 |
-
- [ ] Ingestion is idempotent — running twice does not duplicate chunks
|
| 771 |
-
- [ ] All 4 test files pass with `pytest`
|
| 772 |
-
- [ ] Backend deploys to Render via Dockerfile (GitHub repo connected)
|
| 773 |
-
- [ ] Frontend deploys to Vercel with `VITE_API_URL` pointing to Render backend
|
| 774 |
-
- [ ] Live URLs accessible and functional
|
| 775 |
-
|
| 776 |
-
---
|
| 777 |
-
|
| 778 |
-
*PRD v1.1 — FinRAG. Updated to reflect actual implementation and split deployment (Render + Vercel).*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -13,3 +13,7 @@ python-multipart>=0.0.6
|
|
| 13 |
pyyaml>=6.0
|
| 14 |
python-dotenv>=1.0.0
|
| 15 |
pytest>=7.0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
pyyaml>=6.0
|
| 14 |
python-dotenv>=1.0.0
|
| 15 |
pytest>=7.0.0
|
| 16 |
+
rank_bm25>=0.2.2
|
| 17 |
+
sentence-transformers>=2.7.0
|
| 18 |
+
ragas>=0.1.0
|
| 19 |
+
datasets>=2.18.0
|
|
@@ -69,12 +69,17 @@ def run_query(chain, question: str) -> dict:
|
|
| 69 |
"source": doc.metadata.get("source", ""),
|
| 70 |
"page": doc.metadata.get("page", None),
|
| 71 |
"chunk_index": doc.metadata.get("chunk_index", None),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
})
|
| 73 |
|
| 74 |
return {
|
| 75 |
"answer": result.get("answer", ""),
|
| 76 |
"source_documents": source_docs,
|
| 77 |
"question": question,
|
|
|
|
| 78 |
}
|
| 79 |
|
| 80 |
|
|
|
|
| 69 |
"source": doc.metadata.get("source", ""),
|
| 70 |
"page": doc.metadata.get("page", None),
|
| 71 |
"chunk_index": doc.metadata.get("chunk_index", None),
|
| 72 |
+
"similarity_score": doc.metadata.get("similarity_score"),
|
| 73 |
+
"bm25_score": doc.metadata.get("bm25_score"),
|
| 74 |
+
"rrf_score": doc.metadata.get("rrf_score"),
|
| 75 |
+
"rerank_score": doc.metadata.get("rerank_score"),
|
| 76 |
})
|
| 77 |
|
| 78 |
return {
|
| 79 |
"answer": result.get("answer", ""),
|
| 80 |
"source_documents": source_docs,
|
| 81 |
"question": question,
|
| 82 |
+
"retrieval_method": "hybrid+rerank",
|
| 83 |
}
|
| 84 |
|
| 85 |
|
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from dotenv import load_dotenv
|
| 3 |
+
|
| 4 |
+
from ragas import evaluate, EvaluationDataset, SingleTurnSample
|
| 5 |
+
from ragas.metrics import Faithfulness, AnswerRelevancy
|
| 6 |
+
from ragas.llms import LangchainLLMWrapper
|
| 7 |
+
from ragas.embeddings import LangchainEmbeddingsWrapper
|
| 8 |
+
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
|
| 9 |
+
|
| 10 |
+
from server.utils import load_config, setup_logger
|
| 11 |
+
|
| 12 |
+
load_dotenv()
|
| 13 |
+
logger = setup_logger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _safe_round(val, decimals: int = 4):
|
| 17 |
+
"""Round float or return None if val is None/NaN."""
|
| 18 |
+
try:
|
| 19 |
+
return round(float(val), decimals)
|
| 20 |
+
except (TypeError, ValueError):
|
| 21 |
+
return None
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _get_api_key() -> str:
|
| 25 |
+
return os.getenv("EURON_API_KEY", "")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _make_llm() -> LangchainLLMWrapper:
|
| 29 |
+
config = load_config()
|
| 30 |
+
llm_cfg = config.get("llm", {})
|
| 31 |
+
llm = ChatOpenAI(
|
| 32 |
+
model=llm_cfg["model"],
|
| 33 |
+
base_url=llm_cfg.get("base_url", "https://api.euron.one/api/v1/euri"),
|
| 34 |
+
api_key=_get_api_key(),
|
| 35 |
+
temperature=0.0,
|
| 36 |
+
)
|
| 37 |
+
return LangchainLLMWrapper(llm)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _make_embeddings() -> LangchainEmbeddingsWrapper:
|
| 41 |
+
emb = OpenAIEmbeddings(
|
| 42 |
+
model="text-embedding-3-small",
|
| 43 |
+
openai_api_key=_get_api_key(),
|
| 44 |
+
openai_api_base="https://api.euron.one/api/v1/euri",
|
| 45 |
+
)
|
| 46 |
+
return LangchainEmbeddingsWrapper(emb)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def run_ragas_eval(eval_log: list[dict], n_pairs: int = 10) -> dict:
|
| 50 |
+
"""
|
| 51 |
+
Run RAGAS on last n_pairs from session eval_log.
|
| 52 |
+
|
| 53 |
+
Computes faithfulness + answer_relevancy (no ground_truth required).
|
| 54 |
+
context_precision + context_recall return null — require labeled ground_truth dataset.
|
| 55 |
+
|
| 56 |
+
Args:
|
| 57 |
+
eval_log: list of {query, answer, contexts, ...} dicts from session
|
| 58 |
+
n_pairs: number of recent pairs to evaluate
|
| 59 |
+
|
| 60 |
+
Returns:
|
| 61 |
+
dict with faithfulness, answer_relevancy, context_precision (null),
|
| 62 |
+
context_recall (null), per_query, sample_count
|
| 63 |
+
"""
|
| 64 |
+
pairs = [p for p in eval_log if p.get("contexts")]
|
| 65 |
+
pairs = pairs[-n_pairs:]
|
| 66 |
+
|
| 67 |
+
if not pairs:
|
| 68 |
+
return {
|
| 69 |
+
"faithfulness": None,
|
| 70 |
+
"answer_relevancy": None,
|
| 71 |
+
"context_precision": None,
|
| 72 |
+
"context_recall": None,
|
| 73 |
+
"per_query": [],
|
| 74 |
+
"sample_count": 0,
|
| 75 |
+
"note": "No session pairs with contexts found. Ask questions first.",
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
samples = [
|
| 79 |
+
SingleTurnSample(
|
| 80 |
+
user_input=p["query"],
|
| 81 |
+
response=p["answer"],
|
| 82 |
+
retrieved_contexts=p["contexts"],
|
| 83 |
+
)
|
| 84 |
+
for p in pairs
|
| 85 |
+
]
|
| 86 |
+
|
| 87 |
+
dataset = EvaluationDataset(samples=samples)
|
| 88 |
+
ragas_llm = _make_llm()
|
| 89 |
+
ragas_emb = _make_embeddings()
|
| 90 |
+
|
| 91 |
+
metrics = [
|
| 92 |
+
Faithfulness(llm=ragas_llm),
|
| 93 |
+
AnswerRelevancy(llm=ragas_llm, embeddings=ragas_emb),
|
| 94 |
+
]
|
| 95 |
+
|
| 96 |
+
logger.info(f"Running RAGAS on {len(samples)} pairs")
|
| 97 |
+
results = evaluate(dataset=dataset, metrics=metrics)
|
| 98 |
+
|
| 99 |
+
scores_df = results.to_pandas()
|
| 100 |
+
per_query = []
|
| 101 |
+
for i, row in scores_df.iterrows():
|
| 102 |
+
per_query.append({
|
| 103 |
+
"query": pairs[i]["query"],
|
| 104 |
+
"faithfulness": _safe_round(row.get("faithfulness")),
|
| 105 |
+
"answer_relevancy": _safe_round(row.get("answer_relevancy")),
|
| 106 |
+
})
|
| 107 |
+
|
| 108 |
+
return {
|
| 109 |
+
"faithfulness": _safe_round(results["faithfulness"]),
|
| 110 |
+
"answer_relevancy": _safe_round(results["answer_relevancy"]),
|
| 111 |
+
"context_precision": None,
|
| 112 |
+
"context_recall": None,
|
| 113 |
+
"per_query": per_query,
|
| 114 |
+
"sample_count": len(samples),
|
| 115 |
+
"note": "context_precision and context_recall require labeled ground_truth dataset",
|
| 116 |
+
}
|
|
@@ -56,6 +56,11 @@ app.include_router(chat.router, prefix="/api")
|
|
| 56 |
app.include_router(eval.router, prefix="/api")
|
| 57 |
app.include_router(upload.router, prefix="/api")
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
# Serve React frontend build if it exists
|
| 60 |
frontend_dist = Path(__file__).resolve().parent.parent / "frontend" / "dist"
|
| 61 |
if frontend_dist.exists():
|
|
|
|
| 56 |
app.include_router(eval.router, prefix="/api")
|
| 57 |
app.include_router(upload.router, prefix="/api")
|
| 58 |
|
| 59 |
+
|
| 60 |
+
@app.get("/health")
|
| 61 |
+
async def health():
|
| 62 |
+
return {"status": "ok", "version": "2.0.0"}
|
| 63 |
+
|
| 64 |
# Serve React frontend build if it exists
|
| 65 |
frontend_dist = Path(__file__).resolve().parent.parent / "frontend" / "dist"
|
| 66 |
if frontend_dist.exists():
|
|
@@ -30,10 +30,11 @@ async def chat(request: Request, body: ChatRequest):
|
|
| 30 |
# Score faithfulness
|
| 31 |
faithfulness = score_faithfulness(result["answer"], result["source_documents"])
|
| 32 |
|
| 33 |
-
# Log to session eval
|
| 34 |
eval_log.append({
|
| 35 |
"query": body.question,
|
| 36 |
"answer": result["answer"],
|
|
|
|
| 37 |
"faithfulness_score": faithfulness["score"],
|
| 38 |
"reason": faithfulness["reason"],
|
| 39 |
})
|
|
@@ -42,6 +43,7 @@ async def chat(request: Request, body: ChatRequest):
|
|
| 42 |
"answer": result["answer"],
|
| 43 |
"sources": result["source_documents"],
|
| 44 |
"faithfulness": faithfulness,
|
|
|
|
| 45 |
}
|
| 46 |
|
| 47 |
|
|
|
|
| 30 |
# Score faithfulness
|
| 31 |
faithfulness = score_faithfulness(result["answer"], result["source_documents"])
|
| 32 |
|
| 33 |
+
# Log to session eval (contexts stored for RAGAS eval)
|
| 34 |
eval_log.append({
|
| 35 |
"query": body.question,
|
| 36 |
"answer": result["answer"],
|
| 37 |
+
"contexts": [doc["content"] for doc in result["source_documents"]],
|
| 38 |
"faithfulness_score": faithfulness["score"],
|
| 39 |
"reason": faithfulness["reason"],
|
| 40 |
})
|
|
|
|
| 43 |
"answer": result["answer"],
|
| 44 |
"sources": result["source_documents"],
|
| 45 |
"faithfulness": faithfulness,
|
| 46 |
+
"retrieval_method": result.get("retrieval_method", "hybrid+rerank"),
|
| 47 |
}
|
| 48 |
|
| 49 |
|
|
@@ -1,6 +1,8 @@
|
|
| 1 |
from fastapi import APIRouter, Request
|
|
|
|
| 2 |
|
| 3 |
from server.eval.precision import run_batch_precision_eval
|
|
|
|
| 4 |
from server.utils import load_config, setup_logger
|
| 5 |
|
| 6 |
logger = setup_logger(__name__)
|
|
@@ -8,6 +10,10 @@ logger = setup_logger(__name__)
|
|
| 8 |
router = APIRouter()
|
| 9 |
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
@router.get("/eval/session")
|
| 12 |
async def get_session_eval_log(request: Request):
|
| 13 |
"""Return the session eval log: list of {query, answer, faithfulness_score, reason}."""
|
|
@@ -27,3 +33,14 @@ async def run_precision_eval():
|
|
| 27 |
|
| 28 |
results = run_batch_precision_eval(ground_truth_path, k=k)
|
| 29 |
return results
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from fastapi import APIRouter, Request
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
|
| 4 |
from server.eval.precision import run_batch_precision_eval
|
| 5 |
+
from server.eval.ragas_eval import run_ragas_eval
|
| 6 |
from server.utils import load_config, setup_logger
|
| 7 |
|
| 8 |
logger = setup_logger(__name__)
|
|
|
|
| 10 |
router = APIRouter()
|
| 11 |
|
| 12 |
|
| 13 |
+
class RagasRequest(BaseModel):
|
| 14 |
+
n_pairs: int = 10
|
| 15 |
+
|
| 16 |
+
|
| 17 |
@router.get("/eval/session")
|
| 18 |
async def get_session_eval_log(request: Request):
|
| 19 |
"""Return the session eval log: list of {query, answer, faithfulness_score, reason}."""
|
|
|
|
| 33 |
|
| 34 |
results = run_batch_precision_eval(ground_truth_path, k=k)
|
| 35 |
return results
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@router.post("/eval/ragas")
|
| 39 |
+
async def run_ragas_evaluation(request: Request, body: RagasRequest):
|
| 40 |
+
"""
|
| 41 |
+
Run RAGAS on last n_pairs from session.
|
| 42 |
+
Returns faithfulness, answer_relevancy (context_precision/recall require ground_truth).
|
| 43 |
+
"""
|
| 44 |
+
eval_log = request.app.state.eval_log
|
| 45 |
+
results = run_ragas_eval(eval_log, n_pairs=body.n_pairs)
|
| 46 |
+
return results
|