Financial Report RAG: 10-K Analysis with Claude (2026 Tutorial)
The short answer
Financial RAG for SEC 10-K and 10-Q analysis parses filings with Unstructured, chunks by section (business overview, risk factors, MD and A, financial statements, notes), extracts tables separately so numeric data stays queryable, and uses Claude for year-over-year comparisons and risk extraction. This tutorial builds an analyst assistant that answers questions like what Apple spent on R and D in 2024 versus 2023, with exact citations.
A 10-K annual report runs 200 to 400 pages, half tables, half risk-factor and management-discussion narrative. Analysts spend hours searching for one disclosure, one revenue segment, one risk mention. A financial RAG system cuts that to seconds, if the system respects the structure of the document and the particular ways finance questions get asked.
This guide walks through building a 10-K RAG system that answers questions like “What is the segment revenue trend in cloud for the last three years?” or “What risk factors mention supply chain concentration?” with citations down to the exact section and page.
What a 10-K looks like structurally
Every 10-K has the same major items, mandated by the SEC:
- Item 1: Business
- Item 1A: Risk Factors
- Item 2: Properties
- Item 3: Legal Proceedings
- Item 5: Market for Registrant’s Common Equity
- Item 7: Management’s Discussion and Analysis (MD&A)
- Item 7A: Quantitative and Qualitative Disclosures About Market Risk
- Item 8: Financial Statements and Supplementary Data
- Item 9A: Controls and Procedures
- Item 15: Exhibits and Financial Statement Schedules
Chunking that ignores this structure produces a soup of context-free paragraphs. Chunking by Item preserves the semantic unit analysts actually search within.
Ingest from SEC EDGAR
EDGAR is the free, authoritative source. The sec-edgar-downloader package pulls filings by ticker.
from sec_edgar_downloader import Downloader
dl = Downloader("MyApp", "research@example.com", "./filings")
dl.get("10-K", "AAPL", limit=5)
dl.get("10-K", "MSFT", limit=5)Each filing lands as a messy HTML or XBRL document. Clean it up with unstructured or sec-parser for structured extraction of Items, sections, and tables.
from sec_parser import SecParser
parser = SecParser()
report = parser.parse_file("filings/AAPL/10-K/2024.htm")
for section in report.sections:
print(f"{section.item_number}: {section.title}")
for sub in section.subsections:
print(f" - {sub.title} ({len(sub.text)} chars)")Store section-level and subsection-level labels as metadata on every chunk so retrieval can filter to Item 1A alone, or MD&A alone, when the question demands it.
Chunk by section, keep tables whole
Finance has strict rules on where to split:
- Chunk by subsection under each Item. Each subsection has a coherent topic (Supply Chain Risks, Cybersecurity Risks, Climate Risks under Item 1A).
- Keep tables as single chunks. Revenue by segment, operating income by region, headcount by function. Never split a financial table.
- Prepend the Item context. Every chunk starts with “Item 1A, Risk Factors, Subsection: Supply Chain Concentration:” so retrieval returns context-complete chunks.
def chunk_10k(report, filing_meta):
chunks = []
for section in report.sections:
for sub in section.subsections:
label = f"Item {section.item_number}, {section.title}, Subsection: {sub.title}"
chunks.append({
"text": f"{label}\n\n{sub.text}",
"item": section.item_number,
"section_title": section.title,
"subsection_title": sub.title,
"ticker": filing_meta["ticker"],
"fiscal_year": filing_meta["fiscal_year"],
"filing_date": filing_meta["filing_date"],
"chunk_type": "narrative",
})
for table in section.tables:
chunks.append({
"text": table.to_markdown(),
"item": section.item_number,
"section_title": section.title,
"ticker": filing_meta["ticker"],
"fiscal_year": filing_meta["fiscal_year"],
"chunk_type": "table",
"table_title": table.caption,
})
return chunksThe chunk_type field is useful at retrieval: a question like “What was 2024 cloud revenue?” wants chunk_type=table, while “What risks did management flag about supply chain concentration?” wants chunk_type=narrative.
Use voyage-finance-2 or sentence-transformers tuned on 10-Ks
Finance language carries specific vocabulary (deferred revenue, segment reporting, non-GAAP measures, forward-looking statements) that general embedding models handle but do not excel at. Voyage voyage-finance-2 is trained on SEC filings and analyst reports; it lifts retrieval 8-12% over general-purpose embeddings on 10-K content.
import voyageai
vo = voyageai.Client()
def embed(text, is_query=False):
result = vo.embed(
[text],
model="voyage-finance-2",
input_type="query" if is_query else "document",
)
return result.embeddings[0]If voyage-finance-2 is not an option, bge-large-en-v1.5 fine-tuned on 1,000-3,000 10-K query-chunk pairs delivers similar quality for self-hosted deployments.
Metadata filtering: ticker, fiscal year, item
Finance questions are almost always scoped to a specific company, a specific fiscal year, and often a specific Item of the 10-K. Make the filter explicit at query time.
def retrieve(query, ticker=None, year_range=None, items=None, k=10):
filters = {}
if ticker:
filters["ticker"] = ticker
if year_range:
filters["fiscal_year"] = {"$gte": year_range[0], "$lte": year_range[1]}
if items:
filters["item"] = {"$in": items}
results = index.query(
vector=embed(query, is_query=True),
filter=filters,
top_k=20,
include_metadata=True,
)
rerank = co.rerank(
model="rerank-english-v3.0",
query=query,
documents=[m["metadata"]["text"] for m in results["matches"]],
top_n=k,
)
return [results["matches"][r.index] for r in rerank.results]Expose these as UI controls in the analyst-facing interface. Analysts naturally think in these filters; letting them set the filter is faster than making a free-text query do the disambiguation.
Handle multi-year trend questions
“What is the segment revenue trend in cloud over three years?” is not a single-chunk retrieval question. It is three retrievals (one per year) merged into one answer. Build this as a multi-step pipeline.
def multi_year_retrieve(query, ticker, years, k_per_year=3):
all_chunks = []
for year in years:
chunks = retrieve(
query, ticker=ticker,
year_range=(year, year), k=k_per_year,
)
for c in chunks:
all_chunks.append({**c["metadata"], "year": year, "text": c["metadata"]["text"]})
return all_chunks
chunks = multi_year_retrieve(
"cloud segment revenue",
ticker="MSFT",
years=[2022, 2023, 2024],
)The generator receives a labeled, year-sorted bundle and produces a comparison. Explicit per-year retrieval beats single-shot retrieval across 50+ chunks because it guarantees representation of every year.
Generation prompt for financial analysis
Finance generations have to respect formatting conventions (dollars with commas, percentages with decimals, “non-GAAP” vs “GAAP” distinctions) and surface source precisely.
SYSTEM = """You are a financial research assistant. Analyze SEC filings
based ONLY on the provided excerpts.
Rules:
1. Every number MUST cite the ticker, fiscal year, Item, and subsection:
"(MSFT 10-K FY2024, Item 8, Segment Information)".
2. Preserve units exactly. $5,678 million is not the same as $5.678 billion
even though they describe the same amount. Report numbers as the source
presents them.
3. Distinguish GAAP from non-GAAP measures. Flag when a non-GAAP metric is
reported ("reported on a non-GAAP basis").
4. For year-over-year comparisons, state the direction, the absolute
change, and the percentage change ("Cloud revenue grew from $X to $Y,
an increase of $Z or P%").
5. Do NOT infer, project, or extrapolate. Report what the sources say.
6. If evidence is missing for part of the question, say so explicitly
("The provided excerpts do not include FY2022 segment data").
7. Flag forward-looking statements as such. Do not quote them as fact.
8. Do NOT provide investment advice. Summarize what the filing reports.
"""Rule 4 is the one that makes the output useful. Analysts need the delta and the percent, not just the two endpoints. Rule 7 is the one that keeps the system out of legal trouble.
Verification: cross-check numbers against source
After generation, extract every dollar amount, percentage, and metric from the response. For each one, verify it appears in at least one retrieved chunk. Flag any number the model produced that is not in the source.
import re
NUMBER_RE = re.compile(r"\$?\s*\d{1,3}(?:,\d{3})*(?:\.\d+)?\s*(?:billion|million|thousand|B|M|K|%)?")
def verify_numbers(response, retrieved_texts):
numbers = set(NUMBER_RE.findall(response))
joined_source = " ".join(retrieved_texts)
unverified = []
for n in numbers:
clean = n.strip().replace(" ", "")
if clean not in joined_source and clean.replace(",", "") not in joined_source.replace(",", ""):
unverified.append(n)
return unverifiedNot foolproof: the model might paraphrase “$5.678 billion” as “$5,678 million” and both are correct. Allow some fuzzy matching. But any number that does not correspond to anything in the source is a red flag that gets surfaced to the analyst.
Compliance and scope
Four safeguards worth building in:
- Not investment advice. Explicit UI disclaimer. The system reports what filings say, not what to buy or sell.
- Only public filings. Scope the index to SEC-filed documents. Do not blend in paid analyst reports or confidential materials in a system that serves retail users.
- Timestamp every answer. 10-K data is historical. The response should carry the filing date so users know how fresh the information is.
- Audit log. Who asked what, which filings were retrieved, what the answer was. Compliance teams and SEC enforcement sometimes need the record.
Frequently asked questions
Official documentation
What is the best way to extract text from a 10-K?
Use sec-parser or sec-edgar-parsed for structured extraction of Items, subsections, and tables. These libraries handle the SEC filing HTML and XBRL formats and preserve the official section structure. Avoid raw HTML scraping; you lose section labels that are essential for retrieval filtering.
How do I handle 10-K tables in a RAG system?
Convert each table to Markdown and store it as a single chunk with a caption. Add a chunk_type=”table” metadata flag so retrieval can filter numeric questions to tables and narrative questions to prose. Never split a financial table mid-row; the resulting chunks lose column headers and become unreadable to the model.
Can I use a financial RAG system for stock recommendations?
You should not. Investment advice is regulated by the SEC in the US and by equivalent regulators elsewhere. Scope the system to describe what filings report. Add a prominent disclaimer that the output is informational, not investment advice. Let human analysts synthesize into recommendations.
Should I include 10-Q filings alongside 10-K?
Yes for most research use cases. 10-Qs are quarterly and provide the most-recent data between annual 10-Ks. Index both with a filing_type metadata flag. For long-trend questions, filter to 10-K only. For recent-quarter questions, filter to 10-Q.
What embedding model works best for financial filings?
Voyage voyage-finance-2 is trained on SEC filings and analyst reports and leads the finance embedding benchmarks. For self-hosted, bge-large-en-v1.5 fine-tuned on 10-K query-chunk pairs is competitive. General-purpose models (OpenAI text-embedding-3) work but trail by 8-12% on finance-specific queries.
How do I handle forward-looking statements?
Flag them in the system prompt. Any sentence containing words like “expect”, “anticipate”, “project”, “will”, “could” is forward-looking under SEC rules. The model should surface forward-looking content as such, not quote it as fact. Analysts need to know they are reading guidance, not reported results.
Build the ingestion carefully (section-aware chunking, table-whole, metadata-rich), use a finance-tuned embedding model, filter aggressively by ticker and year, and verify numbers against source after generation. The result is a tool analysts actually use, not a demo that falls apart on the first serious question.
