Business Apps

Summarize Long PDFs with Python and Chunking

Turn a 200-page PDF into a one-page brief with Python: split it by page, summarise each chunk with page citations, then merge the summaries in a second pass.

A 200-page annual report will not fit into a single API request, and pasting the first fifty pages into a chat window gives you a confident summary of a quarter of the document. This guide builds a script that handles the whole thing: it reads the PDF page by page, summarises each slice separately, merges those slices into one brief, and keeps a page number attached to every claim so you can check the original. Give it about an hour and you will have a reusable tool that turns any length of PDF into a one-page brief you can actually trust.

This guide is part of AI Document Processing with Python, the section on getting structured, reliable output out of business documents. If you want fixed fields rather than prose, Extract Invoice Data from PDFs with Python and AI covers that job instead.

Prerequisites

You need Python 3.10 or newer and an OpenAI API key. Work inside a virtual environment — an isolated folder holding this project's packages — so these installs do not collide with anything else on your machine. If you have not made one, follow Create a Python Virtual Environment for AI first.

python -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate
pip install "pdfplumber>=0.11" "openai>=1.40" "tiktoken>=0.7" "python-dotenv>=1.0"
pip freeze > requirements.txt

Put your key in a .env file beside your script:

OPENAI_API_KEY=sk-your-key-here

Then keep that file out of version control:

echo ".env" >> .gitignore

A key committed to a public repository gets scraped and spent by strangers, often within the hour, so run that line before your first commit.

Finally, drop a PDF called report.pdf in the same folder. Any long text-based document works — an annual report, a research paper, a policy manual.

Step 1 — Pull the text out one page at a time

Most PDF tutorials read the whole document into a single string. Do not do that here. The page number is the only thing that will let a reader verify a claim later, and once you flatten 200 pages into one blob that information is gone forever.

pdfplumber gives you a list of page objects, so keep the number alongside the text from the start. Pages that hold only images return None from extract_text(), which is why the or "" below matters — without it the script crashes halfway through a long document.

from pathlib import Path

import pdfplumber


def extract_pages(pdf_path: str) -> list[dict]:
    """Return [{'page': 1, 'text': '...'}, ...] skipping pages with no text."""
    pages = []
    with pdfplumber.open(pdf_path) as pdf:
        for number, page in enumerate(pdf.pages, start=1):
            text = (page.extract_text() or "").strip()
            if text:
                pages.append({"page": number, "text": text})
    return pages


if __name__ == "__main__":
    pages = extract_pages("report.pdf")
    print(f"{len(pages)} pages contain text")
    print(pages[0]["text"][:300])

Run it with python extract.py. If the page count is far lower than the real document, your PDF is scanned images rather than digital text and needs optical character recognition before any of this works.

That page-tagged list is the input to everything that follows. The full pipeline has five stops, and it loops back on itself when the merged result is still too long for one request.

The map-reduce pipeline that turns a long PDF into one brief A data flow starting with page-by-page text extraction, then token-sized chunking, then one summary call per chunk, then a merge pass, then a length check that either loops back to the merge or produces the final cited brief. Extract page text page by page Tag chunks by page ~3,000 tokens each Summarise each one call per chunk Merge summaries second model pass Still too long? check the tokens Final brief cited to pages merge again if needed
Every stage keeps the page numbers attached, and the loop at the bottom repeats the merge until the combined summaries fit in one request.

Step 2 — Group pages into token-sized chunks

Models measure input in tokens, not pages — a token is a fragment of text roughly three quarters of a word. A page of dense prose is often 400 to 600 tokens, but a page of tables can be far more, so counting pages is a poor proxy. Count tokens directly with tiktoken, the same tokeniser OpenAI models use.

The rule for grouping is simple: add whole pages to the current chunk until adding the next one would push it over your limit, then start a new chunk. Never split mid-page, because then a bullet's page citation becomes ambiguous. Each finished chunk carries its first and last page number and embeds a [page N] marker before each page's text, which is what the model will quote back to you.

import tiktoken

ENCODER = tiktoken.get_encoding("o200k_base")


def count_tokens(text: str) -> int:
    return len(ENCODER.encode(text))


def _finish(pages: list[dict]) -> dict:
    body = "\n\n".join(f"[page {p['page']}]\n{p['text']}" for p in pages)
    return {
        "first_page": pages[0]["page"],
        "last_page": pages[-1]["page"],
        "text": body,
    }


def build_chunks(pages: list[dict], max_tokens: int = 3000) -> list[dict]:
    """Group whole pages into chunks of at most max_tokens each."""
    chunks: list[dict] = []
    current: list[dict] = []
    total = 0
    for page in pages:
        size = count_tokens(page["text"])
        if current and total + size > max_tokens:
            chunks.append(_finish(current))
            current, total = [], 0
        current.append(page)
        total += size
    if current:
        chunks.append(_finish(current))
    return chunks

One page can exceed max_tokens on its own — a fold-out table, usually. This code lets that page through as an oversized chunk rather than mangling it; if your document is full of them, lower max_tokens or split that page's text on paragraph boundaries. Split Long Text into Chunks for AI APIs goes deeper on splitting strategies, and Count Tokens in Python Before You Send explains the counting side.

Step 3 — Summarise each chunk with a prompt that forbids invention

This is the map half of map-reduce: the same operation applied independently to every chunk. Three instructions do most of the work here. Tell the model to use only what is in front of it. Tell it to write bullets, not paragraphs, because bullets survive merging far better than prose. And tell it to end each bullet with the page it came from.

Set temperature=0 as well. Temperature controls how much randomness the model injects; at zero it sticks closest to the supplied text, which is exactly what you want for factual extraction. What Is Temperature in an LLM API? covers the parameter in full.

import os
import time

from dotenv import load_dotenv
from openai import OpenAI, RateLimitError

load_dotenv()  # reads .env, which is listed in .gitignore
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

CHUNK_SYSTEM = (
    "You summarise one slice of a longer report.\n"
    "Use only facts stated in the supplied text. Do not add background "
    "knowledge, do not infer, do not guess at anything the text omits.\n"
    "Write 3 to 6 bullet points. Keep every number, date and name exactly "
    "as written.\n"
    "End each bullet with the page it came from, in the form (p. 42). The "
    "text is marked with [page N] headers; use those numbers."
)


def summarise_chunk(chunk: dict, model: str = "gpt-4o-mini") -> str:
    for attempt in range(4):
        try:
            response = client.chat.completions.create(
                model=model,
                temperature=0,
                messages=[
                    {"role": "system", "content": CHUNK_SYSTEM},
                    {"role": "user", "content": chunk["text"]},
                ],
            )
            return response.choices[0].message.content.strip()
        except RateLimitError:
            time.sleep(2 ** attempt)
    raise RuntimeError("Rate limited four times in a row; slow the loop down.")


def summarise_all(chunks: list[dict]) -> list[str]:
    summaries = []
    for index, chunk in enumerate(chunks, start=1):
        print(f"summarising chunk {index}/{len(chunks)} "
              f"(pages {chunk['first_page']}-{chunk['last_page']})")
        summaries.append(summarise_chunk(chunk))
    return summaries

The retry loop matters more than it looks. Thirty-odd calls fired back to back will trip a rate limit on a new account, and one unhandled error two thirds of the way through a long report means starting over. Fix the 429 Rate-Limit Error in Python explains the backoff pattern in detail. For a document you will process repeatedly, cache each chunk summary to a file keyed by the chunk's page range so a rerun costs nothing.

Step 4 — Merge the summaries, and repeat when the merge is too long

This is the reduce half. Joining forty chunk summaries can easily produce more text than a single request will take, so the merge itself has to be chunked — and the result of that merge might still be too long, which is why the function loops.

The merge prompt has a different job from the summarise prompt. It is not reading a report; it is reading notes about a report. Its instructions are to group related points, drop exact duplicates, and above all carry the (p. N) markers through untouched. Losing the citations at this stage is the single most common way this pipeline goes wrong.

MERGE_SYSTEM = (
    "You merge bullet-point notes taken from different parts of one report.\n"
    "Group related points together and remove exact duplicates. Keep every "
    "number and every page citation such as (p. 42) exactly as written.\n"
    "If two notes disagree, keep both and mark the disagreement.\n"
    "Never add a fact that is not already in the notes."
)


def merge_group(parts: list[str], model: str = "gpt-4o-mini") -> str:
    response = client.chat.completions.create(
        model=model,
        temperature=0,
        messages=[
            {"role": "system", "content": MERGE_SYSTEM},
            {"role": "user", "content": "\n\n".join(parts)},
        ],
    )
    return response.choices[0].message.content.strip()


def group_by_tokens(parts: list[str], max_tokens: int) -> list[list[str]]:
    groups: list[list[str]] = []
    current: list[str] = []
    total = 0
    for part in parts:
        size = count_tokens(part)
        if current and total + size > max_tokens:
            groups.append(current)
            current, total = [], 0
        current.append(part)
        total += size
    if current:
        groups.append(current)
    return groups


def reduce_until_short(summaries: list[str], max_tokens: int = 3000,
                       max_rounds: int = 4) -> str:
    """Merge repeatedly until one summary fits inside max_tokens."""
    level = summaries
    for round_number in range(1, max_rounds + 1):
        if len(level) == 1 and count_tokens(level[0]) <= max_tokens:
            break
        groups = group_by_tokens(level, max_tokens)
        print(f"merge round {round_number}: {len(level)} notes -> {len(groups)}")
        level = [merge_group(group) for group in groups]
    return "\n\n".join(level)

On a 200-page report with 3,000-token chunks you will typically see two merge rounds: forty-odd chunk summaries collapse into three or four grouped summaries, and those collapse into one. Each round is one more layer of compression, which is exactly the problem the next section addresses. This staged prompting is a general technique — Chain Prompts Together in Python shows other shapes it takes.

Step 5 — Write the final structured brief

The merged text is accurate but shapeless. One last call gives it a form a human will actually read: a short executive summary, the key figures pulled out as their own list, and any contradictions flagged for follow-up. Asking for a fixed structure here also makes the output diffable — run the same script on next quarter's report and you can compare section by section. Write System Prompts that Control Output Format covers how to make that structure stick.

BRIEF_SYSTEM = (
    "You turn merged notes into a one-page brief in Markdown.\n"
    "Use exactly these headings: ## Executive summary, ## Key figures, "
    "## Open questions.\n"
    "The executive summary is one paragraph of at most 120 words.\n"
    "Key figures is a bullet list of the concrete numbers, each with its "
    "page citation.\n"
    "Open questions lists anything the notes contradict or leave unclear.\n"
    "Keep every (p. N) citation. Add nothing that is not in the notes."
)


def write_brief(merged: str, title: str, model: str = "gpt-4o") -> str:
    response = client.chat.completions.create(
        model=model,
        temperature=0,
        messages=[
            {"role": "system", "content": BRIEF_SYSTEM},
            {"role": "user", "content": f"Report: {title}\n\n{merged}"},
        ],
    )
    return response.choices[0].message.content.strip()


if __name__ == "__main__":
    pages = extract_pages("report.pdf")
    chunks = build_chunks(pages, max_tokens=3000)
    print(f"{len(pages)} pages -> {len(chunks)} chunks")

    summaries = summarise_all(chunks)
    merged = reduce_until_short(summaries)
    brief = write_brief(merged, title="report.pdf")

    Path("brief.md").write_text(brief, encoding="utf-8")
    Path("notes.md").write_text(merged, encoding="utf-8")
    print("wrote brief.md and notes.md")

Note the model change on this final call. The cheap model is fine for the repetitive chunk work, but the last pass is one call over a small amount of text and it decides how the whole thing reads, so paying for the stronger model here costs very little. Writing notes.md as well as brief.md is deliberate: the notes are your middle layer, and when a figure in the brief looks wrong the notes tell you which chunk it came from.

What each pass loses, and why the citations save you

Every summarisation pass is lossy compression. The first pass turns six pages into five bullets and throws away the wording, the caveats and the small examples. The second pass turns eight sets of bullets into one set and throws away whatever did not repeat. This is drift: the output stays fluent and confident while drifting further from what the document actually said. Nothing in the API warns you it is happening.

You cannot eliminate drift, but you can make it survivable. Keep the number of passes as low as the token budget allows. Ask explicitly for numbers, dates and names, because those are the first things generic summarisation discards. And carry a page citation on every claim, so a reader who doubts a line can open that page and settle it in ten seconds. A brief without citations is a claim about a document; a brief with citations is a map into it.

What survives and what drops out at each summarisation pass A three-row comparison matrix listing the raw pages, the chunk summaries and the merged brief, and for each one what detail survives the pass and what detail is discarded. Stage What survives What drops out Raw PDF pages every word, page N Everything nothing lost yet Nothing yet but far too long Chunk summaries one per 3k tokens Facts and figures with page numbers Wording, asides most examples Merged brief one page to read Themes, totals citations survive Section detail open the cited page
Detail leaves the pipeline at every stage; the page citations are the thread that lets a reader climb back up to the original wording.

Chunk size quick reference

The numbers below assume a report of roughly 100,000 tokens, which is what a 200-page document of ordinary prose comes to. Measure your own with count_tokens before choosing.

Chunk sizeChunk callsMerge roundsBest for
1,500 tokens~672-3Dense contracts where small clauses matter
3,000 tokens~342The sensible default for most reports
8,000 tokens~131Narrative documents; fewer calls, flatter bullets
20,000 tokens~51Near single-call behaviour with light chunking

Fewer, larger chunks cost less and drift less at the merge stage, but each individual summary compresses harder. Smaller chunks preserve detail per section and then lose it again in the extra merge round. Three thousand tokens sits near the middle for a reason.

Troubleshooting

  • AttributeError: 'NoneType' object has no attribute 'strip'extract_text() returned None on an image-only page. The (page.extract_text() or "") in Step 1 fixes it; if you removed that guard, put it back.
  • Every page comes back empty — the PDF is a scan, so there is no text layer to extract. You need optical character recognition first; pdfplumber cannot read pixels.
  • openai.BadRequestError: ... maximum context length ... — one chunk is bigger than the model's input limit, almost always a single enormous page. Lower max_tokens in build_chunks, and see Fix the Context-Length-Exceeded Error in Python.
  • openai.RateLimitError — the chunk loop is firing faster than your account allows. The backoff in summarise_chunk absorbs short bursts; for very long documents add a time.sleep(0.5) between chunks.
  • Citations vanish after the merge — the merge prompt is being ignored because the notes arrived without visible (p. N) markers. Print one chunk summary and confirm the map pass is producing them before blaming the reduce pass.

When to use chunking vs. a single long-context call

  • Use one long call when the whole document fits inside the model's input limit and you want maximum coherence. Nothing is merged, so nothing is lost twice, and the model can connect a point on page 12 to a caveat on page 180.
  • Use chunking when the document is larger than the limit, when you want a per-section summary as well as an overall one, or when you need every claim traceable to a page. It also fails gracefully: one bad chunk costs you one bullet list, not the whole run.
  • Use chunking for cost control on repeat work, because chunk summaries are cacheable and a single giant call is not. Estimate OpenAI API Costs with Python helps you compare the two before you commit.
Choosing between one long call and a chunked map-reduce run A decision tree that starts by counting the document tokens, branches into well under the limit, near the limit and far over the limit, and gives the recommended approach for each case. Count the tokens before you choose Well under limit a short paper Near the limit hard to predict Far over limit a 200-page report One long call no merge, no drift Try one call chunk if it fails Map then reduce chunk, cite, merge
Count the tokens first: the answer is a single call for anything comfortably inside the window, and map-reduce for anything that is not.

You now have a script that survives any document length: extraction that keeps page numbers, chunking measured in tokens, a map pass that refuses to invent, a reduce pass that repeats until the result fits, and a final brief that carries its evidence with it. Point it at a report you already know well and read the brief critically — the citations make that check quick, and the mistakes you find will tell you which prompt to tighten. When you want to ask specific questions of the same PDF rather than summarise it whole, Build a Document Question-Answering Tool is the next step.

Back to AI Document Processing with Python.

Frequently asked questions

How do I summarize a PDF that is too long for the API?

Split it into chunks that each fit comfortably in one request, summarise every chunk separately, then run a second pass that merges those summaries into one brief. This two-stage pattern is called map-reduce, and it works for any document length because the merge step can be repeated until the result is short enough.

What chunk size should I use for PDF summarization?

Around 3,000 tokens of text per chunk is a sensible default. That is roughly six to eight pages of dense prose. Smaller chunks keep more detail but cost more calls; larger chunks are cheaper but flatten the detail inside each summary. Measure with tiktoken rather than guessing from page counts.

How do I stop the model inventing facts in a summary?

Give it a system instruction that says to use only what is in the supplied text, set temperature to 0, and require every bullet to end with the page number it came from. Citations make invention visible: if you cannot find the claim on the cited page, the bullet is wrong and you delete it.

Is chunking still needed if the model has a huge context window?

Not always. If your document fits inside one request and you can afford the tokens, a single call gives a more coherent summary because nothing is lost in a merge. Chunking wins when the document is larger than the window, when you want per-section detail, or when you need each claim traced to a page.

Why do my summaries get vaguer with every pass?

Each summarisation pass compresses, and compression throws away specifics: exact wording, small figures, side examples. Two passes of ten-to-one compression leave very little of the original. Keep passes shallow, ask explicitly for numbers and page references, and never summarise a summary more times than you must.