Clean extracted SEC filing sections for NLP
Turn an extracted 10-K or 10-Q section into clean paragraphs for embeddings, classifiers and LLMs: drop page numbers, table rows and boilerplate, normalize text.
The Filing Section Extractor already does the heavy lifting: it finds the item, strips the HTML and returns readable text. For NLP you usually want one more pass. Filings are written for print, so an extracted section still carries page numbers, financial tables flattened into lines of numbers, unit captions and short subheadings. They add noise to embeddings and confuse sentence splitters.
This tutorial cleans the MD&A (Part I, Item 2) of NVIDIA's 10-Q for the quarter ended July 26, 2026.
Text or HTML?
The extractor has two modes:
type=text: paragraphs separated by blank lines. Table rows become one line each. Best for NLP.type=html: the section's markup with<p>, bold and lists kept. Best for displaying the section in a reader.
Start from text.
The cleaning script
import os
import re
import unicodedata
import requests
BASE = "https://secfeedapi.com"
HEADERS = {"Authorization": f"Bearer {os.environ['SECFEED_KEY']}"}
url = "https://www.sec.gov/Archives/edgar/data/1045810/000104581026000075/0001045810-26-000075-index.htm"
r = requests.get(f"{BASE}/v1/extractor", params={"url": url, "item": "part1item2", "type": "text"},
headers=HEADERS, timeout=120)
raw = r.text
def is_table_row(line):
tokens = line.split()
numeric = sum(bool(re.fullmatch(r"[\$\(\)%,.\-\d]+|pts|bps", t)) for t in tokens)
return len(tokens) >= 3 and numeric / len(tokens) >= 0.4
def clean(text):
text = unicodedata.normalize("NFKC", text) # non-breaking spaces, ligatures
text = text.translate(str.maketrans("‘’“”", "''\"\"")) # curly quotes
kept, removed = [], {"page numbers": 0, "table rows": 0, "boilerplate": 0, "headings": 0}
for line in text.splitlines():
s = " ".join(line.split()) # collapse whitespace
if not s:
continue
if re.fullmatch(r"\d{1,3}|Table of Contents", s):
removed["page numbers"] += 1
elif s.startswith("©") or s.lower().startswith("(in millions") or s.startswith("($ in"):
removed["boilerplate"] += 1
elif is_table_row(s):
removed["table rows"] += 1
elif len(s) < 60 and not s.endswith("."):
removed["headings"] += 1 # drop, or keep as section markers
else:
kept.append(s)
return "\n\n".join(kept), removed
text, removed = clean(raw)
sentences = re.split(r"(?<=[.!?])\s+(?=[A-Z])", text)
print(f"raw: {len(raw.splitlines()):>4} lines {len(raw.split()):>5} words")
print(f"cleaned: {text.count(chr(10) * 2) + 1:>4} paragraphs {len(text.split()):>5} words {len(sentences)} sentences")
print("removed:", removed)
print("\nfirst cleaned paragraph about revenue:")
print(next(p for p in text.split("\n\n") if "revenue" in p.lower() and "%" in p)[:500])
Output
raw: 400 lines 5894 words
cleaned: 83 paragraphs 5110 words 182 sentences
removed: {'page numbers': 8, 'table rows': 56, 'boilerplate': 10, 'headings': 44}
first cleaned paragraph about revenue:
The U.S. government, or USG, granted licenses that would allow us to ship small amounts of H200 products to specific China-based customers, but such sales were restricted by the PRC government, and we have been unable to sell all the products for which we have licenses. During the first half of fiscal year 2027, we incurred a $0.4 billion charge associated with H200 for excess inventory and purchase obligations, as the demand for H200 products diminished. After incurring that charge, we have mad
About 13% of the words were noise. 56 lines were flattened table rows such as Revenue $ 96,221 $ 81,615 $ 46,743 18 % 106 %. Those are useless to a language model and badly hurt sentence splitting. The financial values they hold are better taken from XBRL, see XBRL-to-JSON financial statements.
What each rule does
| Rule | Why |
|---|---|
| NFKC normalization | Turns non-breaking spaces and other print characters into plain ones |
| Straight quotes | Filings use curly quotes. Normalizing them makes exact matching and deduplication work |
| Page numbers | A line that is only 1 to 3 digits is a page footer |
| Boilerplate | Copyright lines and unit captions such as ($ in millions, except per share data) |
| Table rows | Lines where 40% or more of the tokens are numbers, $, % or pts |
| Headings | Short lines without a final period. Drop them, or keep them to split the section into chunks |
Tuning for your use case
- Chunking for embeddings or RAG. Instead of dropping headings, start a new chunk at each one. MD&A headings ("Results of Operations", "Liquidity and Capital Resources") make natural chunk boundaries, and you can store the heading as metadata.
- Sentiment or tone models. Also remove the forward-looking statements disclaimer at the top of MD&A. It is the same in every filing and dominates short-window comparisons.
- Keep the numbers in prose. The table rule only drops lines that are mostly numbers, so sentences like "a $0.4 billion charge" survive.
- Dedupe across filings. After cleaning, hash each paragraph. Paragraphs that repeat quarter after quarter can be dropped to focus on what changed, see Extract a 10-K section across multiple years.