CalcuOnline
HomeTech Tutorials / ReviewsFinance & BusinessAutomating Financial Statement Auditing and Risk Assessment Using RAG and LangChain
Automating Financial Statement Auditing and Risk Assessment Using RAG and LangChain

Automating Financial Statement Auditing and Risk Assessment Using RAG and LangChain

Finance & Business 5.0 Updated 24 August 2026

Scope: an audit assistant, not an auditor

This pipeline assists a human auditor by (1) computing standard financial ratios and flagging statistical outliers against prior periods and industry benchmarks, and (2) using retrieval-augmented generation to let an LLM answer specific questions about a company's financial statements grounded in the actual filed documents, citing the source page/section for every claim. It does not replace auditor judgment, materiality assessment, or professional skepticism — every flagged item routes to a human for review, and the system is designed to make false negatives (missed issues) less likely than a purely manual first pass, not to make final determinations.

1. Technical architecture

┌─────────────────────┐   ┌────────────────────┐   ┌─────────────────────────┐
│ 10-K/10-Q PDFs,          │──▶│ Document loader &      │──▶│ Chunking (section-aware) │
│ prior audit notes          │   │ table extraction        │   │ + embedding                │
└─────────────────────┘   └────────────────────┘   └────────────┬────────────┘
                                                                     ▼
                                                       ┌─────────────────────────┐
                                                       │ Vector store (Chroma)      │
                                                       └────────────┬────────────┘
┌─────────────────────┐                                            │
│ Structured financials   │                                            │
│ (extracted line items)   │                                            │
└──────────┬──────────┘                                            │
            ▼                                                          │
┌─────────────────────┐                                            │
│ Ratio & anomaly engine   │                                            │
│ (YoY variance, Beneish M-│                                            │
│  Score style checks)      │                                            │
└──────────┬──────────┘                                            │
            │  flagged items                                          │
            └───────────────────────┐                ┌───────────────┘
                                       ▼                ▼
                          ┌─────────────────────────────────┐
                          │ LangChain RAG chain: for each        │
                          │ flagged item, retrieve supporting      │
                          │ context + generate cited explanation   │
                          └──────────────┬──────────────────┘
                                           ▼
                          ┌─────────────────────────────────┐
                          │ Audit review queue (flag, ratio,      │
                          │ citation, LLM explanation, status)     │
                          └─────────────────────────────────┘

2. Complete code implementation

2.1 Document ingestion and chunking

# audit/ingest.py
import re
from pathlib import Path
from langchain_community.document_loaders import PyPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma

SECTION_HEADERS = re.compile(r"^(ITEM\s+\d+[A-Z]?\.|Note\s+\d+\s*[—\-:])", re.IGNORECASE | re.MULTILINE)


def load_and_chunk(pdf_path: str, company: str, fiscal_year: int):
    loader = PyPDFLoader(pdf_path)
    pages = loader.load()  # one Document per PDF page, with page_content + metadata

    splitter = RecursiveCharacterTextSplitter(
        chunk_size=1200,
        chunk_overlap=200,
        separators=["\n\n", "\n", ". ", " "],
    )
    chunks = splitter.split_documents(pages)

    for chunk in chunks:
        chunk.metadata.update({
            "company": company,
            "fiscal_year": fiscal_year,
            "source_file": Path(pdf_path).name,
        })
    return chunks


def build_vector_store(chunks, persist_dir: str = "audit/chroma_db"):
    if not chunks:
        raise ValueError("No document chunks to index — check that the PDF loaded correctly")
    embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
    store = Chroma.from_documents(chunks, embeddings, persist_directory=persist_dir)
    store.persist()
    return store

2.2 Ratio and anomaly engine

# audit/anomaly_engine.py
from dataclasses import dataclass


@dataclass
class FinancialStatement:
    fiscal_year: int
    revenue: float
    cost_of_goods_sold: float
    net_income: float
    total_assets: float
    total_liabilities: float
    receivables: float
    operating_cash_flow: float


@dataclass
class Flag:
    metric: str
    current_value: float
    prior_value: float
    pct_change: float
    severity: str  # "info" | "warning" | "high"
    rationale: str


def _pct_change(current: float, prior: float) -> float | None:
    if prior == 0:
        return None
    return (current - prior) / abs(prior)


def compute_flags(current: FinancialStatement, prior: FinancialStatement,
                   variance_threshold: float = 0.25) -> list[Flag]:
    flags: list[Flag] = []

    gross_margin_current = (current.revenue - current.cost_of_goods_sold) / current.revenue if current.revenue else 0
    gross_margin_prior = (prior.revenue - prior.cost_of_goods_sold) / prior.revenue if prior.revenue else 0
    margin_delta = gross_margin_current - gross_margin_prior
    if abs(margin_delta) > 0.10:
        flags.append(Flag(
            metric="gross_margin",
            current_value=round(gross_margin_current, 4),
            prior_value=round(gross_margin_prior, 4),
            pct_change=round(margin_delta, 4),
            severity="warning" if abs(margin_delta) < 0.15 else "high",
            rationale="Gross margin shifted more than 10 percentage points year over year.",
        ))

    receivables_growth = _pct_change(current.receivables, prior.receivables)
    revenue_growth = _pct_change(current.revenue, prior.revenue)
    if receivables_growth is not None and revenue_growth is not None:
        gap = receivables_growth - revenue_growth
        if gap > variance_threshold:
            flags.append(Flag(
                metric="receivables_vs_revenue_growth",
                current_value=round(receivables_growth, 4),
                prior_value=round(revenue_growth, 4),
                pct_change=round(gap, 4),
                severity="high" if gap > 0.40 else "warning",
                rationale="Receivables grew significantly faster than revenue — a classic revenue-recognition red flag.",
            ))

    ni_growth = _pct_change(current.net_income, prior.net_income)
    ocf_growth = _pct_change(current.operating_cash_flow, prior.operating_cash_flow)
    if ni_growth is not None and ocf_growth is not None:
        divergence = ni_growth - ocf_growth
        if divergence > variance_threshold:
            flags.append(Flag(
                metric="net_income_vs_operating_cash_flow",
                current_value=round(ni_growth, 4),
                prior_value=round(ocf_growth, 4),
                pct_change=round(divergence, 4),
                severity="high" if divergence > 0.40 else "warning",
                rationale="Net income growth substantially outpaced operating cash flow growth — possible earnings quality concern.",
            ))

    debt_to_assets = current.total_liabilities / current.total_assets if current.total_assets else 0
    if debt_to_assets > 0.80:
        flags.append(Flag(
            metric="leverage_ratio",
            current_value=round(debt_to_assets, 4),
            prior_value=0.0,
            pct_change=0.0,
            severity="warning",
            rationale="Total liabilities exceed 80% of total assets, indicating high leverage relative to typical thresholds.",
        ))

    return flags

2.3 RAG-grounded explanation chain

# audit/rag_chain.py
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
from langchain.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from openai import RateLimitError, APITimeoutError

PROMPT = ChatPromptTemplate.from_template(
    """You are assisting a financial statement audit. Using ONLY the context
below, explain the following flagged metric in 2-3 sentences and cite the
source page for every factual claim using the format [p.<page>]. If the
context does not contain enough information to explain the flag, say so
explicitly rather than speculating.

Flagged metric: {metric}
Rationale: {rationale}
Current value: {current_value}, Prior value: {prior_value}

Context:
{context}
"""
)


class AuditRagChain:
    def __init__(self, persist_dir: str = "audit/chroma_db"):
        embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
        self.store = Chroma(persist_directory=persist_dir, embedding_function=embeddings)
        self.llm = ChatOpenAI(model="gpt-4o", temperature=0)
        self.chain = PROMPT | self.llm | StrOutputParser()

    def explain_flag(self, flag, company: str, fiscal_year: int, max_retries: int = 3) -> str:
        query = f"{flag.metric} {flag.rationale} {company} {fiscal_year}"
        docs = self.store.similarity_search(query, k=4, filter={"company": company})
        if not docs:
            return "No supporting context found in the indexed filings for this flag — requires manual review."

        context = "\n\n".join(f"[p.{d.metadata.get('page', '?')}] {d.page_content}" for d in docs)

        for attempt in range(max_retries):
            try:
                return self.chain.invoke({
                    "metric": flag.metric,
                    "rationale": flag.rationale,
                    "current_value": flag.current_value,
                    "prior_value": flag.prior_value,
                    "context": context,
                })
            except RateLimitError:
                if attempt == max_retries - 1:
                    return "LLM explanation unavailable (rate limited) — flag requires manual review."
                import time
                time.sleep(2 ** attempt)
            except APITimeoutError:
                if attempt == max_retries - 1:
                    return "LLM explanation timed out — flag requires manual review."
                continue

3. Step-by-step configuration guide

  1. Install dependencies:
    pip install langchain langchain-openai langchain-community chromadb pypdf --break-system-packages
    
  2. Set your API key: export OPENAI_API_KEY="sk-...".
  3. Extract structured financials — for most public filings, pull the standardized line items from a source like the SEC's XBRL "company facts" API rather than parsing tables out of the PDF by hand; use those values to populate FinancialStatement. PDF ingestion (ingest.py) is for narrative sections (MD&A, footnotes) that need RAG-based Q&A, not for the numeric statements themselves.
  4. Build the index per filing:
    from audit.ingest import load_and_chunk, build_vector_store
    chunks = load_and_chunk("filings/acme_corp_10k_2025.pdf", company="Acme Corp", fiscal_year=2025)
    build_vector_store(chunks)
    
  5. Run the anomaly engine against two consecutive periods' FinancialStatement objects to get a list[Flag].
  6. Generate grounded explanations:
    from audit.rag_chain import AuditRagChain
    rag = AuditRagChain()
    for flag in flags:
        explanation = rag.explain_flag(flag, company="Acme Corp", fiscal_year=2025)
        print(flag.metric, flag.severity, "->", explanation)
    
  7. Route every flag to the audit review queue (a simple table: metric, severity, citation, explanation, reviewer, status) rather than auto-resolving anything — this system's job ends at surfacing well-explained candidates for human review.

4. Error handling and edge cases

Related Reviews

Bank of India Car Loan Review: Consistently Among the Lowest Advertised Rates
Bank of India Car Loan Review: Consistently Among the Lowest Advertised Rates
4.0
Bank of India regularly appears among the cheapest car loan quotes in India at around 7.60% p.a. entry, though its top-end range (up to ~12.55%) is wider than most PSU peers, making profile-specific quotes especially important here.
Read Review → Finance & Business
Axis Bank Car Loan Review: A Reasonable Private-Bank Middle Ground
Axis Bank Car Loan Review: A Reasonable Private-Bank Middle Ground
3.0
Axis Bank prices car loans from roughly 8.75%-9.40% p.a., broadly in line with HDFC Bank and ICICI Bank, with a fast "Xpress"-style digital approval flow but no standout rate or fee advantage over its private-bank peers.
Read Review → Finance & Business
Punjab National Bank Car Loan Review: A Steady, Wide-Reach PSU Option
Punjab National Bank Car Loan Review: A Steady, Wide-Reach PSU Option
4.0
PNB combines a competitive entry rate (roughly 7.55%-10.70% p.a.) with low processing fees starting around Rs.1,000 and up to 100% on-road funding, backed by one of the largest PSU branch networks in India.
Read Review → Finance & Business