A LangGraph pipeline that demonstrates how an SLM pre-processing step preserves footnote context in RAG summarization — preventing misleading summaries where footnotes materially change the meaning of the main text.
Standard RAG pipelines chunk documents naively. When footnotes are separated from the sentences they qualify, the LLM summarizer never sees the critical fine print — producing summaries that can be dangerously misleading (e.g., reporting "$2.4B revenue with 34% growth" without noting that nearly all growth came from an acquisition and organic growth was only 3.2%).
Load Document
│
├──► Naive Chunker ──► FAISS (raw) ──► LLM Summary ─── (baseline)
│
└──► SLM Stitcher ──► Enriched Chunker ──► FAISS (enriched) ──► LLM Summary ─── (with footnotes)
│
└─ SLM inlines footnote text next to citing sentences
e.g., "revenue of $2.4B [1] {FOOTNOTE [1]: includes $820M from acquisition; organic growth was 3.2%}"
Both summaries → Markdown report + heatmap PNG + interactive HTML audit report.
| Step | Component | Default Model |
|---|---|---|
| Pre-processing | SLM Stitcher — inlines footnote definitions | gpt-5-mini |
| Chunking | RecursiveCharacterTextSplitter (footnote-boundary-aware) |
— |
| Storage & Retrieval | FAISS in-memory vector store | text-embedding-3-small |
| Summarization | LLM Summarizer — financial analyst | gpt-5.2 |
# 1. Clone
git clone https://github.com/mocelj/rag-footprint.git
cd rag-footprint
# 2. Virtual environment
python3 -m venv .venv && source .venv/bin/activate
# 3. Install dependencies
pip install -r requirements.txt
# 4. Configure API key
cp .env.example .env
# Edit .env → add your OPENAI_API_KEY# Run with the included test document
python3 src/LangGraph_Footnote_RAG_Advanced.py
# Run with the sample earnings PDF (generate it first)
python3 src/generate_sample_pdf.py
python3 src/LangGraph_Footnote_RAG_Advanced.py data/Exemplar_Corp_Q3_2025_Earnings.pdf
# Run with any .txt or .pdf
python3 src/LangGraph_Footnote_RAG_Advanced.py path/to/your/document.pdf
# Re-render reports from a previous run (zero API calls)
python3 src/LangGraph_Footnote_RAG_Advanced.py --rerender data/state_Exemplar_Corp_Q3_2025_Earnings_20260228_164328.jsonEach run produces timestamped files in the input file's directory:
| Output | Description |
|---|---|
summary_report_<name>_<timestamp>.md |
Side-by-side Markdown comparison of baseline vs. enriched summaries |
heatmap_<name>_<timestamp>.png |
Visual heatmap showing footnote coverage per chunk |
audit_report_<name>_<timestamp>.html |
Interactive HTML report with scorecard, chunk inspector, and footnote registry |
state_<name>_<timestamp>.json |
Serialized pipeline state — use with --rerender to regenerate reports without API calls |
The repo includes a PDF generator that creates a realistic 8-page earnings report for the fictitious company Exemplar Corp — containing 40 footnotes that materially qualify or contradict the main text claims:
python3 src/generate_sample_pdf.py
# → data/Exemplar_Corp_Q3_2025_Earnings.pdfDisclaimer: Exemplar Corp is entirely fictitious. All persons, figures, and entities in the sample document are imaginary.
All model choices are configurable via .env:
| Variable | Default | Purpose |
|---|---|---|
PROVIDER |
azure |
openai or azure — selects which backend to use |
OPENAI_API_KEY |
— | Required when PROVIDER=openai |
AZURE_OPENAI_ENDPOINT |
— | Required when PROVIDER=azure |
AZURE_OPENAI_API_KEY |
— | Azure API key (omit for Entra ID / RBAC keyless auth) |
AZURE_OPENAI_API_VERSION |
2024-12-01-preview |
Azure API version (optional — code defaults if unset) |
SLM_MODEL |
gpt-5-mini |
Lightweight model (or Azure deployment name) for footnote stitching |
LLM_MODEL |
gpt-5.2 |
Powerful model (or Azure deployment name) for summarization |
EMBEDDING_MODEL |
text-embedding-3-small |
Embedding model (or Azure deployment name) for FAISS vectors |
To use Azure OpenAI instead of the public OpenAI API, set PROVIDER=azure in your .env.
Option A — API Key auth:
PROVIDER=azure
AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/
AZURE_OPENAI_API_KEY=your-azure-openai-api-key
# AZURE_OPENAI_API_VERSION=2024-12-01-preview # optional — code defaults if unset
SLM_MODEL=gpt-5-mini
LLM_MODEL=gpt-5
EMBEDDING_MODEL=text-embedding-3-smallOption B — Entra ID / RBAC (keyless):
If your Azure OpenAI resource has API keys disabled, leave AZURE_OPENAI_API_KEY unset. The pipeline will authenticate via DefaultAzureCredential (Entra ID token).
PROVIDER=azure
AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/
# AZURE_OPENAI_API_KEY is intentionally omitted — using Entra ID / RBAC
# AZURE_OPENAI_API_VERSION=2024-12-01-preview # optional — code defaults if unset
SLM_MODEL=gpt-5-mini
LLM_MODEL=gpt-5
EMBEDDING_MODEL=text-embedding-3-smallPrerequisites for keyless auth:
pip install azure-identityaz loginwith an identity that has the Cognitive Services OpenAI User role on the resource
The pipeline auto-detects: if AZURE_OPENAI_API_KEY is set it uses key auth, otherwise it falls back to DefaultAzureCredential. No code changes required.
Financial, legal, and regulatory documents routinely place material qualifications in footnotes — fine print that can reverse the meaning of headline claims. Standard RAG pipelines chunk text by character count, inevitably splitting footnote references (in the body) from their definitions (at the page bottom). When the LLM summarizer retrieves chunks, it sees the claim but never the qualifying footnote, producing a summary that is factually misleading.
The pipeline inserts an SLM pre-processing layer (the Stitcher) between document loading and chunking. This smaller, cheaper model re-writes the document with every footnote definition inlined next to its citing sentence, so that downstream chunking and retrieval always keep claim + qualification together.
┌─────────────┐
│ Load Doc │ ← .txt or .pdf
└──────┬──────┘
│
┌─────┴─────┐
│ │
▼ ▼
┌──────────┐ ┌───────────────┐
│ Naive │ │ SLM Footnote │ ← gpt-5-mini
│ Chunker │ │ Stitcher │
└────┬─────┘ └──────┬────────┘
│ │
│ ┌────┴─────┐
│ │ Enriched │
│ │ Chunker │
│ └────┬─────┘
▼ ▼
┌────────┐ ┌──────────┐
│ FAISS │ │ FAISS │ ← text-embedding-3-small
│ (raw) │ │(enriched)│
└────┬───┘ └────┬─────┘
▼ ▼
┌────────┐ ┌──────────┐
│ LLM │ │ LLM │ ← gpt-5.2
│Summary │ │ Summary │
└────┬───┘ └────┬─────┘
│ │
└──────┬──────┘
▼
┌─────────────┐
│ Reports │ → .md + .png + .html
└─────────────┘
The stitcher is the core of the pipeline. It solves the problem that footnote references in the body text (e.g., [30]) are often separated from their definitions by thousands of characters, landing in different batches when the document exceeds the SLM's context window.
Before any batching, a regex scan collects every footnote definition from the raw text:
[N] Definition text that may span
multiple lines...
The regex matches lines starting with [N] and captures text up to the next [N] definition, a Page N/M marker, or a CONFIDENTIAL header. Results are stored in a {marker_int: full_text} dictionary. If a marker appears more than once (e.g., on different pages), the longer definition wins.
Instead of slicing the document at arbitrary character offsets (which split body text from its page's footnote definitions), the text is split on Page N/M markers — natural page boundaries from PDF extraction. Adjacent pages are then merged into batches up to ~4,500 characters to keep the number of SLM calls reasonable while preserving the body-plus-definitions relationship within each page.
Each batch is scanned for [N] markers. A --- FOOTNOTE DEFINITIONS (for reference) --- appendix is appended listing the full definition (from the Step 1 dictionary) for every marker found in the batch. This ensures that even cross-page references — where the body cites a footnote defined on a different page — have the definition available within the same SLM context window.
Each enriched batch is sent to the SLM with a system prompt instructing it to:
- Find every
[N]marker in the body text - Look up its definition (from the in-page footnotes or the injected appendix)
- Inline the definition as
{FOOTNOTE [N]: <text>}right after the citing sentence - Remove the original footnote section and the appendix from the output
- Mark any truly missing footnote as
{FOOTNOTE [N]: MISSING — no matching footnote found}
The same footnote marker may be inlined in multiple batches. The de-duplication strategy prefers:
- Real content over MISSING — if one batch produced MISSING but another resolved the definition, the resolved version wins
- Longer text over truncated — if both have content, the longer (more complete) text is kept
A final pass compares the SLM's footnote registry against the pre-extracted global dictionary:
- MISSING entries are replaced with the pre-extracted definition and marked
"backfilled" - Truncated entries (SLM text < 60% of known definition length) are replaced
- Unresolved markers that the SLM never encountered are added directly
The enriched text is also patched in-place: any remaining {FOOTNOTE [N]: MISSING ...} blocks are replaced with the real definition.
| Step | Description |
|---|---|
| Enriched Chunker | Splits stitched text with RecursiveCharacterTextSplitter, but first collapses newlines inside {FOOTNOTE ...} blocks so the splitter treats each annotation as a single token — preventing footnote blocks from being split across chunks. |
| Multi-Query FAISS Retrieval | Two independent vector stores are built (raw chunks vs. enriched chunks) using the same embedding model. Instead of a single generic query, 5 topical sub-queries are run against each store (financial performance, cash flow/liquidity, risks, outlook, operational highlights). Results are deduplicated, giving the LLM significantly broader context than a single top-K retrieval. |
| LLM Summarization | The Summarizer LLM receives the combined retrieved chunks and system prompts from external files (llm-prompt-raw.txt, llm-prompt-enriched.txt). The enriched prompt explicitly instructs the LLM to incorporate {FOOTNOTE} qualifications and not present claims as unqualified facts. |
| Reports | A Markdown comparison report, a matplotlib heatmap showing footnote density per chunk, and a self-contained interactive HTML audit report with scorecard, chunk inspector, footnote registry, and sentence-level semantic diff. |
The HTML audit report includes a visual semantic diff of the two summaries. Each sentence is embedded and compared by cosine similarity (threshold 0.82):
| Colour | Meaning |
|---|---|
| Amber left-border | Sentence appears only in the baseline summary — information not surfaced by footnote stitching |
| Green left-border | Sentence appears only in the enriched summary — new context added by footnote stitching |
| No border | Sentence is semantically shared by both summaries |
This makes it immediately visible what the SLM enrichment adds (green) and what it might lose (amber).
All LLM/SLM prompts are loaded from text files at startup rather than hardcoded:
| File | Purpose |
|---|---|
slm-prompt.txt |
SLM system prompt — footnote stitching rules and output format |
llm-prompt-raw.txt |
LLM prompt for the baseline (no-footnote) summary |
llm-prompt-enriched.txt |
LLM prompt for the enriched summary — includes instructions to respect {FOOTNOTE} annotations |
This makes prompt iteration easy without touching Python code.
After each full pipeline run, the complete graph state (raw text, enriched text, chunks, summaries, footnote registry) is serialized to a JSON file. The --rerender flag reloads this state and regenerates only the report artifacts — zero API calls — allowing rapid iteration on report formatting.
rag-footprint/
├── src/
│ ├── LangGraph_Footnote_RAG_Advanced.py # Main pipeline (LangGraph)
│ ├── rag_heatmap_visualizer.py # Heatmap generator (matplotlib)
│ ├── audit_report_generator.py # Interactive HTML report
│ ├── generate_sample_pdf.py # Exemplar Corp PDF generator
│ ├── slm-prompt.txt # SLM system prompt (footnote stitcher)
│ ├── llm-prompt-raw.txt # LLM prompt — baseline summarization
│ └── llm-prompt-enriched.txt # LLM prompt — enriched summarization
├── data/
│ ├── Footnote_Validation_Doc.txt # Minimal test document (3 footnotes)
│ └── Exemplar_Corp_Q3_2025_Earnings.pdf # Generated sample (40 footnotes)
├── examples/ # Pre-built sample outputs (Exemplar Corp)
│ ├── audit_report_*.html # Interactive HTML audit report
│ ├── heatmap_*.png # Footnote coverage heatmap
│ └── summary_report_*.md # Markdown summary report
├── requirements.txt
├── .env.example
├── .gitignore
└── README.md
The examples/ directory contains pre-built outputs from a pipeline run on the Exemplar Corp Q3 2025 Earnings document:
| File | Description |
|---|---|
audit_report_*.html |
Interactive HTML report with semantic diff — open in a browser to explore baseline vs. enriched summaries side-by-side |
heatmap_*.png |
Visual heatmap showing footnote coverage across document chunks |
summary_report_*.md |
Markdown summary with retrieval stats, key differences, and colour-coding legend |
These files are regeneratable via --rerender and require no API calls.
MIT