benroshan Claude Sonnet 4.6 commited on
Commit
8446976
·
1 Parent(s): 43e87a7

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 CHANGED
@@ -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/
config.yaml CHANGED
@@ -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
docs/api-spec.md ADDED
@@ -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" }`
docs/architecture.md ADDED
@@ -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
docs/decisions.md ADDED
@@ -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) |
docs/structure.md ADDED
@@ -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
+ ```
finrag.md DELETED
@@ -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).*
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.txt CHANGED
@@ -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
server/chain.py CHANGED
@@ -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
 
server/eval/ragas_eval.py ADDED
@@ -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
+ }
server/main.py CHANGED
@@ -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():
server/routes/chat.py CHANGED
@@ -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
 
server/routes/eval.py CHANGED
@@ -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