Part 1: The Hallucination Taxonomy - What "Making Things Up" Looks Like in Finance
Hallucination is not a single failure mode. In finance, it manifests in seven distinct forms, each requiring a different mitigation strategy. After deploying LLMs across regulatory reporting, treasury operations, and capital markets at multiple institutions, we've cataloged these precisely because a hallucination in finance isn't a wrong answer — it's a regulatory violation, a capital miscalculation, or a customer loss.
Type 1: Factual Fabrication (The Classic)
The LLM invents facts not present in any source.
Prompt: "What was Meridian Holdings' Q2 2026 net interest margin?"
GPT-4o: "2.87%, up 12 bps from Q1."
Reality: The document says 2.75%. The LLM fabricated both the number and the trend.Cost: Incorrect FR Y-9C filing → Fed MRRA (Matter Requiring Attention) → supervisory escalation.
Type 2: Numerical Drift (The Silent Killer)
The LLM gets the right fact but subtly alters the number.
Source: "Allowance for credit losses: $342.7 million"
LLM output: "ACL: $347.2 million"Cost: $4.5M capital miscalculation. CET1 ratio drops below well-capitalized threshold.
Type 3: Contextual Misattribution (The Sophisticated One)
The LLM uses a real fact but attributes it to the wrong entity, period, or line item.
Source: "Subsidiary A's commercial real estate exposure: $1.2B"
LLM output: "The bank's total CRE exposure is $1.2B"Cost: Concentration risk misreported. Stress capital buffer miscalculated.
Type 4: Logical Fabrication (The Reasoning Leap)
The LLM draws a conclusion not supported by the premises.
Source: "Loan loss provision increased 15% QoQ. NPL ratio stable at 0.8%."
LLM output: "Asset quality is deteriorating, warranting increased reserves."
Reality: The stable NPL ratio contradicts this conclusion.Cost: Misleading MD&A commentary. Investor lawsuit.
Type 5: Temporal Hallucination (The Time Traveler)
The LLM mixes data from different periods.
Source: "Q1 2026 deposits: $48B. Q4 2025 deposits: $46B."
LLM output: "Deposits grew from $48B to $46B" (reversed)Cost: Liquidity coverage ratio (LCR) misreported.
Type 6: Regulatory Hallucination (The Authority Inventor)
The LLM cites non-existent rules or misquotes existing ones.
LLM output: "Per FRB SR 24-08, banks must report crypto exposures at fair value."
Reality: SR 24-08 does not exist. The actual guidance is different.Cost: Filing based on fake authority. Examiner finding.
Type 7: Schema Hallucination (The Format Breaker)
The LLM produces output that doesn't conform to the required schema, inventing fields or omitting required ones.
Required: FR Y-9C Schedule HC item "1.a. Cash and balances"
LLM output: Creates a new field "1.a.i. Digital currency holdings" that doesn't existCost: Filing rejected by Fed's EDP system. Resubmission required.
Part 2: The Mitigation Stack - Defense-in-Depth Against Hallucination
No single technique eliminates hallucination. We deploy nine layered mitigations, each catching a different class of error. The architecture is modeled on how financial institutions actually manage risk: multiple independent controls, each with its own failure mode.
The Nine-Layer Stack
| Layer | Technique | Catches | Cost |
|---|---|---|---|
| L1 | Retrieval Provenance — every token traced to source chunk | Type 1, 3 | Low |
| L2 | Numerical Grounding — every number regex-matched to source | Type 2 | Low |
| L3 | Schema-Constrained Generation — JSON schema enforcement | Type 7 | Low |
| L4 | Self-Consistency Voting — multiple inference paths, majority wins | Type 1, 4 | Medium |
| L5 | Chain-of-Verification — LLM verifies its own outputs in second pass | Type 1, 3, 4 | Medium |
| L6 | Groundedness Scoring — NLI model scores claim-to-source entailment | Type 1, 3, 4 | Medium |
| L7 | Multi-Model Consensus — different LLMs vote on critical outputs | Type 1, 4 | High |
| L8 | Deterministic Post-Processing — classical code validates LLM outputs | Type 2, 5 | Low |
| L9 | Confidence-Based Routing — low confidence → classical fallback | All | Medium |
The Critical Insight
You cannot eliminate hallucination. You can only detect it before it reaches production. The goal is not a hallucination-free LLM — that's impossible. The goal is a hallucination-free decision pipeline, where every LLM output is verified by an independent control before it affects any financial number.
Part 3: Real-Time Use Case - FR Y-9C Regulatory Filing at a Regional Bank
Scenario
A $85B regional bank must file its quarterly FR Y-9C (Parent Company Only) report with the Federal Reserve within 45 days of quarter-end. The filing has 11 schedules, 2,000+ line items, and requires:
Extracting data from the general ledger (structured)
Interpreting management footnotes and accounting memos (unstructured)
Mapping to the regulatory taxonomy (complex rules)
Calculating derived fields (arithmetic)
Reconciling to the prior quarter (consistency)
Generating explanatory commentary (narrative)
Zero tolerance for hallucination — a single wrong number can trigger supervisory action
The Hallucination Risk Surface
| Filing Component | Hallucination Risk | Mitigation |
|---|---|---|
| GL data extraction | Type 2 (numerical drift) | L2 + L8 (deterministic validation) |
| Footnote interpretation | Type 1, 3 (fabrication, misattribution) | L1 + L5 + L6 (provenance + verification + NLI) |
| Regulatory mapping | Type 6 (fake rules) | L1 + L3 (RAG + schema) |
| Derived calculations | Type 2 (arithmetic errors) | L8 (classical calculator) |
| Prior quarter reconciliation | Type 5 (temporal mixing) | L8 (deterministic comparison) |
| MD&A commentary | Type 1, 4 (fabrication, logic leaps) | L4 + L6 + L7 (voting + NLI + multi-model) |
Architecture

Part 4: Full Code Implementation
Prerequisites
pip install langgraph langchain langchain-openai langchain-community \
chromadb pydantic numpy pandas jsonschema \
langchain-chroma tenacity transformers sentence-transformersStep 1: Provenance-Aware Data Types (Layer L1)
Every fact in the system carries its source. This is the foundation of all hallucination mitigation.
# provenance.py
"""
Provenance-aware types. Every fact traces back to a source chunk.
This is Layer L1 — Retrieval Provenance.
"""
from pydantic import BaseModel, Field
from typing import Optional, List, Literal
from decimal import Decimal
from datetime import date
class SourceChunk(BaseModel):
"""A retrievable unit of source material with full metadata."""
chunk_id: str
document_id: str
document_type: Literal["GL_extract", "management_memo", "regulatory_guidance",
"prior_filing", "board_minutes", "audit_workpaper"]
page_or_section: str
content: str
effective_date: date
author: Optional[str] = None
hash: str # SHA-256 of content — detects tampering
class ProvenanceClaim(BaseModel):
"""
A factual claim with full provenance chain.
EVERY output from an LLM must be wrapped in this.
"""
claim: str
source_chunks: List[SourceChunk]
confidence: float = Field(ge=0.0, le=1.0)
claim_type: Literal["factual", "numerical", "interpretive", "derived"]
groundedness_score: Optional[float] = None # Filled by L6
verified_by: List[str] = Field(default_factory=list) # Which layers verified this
def is_ungrounded(self) -> bool:
"""A claim is ungrounded if it has no source chunks."""
return len(self.source_chunks) == 0
class NumericalFact(ProvenanceClaim):
"""A numerical fact with explicit value and unit."""
value: Decimal
unit: str # "USD_millions", "percent", "count", etc.
as_of_date: date
entity: str # "Bank", "Subsidiary A", "Consolidated"
line_item: str # FR Y-9C line item reference
def matches_source(self, source_text: str) -> bool:
"""Layer L2: verify the number appears in the source."""
# Format the number as it would appear in source
formatted_variants = [
f"{self.value:,.2f}",
f"{self.value:,.0f}",
f"{self.value}",
f"${self.value:,.2f}",
f"${self.value:,.0f}",
]
return any(v in source_text for v in formatted_variants)
class FilingLineItem(BaseModel):
"""A single line item in the FR Y-9C filing."""
schedule: str # "HC", "HI", "IS", etc.
line_item: str # "1.a. Cash and balances"
value: Decimal
unit: str
as_of_date: date
provenance: ProvenanceClaim
prior_quarter_value: Optional[Decimal] = None
variance_explanation: Optional[str] = None
groundedness_score: Optional[float] = None
requires_human_review: bool = FalseStep 2: Groundedness Scorer (Layer L6)
Uses a Natural Language Inference (NLI) model to score whether each LLM claim is entailed by its source.
# groundedness.py
"""
Layer L6: Groundedness scoring via NLI.
For each claim, we verify that the source chunks ENTAIL the claim.
"""
from typing import List, Tuple
from transformers import pipeline
from provenance import ProvenanceClaim, SourceChunk
class GroundednessScorer:
"""
Uses a pre-trained NLI model to score claim-to-source entailment.
Scores:
- ENTAILMENT (score > 0.7): source supports the claim
- NEUTRAL (0.3-0.7): source neither supports nor contradicts
- CONTRADICTION (< 0.3): source contradicts the claim
This catches Type 1 (fabrication) and Type 3 (misattribution).
"""
def __init__(self):
# In production: use a fine-tuned NLI model on financial text
# For demo: use the standard multi-NLI model
self.nli = pipeline(
"text-classification",
model="facebook/bart-large-mnli",
device=-1 # CPU; use "cuda:0" in production
)
def score_claim(self, claim: ProvenanceClaim) -> float:
"""
Score a single claim against its source chunks.
Returns a groundedness score in [0, 1].
"""
if claim.is_ungrounded():
return 0.0 # No source = no groundedness
# Combine source chunks into premise
premise = " ".join([chunk.content for chunk in claim.source_chunks])
hypothesis = claim.claim
# Truncate to model's max length
max_len = 512
premise = premise[:max_len * 4] # Rough char estimate
hypothesis = hypothesis[:max_len]
# Run NLI
result = self.nli(
f"PREMISE: {premise} HYPOTHESIS: {hypothesis}",
top_k=None,
candidate_labels=["entailment", "neutral", "contradiction"]
)
# Convert to groundedness score
scores = {r["label"]: r["score"] for r in result}
entailment = scores.get("entailment", 0.0)
contradiction = scores.get("contradiction", 0.0)
# Groundedness = entailment - contradiction (clamped to [0, 1])
groundedness = max(0.0, min(1.0, entailment - contradiction))
return round(groundedness, 3)
def score_batch(self, claims: List[ProvenanceClaim]) -> List[float]:
"""Score multiple claims."""
return [self.score_claim(c) for c in claims]
def flag_ungrounded(self, claim: ProvenanceClaim, threshold: float = 0.6) -> bool:
"""Returns True if the claim should be flagged for review."""
score = self.score_claim(claim)
claim.groundedness_score = score
return score < thresholdStep 3: Self-Consistency Verifier (Layer L4)
Runs the same extraction multiple times with different sampling parameters. If outputs disagree, flag for review.
# self_consistency.py
"""
Layer L4: Self-consistency voting.
Run the same extraction N times. If outputs disagree, flag for review.
"""
import asyncio
from typing import List, Callable, Any
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
import hashlib
import json
class SelfConsistencyVerifier:
"""
Runs the same prompt multiple times with temperature > 0.
Aggregates outputs via voting. Disagreements trigger review.
Catches Type 1 (fabrication) and Type 4 (logical leaps).
"""
def __init__(self, n_runs: int = 3, temperature: float = 0.3):
self.n_runs = n_runs
self.temperature = temperature
self.llm = ChatOpenAI(model="gpt-4o", temperature=temperature)
async def run_consistency_check(
self,
prompt: str,
output_parser: Callable[[str], Any]
) -> dict:
"""
Run the same prompt N times and check consistency.
Returns:
{
"consensus": parsed output (if consensus reached),
"agreement_rate": float 0-1,
"all_outputs": list of parsed outputs,
"disagreements": list of fields that disagreed,
"requires_review": bool
}
"""
# Run N times in parallel
tasks = [self.llm.ainvoke([HumanMessage(content=prompt)]) for _ in range(self.n_runs)]
responses = await asyncio.gather(*tasks)
# Parse each output
parsed_outputs = []
for r in responses:
try:
parsed = output_parser(r.content)
parsed_outputs.append(parsed)
except Exception as e:
# If parsing fails, treat as disagreement
parsed_outputs.append(None)
# Check consistency
if len(parsed_outputs) == 0 or all(p is None for p in parsed_outputs):
return {
"consensus": None,
"agreement_rate": 0.0,
"all_outputs": parsed_outputs,
"disagreements": ["all_outputs_unparseable"],
"requires_review": True
}
# Find consensus (majority vote per field)
valid_outputs = [p for p in parsed_outputs if p is not None]
consensus, agreement_rate, disagreements = self._find_consensus(valid_outputs)
requires_review = agreement_rate < 0.67 or len(disagreements) > 0
return {
"consensus": consensus,
"agreement_rate": agreement_rate,
"all_outputs": parsed_outputs,
"disagreements": disagreements,
"requires_review": requires_review
}
def _find_consensus(self, outputs: List[dict]) -> tuple:
"""Find majority-vote consensus across outputs."""
if not outputs:
return None, 0.0, []
# Assume outputs are dicts with comparable fields
consensus = {}
disagreements = []
all_keys = set()
for o in outputs:
if isinstance(o, dict):
all_keys.update(o.keys())
for key in all_keys:
values = [o.get(key) for o in outputs if isinstance(o, dict) and key in o]
if not values:
continue
# Hash values for comparison
hashed = [hashlib.md5(json.dumps(v, sort_keys=True, default=str).encode()).hexdigest()
for v in values]
most_common = max(set(hashed), key=hashed.count)
agreement = hashed.count(most_common) / len(hashed)
# Find the actual value
for v, h in zip(values, hashed):
if h == most_common:
consensus[key] = v
break
if agreement < 1.0:
disagreements.append(key)
agreement_rate = (len(all_keys) - len(disagreements)) / max(len(all_keys), 1)
return consensus, agreement_rate, disagreements
def parse_json_output(text: str) -> dict:
"""Robust JSON parser for LLM outputs."""
import re
# Try to extract JSON from markdown code blocks
json_match = re.search(r'```(?:json)?\s*([\s\S]*?)\s*```', text)
if json_match:
text = json_match.group(1)
return json.loads(text)Step 4: Chain-of-Verification (Layer L5)
The LLM generates outputs, then verifies them in a second pass.
# verification.py
"""
Layer L5: Chain-of-verification.
The LLM generates an output, then verifies each claim in a second pass.
"""
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage
from typing import List
import json
import re
class ChainOfVerifier:
"""
Two-pass verification:
Pass 1: Generate the output
Pass 2: Verify each claim against the source
Catches Type 1 (fabrication), Type 3 (misattribution), Type 4 (logic leaps).
"""
def __init__(self):
self.llm = ChatOpenAI(model="gpt-4o", temperature=0)
async def generate_and_verify(
self,
source_text: str,
generation_prompt: str
) -> dict:
"""
Generate an output, then verify each claim.
Returns:
{
"output": generated output,
"claims": list of claims extracted,
"verifications": list of {claim, verdict, reason},
"verified_output": output with unverified claims removed,
"verification_rate": float
}
"""
# Pass 1: Generate
generation_response = await self.llm.ainvoke([
SystemMessage(content="You are a precise financial analyst. Extract facts only from the provided source."),
HumanMessage(content=f"SOURCE:\n{source_text}\n\nTASK:\n{generation_prompt}")
])
output = generation_response.content
# Extract individual claims
claims = self._extract_claims(output)
# Pass 2: Verify each claim
verifications = []
for claim in claims:
verification = await self._verify_claim(claim, source_text)
verifications.append(verification)
# Build verified output (remove unverified claims)
verified_claims = [v["claim"] for v in verifications if v["verdict"] == "VERIFIED"]
verified_output = self._rebuild_output(output, verified_claims)
verification_rate = len(verified_claims) / max(len(claims), 1)
return {
"output": output,
"claims": claims,
"verifications": verifications,
"verified_output": verified_output,
"verification_rate": verification_rate
}
def _extract_claims(self, text: str) -> List[str]:
"""Extract individual factual claims from text."""
prompt = f"""Extract each factual claim from this text as a separate bullet point.
Include numerical facts, attributions, and conclusions.
TEXT:
{text}
Return as JSON array of strings."""
# Synchronous call for simplicity in demo
response = self.llm.invoke([
SystemMessage(content="Extract claims. Return only JSON array."),
HumanMessage(content=prompt)
])
try:
json_match = re.search(r'\[[\s\S]*\]', response.content)
if json_match:
return json.loads(json_match.group(0))
except:
pass
return [text] # Fallback: treat whole text as one claim
async def _verify_claim(self, claim: str, source_text: str) -> dict:
"""Verify a single claim against the source."""
prompt = f"""Verify whether this claim is SUPPORTED by the source text.
CLAIM: {claim}
SOURCE:
{source_text}
Verdict must be one of:
- VERIFIED: the source explicitly supports this claim
- UNSUPPORTED: the source does not mention this
- CONTRADICTED: the source contradicts this claim
- AMBIGUOUS: unclear whether supported
Return JSON: {{"verdict": "...", "reason": "..."}}"""
response = await self.llm.ainvoke([
SystemMessage(content="You are a strict fact-checker. Return only JSON."),
HumanMessage(content=prompt)
])
try:
json_match = re.search(r'\{[\s\S]*\}', response.content)
if json_match:
result = json.loads(json_match.group(0))
return {"claim": claim, **result}
except:
pass
return {"claim": claim, "verdict": "AMBIGUOUS", "reason": "Parse error"}
def _rebuild_output(self, original: str, verified_claims: List[str]) -> str:
"""Rebuild output keeping only verified claims."""
# Simple approach: keep paragraphs that contain verified claims
paragraphs = original.split('\n\n')
verified_paragraphs = []
for para in paragraphs:
if any(claim in para for claim in verified_claims):
verified_paragraphs.append(para)
return '\n\n'.join(verified_paragraphs) if verified_paragraphs else original
Join the conversation! Your thoughts help the community grow.