benroshan commited on
Commit
62fa672
Β·
1 Parent(s): b06c0a7

docs: update architecture.md to reflect current shipped state (v1.3.0 stack)

Browse files
Files changed (1) hide show
  1. docs/architecture.md +51 -25
docs/architecture.md CHANGED
@@ -4,7 +4,7 @@
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-10 β†’ top-5) β†’ LLM answer β†’ LangSmith trace. ParentDocumentRetriever stores 200-char child chunks for retrieval but returns 800-char parent chunks to LLM. Multi-workspace: each workspace has its own ChromaDB collection; vectorstore + retriever cached per workspace to prevent OOM on repeated queries. Eval runs offline via `scripts/run_eval_versioned.py`; results served by a separate `eval-dashboard/` static site.
8
 
9
  ## Component breakdown
10
 
@@ -16,9 +16,17 @@ Query β†’ hybrid retrieval (ChromaDB dense + BM25 sparse) β†’ weighted RRF fusio
16
  | Reranker | cross-encoder/ms-marco-TinyBERT-L-2-v2 | Re-score top-10 β†’ return top-5 (~17MB; chosen over MiniLM-L-6-v2 ~85MB for lower memory footprint) |
17
  | Embeddings | Euron API (text-embedding-3-small) | API-based; Groq has no embeddings endpoint. |
18
  | LLM | Groq (llama-3.3-70b-versatile) via langchain-groq | Fast open-weight inference; OpenAI-compatible |
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
  | Workspace | ChromaDB collection per workspace | Isolated document sets; switcher in frontend sidebar |
23
  | Retriever cache | Module-level dict keyed by workspace | Singleton vectorstore+retriever per workspace; invalidate on ingest |
24
  | Eval | Separate `eval-dashboard/` Vite+React static site | Reads versioned JSON run files; metrics: answer_correctness, answer_relevancy, context_recall, precision@5, latency p50/p95/p99 |
@@ -33,42 +41,60 @@ Query β†’ hybrid retrieval (ChromaDB dense + BM25 sparse) β†’ weighted RRF fusio
33
  ## Data flow
34
 
35
  ### Ingestion
36
- 1. `run_ingest.py` or `POST /api/upload` β†’ load PDFs/TXT/CSV
37
- 2. `ParentDocumentRetriever`: split into 800-char parent + 200-char child chunks
38
- 3. Embed child chunks via Euron API (text-embedding-3-small) β†’ store in ChromaDB
39
- 4. Store parent chunks in `InMemoryStore`
40
- 5. Build BM25 index over child chunk corpus
 
 
 
41
 
42
  ### Query
43
- 1. `POST /api/chat` receives question
44
- 2. `dense_retrieve`: ChromaDB top-10 by cosine similarity (workspace-specific collection)
45
- 3. `sparse_retrieve`: BM25 top-10 by keyword score
46
- 4. `reciprocal_rank_fusion`: merge β†’ deduplicate β†’ RRF score
47
- 5. `Reranker.rerank`: cross-encoder score β†’ return top-5 parent chunks
48
- 6. `ConversationalRetrievalChain`: LLM answers with context + memory
49
- 7. Response includes: answer, sources (with scores), retrieval_method
 
 
 
 
 
 
50
 
51
  ## Key design decisions
52
  - **API embeddings over local**: Euron API embeddings ~0MB RAM; Groq has no embeddings endpoint so Euron is retained for embeddings.
53
- - **ParentDocumentRetriever**: small chunks improve retrieval precision; large parent chunks improve answer faithfulness
54
- - **Cross-encoder reranker**: bi-encoder (ChromaDB) is fast but approximate; cross-encoder is slower but more accurate on top-20 pool
55
- - **BM25 weight 0.3**: regulatory text has exact keyword matches (section numbers); sparse retrieval catches what dense misses
56
- - **RAGAS benchmark pre-computed locally**: `nest_asyncio` cannot patch `uvloop` (used by uvicorn on Linux), making live RAGAS eval impossible on prod. Run `scripts/run_ragas_local.py` locally, commit JSON results, Vercel builds dashboard from file.
 
 
 
 
 
 
57
  - **TinyBERT-L-2-v2 reranker**: MiniLM-L-6-v2 (~85MB) vs TinyBERT-L-2-v2 (~17MB) β€” same ranking quality at demo corpus scale with lower memory footprint.
58
  - **Singleton vectorstore/retriever cache**: each workspace caches its Chroma vectorstore + HybridRetriever in a module-level dict. Without cache, every chat request created a new Chroma instance (full embedding reload). Cache is invalidated after ingest.
59
  - **Multi-workspace isolation**: each workspace maps to one ChromaDB collection. Frontend workspace switcher passes `workspace_id` on every request; backend resolves the correct collection before retrieval.
60
  - **URL size guard**: `url_loader.py` enforces a max content size before embedding URL content, preventing memory spikes from large external pages.
61
- - **Eval dashboard separate site**: eval runs offline, results versioned as JSON. Separates eval tooling from user-facing app; no live eval endpoint on prod backend. Per-message faithfulness badge removed from UI β€” moved to dedicated dashboard.
62
  - **answer_correctness over faithfulness**: faithfulness (LLM judge vs retrieved chunks) is circular β€” inflates when eval pairs are corpus-aligned. answer_correctness (LLM judge vs ground_truth reference) is an independent signal.
63
  - **HF Spaces Docker (UID 1000)**: model weights baked into image under `HF_HOME=/app/.cache/huggingface` as user 1000 at build time; `HF_HUB_OFFLINE=1` set after download to block runtime network calls.
64
 
65
  ## Known limitations
66
- - InMemoryStore for parent chunks: does not survive server restart (re-ingest required)
67
  - BM25 index rebuilt in memory on each startup (not persisted to disk)
68
  - HF Spaces free tier: ephemeral filesystem β€” chroma_db lost on cold start (re-upload required)
69
- - Euron embedding API sequential: ~5s/chunk β€” 30 chunks = ~150s blocking upload
 
 
70
 
71
  ## Future improvements
72
- - Persist BM25 index to disk (pickle)
73
- - Move Euron embedding to background task (return 202, poll for ready)
74
- - Streaming responses from LLM
 
 
 
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
+ Upload β†’ chunk (500-char) β†’ optional contextual augmentation (LLM prepends 2-sentence context per chunk) β†’ embed via Euron API β†’ store in ChromaDB + BM25 index. Query β†’ optional HyDE expand β†’ optional multi-query expand β†’ hybrid retrieval (ChromaDB dense + BM25 sparse) β†’ weighted RRF fusion β†’ cross-encoder rerank (top-10 β†’ top-5) β†’ Tavily web search β†’ LLM answer via SSE stream β†’ LangSmith trace. Multi-workspace: each workspace has its own ChromaDB collection; vectorstore + retriever cached per workspace. Upload returns 202 immediately; embed + contextualize run as background task polled via `GET /api/upload/status/{job_id}`. Eval runs offline via `scripts/run_eval_versioned.py`; results served by a separate `eval-dashboard/` static site.
8
 
9
  ## Component breakdown
10
 
 
16
  | Reranker | cross-encoder/ms-marco-TinyBERT-L-2-v2 | Re-score top-10 β†’ return top-5 (~17MB; chosen over MiniLM-L-6-v2 ~85MB for lower memory footprint) |
17
  | Embeddings | Euron API (text-embedding-3-small) | API-based; Groq has no embeddings endpoint. |
18
  | LLM | Groq (llama-3.3-70b-versatile) via langchain-groq | Fast open-weight inference; OpenAI-compatible |
19
+ | Chunking | RecursiveCharacterTextSplitter (500-char, overlap 50) | Single-pass split; semantic chunking available but disabled (ablation: +9.3pp recall, βˆ’27.3pp P@5, 5Γ— latency) |
20
+ | Contextual retrieval | LLM (llama-3.1-8b-instant) at ingest time | Prepends 2-sentence situating context per chunk before embedding; +18% recall. Async via Semaphore(3). |
21
+ | HyDE | Groq LLM generates hypothetical answer before dense search | Closes question/answer vector space gap; +21pp recall. ON by default. |
22
+ | Multi-Query | Groq LLM generates 3 query phrasings | Widens candidate pool before RRF; best-rank dedup. ON by default. |
23
+ | Web search | Tavily (advanced, max 2 results, 800-char truncation) | Mandatory on every query; grounded answers for open-domain questions |
24
  | Memory | ConversationBufferWindowMemory (k=10) | Last 10 conversation turns |
25
+ | Chain | `stream_query_with_web()` β€” direct LLM call bypassing ConversationalRetrievalChain | Bypasses chain to prevent condensation step stripping web context; yields SSE token stream |
26
+ | Streaming | FastAPI `StreamingResponse` + SSE | Token events during generation; `done` event with sources + retrieval_method |
27
+ | Async upload | 202 + job_id; background `_embed_and_contextualize_bg` | User queryable in <3s; embed + contextualize run in background; poll `GET /api/upload/status/{job_id}` |
28
+ | File serving | `GET /api/files/{filename}` β†’ FileResponse | Serves uploaded docs for citation popover "Open page N β†’" links; path traversal blocked via `is_relative_to()` |
29
+ | Citation | `[N]` markers in LLM answer β†’ CitationPopover (React) | Clickable superscripts show full chunk text, page, rerank score; PDF page link via file serving route |
30
  | Workspace | ChromaDB collection per workspace | Isolated document sets; switcher in frontend sidebar |
31
  | Retriever cache | Module-level dict keyed by workspace | Singleton vectorstore+retriever per workspace; invalidate on ingest |
32
  | Eval | Separate `eval-dashboard/` Vite+React static site | Reads versioned JSON run files; metrics: answer_correctness, answer_relevancy, context_recall, precision@5, latency p50/p95/p99 |
 
41
  ## Data flow
42
 
43
  ### Ingestion
44
+ 1. `POST /api/upload` β†’ parse PDFs/TXT/CSV β†’ returns 202 + `job_id` immediately
45
+ 2. Background task `_embed_and_contextualize_bg` starts:
46
+ a. `RecursiveCharacterTextSplitter` (500-char, overlap 50) β†’ chunks
47
+ b. `embed_and_store()`: Euron API embeds chunks β†’ store in ChromaDB (workspace collection)
48
+ c. Rebuild BM25 index from new corpus
49
+ d. `generate_briefing()`: LLM summarises first 6 chunks β†’ 5 bullets + 3 suggested questions
50
+ e. If `contextual_retrieval.enabled`: `contextualize_chunks_async()` β€” Groq 8B prepends 2-sentence context per chunk (Semaphore(3) β†’ max 3000 TPM burst); replace old chunk IDs in ChromaDB with contextual versions
51
+ 3. Frontend polls `GET /api/upload/status/{job_id}` every 2s β†’ stages: embedding β†’ contextualizing β†’ ready
52
 
53
  ### Query
54
+ 1. `POST /api/chat` receives question β†’ returns `StreamingResponse` (text/event-stream)
55
+ 2. `condense_question()`: LLM rewrites follow-up question using chat history β†’ standalone query for search
56
+ 3. Tavily web search (advanced, max 2 results, 800-char/result) runs in parallel with retrieval
57
+ 4. `HybridRetriever._get_relevant_documents()`:
58
+ a. If `multi_query_enabled`: LLM generates 3 phrasings; retrieve for each; pool + best-rank dedup
59
+ b. If `hyde_enabled`: LLM generates hypothetical answer; embed fake answer for dense search
60
+ c. `dense_retrieve`: ChromaDB top-10 per query phrasing (cosine similarity)
61
+ d. `sparse_retrieve`: BM25 top-10 per query phrasing
62
+ e. `reciprocal_rank_fusion`: merge β†’ deduplicate β†’ weighted RRF (dense 0.7, sparse 0.3)
63
+ f. `Reranker.rerank`: cross-encoder scores all candidates jointly β†’ top-5 chunks
64
+ 5. `stream_query_with_web()`: direct LLM call with RAG chunks + Tavily results + chat history β†’ streams tokens
65
+ 6. SSE events: `{"type": "token", "content": "..."}` per chunk; `{"type": "done", "sources": [...], "retrieval_method": "..."}` at end
66
+ 7. Sources include per-chunk scores: similarity, bm25, rrf, rerank
67
 
68
  ## Key design decisions
69
  - **API embeddings over local**: Euron API embeddings ~0MB RAM; Groq has no embeddings endpoint so Euron is retained for embeddings.
70
+ - **RecursiveCharacterTextSplitter 500-char**: single-pass chunking; semantic chunking ablation (v1.4.0) showed +9.3pp recall but βˆ’27.3pp P@5 and 5Γ— latency β€” rejected.
71
+ - **Cross-encoder reranker**: bi-encoder (ChromaDB) is fast but approximate; cross-encoder is slower but more accurate on top-10 pool.
72
+ - **BM25 weight 0.3**: regulatory text has exact keyword matches (section numbers); sparse retrieval catches what dense misses.
73
+ - **HyDE ON by default**: hypothetical answer embedding closes question/answer vector space gap. Measured +21pp recall (0.51β†’0.72, v1.1.0). Adds one Groq call per query (~200ms latency).
74
+ - **Multi-Query ON by default**: 3 phrasings widen candidate pool before RRF. Best-rank dedup ensures highest-confidence rank carried into fusion. Adds one Groq call per query.
75
+ - **Contextual retrieval**: LLM prepends 2-sentence situating context to each chunk at ingest before embedding. +18% recall (v1.3.0). `asyncio.Semaphore(3)` caps parallel Groq calls at 3000 TPM β€” safe under 6000 TPM free limit.
76
+ - **Mandatory web search**: always-on Tavily + RAG prevents hallucination on open-domain queries. Toggle removed after opt-in caused wrong corpus docs to be cited with high faithfulness score.
77
+ - **Streaming SSE**: `stream_query_with_web()` yields token events via FastAPI `StreamingResponse`. Bypasses `ConversationalRetrievalChain` condensation step (which strips web context). Direct LLM call with full context.
78
+ - **Async upload (202 pattern)**: parse+chunk synchronous (<1s) β†’ return 202 + job_id β†’ embed+contextualize in `BackgroundTask`. Frontend polls `GET /api/upload/status/{job_id}`. User queryable in <3s without waiting ~40s for contextualization.
79
+ - **Citation popover**: `[N]` markers in LLM output β†’ clickable `<sup>` β†’ `CitationPopover` shows full chunk text, source, page, rerank score. PDF sources get "Open page N β†’" link via `GET /api/files/{filename}`. `Path.is_relative_to()` guards against traversal.
80
  - **TinyBERT-L-2-v2 reranker**: MiniLM-L-6-v2 (~85MB) vs TinyBERT-L-2-v2 (~17MB) β€” same ranking quality at demo corpus scale with lower memory footprint.
81
  - **Singleton vectorstore/retriever cache**: each workspace caches its Chroma vectorstore + HybridRetriever in a module-level dict. Without cache, every chat request created a new Chroma instance (full embedding reload). Cache is invalidated after ingest.
82
  - **Multi-workspace isolation**: each workspace maps to one ChromaDB collection. Frontend workspace switcher passes `workspace_id` on every request; backend resolves the correct collection before retrieval.
83
  - **URL size guard**: `url_loader.py` enforces a max content size before embedding URL content, preventing memory spikes from large external pages.
84
+ - **Eval dashboard separate site**: eval runs offline via `scripts/run_eval_versioned.py`; results versioned as JSON. No live eval endpoint on prod. Per-message faithfulness badge removed β€” eval moved to dedicated dashboard.
85
  - **answer_correctness over faithfulness**: faithfulness (LLM judge vs retrieved chunks) is circular β€” inflates when eval pairs are corpus-aligned. answer_correctness (LLM judge vs ground_truth reference) is an independent signal.
86
  - **HF Spaces Docker (UID 1000)**: model weights baked into image under `HF_HOME=/app/.cache/huggingface` as user 1000 at build time; `HF_HUB_OFFLINE=1` set after download to block runtime network calls.
87
 
88
  ## Known limitations
 
89
  - BM25 index rebuilt in memory on each startup (not persisted to disk)
90
  - HF Spaces free tier: ephemeral filesystem β€” chroma_db lost on cold start (re-upload required)
91
+ - Euron embedding API sequential: ~1.7s/chunk β€” 30 chunks = ~52s in background (user unblocked via 202, but contextual refresh still takes ~15s with Semaphore(3))
92
+ - Groq free tier: contextual retrieval 429s frequent at large doc scale (>30 chunks) even at max_concurrent=3; some chunks fall back to non-contextual text
93
+ - `anchorRect` in CitationPopover stale after page scroll (acceptable for demo)
94
 
95
  ## Future improvements
96
+ - Persist BM25 index to disk (pickle) β€” eliminates ~1s rebuild on startup
97
+ - Mount HF persistent storage bucket β€” eliminate chroma_db loss on cold start
98
+ - Metadata filtering: tag chunks with `{source_type, year, doc_name}` at ingest; pass `filter` param in `/api/chat` for scoped retrieval
99
+ - Document comparison mode: retrieve from two collections, synthesise structured diff answer
100
+ - Agentic mode (LangGraph): replace ConversationalRetrievalChain with graph β€” nodes for retrieval, web search, calculator, synthesiser