RAG Pipeline Walkthrough#
Notebook 01 described the data; notebook 03 reports the benchmark numbers. This middle notebook explains how the pipeline itself is wired together – function by function, with small concrete inputs and outputs – so that when the results show up in notebook 03 you already know exactly what was done to produce each number.
Everything here is imported from src/rag_benchmark.py
and src/embeddings_index.py. The runnable
scripts call OpenAI; this notebook stays mostly offline and uses saved
CSV outputs from a previous run to show what the model actually
returned. That way the walkthrough is cheap to re-execute and does not
require an API key (it does briefly call the embeddings API in Step 3b
to embed one query, but that costs a fraction of a cent and can be
skipped).
Two flavors of retrieval#
“RAG” just means retrieve text from a corpus, then generate. It does not specify how retrieval works. We benchmark two retrieval families in this homework:
Structured retrieval (
find_10k_filing): the question already carries a CIK and a fiscal-year-end, so we look up the matching 10-K directly and run a keyword-based chunk extractor over it. This is what therag_cleanedandrag_rawconditions use.Dense semantic retrieval (
semantic_search): the corpus is pre-chunked and every chunk is embedded into a 1536-dim vector with the OpenAI embeddings API. At query time we embed the question and ask FAISS for the top-k nearest chunks across the whole corpus – no CIK/year prefilter. This is what therag_semantic_cleanedcondition uses, and it is what most people mean when they say “classical RAG.”
Both end up calling the same downstream code (build_rag_prompt,
run_prompt, extract_number, is_correct). Only the retrieval step
changes.
The pipeline at a glance#
For each (company, fiscal year, metric) question we do the same
thing in four conditions (baseline, RAG-cleaned, RAG-raw,
RAG-semantic-cleaned):
build_test_cases() # generate questions from Compustat
|
v
---- pick a retrieval path (skipped in baseline) ----
structured: find_10k_filing() + extract_financial_context_*()
semantic: semantic_search() over a prebuilt FAISS index
|
v
build_rag_prompt() # assemble the prompt
|
v
run_prompt() # OpenAI chat.completions call
|
v
extract_number() # parse a number out of the reply
|
v
is_correct() # compare to Compustat ground truth
The sections below walk through those steps one at a time.
from pathlib import Path
import pandas as pd
from settings import config
from rag_benchmark import (
MODEL_ID,
MODEL_INPUT_PRICE,
MODEL_OUTPUT_PRICE,
SYSTEM_MSG,
METRICS,
MAX_WORDS_CLEANED,
MAX_WORDS_RAW,
FINANCIAL_KEYWORDS,
build_test_cases,
extract_financial_context_cleaned,
extract_financial_context_raw,
load_filing_index,
find_10k_filing,
build_rag_prompt,
extract_number,
is_correct,
calculate_cost,
)
from embeddings_index import (
EMBED_MODEL,
CHUNK_SIZE,
CHUNK_OVERLAP,
chunk_text,
load_index,
semantic_search,
)
DATA_DIR = Path(config("DATA_DIR"))
OUTPUT_DIR = Path(config("OUTPUT_DIR"))
Step 1: generate benchmark questions#
build_test_cases(min_year=...) reads Compustat, filters to recent
fiscal years, and produces one dictionary per
(company, fiscal_year, metric) combination. The three metrics are
fixed in rag_benchmark.py:
METRICS
{'sale': 'total revenue (net sales)',
'ni': 'net income (loss)',
'at': 'total assets'}
Each returned test case is a plain dict. The LLM will see
question, the grader will compare its answer against expected,
and the retriever keys off cik and fiscal_year_end.
test_cases = build_test_cases(min_year=2023)
print(f"Total questions: {len(test_cases)}")
print(f" = {len({tc['cik'] for tc in test_cases})} companies "
f"x {len({tc['fiscal_year'] for tc in test_cases})} fiscal years "
f"x {len(METRICS)} metrics "
"(minus any missing Compustat rows)")
sample_case = test_cases[0]
sample_case
Total questions: 96
= 10 companies x 4 fiscal years x 3 metrics (minus any missing Compustat rows)
{'company_name': 'APPLE INC',
'cik': '0000320193',
'fiscal_year': 2023,
'fiscal_year_end': '2023-09-30',
'metric': 'sale',
'metric_label': 'total revenue (net sales)',
'expected': 383285.0,
'question': "What was APPLE INC's total revenue (net sales) for the fiscal year ending 2023-09-30? Report a single number in millions of US dollars."}
The natural-language question assembled for the LLM:
print(sample_case["question"])
What was APPLE INC's total revenue (net sales) for the fiscal year ending 2023-09-30? Report a single number in millions of US dollars.
Step 2: find the matching 10-K#
Retrieval here is a structured lookup, not semantic search. We have a filing index keyed by CIK, and for each question we keep the first 10-K whose filing date falls in the 180 days after the fiscal year end. That window is wide enough to catch the annual report but narrow enough to avoid grabbing the next year’s filing.
tenk_idx = load_filing_index()
print(f"10-K filings in the index: {len(tenk_idx)}")
tenk_idx[["cik", "coname", "form", "fdate"]].sort_values("fdate").head()
10-K filings in the index: 48
| cik | coname | form | fdate | |
|---|---|---|---|---|
| 47 | 0001065280 | NETFLIX INC | 10-K | 2022-01-27 |
| 46 | 0001652044 | Alphabet Inc. | 10-K | 2022-02-02 |
| 45 | 0001018724 | AMAZON COM INC | 10-K | 2022-02-04 |
| 44 | 0001318605 | Tesla, Inc. | 10-K | 2022-02-07 |
| 43 | 0000200406 | JOHNSON & JOHNSON | 10-K | 2022-02-17 |
find_10k_filing takes the index, a CIK, a fiscal-year-end date,
a directory of filings on disk, and a chunk-extractor function. It
returns the extracted context plus a bit of provenance, or None
if the filing is missing from our corpus.
fc_clean = find_10k_filing(
tenk_idx,
cik=sample_case["cik"],
fiscal_year_end=sample_case["fiscal_year_end"],
filings_dir=DATA_DIR / "wrds_clean_filings",
extractor=extract_financial_context_cleaned,
)
{k: (len(v) if k == "context" else v) for k, v in fc_clean.items()}
{'context': 18868,
'company_name': 'Apple Inc.',
'filing_date': '2023-11-03',
'filing_bytes': 1190332}
The same call against the raw archive – same CIK, same date, but
pointing at wrds_raw_filings and using the raw extractor. Notice
the context length jumps because the raw text carries markup.
fc_raw = find_10k_filing(
tenk_idx,
cik=sample_case["cik"],
fiscal_year_end=sample_case["fiscal_year_end"],
filings_dir=DATA_DIR / "wrds_raw_filings",
extractor=extract_financial_context_raw,
)
{k: (len(v) if k == "context" else v) for k, v in fc_raw.items()}
{'context': 72546,
'company_name': 'Apple Inc.',
'filing_date': '2023-11-03',
'filing_bytes': 9569569}
Step 3: chunk selection#
extract_financial_context_cleaned is the real workhorse of the
retrieval step. It scans the 10-K for two kinds of content:
Keyword + number lines (e.g. “Total revenue $383,285”) and grabs a few lines of surrounding context.
Financial narrative paragraphs that mention at least two financial keywords and contain a dollar figure.
The keyword list is the same set the LLM is trying to answer about:
FINANCIAL_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']
Both conditions (cleaned and raw) use the same extractor logic –
extract_financial_context_raw is deliberately a thin wrapper. The
only difference between conditions is the text WRDS hands us. A
toy example makes the behavior concrete:
toy_10k = """
ITEM 7. RESULTS OF OPERATIONS
The following discussion compares our fiscal 2024 results to fiscal 2023.
Net sales increased 8% to $383,285 million, driven by higher iPhone and
Services revenue.
Total assets grew to $352,755 million as of September 28, 2024.
Unrelated filler about forward-looking statements goes here and should
not be picked up by the chunker because it has neither a keyword nor a
dollar figure.
Net income for fiscal 2024 was $96,995 million, compared to $99,803
million in the prior year.
"""
print(extract_financial_context_cleaned(toy_10k, max_words=500))
ITEM 7. RESULTS OF OPERATIONS
The following discussion compares our fiscal 2024 results to fiscal 2023.
Net sales increased 8% to $383,285 million, driven by higher iPhone and
Services revenue.
Total assets grew to $352,755 million as of September 28, 2024.
Unrelated filler about forward-looking statements goes here and should
---
The following discussion compares our fiscal 2024 results to fiscal 2023.
Net sales increased 8% to $383,285 million, driven by higher iPhone and
Services revenue.
Total assets grew to $352,755 million as of September 28, 2024.
Unrelated filler about forward-looking statements goes here and should
not be picked up by the chunker because it has neither a keyword nor a
dollar figure.
---
Unrelated filler about forward-looking statements goes here and should
not be picked up by the chunker because it has neither a keyword nor a
dollar figure.
Net income for fiscal 2024 was $96,995 million, compared to $99,803
million in the prior year.
Even on real filings, the extracted context is a small fraction of the full 10-K. Here is the cleaned vs raw comparison for our sample filing:
cleaned_words = len(fc_clean["context"].split())
raw_words = len(fc_raw["context"].split())
pd.DataFrame({
"condition": ["rag_cleaned", "rag_raw"],
"budget_words": [MAX_WORDS_CLEANED, MAX_WORDS_RAW],
"actual_words": [cleaned_words, raw_words],
"chars": [len(fc_clean["context"]), len(fc_raw["context"])],
"chars_per_word": [
round(len(fc_clean["context"]) / max(cleaned_words, 1), 1),
round(len(fc_raw["context"]) / max(raw_words, 1), 1),
],
})
| condition | budget_words | actual_words | chars | chars_per_word | |
|---|---|---|---|---|---|
| 0 | rag_cleaned | 3000 | 3009 | 18868 | 6.3 |
| 1 | rag_raw | 3000 | 2996 | 72546 | 24.2 |
The raw chunks have far more characters per word – that’s the SGML/HTML/XBRL markup that WRDS’s cleaning pipeline strips. Those characters become input tokens, and input tokens cost money.
First 500 characters of the two contexts so you can see the difference directly:
print("--- cleaned (first 500 chars) ---")
print(fc_clean["context"][:500])
print("\n--- raw (first 500 chars) ---")
print(fc_raw["context"][:500])
--- cleaned (first 500 chars) ---
Form 10-K Summary
57
This Annual Report on Form 10-K (Form 10-K) contains forward-looking statements, within the meaning of the Private Securities Litigation Reform Act of 1995, that involve risks and uncertainties. Many of the forward-looking statements are located in Part I, Item 1 of this Form 10-K under the heading Business and Part II, Item 7 of this Form 10-K under the heading Managements Discussion and Analysis of Financial Condition and Results of Operations. Forward-looking statement
--- raw (first 500 chars) ---
<DESCRIPTION>EX-31.1
<TEXT>
<html><head>
<!-- Document created using Wdesk -->
<!-- Copyright 2023 Workiva -->
<title>Document</title></head><body><div id="i2369e0a153494a43a2376c67a756c66b_1"></div><div style="min-height:42.75pt;width:100%"><div><font><br></font></div></div><div style="text-align:right"><font style="color:#000000;font-family:'Helvetica',sans-serif;font-size:9pt;font-weight:700;line-height:120%">Exhibit 31.1</font></div><div style="margin-top:12pt;text-align:center"><font style=
Step 3b: dense semantic retrieval (the “classical RAG” path)#
The structured-lookup retrieval above works because the question already names the company and the fiscal year, and we have a clean index keyed on those. Most production RAG stacks do not have that luxury – the question is free text and there is no obvious join key. So they fall back to semantic search: embed the corpus once, embed the query, and ask a vector index for the most similar chunks.
Three pieces are involved:
A chunker that splits each filing into overlapping passages.
chunk_text(text, chunk_size, overlap)is a sliding character window – nothing fancy, justchunk_sizecharacters at a time withoverlapcharacters of overlap so a sentence isn’t sliced in half.An embedding model that turns each chunk (and later, each query) into a fixed-length vector. We use OpenAI
text-embedding-3-small– 1536 dimensions, same API client we already use for the chat completions.A vector index that, given a query vector, returns the top-k most similar chunk vectors. We use FAISS (
IndexFlatIPwith L2-normalized vectors, which gives exact cosine similarity over the ~55k chunks in the cleaned corpus).
The build (task_build_embeddings in dodo.py -> python src/embeddings_index.py) chunks all 48 cleaned filings, embeds every
chunk in batches, and writes:
_data/embeddings/cleaned_chunks.parquet– one row per chunk with metadata (cik, accession, fdate, char_start, char_end, text)._data/embeddings/cleaned_index.faiss– the FAISS index, with row order aligned to the parquet.
We do this once and cache, so subsequent benchmark runs only pay the query-side embedding cost.
Chunking demo#
Same toy 10-K we used above for the keyword extractor, this time fed
to chunk_text:
toy_chunks = chunk_text(toy_10k, chunk_size=200, overlap=40)
pd.DataFrame([
{
"chunk_idx": i,
"char_start": s,
"char_end": e,
"preview": (t[:80] + "...") if len(t) > 80 else t,
}
for i, (t, s, e) in enumerate(toy_chunks)
])
| chunk_idx | char_start | char_end | preview | |
|---|---|---|---|---|
| 0 | 0 | 0 | 200 | \nITEM 7. RESULTS OF OPERATIONS\n\nThe followi... |
| 1 | 1 | 160 | 360 | higher iPhone and\nServices revenue.\n\nTotal... |
| 2 | 2 | 320 | 516 | re and should\nnot be picked up by the chunker... |
In the real build we use chunk_size=2000, overlap=200. Smaller
chunks here just so the demo fits on a screen. The values used by
the build are imported from embeddings_index.py:
print(f"CHUNK_SIZE = {CHUNK_SIZE}")
print(f"CHUNK_OVERLAP = {CHUNK_OVERLAP}")
print(f"EMBED_MODEL = {EMBED_MODEL}")
CHUNK_SIZE = 2000
CHUNK_OVERLAP = 200
EMBED_MODEL = text-embedding-3-small
Loading the prebuilt index#
Once the build is done, querying is fast and offline (modulo a single embedding call per query). Each chunk row carries its source filing’s CIK and filing date, so we can tell after retrieval which company / year the chunk came from.
chunks_df, faiss_index = load_index(
DATA_DIR / "embeddings" / "cleaned_chunks.parquet",
DATA_DIR / "embeddings" / "cleaned_index.faiss",
)
print(f"chunks in index: {len(chunks_df):,}")
print(f"distinct filings covered: {chunks_df['accession'].nunique()}")
print(f"vector dimension: {faiss_index.d}")
chunks_df.head(3)[["coname", "fdate", "chunk_idx", "char_start", "char_end"]]
chunks in index: 63,252
distinct filings covered: 48
vector dimension: 1536
| coname | fdate | chunk_idx | char_start | char_end | |
|---|---|---|---|---|---|
| 0 | Walmart Inc. | 2026-03-13 | 0 | 0 | 2000 |
| 1 | Walmart Inc. | 2026-03-13 | 1 | 1800 | 3800 |
| 2 | Walmart Inc. | 2026-03-13 | 2 | 3600 | 5600 |
A semantic-search query#
semantic_search(client, question, chunks_df, faiss_index, top_k, max_words) embeds the question, calls FAISS for the top-k chunks,
and concatenates them up to a word budget. It returns the same shape
of dict as find_10k_filing plus a top_k_filings field that lets
us inspect which filings the chunks came from.
We need an OpenAI client only to embed the question (the corpus embeddings are already cached). If you do not have an API key handy, skip the next two cells and read the prose – the rest of the notebook does not depend on this call.
api_key = config("OPENAI_API_KEY", default=None)
if api_key:
from openai import OpenAI
client = OpenAI(api_key=api_key)
sem_result = semantic_search(
client,
sample_case["question"],
chunks_df,
faiss_index,
top_k=5,
max_words=600,
)
else:
client = None
sem_result = None
print("Set OPENAI_API_KEY to run the live retrieval cell below.")
sem_result is None or pd.DataFrame(sem_result["top_k_filings"])
| rank | cik | coname | fdate | score | |
|---|---|---|---|---|---|
| 0 | 0 | 0000320193 | Apple Inc. | 2022-10-28 | 0.696436 |
| 1 | 1 | 0000320193 | Apple Inc. | 2022-10-28 | 0.690374 |
| 2 | 2 | 0000320193 | Apple Inc. | 2024-11-01 | 0.686319 |
| 3 | 3 | 0000320193 | Apple Inc. | 2023-11-03 | 0.679418 |
| 4 | 4 | 0000320193 | Apple Inc. | 2023-11-03 | 0.679405 |
Read across the rows: each row is one of the top-k chunks the
semantic retriever picked, and the coname / fdate columns tell
us whether the retriever is finding chunks from the right filing
(the question asks about sample_case["company_name"] for fiscal
year sample_case["fiscal_year_end"]).
Keyword vs semantic: side by side#
Same question, two retrievers. The keyword extractor pulls passages only from the matched filing; the semantic retriever pulls from anywhere in the corpus, but tends to land on the right company when the question is specific enough.
if sem_result is not None:
sem_filings = sorted({r["coname"] for r in sem_result["top_k_filings"]})
sem_chars = len(sem_result["context"])
sem_words = len(sem_result["context"].split())
else:
sem_filings, sem_chars, sem_words = ["(skipped)"], 0, 0
pd.DataFrame({
"retriever": ["keyword (structured)", "semantic (global FAISS)"],
"filings_used": [
fc_clean["company_name"],
", ".join(sem_filings),
],
"context_chars": [len(fc_clean["context"]), sem_chars],
"context_words": [len(fc_clean["context"].split()), sem_words],
})
| retriever | filings_used | context_chars | context_words | |
|---|---|---|---|---|
| 0 | keyword (structured) | Apple Inc. | 18868 | 3009 |
| 1 | semantic (global FAISS) | Apple Inc. | 2060 | 340 |
Step 4: assemble the prompt#
build_rag_prompt wraps the retrieved context and the question into
a single user message. When filing_context is None (baseline or
missing filing) it falls back to sending the bare question – this is
how the baseline condition and “filing not found” cases are handled
with the same downstream code path.
print("--- BASELINE PROMPT (no context) ---")
print(build_rag_prompt(sample_case["question"], None))
--- BASELINE PROMPT (no context) ---
What was APPLE INC's total revenue (net sales) for the fiscal year ending 2023-09-30? Report a single number in millions of US dollars.
print("--- RAG PROMPT (first 1200 chars) ---")
rag_prompt = build_rag_prompt(sample_case["question"], fc_clean)
print(rag_prompt[:1200])
print("...")
print(f"\n[total length: {len(rag_prompt):,} chars]")
--- RAG PROMPT (first 1200 chars) ---
Use the following SEC filing excerpts to answer the question. Extract the specific numbers from the excerpts.
SEC FILING EXCERPTS (Source: Apple Inc., 10-K, filed 2023-11-03):
Form 10-K Summary
57
This Annual Report on Form 10-K (Form 10-K) contains forward-looking statements, within the meaning of the Private Securities Litigation Reform Act of 1995, that involve risks and uncertainties. Many of the forward-looking statements are located in Part I, Item 1 of this Form 10-K under the heading Business and Part II, Item 7 of this Form 10-K under the heading Managements Discussion and Analysis of Financial Condition and Results of Operations. Forward-looking statements provide current expectations of future events based on certain assumptions and include any statement that does not directly relate to any historical or current fact. For example, statements in this Form 10-K regarding the potential future impact of macroeconomic conditions on the Companys business and results of operations are forward-looking statements. Forward-looking statements can also be identified by words such as future, anticipates, believes, estimates, expects, intends, plans, predicts, will, would, could,
...
[total length: 19,248 chars]
The system message that accompanies every call is also fixed:
print(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.
Step 5: the LLM call#
run_prompt(client, model_id, prompt, system_msg=...) is a thin
wrapper around openai.chat.completions.create. It measures wall
time, captures token counts, and returns a dict:
{
"output": "...", # the model's reply text
"latency_s": 0.87,
"input_tokens": 1234,
"output_tokens": 12,
}
We do not call the API here – that happens in
src/rag_benchmark.py at build time and the results are cached to
CSV. Let’s load the cached results and look at one real reply for
the sample question.
df_baseline = pd.read_csv(OUTPUT_DIR / "baseline_results.csv")
df_clean = pd.read_csv(OUTPUT_DIR / "rag_cleaned_results.csv")
df_raw = pd.read_csv(OUTPUT_DIR / "rag_raw_results.csv")
df_semantic = pd.read_csv(OUTPUT_DIR / "rag_semantic_cleaned_results.csv")
sample_cik_int = int(sample_case["cik"])
def pick(df):
m = (
(df["cik"].astype(int) == sample_cik_int)
& (df["fiscal_year"] == sample_case["fiscal_year"])
& (df["metric"] == sample_case["metric"])
)
return df.loc[m].iloc[0]
row_b = pick(df_baseline)
row_c = pick(df_clean)
row_r = pick(df_raw)
row_s = pick(df_semantic)
pd.DataFrame({
"condition": ["baseline", "rag_cleaned", "rag_raw", "rag_semantic_cleaned"],
"answer": [row_b["answer"], row_c["answer"], row_r["answer"], row_s["answer"]],
"latency_s": [row_b["latency_s"], row_c["latency_s"], row_r["latency_s"], row_s["latency_s"]],
"input_tokens": [row_b["input_tokens"], row_c["input_tokens"], row_r["input_tokens"], row_s["input_tokens"]],
"output_tokens":[row_b["output_tokens"], row_c["output_tokens"], row_r["output_tokens"], row_s["output_tokens"]],
})
| condition | answer | latency_s | input_tokens | output_tokens | |
|---|---|---|---|---|---|
| 0 | baseline | Apple Inc's total revenue for the fiscal year ... | 2.827 | 82 | 30 |
| 1 | rag_cleaned | 383,285 | 1.223 | 4227 | 3 |
| 2 | rag_raw | 394328 million | 1.886 | 21908 | 3 |
| 3 | rag_semantic_cleaned | 383,285 | 4.520 | 5404 | 3 |
Input-token counts line up with intuition: baseline is tiny (just the question), RAG-cleaned is larger (question + chunked context), and RAG-raw is larger still (same chunking, but each chunk carries markup).
Step 6: parse a number out of the reply#
The LLM answers in prose (“Apple’s fiscal 2024 revenue was
approximately $383 billion.”) but the grader needs a numeric value
in millions of USD. extract_number handles the common shapes:
examples = [
"Apple's fiscal 2024 revenue was approximately $383 billion.",
"Net income was 96,995 million.",
"The total assets figure is $352,755.",
"Approximately -2,000 million (net loss).",
"I don't know.",
]
pd.DataFrame({
"llm_reply": examples,
"extracted_millions": [extract_number(x) for x in examples],
})
| llm_reply | extracted_millions | |
|---|---|---|
| 0 | Apple's fiscal 2024 revenue was approximately ... | 383000.0 |
| 1 | Net income was 96,995 million. | 96995.0 |
| 2 | The total assets figure is $352,755. | 352755.0 |
| 3 | Approximately -2,000 million (net loss). | 2000.0 |
| 4 | I don't know. | NaN |
The parser’s preference order is:
"X billion"->X * 1000(convert to millions)."X million"->X.Otherwise, fall back to the last numeric token in the string (typically the figure the model quoted last).
It’s deliberately forgiving – these are small models and the prose varies. The 5% and 10% tolerance bands in the next step absorb any rounding.
Step 7: grade the answer#
is_correct(expected, extracted, tolerance) is a relative error
check with a guard for zero expected values.
expected = sample_case["expected"]
examples = [expected, expected * 1.03, expected * 1.08, expected * 0.5, None]
pd.DataFrame({
"expected": [expected] * len(examples),
"extracted": examples,
"within_5pct": [is_correct(expected, x, 0.05) for x in examples],
"within_10pct": [is_correct(expected, x, 0.10) for x in examples],
})
| expected | extracted | within_5pct | within_10pct | |
|---|---|---|---|---|
| 0 | 383285.0 | 383285.00 | True | True |
| 1 | 383285.0 | 394783.55 | True | True |
| 2 | 383285.0 | 413947.80 | False | True |
| 3 | 383285.0 | 191642.50 | False | False |
| 4 | 383285.0 | NaN | False | False |
Notebook 03 reports accuracy at both tolerance levels because a fair number of correct answers in this task miss 5% by rounding and land comfortably inside 10%.
Step 8: cost accounting#
calculate_cost(input_tokens, output_tokens) applies the published
gpt-4o-mini prices (per million tokens):
print(f"Model: {MODEL_ID}")
print(f"Input price (USD/M tokens): {MODEL_INPUT_PRICE}")
print(f"Output price (USD/M tokens): {MODEL_OUTPUT_PRICE}")
pd.DataFrame({
"condition": ["baseline", "rag_cleaned", "rag_raw", "rag_semantic_cleaned"],
"input_tokens": [row_b["input_tokens"], row_c["input_tokens"], row_r["input_tokens"], row_s["input_tokens"]],
"output_tokens": [row_b["output_tokens"], row_c["output_tokens"], row_r["output_tokens"], row_s["output_tokens"]],
"usd_per_query": [
calculate_cost(row_b["input_tokens"], row_b["output_tokens"]),
calculate_cost(row_c["input_tokens"], row_c["output_tokens"]),
calculate_cost(row_r["input_tokens"], row_r["output_tokens"]),
calculate_cost(row_s["input_tokens"], row_s["output_tokens"]),
],
})
Model: gpt-4o-mini
Input price (USD/M tokens): 0.15
Output price (USD/M tokens): 0.6
| condition | input_tokens | output_tokens | usd_per_query | |
|---|---|---|---|---|
| 0 | baseline | 82 | 30 | 0.000030 |
| 1 | rag_cleaned | 4227 | 3 | 0.000636 |
| 2 | rag_raw | 21908 | 3 | 0.003288 |
| 3 | rag_semantic_cleaned | 5404 | 3 | 0.000812 |
One RAG call on this sample question costs well under a cent – which is the whole point of the exercise: if a small, cheap model can read the 10-K, you don’t need an expensive one to know the answer.
Putting it together end-to-end#
Here is what the pipeline does for our one sample question, traced through every step.
trace = {
"1. test case": f"{sample_case['company_name']} / "
f"{sample_case['metric_label']} / "
f"FY ending {sample_case['fiscal_year_end']}",
"2. ground truth": f"{sample_case['expected']:,.0f} (USD millions, from Compustat)",
"3. filing found": f"{fc_clean['company_name']}, "
f"filed {fc_clean['filing_date']}, "
f"{fc_clean['filing_bytes']:,} bytes on disk",
"4. context chars": f"cleaned={len(fc_clean['context']):,} "
f"raw={len(fc_raw['context']):,} "
f"semantic={row_s['context_chars']:,}",
"5. llm reply (rag_cleaned)": row_c["answer"][:200],
"5. llm reply (rag_semantic)": row_s["answer"][:200],
"6. extracted (cleaned)": f"{row_c['extracted']}",
"6. extracted (semantic)": f"{row_s['extracted']}",
"7. correct @ 10% (cleaned)": bool(row_c["correct_10pct"]),
"7. correct @ 10% (semantic)": bool(row_s["correct_10pct"]),
"7. semantic top-1 chunk match": bool(row_s["top_chunk_cik_match"]),
"8. cost cleaned (USD)": calculate_cost(row_c["input_tokens"], row_c["output_tokens"]),
"8. cost semantic (USD)": calculate_cost(row_s["input_tokens"], row_s["output_tokens"]),
}
for k, v in trace.items():
print(f"{k:>32}: {v}")
1. test case: APPLE INC / total revenue (net sales) / FY ending 2023-09-30
2. ground truth: 383,285 (USD millions, from Compustat)
3. filing found: Apple Inc., filed 2023-11-03, 1,190,332 bytes on disk
4. context chars: cleaned=18,868 raw=72,546 semantic=18,596
5. llm reply (rag_cleaned): 383,285
5. llm reply (rag_semantic): 383,285
6. extracted (cleaned): 383285.0
6. extracted (semantic): 383285.0
7. correct @ 10% (cleaned): True
7. correct @ 10% (semantic): True
7. semantic top-1 chunk match: True
8. cost cleaned (USD): 0.000636
8. cost semantic (USD): 0.000812
Where to go from here#
Notebook 03 scales this up: it runs the full grid of questions through all four conditions and reports aggregate accuracy, latency, token usage, and cost. Before opening it, you should now be able to predict the qualitative shape of the results:
Baseline is cheap but inaccurate (the model is guessing).
RAG-cleaned (structured) is accurate and still cheap (small model, compact context, clean text, exact filing match).
RAG-raw (structured) is accurate-ish but costs more per query and sometimes trips on markup – WRDS’s cleaning pipeline earns its keep.
RAG-semantic-cleaned trades the exact filing match for a global FAISS lookup. Whether this helps or hurts depends on whether the top-k chunks land on the right company;
top_chunk_cik_matchin the per-question CSV tells you.
Levers you can pull:
Change
FINANCIAL_KEYWORDS/MAX_WORDS_*insrc/rag_benchmark.pyto reshape the keyword chunker.Change
CHUNK_SIZE/CHUNK_OVERLAP/EMBED_MODELinsrc/embeddings_index.py(then reruntask_build_embeddings) to reshape the semantic chunker.Change
top_k/max_wordsinrun_rag_semanticto give the LLM more or fewer retrieved chunks per question.