02. RAG Benchmark: Can Retrieved Context Fix a Weak Model?#

What is RAG?#

Retrieval-augmented generation (RAG) is a two-stage pattern for getting accurate, grounded answers from a large language model:

  1. Retrieve relevant documents from a corpus (the previous notebook showed how to do this with FAISS and semantic embeddings).

  2. Generate an answer by injecting the retrieved text into the LLM prompt, so the model reads the source material instead of relying on whatever it memorized during training.

The key insight is that even a small, inexpensive model can produce accurate answers when you put the right evidence in front of it. Without that evidence, the model must guess from parametric memory โ€“ which is often stale, imprecise, or simply wrong for specific financial figures.

The experiment#

We test this claim with a controlled benchmark:

  1. Ground truth comes from Compustat โ€“ the standard source for audited financial data (revenue, net income, total assets).

  2. Baseline: ask gpt-4o-mini each question with no context. The model must rely on whatever it learned during pre-training.

  3. RAG-enhanced: ask the same model the same question, but first retrieve the relevant 10-K filing from our WRDS SEC corpus and inject key sections (financial tables, MD&A) into the prompt.

  4. Evaluate: compare each answer to the Compustat ground truth within 5% and 10% tolerance bands.

The data#

The SEC filings come from the WRDS SEC Analytics Suite, the same corpus indexed in notebook 01. For each question we know the company (CIK) and fiscal year end date, so we can look up the corresponding 10-K filing directly โ€“ no vector search needed here. We read the cleaned filing text and extract sections most likely to contain the answer (financial tables, results of operations, MD&A).

Setup#

import os
import re
import time
from pathlib import Path

import pandas as pd
from openai import OpenAI

from settings import config

DATA_DIR = Path(config("DATA_DIR"))
OUTPUT_DIR = Path(config("OUTPUT_DIR"))

client = OpenAI(api_key=config("OPENAI_API_KEY"))

MODEL_ID = "gpt-4o-mini"
MODEL_INPUT_PRICE = 0.15  # per million tokens
MODEL_OUTPUT_PRICE = 0.60

Helper functions#

def run_prompt(api_client, model_id, prompt, system_msg=None, max_tokens=300):
    """Send a prompt and capture the response, timing, and token usage."""
    messages = []
    if system_msg:
        messages.append({"role": "system", "content": system_msg})
    messages.append({"role": "user", "content": prompt})

    start = time.time()
    response = api_client.chat.completions.create(
        model=model_id,
        messages=messages,
        max_tokens=max_tokens,
    )
    latency = time.time() - start
    return {
        "output": response.choices[0].message.content,
        "latency_s": round(latency, 3),
        "input_tokens": response.usage.prompt_tokens,
        "output_tokens": response.usage.completion_tokens,
        "total_tokens": response.usage.total_tokens,
    }


def calculate_cost(input_tokens, output_tokens, input_price, output_price):
    """Cost in USD from token counts and per-million-token prices."""
    return round(
        (input_tokens * input_price + output_tokens * output_price) / 1_000_000, 6
    )


def extract_number(text):
    """Extract a numeric value from LLM response.

    Handles dollar signs, commas, and scale words (billion/million).
    Returns value in millions to match Compustat units.
    """
    if text is None:
        return None
    cleaned = text.replace(",", "").replace("$", "")

    # Try "X billion" first (convert to millions)
    m = re.search(r"([\d.]+)\s*billion", cleaned, re.IGNORECASE)
    if m:
        return float(m.group(1)) * 1000

    # Try "X million"
    m = re.search(r"([\d.]+)\s*million", cleaned, re.IGNORECASE)
    if m:
        return float(m.group(1))

    # Fall back to last number in the text
    numbers = re.findall(r"-?\d+\.?\d*", cleaned)
    if numbers:
        return float(numbers[-1])

    return None


def is_correct(expected, extracted, tolerance=0.05):
    """Check if extracted value is within tolerance of expected."""
    if expected is None or extracted is None:
        return False
    if expected == 0:
        return abs(extracted) < 1  # close to zero
    return abs(extracted - expected) / abs(expected) <= tolerance

Load Compustat ground truth#

To measure accuracy we need numbers we can trust. Compustat provides audited annual financial data for public companies. We load revenue (sale), net income (ni), and total assets (at) โ€“ all reported in millions of USD.

from pull_CRSP_Compustat import load_compustat

comp = load_compustat(DATA_DIR)

# Keep only annual data with non-null key metrics
comp = comp.dropna(subset=["sale"])
print(f"Compustat sample: {len(comp)} company-years, {comp['conm'].nunique()} companies")
print(f"Date range: {comp['datadate'].min().date()} to {comp['datadate'].max().date()}")
comp[["conm", "year", "sale", "ni", "at"]].tail(10)
Compustat sample: 228 company-years, 19 companies
Date range: 2014-01-31 to 2026-01-31
conm year sale ni at
218 AMC ENTERTAINMENT HOLDINGS 2016 3235.846 111.667 8641.841
219 AMC ENTERTAINMENT HOLDINGS 2017 5079.2 -487.2 9805.9
220 AMC ENTERTAINMENT HOLDINGS 2018 5460.8 110.1 9495.8
221 AMC ENTERTAINMENT HOLDINGS 2019 5471.0 -149.1 13675.8
222 AMC ENTERTAINMENT HOLDINGS 2020 1242.4 -4589.1 10276.4
223 AMC ENTERTAINMENT HOLDINGS 2021 2527.9 -1269.1 10821.5
224 AMC ENTERTAINMENT HOLDINGS 2022 3911.4 -973.6 9135.6
225 AMC ENTERTAINMENT HOLDINGS 2023 4812.6 -396.6 9009.2
226 AMC ENTERTAINMENT HOLDINGS 2024 4637.2 -352.6 8247.5
227 AMC ENTERTAINMENT HOLDINGS 2025 4848.9 -632.4 8017.8

Generate benchmark Q&A pairs#

We create one question per (company, year, metric) combination. Each question asks for a specific financial figure in plain English โ€“ the kind of question an analyst might ask. The expected answer comes directly from Compustat.

METRICS = {
    "sale": "total revenue (net sales)",
    "ni": "net income (loss)",
    "at": "total assets",
}

# Use only recent fiscal years where we have 10-K filings on disk.
recent = comp[comp["year"] >= 2024].copy()

test_cases = []
for _, row in recent.iterrows():
    for col, label in METRICS.items():
        value = row[col]
        if pd.isna(value):
            continue
        test_cases.append(
            {
                "company_name": row["conm"],
                "cik": str(row["cik"]).strip(),
                "fiscal_year": int(row["year"]),
                "fiscal_year_end": row["datadate"].strftime("%Y-%m-%d"),
                "metric": col,
                "metric_label": label,
                "expected": float(value),
                "question": (
                    f"What was {row['conm']}'s {label} for the fiscal year "
                    f"ending {row['datadate'].strftime('%Y-%m-%d')}? "
                    f"Report a single number in millions of US dollars."
                ),
            }
        )

print(f"Generated {len(test_cases)} test cases")
pd.DataFrame(test_cases)[["company_name", "fiscal_year", "metric_label", "expected"]].head(10)
Generated 117 test cases
company_name fiscal_year metric_label expected
0 APPLE INC 2024 total revenue (net sales) 391035.0
1 APPLE INC 2024 net income (loss) 93736.0
2 APPLE INC 2024 total assets 364980.0
3 APPLE INC 2025 total revenue (net sales) 416161.0
4 APPLE INC 2025 net income (loss) 112010.0
5 APPLE INC 2025 total assets 359241.0
6 BOEING CO 2024 total revenue (net sales) 66960.0
7 BOEING CO 2024 net income (loss) -11817.0
8 BOEING CO 2024 total assets 156363.0
9 BOEING CO 2025 total revenue (net sales) 89463.0

Baseline evaluation (no context)#

First we establish the baseline: ask gpt-4o-mini each question with no additional context. The model must rely entirely on whatever financial data it absorbed during pre-training. For recent fiscal years this is especially challenging โ€“ the modelโ€™s training data may not include the latest annual reports.

SYSTEM_MSG = (
    "You are a financial analyst. Answer the question with a single numeric "
    "value in millions of US dollars. If you are uncertain, give your best "
    "estimate. Do not refuse to answer."
)

baseline_results = []
for i, tc in enumerate(test_cases):
    result = run_prompt(client, MODEL_ID, tc["question"], system_msg=SYSTEM_MSG)
    extracted = extract_number(result["output"])
    correct_5 = is_correct(tc["expected"], extracted, tolerance=0.05)
    correct_10 = is_correct(tc["expected"], extracted, tolerance=0.10)
    baseline_results.append(
        {
            **tc,
            "answer": result["output"],
            "extracted": extracted,
            "correct_5pct": correct_5,
            "correct_10pct": correct_10,
            "latency_s": result["latency_s"],
            "input_tokens": result["input_tokens"],
            "output_tokens": result["output_tokens"],
        }
    )
    if (i + 1) % 10 == 0:
        print(f"  Baseline: {i + 1}/{len(test_cases)} done")

df_baseline = pd.DataFrame(baseline_results)
n = len(df_baseline)
print(f"\nBaseline accuracy (5%):  {df_baseline['correct_5pct'].sum()}/{n} ({df_baseline['correct_5pct'].mean():.1%})")
print(f"Baseline accuracy (10%): {df_baseline['correct_10pct'].sum()}/{n} ({df_baseline['correct_10pct'].mean():.1%})")
  Baseline: 10/117 done
  Baseline: 20/117 done
  Baseline: 30/117 done
  Baseline: 40/117 done
  Baseline: 50/117 done
  Baseline: 60/117 done
  Baseline: 70/117 done
  Baseline: 80/117 done
  Baseline: 90/117 done
  Baseline: 100/117 done
  Baseline: 110/117 done
Baseline accuracy (5%):  10/117 (8.5%)
Baseline accuracy (10%): 23/117 (19.7%)

Build filing lookup for RAG#

This is the retrieval step. For each question we know the company (CIK) and fiscal year end date. We look up the corresponding 10-K filing from the WRDS metadata index, read the cleaned full text, and extract the sections most likely to contain the answer โ€“ financial tables, MD&A, and results of operations.

The retrieval here is structured lookup (match on CIK + date) rather than vector search. In production RAG systems you would typically use the FAISS-based semantic search from notebook 01. We use direct lookup here so the benchmark isolates the effect of having context from the quality of the retrieval itself.

from pull_sec_filings_raw import edgar_to_wrds_path

# Load filing metadata
filing_idx = pd.read_parquet(DATA_DIR / "sec_filings_metadata" / "filing_index.parquet")
filing_idx["fdate"] = pd.to_datetime(filing_idx["fdate"])
tenk_idx = filing_idx[filing_idx["form"] == "10-K"].copy()
print(f"10-K filings in index: {len(tenk_idx)}")

filings_dir = DATA_DIR / "wrds_clean_filings"


def extract_financial_context(text, max_words=3000):
    """Extract financial context from a 10-K filing.

    Uses two strategies:
    1. Find table rows with key financial figures (revenue, income, assets)
       and include surrounding lines for context.
    2. Find narrative paragraphs discussing financial results.
    """
    keywords = [
        "net sales", "total net revenue", "total revenue",
        "net income", "net loss", "net earnings",
        "total assets",
        "results of operations",
        "operating income", "gross margin", "gross profit",
    ]

    lines = text.split("\n")
    context_parts = []
    total_words = 0
    seen = set()

    # Strategy 1: Find lines with key financial table data and grab context
    for i, line in enumerate(lines):
        lower = line.lower().strip()
        if not lower:
            continue
        # Look for lines like "Total net sales $ 391,035"
        has_keyword = any(kw in lower for kw in keywords)
        has_number = bool(re.search(r"[\d,]{3,}", line))
        if has_keyword and has_number:
            # Grab a window of lines around this match
            start = max(0, i - 5)
            end = min(len(lines), i + 6)
            chunk = "\n".join(lines[start:end]).strip()
            chunk_key = chunk[:100]
            if chunk_key in seen:
                continue
            seen.add(chunk_key)
            words = chunk.split()
            if total_words + len(words) > max_words:
                continue
            context_parts.append(chunk)
            total_words += len(words)

    # Strategy 2: Narrative paragraphs with financial discussion
    paras = [p.strip() for p in text.split("\n\n") if p.strip()]
    for para in paras:
        words = para.split()
        if len(words) < 20 or len(words) > 500:
            continue
        lower = para.lower()
        hits = sum(1 for kw in keywords if kw in lower)
        has_dollar = bool(re.search(r"\$\s*[\d,]+", para))
        if hits >= 2 and has_dollar:
            para_key = para[:100]
            if para_key in seen:
                continue
            seen.add(para_key)
            if total_words + len(words) > max_words:
                continue
            context_parts.append(para)
            total_words += len(words)

    return "\n\n---\n\n".join(context_parts)


def find_10k_filing(cik, fiscal_year_end):
    """Find the 10-K filing for a given company and fiscal year end date.

    Returns the filing context or None if not found.
    """
    fy_end = pd.Timestamp(fiscal_year_end)

    # Find 10-Ks for this CIK filed within 6 months after fiscal year end
    cik_filings = tenk_idx[tenk_idx["cik"] == cik].copy()
    cik_filings = cik_filings[
        (cik_filings["fdate"] >= fy_end)
        & (cik_filings["fdate"] <= fy_end + pd.Timedelta(days=180))
    ]

    if cik_filings.empty:
        return None

    # Take the earliest filing after FY end (the actual 10-K for that year)
    filing = cik_filings.sort_values("fdate").iloc[0]

    wrds_relpath = edgar_to_wrds_path(filing["fname"])
    filepath = filings_dir / wrds_relpath
    if not filepath.exists():
        return None

    text = filepath.read_text(encoding="utf-8", errors="ignore")
    context = extract_financial_context(text)

    if not context:
        return None

    return {
        "context": context,
        "company_name": filing["coname"],
        "filing_date": str(filing["fdate"].date()),
    }


# Quick test
test_result = find_10k_filing("0000320193", "2024-09-30")
if test_result:
    print(f"Apple 10-K found: filed {test_result['filing_date']}, "
          f"context length: {len(test_result['context'])} chars")
10-K filings in index: 239
Apple 10-K found: filed 2024-11-01, context length: 19465 chars

RAG prompt construction#

The prompt template is straightforward: we prepend the retrieved filing excerpts to the question and instruct the model to extract numbers from the provided text. This is the core of RAG โ€“ the model no longer needs to know the answer; it just needs to read it.

def build_rag_prompt(question, filing_context):
    """Build a prompt with retrieved SEC filing context."""
    if filing_context is None:
        return question

    source = f"{filing_context['company_name']}, 10-K, filed {filing_context['filing_date']}"

    return (
        "Use the following SEC filing excerpts to answer the question. "
        "Extract the specific numbers from the excerpts.\n\n"
        f"SEC FILING EXCERPTS (Source: {source}):\n"
        f"{filing_context['context']}\n\n"
        f"QUESTION: {question}\n\n"
        "Answer with a single number in millions of US dollars."
    )

RAG-enhanced evaluation#

Now we repeat the same questions, but this time each prompt includes the retrieved SEC filing excerpts. The model, the system message, and the questions are identical โ€“ the only difference is the added context. Any improvement in accuracy is attributable to RAG.

rag_results = []
filings_found = 0
for i, tc in enumerate(test_cases):
    filing_context = find_10k_filing(tc["cik"], tc["fiscal_year_end"])
    if filing_context:
        filings_found += 1
    rag_prompt = build_rag_prompt(tc["question"], filing_context)
    result = run_prompt(client, MODEL_ID, rag_prompt, system_msg=SYSTEM_MSG)
    extracted = extract_number(result["output"])
    correct_5 = is_correct(tc["expected"], extracted, tolerance=0.05)
    correct_10 = is_correct(tc["expected"], extracted, tolerance=0.10)

    rag_results.append(
        {
            **tc,
            "answer": result["output"],
            "extracted": extracted,
            "correct_5pct": correct_5,
            "correct_10pct": correct_10,
            "latency_s": result["latency_s"],
            "input_tokens": result["input_tokens"],
            "output_tokens": result["output_tokens"],
            "filing_found": filing_context is not None,
        }
    )
    if (i + 1) % 10 == 0:
        print(f"  RAG: {i + 1}/{len(test_cases)} done")

df_rag = pd.DataFrame(rag_results)
n = len(df_rag)
print(f"\nFilings found: {filings_found}/{n}")
print(f"RAG accuracy (5%):  {df_rag['correct_5pct'].sum()}/{n} ({df_rag['correct_5pct'].mean():.1%})")
print(f"RAG accuracy (10%): {df_rag['correct_10pct'].sum()}/{n} ({df_rag['correct_10pct'].mean():.1%})")
  RAG: 10/117 done
  RAG: 20/117 done
  RAG: 30/117 done
  RAG: 40/117 done
  RAG: 50/117 done
  RAG: 60/117 done
  RAG: 70/117 done
  RAG: 80/117 done
  RAG: 90/117 done
  RAG: 100/117 done
  RAG: 110/117 done
Filings found: 90/117
RAG accuracy (5%):  71/117 (60.7%)
RAG accuracy (10%): 76/117 (65.0%)

Results comparison#

print("=" * 60)
print("RAG BENCHMARK RESULTS")
print("=" * 60)

baseline_cost = sum(
    calculate_cost(r["input_tokens"], r["output_tokens"], MODEL_INPUT_PRICE, MODEL_OUTPUT_PRICE)
    for r in baseline_results
)
rag_cost = sum(
    calculate_cost(r["input_tokens"], r["output_tokens"], MODEL_INPUT_PRICE, MODEL_OUTPUT_PRICE)
    for r in rag_results
)

summary = pd.DataFrame(
    {
        "Metric": [
            "Accuracy (5% tolerance)",
            "Accuracy (10% tolerance)",
            "Avg latency (s)",
            "Total cost (USD)",
        ],
        "Baseline": [
            f"{df_baseline['correct_5pct'].mean():.1%}",
            f"{df_baseline['correct_10pct'].mean():.1%}",
            f"{df_baseline['latency_s'].mean():.2f}",
            f"${baseline_cost:.4f}",
        ],
        "RAG": [
            f"{df_rag['correct_5pct'].mean():.1%}",
            f"{df_rag['correct_10pct'].mean():.1%}",
            f"{df_rag['latency_s'].mean():.2f}",
            f"${rag_cost:.4f}",
        ],
    }
)
print(summary.to_string(index=False))
============================================================
RAG BENCHMARK RESULTS
============================================================
                  Metric Baseline     RAG
 Accuracy (5% tolerance)     8.5%   60.7%
Accuracy (10% tolerance)    19.7%   65.0%
         Avg latency (s)     1.35    1.08
        Total cost (USD)  $0.0044 $0.0604
# Per-question breakdown
comparison = pd.DataFrame(
    {
        "Company": [tc["company_name"] for tc in test_cases],
        "Year": [tc["fiscal_year"] for tc in test_cases],
        "Metric": [tc["metric_label"] for tc in test_cases],
        "Expected": [tc["expected"] for tc in test_cases],
        "Baseline": df_baseline["extracted"].values,
        "Base_OK": df_baseline["correct_10pct"].values,
        "RAG": df_rag["extracted"].values,
        "RAG_OK": df_rag["correct_10pct"].values,
    }
)
comparison.to_csv(OUTPUT_DIR / "rag_benchmark_results.csv", index=False)
print(f"\nSaved detailed results to {OUTPUT_DIR / 'rag_benchmark_results.csv'}")
comparison
Saved detailed results to /Users/jbejarano/GitRepositories/finm-32900-ALL/case_study_sec_filings/_output/rag_benchmark_results.csv
Company Year Metric Expected Baseline Base_OK RAG RAG_OK
0 APPLE INC 2024 total revenue (net sales) 391035.0 400000.0 True 391035.0 True
1 APPLE INC 2024 net income (loss) 93736.0 2024.0 False 93736.0 True
2 APPLE INC 2024 total assets 364980.0 2023.0 False 364980.0 True
3 APPLE INC 2025 total revenue (net sales) 416161.0 400000.0 True 416161.0 True
4 APPLE INC 2025 net income (loss) 112010.0 2023.0 False 94695.0 False
... ... ... ... ... ... ... ... ...
112 AMC ENTERTAINMENT HOLDINGS 2024 net income (loss) -352.6 100.0 False -352.6 True
113 AMC ENTERTAINMENT HOLDINGS 2024 total assets 8247.5 10000.0 False 8247.5 True
114 AMC ENTERTAINMENT HOLDINGS 2025 total revenue (net sales) 4848.9 2023.0 False 5000.0 True
115 AMC ENTERTAINMENT HOLDINGS 2025 net income (loss) -632.4 200.0 False 40.0 False
116 AMC ENTERTAINMENT HOLDINGS 2025 total assets 8017.8 5000.0 False 6000.0 False

117 rows ร— 8 columns

Takeaways#

Without context, the model must rely on memorized (and often stale or imprecise) financial data โ€“ resulting in frequent hallucinations. With RAG, the same model receives the actual SEC filing text containing the reported figures and can extract them accurately.

This is the core value proposition of retrieval-augmented generation: ground the model in source documents so it doesnโ€™t have to guess. A cheap, fast model with good retrieval beats an expensive model working from memory alone.

Connecting the two notebooks#

Together, these notebooks illustrate the full RAG pipeline:

  • Notebook 01 built a semantic search index over SEC filings using vector embeddings and FAISS, and showed how to query it with natural-language questions.

  • Notebook 02 (this one) demonstrated why retrieval matters: the same LLM that hallucinates without context becomes accurate when we inject the right source documents.

In a production system, the retrieval step from notebook 01 would feed directly into the generation step from notebook 02 โ€“ the query identifies the most relevant filing passages, and those passages become the context window for the LLM.