Spaces:
Sleeping
Sleeping
| """app.py β Multimodal Financial RAG Β· Hugging Face Space (v2.0) | |
| Production-grade RAG for financial documents: chart understanding, hybrid RRF | |
| retrieval, numeric guardrails, source citations. | |
| Model currency (v2.0): defaults to gemini-2.5-flash β Google retired | |
| gemini-2.0-flash and the gemini-1.5-* family in favor of the 2.5/3.x | |
| generations. See utils/generator.py for the full pricing/model table. | |
| GitHub: https://github.com/Mattral/RAG-Multimodal-Financial-Doc-Analysis-and-Recall | |
| Full source of truth: src/rag_system/ in the same repository. | |
| """ | |
| from __future__ import annotations | |
| import contextlib | |
| import sys | |
| from pathlib import Path | |
| from typing import Optional, Tuple | |
| import gradio as gr | |
| sys.path.insert(0, str(Path(__file__).parent)) | |
| from utils.generator import GenerationResult, generate | |
| from utils.guardrails import GuardrailResult, run_guardrails | |
| from utils.pdf_processor import IngestResult, ingest_pdf | |
| from utils.retriever import EmbeddingModel, VectorIndex | |
| # ββ Singletons ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _embedding_model: Optional[EmbeddingModel] = None | |
| _vector_index: Optional[VectorIndex] = None | |
| _ingested_filename: Optional[str] = None | |
| def _get_embedder() -> EmbeddingModel: | |
| global _embedding_model | |
| if _embedding_model is None: | |
| _embedding_model = EmbeddingModel("BAAI/bge-small-en-v1.5") | |
| return _embedding_model | |
| # ββ Config β current stable models (v2.0) βββββββββββββββββββββββββββββββββββββ | |
| PROVIDER_MODELS = { | |
| "Google Gemini (Free tier available)": [ | |
| "gemini-2.5-flash", "gemini-2.5-pro", "gemini-3.5-flash", "gemini-3.1-flash-lite", | |
| ], | |
| "OpenAI": [ | |
| "gpt-4o-mini", "gpt-4o", | |
| ], | |
| } | |
| EXAMPLE_QUESTIONS = [ | |
| "What was total revenue in the most recent quarter?", | |
| "How did gross margin change year-over-year?", | |
| "What are the key risk factors related to competition?", | |
| "What guidance did management provide for next quarter?", | |
| "Describe any charts or tables showing revenue trends.", | |
| "What was earnings per share (EPS)?", | |
| ] | |
| CUSTOM_CSS = """ | |
| :root { --color-accent: #6366f1; } | |
| .source-card { border-left: 3px solid var(--color-accent); padding-left: 12px; margin: 10px 0; } | |
| .pipeline-log { font-family: 'JetBrains Mono', monospace; font-size: 0.82em; line-height: 1.7; } | |
| #component-0 { max-width: 1400px; margin: 0 auto; } | |
| .tab-nav { font-weight: 600; } | |
| """ | |
| GITHUB_URL = "https://github.com/Mattral/RAG-Multimodal-Financial-Doc-Analysis-and-Recall" | |
| # ββ Vision helper βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _make_vision_fn(provider: str, api_key: str): | |
| prompt = ( | |
| "You are analyzing a page from a financial report. " | |
| "Extract ALL numeric data: axis values, data points, table cells, " | |
| "chart titles, legend entries, footnotes. Be exhaustive." | |
| ) | |
| use_openai = "openai" in provider.lower() or "gpt" in provider.lower() | |
| def vision_fn(image) -> str: | |
| import base64 | |
| import io | |
| import httpx | |
| buf = io.BytesIO() | |
| image.save(buf, format="PNG") | |
| b64 = base64.b64encode(buf.getvalue()).decode() | |
| if use_openai: | |
| payload = { | |
| "model": "gpt-4o-mini", | |
| "messages": [{"role": "user", "content": [ | |
| {"type": "text", "text": prompt}, | |
| {"type": "image_url", | |
| "image_url": {"url": f"data:image/png;base64,{b64}", "detail": "low"}}, | |
| ]}], | |
| "max_tokens": 600, | |
| } | |
| with httpx.Client(timeout=60) as c: | |
| r = c.post("https://api.openai.com/v1/chat/completions", | |
| headers={"Authorization": f"Bearer {api_key}"}, json=payload) | |
| r.raise_for_status() | |
| return r.json()["choices"][0]["message"]["content"] | |
| else: | |
| # Current stable Gemini vision model (v2.0 β see utils/generator.py note) | |
| url = ( | |
| f"https://generativelanguage.googleapis.com/v1beta/models/" | |
| f"gemini-2.5-flash:generateContent?key={api_key}" | |
| ) | |
| payload = {"contents": [{"parts": [ | |
| {"text": prompt}, | |
| {"inline_data": {"mime_type": "image/png", "data": b64}}, | |
| ]}], "generationConfig": {"maxOutputTokens": 600}} | |
| with httpx.Client(timeout=60) as c: | |
| r = c.post(url, json=payload) | |
| r.raise_for_status() | |
| return r.json()["candidates"][0]["content"]["parts"][0]["text"] | |
| return vision_fn | |
| # ββ Ingest ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def do_ingest(pdf_path: str, enable_vision: bool, provider: str, api_key: str): | |
| global _vector_index, _ingested_filename | |
| if not pdf_path: | |
| return "### No file selected\nPlease upload a PDF above.", gr.update(visible=False) | |
| vision_fn = None | |
| if enable_vision and api_key and api_key.strip(): | |
| with contextlib.suppress(Exception): | |
| vision_fn = _make_vision_fn(provider, api_key) | |
| try: | |
| result: IngestResult = ingest_pdf(pdf_path, process_vision=enable_vision, vision_fn=vision_fn) | |
| except Exception as exc: | |
| return f"### Ingestion failed\n\n```\n{str(exc)[:400]}\n```", gr.update(visible=False) | |
| _ingested_filename = result.filename | |
| try: | |
| _vector_index = VectorIndex(embedding_model=_get_embedder()) | |
| index_steps = _vector_index.build(result.chunks) | |
| except Exception as exc: | |
| return f"### Indexing failed\n\n```\n{str(exc)[:400]}\n```", gr.update(visible=False) | |
| all_steps = result.processing_steps + [""] + index_steps | |
| steps_block = "\n".join(f" {s}" for s in all_steps) | |
| summary = ( | |
| f"### `{result.filename}` ready for querying\n\n" | |
| f"| | |\n|---|---|\n" | |
| f"| **Pages processed** | {result.num_pages} |\n" | |
| f"| **Text chunks** | {result.num_chunks - result.num_tables - result.num_charts} |\n" | |
| f"| **Tables extracted** | {result.num_tables} |\n" | |
| f"| **Visual descriptions** | {result.num_charts} |\n" | |
| f"| **Total indexed** | {result.num_chunks} chunks |\n\n" | |
| f"<details><summary>Full pipeline log (click to expand)</summary>\n\n" | |
| f"```\n{steps_block}\n```\n\n</details>\n\n" | |
| f"---\n*Enter a question below and click **Analyze** β*" | |
| ) | |
| return summary, gr.update(visible=True) | |
| # ββ Query βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def do_query( | |
| question: str, provider: str, model: str, api_key: str, top_k: int, enable_guardrails: bool, | |
| ) -> Tuple[str, str, str, str, str]: | |
| if not question or not question.strip(): | |
| msg = "### Please enter a question." | |
| return msg, msg, msg, msg, msg | |
| if _vector_index is None: | |
| msg = "### No document indexed β please process a PDF first." | |
| return msg, msg, msg, msg, msg | |
| chunks, retrieval_steps = _vector_index.search(question.strip(), top_k=int(top_k)) | |
| if not chunks: | |
| msg = "### No relevant chunks found. Try rephrasing your question." | |
| return msg, msg, msg, msg, msg | |
| prov_key = "openai" if "openai" in provider.lower() else "gemini" | |
| gen: GenerationResult = generate(question, chunks, prov_key, model, api_key or "") | |
| if enable_guardrails: | |
| guard: GuardrailResult = run_guardrails(question, gen.answer, [c.text for c in chunks]) | |
| else: | |
| guard = GuardrailResult( | |
| overall_passed=True, numeric_grounding_passed=True, | |
| pii_detected=False, injection_detected=False, | |
| ungrounded_numbers=[], pii_entities=[], redacted_query=None, | |
| details=["Guardrails disabled by user setting."], warnings=[], | |
| ) | |
| return ( | |
| _fmt_answer(gen, guard), _fmt_sources(chunks), | |
| _fmt_pipeline(retrieval_steps, gen), _fmt_guardrails(guard), | |
| _fmt_metrics(gen, chunks), | |
| ) | |
| def _fmt_answer(gen: GenerationResult, guard: GuardrailResult) -> str: | |
| badge = f"**Guardrails: {'All passed' if guard.overall_passed else 'Warnings β see Guardrails tab'}**" | |
| md = f"## Answer\n\n{gen.answer}\n\n---\n{badge}" | |
| for w in guard.warnings: | |
| md += f"\n\n> {w}" | |
| if guard.redacted_query: | |
| md += "\n\n> Your query contained PII β it was redacted before processing." | |
| return md | |
| def _fmt_sources(chunks) -> str: | |
| if not chunks: | |
| return "No sources retrieved." | |
| type_icons = {"text": "[text]", "table": "[table]", "chart_description": "[chart]"} | |
| md = f"## Retrieved Sources ({len(chunks)} chunks)\n\n" | |
| for r in chunks: | |
| icon = type_icons.get(r.chunk.chunk_type, "[text]") | |
| md += ( | |
| f"### {icon} {r.source}\n\n" | |
| f"*Rank #{r.rank} Β· RRF score: `{r.rrf_score:.5f}` " | |
| f"(dense: `{r.dense_score:.3f}`, BM25: `{r.bm25_score:.3f}`)*\n\n" | |
| f"> {r.text[:450]}{'...' if len(r.text) > 450 else ''}\n\n---\n\n" | |
| ) | |
| return md | |
| def _fmt_pipeline(retrieval_steps, gen: GenerationResult) -> str: | |
| md = "## Pipeline Transparency\n\n" | |
| md += f"*Document: `{_ingested_filename or 'unknown'}`*\n\n" | |
| md += "### Stage 1 β Document Ingestion\n" | |
| md += "_Text extraction, table detection, semantic chunking, embedding β FAISS index._\n\n" | |
| md += "### Stage 2 β Hybrid Retrieval\n" | |
| for s in retrieval_steps: | |
| md += f"- {s}\n" | |
| md += "\n### Stage 3 β LLM Generation\n" | |
| for s in gen.steps: | |
| md += f"- {s}\n" | |
| md += "\n### Stage 4 β Guardrails\n_See Guardrails tab for detailed results._\n\n" | |
| md += "---\n### RRF Algorithm\n" | |
| md += ( | |
| "```\nscore(d) = 0.7 / (60 + dense_rank(d) + 1)\n" | |
| " + 0.3 / (60 + bm25_rank(d) + 1)\n```\n\n" | |
| "k=60 per Cormack et al. (2009). " | |
| f"[See production implementation β]({GITHUB_URL}/blob/main/src/rag_system/components/retriever/__init__.py)" | |
| ) | |
| return md | |
| def _fmt_guardrails(g: GuardrailResult) -> str: | |
| md = "## Guardrail Results\n\n" | |
| md += "\n".join(g.details) | |
| if g.warnings: | |
| md += "\n\n### Warnings\n" | |
| for w in g.warnings: | |
| md += f"\n- {w}" | |
| md += ( | |
| "\n\n---\n### Guardrail Chain\n\n" | |
| "| Check | Method | What it catches |\n|---|---|---|\n" | |
| "| Injection detection | Regex pattern matching | Jailbreak attempts |\n" | |
| "| PII redaction | Regex (SSN, IBAN, CUSIP, ISIN, card, email, phone) | Accidental PII |\n" | |
| "| Numeric grounding | Extract all numbers β verify each appears in context | Hallucinated figures |\n\n" | |
| f"[Production implementation β]({GITHUB_URL}/blob/main/src/rag_system/components/guardrails/__init__.py)" | |
| ) | |
| return md | |
| def _fmt_metrics(gen: GenerationResult, chunks) -> str: | |
| chunk_types: dict = {} | |
| for r in chunks: | |
| chunk_types[r.chunk.chunk_type] = chunk_types.get(r.chunk.chunk_type, 0) + 1 | |
| md = "## Performance Metrics\n\n" | |
| md += "| Metric | Value |\n|---|---|\n" | |
| md += f"| **Provider** | {gen.provider} Β· `{gen.model}` |\n" | |
| md += f"| **Latency** | {gen.latency_ms:.0f} ms |\n" | |
| md += f"| **Prompt tokens** | {gen.prompt_tokens:,} |\n" | |
| md += f"| **Completion tokens** | {gen.completion_tokens:,} |\n" | |
| md += f"| **Estimated cost** | ${gen.cost_usd:.5f} USD |\n" | |
| md += f"| **Chunks retrieved** | {len(chunks)} |\n" | |
| for ct, cnt in chunk_types.items(): | |
| md += f"| β³ {ct} | {cnt} |\n" | |
| return md | |
| def update_models(provider: str): | |
| models = PROVIDER_MODELS.get(provider, ["gemini-2.5-flash"]) | |
| return gr.update(choices=models, value=models[0]) | |
| # ββ UI ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def create_demo() -> gr.Blocks: | |
| with gr.Blocks( | |
| title="Multimodal Financial RAG", | |
| theme=gr.themes.Soft( | |
| primary_hue="indigo", secondary_hue="blue", neutral_hue="slate", | |
| font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui"], | |
| font_mono=[gr.themes.GoogleFont("JetBrains Mono"), "monospace"], | |
| ), | |
| css=CUSTOM_CSS, | |
| ) as demo: | |
| gr.Markdown(f""" | |
| # Multimodal Financial RAG | |
| **Production-grade document intelligence** β charts Β· tables Β· hybrid retrieval Β· numeric guardrails Β· source citations | |
| []({GITHUB_URL}) | |
| []({GITHUB_URL}) | |
| > Upload a 10-K, 10-Q, or earnings release PDF. Ask a question. Get a grounded, cited answer. | |
| > Every number in the answer is cross-checked against the source. | |
| """) | |
| with gr.Accordion("Why This System Stands Out", open=False): | |
| gr.Markdown(f""" | |
| | Feature | What it means for financial analysis | | |
| |---|---| | |
| | **Vision-Language chart extraction** | Describes charts, graphs, and complex tables visually β not just OCR | | |
| | **Hybrid RRF retrieval** | Dense semantic search + BM25 keyword matching fused with RRF | | |
| | **Numeric grounding guardrail** | Every number verified against source text β hallucinations flagged | | |
| | **PII + injection protection** | SSNs, IBANs, CUSIPs redacted; prompt injection blocked | | |
| | **Page-level citations** | Every claim maps to a specific document and page number | | |
| | **Enterprise codebase** | This demo mirrors [`src/rag_system/`]({GITHUB_URL}/tree/main/src/rag_system) | | |
| """) | |
| gr.Markdown("---") | |
| with gr.Row(equal_height=False): | |
| with gr.Column(scale=1, min_width=360): | |
| gr.Markdown("### API Provider") | |
| with gr.Group(): | |
| provider_radio = gr.Radio( | |
| choices=list(PROVIDER_MODELS.keys()), | |
| value="Google Gemini (Free tier available)", | |
| label="Choose your LLM provider", | |
| info="Gemini has a generous free tier at aistudio.google.com", | |
| ) | |
| model_dd = gr.Dropdown( | |
| choices=PROVIDER_MODELS["Google Gemini (Free tier available)"], | |
| value="gemini-2.5-flash", | |
| label="Model", | |
| ) | |
| api_key_box = gr.Textbox( | |
| label="API Key", type="password", | |
| placeholder="AIza... (Gemini) or sk-... (OpenAI)", | |
| info="Never stored. Sent directly to the provider API from your session.", | |
| ) | |
| provider_radio.change(update_models, provider_radio, model_dd) | |
| gr.Markdown("---") | |
| gr.Markdown("### Document") | |
| with gr.Group(): | |
| pdf_input = gr.File( | |
| label="Upload Financial PDF", file_types=[".pdf"], type="filepath", | |
| ) | |
| enable_vision_chk = gr.Checkbox( | |
| value=True, label="Visual chart/table extraction (requires API key)", | |
| info="Vision LLM describes each page image β richer retrieval", | |
| ) | |
| ingest_btn = gr.Button("Process Document", variant="primary", size="lg") | |
| gr.Markdown("---") | |
| gr.Markdown("### Query") | |
| question_box = gr.Textbox( | |
| label="Ask a question", | |
| placeholder="e.g. What was total revenue in Q3 2023?", | |
| lines=2, | |
| ) | |
| gr.Markdown("**Quick examples:**") | |
| for row_start in range(0, len(EXAMPLE_QUESTIONS), 2): | |
| with gr.Row(): | |
| for ex in EXAMPLE_QUESTIONS[row_start:row_start + 2]: | |
| btn = gr.Button(ex[:38] + ("β¦" if len(ex) > 38 else ""), | |
| size="sm", variant="secondary") | |
| btn.click(fn=lambda q=ex: q, outputs=question_box) | |
| with gr.Accordion("Advanced Settings", open=False): | |
| top_k_slider = gr.Slider( | |
| minimum=3, maximum=15, value=5, step=1, | |
| label="Top-K chunks to retrieve", | |
| ) | |
| guardrails_chk = gr.Checkbox( | |
| value=True, label="Enable guardrails (numeric grounding + PII check)", | |
| ) | |
| analyze_btn = gr.Button("Analyze & Answer", variant="primary", size="lg") | |
| with gr.Column(scale=2): | |
| ingest_status = gr.Markdown( | |
| "### Upload a PDF and click **Process Document** to begin." | |
| ) | |
| with gr.Group(visible=False) as results_group, gr.Tabs(): | |
| with gr.TabItem("Answer"): | |
| answer_out = gr.Markdown(label="") | |
| with gr.TabItem("Sources"): | |
| sources_out = gr.Markdown(label="") | |
| with gr.TabItem("Pipeline"): | |
| pipeline_out = gr.Markdown(label="") | |
| with gr.TabItem("Guardrails"): | |
| guardrail_out = gr.Markdown(label="") | |
| with gr.TabItem("Metrics"): | |
| metrics_out = gr.Markdown(label="") | |
| gr.Markdown("---") | |
| with gr.Accordion("Technical Architecture", open=False): | |
| gr.Markdown(f""" | |
| ``` | |
| βββ pdfplumber ββββββββββββββββββ text + table extraction (per page) | |
| βββ pdf2image + Vision LLM ββββββ chart/graph descriptions | |
| βΌ | |
| Semantic chunker (β€800 chars, 100-char overlap, paragraph boundaries) | |
| βΌ | |
| BAAI/bge-small-en-v1.5 ββββββββββ 384-dim embeddings | |
| ββββββββ FAISS IndexFlatIP (cosine similarity) | |
| ββββββββ BM25Okapi (keyword index) | |
| βΌ | |
| Query β RRF fusion: 0.7/(60+dense_rank+1) + 0.3/(60+bm25_rank+1) | |
| βΌ | |
| top-k chunks | |
| βΌ | |
| System prompt + context + question | |
| βΌ | |
| OpenAI GPT-4o-mini OR Gemini 2.5 Flash | |
| βΌ | |
| Generated answer | |
| βΌ | |
| Guardrails: injection β PII β numeric grounding | |
| βΌ | |
| Grounded answer + citations | |
| ``` | |
| **Model currency (v2.0)**: defaults to `gemini-2.5-flash`. Google retired | |
| `gemini-2.0-flash` and the `gemini-1.5-*` family β this demo tracks | |
| whichever Gemini generation is currently GA. See `utils/generator.py` | |
| for the full model/pricing table. | |
| **This demo mirrors [`src/rag_system/`]({GITHUB_URL}/tree/main/src/rag_system).** | |
| The full enterprise system additionally includes: multi-tenancy Β· pgvector/Qdrant Β· | |
| Redis semantic cache Β· LangGraph agentic flow Β· Program-of-Thought calculator Β· | |
| OpenTelemetry Β· Prometheus/Grafana Β· Kubernetes Β· Terraform Β· RAGAS evaluation Β· 520+ tests. | |
| [**View the full codebase β**]({GITHUB_URL}) | |
| """) | |
| gr.Markdown("---") | |
| gr.Markdown(f""" | |
| <div align="center"> | |
| ### Useful? Star the repo. | |
| [**View Full Codebase**]({GITHUB_URL}) Β· [**Report a Bug**]({GITHUB_URL}/issues) | |
| *This Space lives in `spaces/rag-financial/` in the main repository and is kept | |
| in sync with the production model lineup.* | |
| MIT License Β· Built with Gradio Β· Embeddings: BAAI/bge-small-en-v1.5 | |
| </div> | |
| """) | |
| ingest_btn.click( | |
| fn=do_ingest, | |
| inputs=[pdf_input, enable_vision_chk, provider_radio, api_key_box], | |
| outputs=[ingest_status, results_group], | |
| show_progress="full", | |
| ) | |
| output_list = [answer_out, sources_out, pipeline_out, guardrail_out, metrics_out] | |
| analyze_btn.click( | |
| fn=do_query, | |
| inputs=[question_box, provider_radio, model_dd, api_key_box, top_k_slider, guardrails_chk], | |
| outputs=output_list, show_progress="full", | |
| ) | |
| question_box.submit( | |
| fn=do_query, | |
| inputs=[question_box, provider_radio, model_dd, api_key_box, top_k_slider, guardrails_chk], | |
| outputs=output_list, show_progress="full", | |
| ) | |
| return demo | |
| if __name__ == "__main__": | |
| demo = create_demo() | |
| demo.launch(server_name="0.0.0.0", server_port=7860, show_error=True) | |