Home / Guides / Text extraction
Text extraction

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

RuleWhy
NFKC normalizationTurns non-breaking spaces and other print characters into plain ones
Straight quotesFilings use curly quotes. Normalizing them makes exact matching and deduplication work
Page numbersA line that is only 1 to 3 digits is a page footer
BoilerplateCopyright lines and unit captions such as ($ in millions, except per share data)
Table rowsLines where 40% or more of the tokens are numbers, $, % or pts
HeadingsShort 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.

Next steps

More text extraction guides

Run this guide with your own key

500 free API calls a month. Every example on this page works on the free plan.

Get free API key →