docs: update architecture.md to reflect current shipped state (v1.3.0 stack)
Browse files- 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) β
|
| 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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
| Memory | ConversationBufferWindowMemory (k=10) | Last 10 conversation turns |
|
| 21 |
-
| Chain | ConversationalRetrievalChain |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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. `
|
| 37 |
-
2. `
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
### Query
|
| 43 |
-
1. `POST /api/chat` receives question
|
| 44 |
-
2. `
|
| 45 |
-
3.
|
| 46 |
-
4. `
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
- **
|
| 54 |
-
- **Cross-encoder reranker**: bi-encoder (ChromaDB) is fast but approximate; cross-encoder is slower but more accurate on top-
|
| 55 |
-
- **BM25 weight 0.3**: regulatory text has exact keyword matches (section numbers); sparse retrieval catches what dense misses
|
| 56 |
-
- **
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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: ~
|
|
|
|
|
|
|
| 70 |
|
| 71 |
## Future improvements
|
| 72 |
-
- Persist BM25 index to disk (pickle)
|
| 73 |
-
-
|
| 74 |
-
-
|
|
|
|
|
|
|
|
|
| 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
|