Summarize Documents

Su Suriya Ravichandran Updated 10 Nov 2026
8 min read ·Lesson 118 of 200

When One Request Is Not Enough

A short report fits in one request, and Claude can summarize it directly. A 300-page manual, a year of meeting notes, or a large research archive usually does not. Even when such a document fits within the context window, a single summary of very long material tends to drop details from the middle, and costs more than it needs to.

The standard solution is map-reduce summarization:

long document
   │
   ├─ split into chunks ─────────────────────────────┐
   │                                                 │
   ▼ map: summarize each chunk (in parallel)         │
 summary 1   summary 2   summary 3   ...             │
   │           │           │                         │
   └───────────┴─────┬─────┴─────────────────────────┘
                     ▼ reduce: combine into one summary
              final summary

Map summarizes each part independently, so each part gets Claude's full attention. Reduce combines the partial summaries into the final result. The approach also parallelizes naturally, because the chunks do not depend on each other (Lesson 75).


Chunking Well

The quality of a map-reduce summary depends heavily on where the chunks are cut. Cutting in the middle of a sentence or a section damages meaning. A good chunker:

  1. Splits at natural boundaries, such as blank lines between paragraphs or headings.
  2. Packs paragraphs into chunks up to a size limit.
  3. Splits only when necessary, for a paragraph that is itself larger than the limit.
def chunk_paragraphs(text: str, max_chars: int) -> list[str]:
    chunks: list[str] = []
    current = ""
    for paragraph in (p.strip() for p in text.split("\n\n")):
        if not paragraph:
            continue
        # A paragraph larger than the limit is sliced; this is the fallback, since it can cut mid-sentence
        for piece in (paragraph[i:i + max_chars] for i in range(0, len(paragraph), max_chars)):
            candidate = f"{current}\n\n{piece}" if current else piece
            if len(candidate) <= max_chars:
                current = candidate
            else:
                chunks.append(current)
                current = piece
    if current:
        chunks.append(current)
    return chunks

Measure chunks in characters, which is simple and fast. Converting to tokens is more precise, and you can count tokens per chunk with count_tokens() (Lesson 46) if you need exact budgets.


The Map Step

Each chunk becomes a document block with a title that tells Claude where it sits in the whole. That position helps the summary stay accurate, for example when a chunk begins in the middle of a section:

document = {
    "type": "document",
    "source": {"type": "text", "media_type": "text/plain", "data": chunk},
    "title": f"Part {index} of {total}",
}

The instruction for each part should ask for what the reduce step needs: the key facts, decisions, names, numbers, and open questions, with the section they came from. A chunk summary that loses the numbers cannot be repaired later.


The Reduce Step

The reduce step receives all the partial summaries, in their original order, and produces one final summary. Its instruction should state the length, the structure, and how to handle gaps:

Combine the partial summaries below into one summary of at most 300 words.
Keep the order of the source. Preserve every number and named person.
If a part seems to be missing information, say so instead of guessing.

If the combined partial summaries are still too long, repeat the reduce step on groups of them, until one summary remains. This is a hierarchical reduce.


Practical Example: Summarizing a Long Document

This example builds a long sample document, splits it, summarizes the parts in parallel, and reduces them. The chunker is tested offline first, so you can confirm its behavior without any API calls.

"""map_reduce_summary.py: summarize a long document with parallel map and a reduce step."""

from concurrent.futures import ThreadPoolExecutor

from anthropic import Anthropic

client = Anthropic()
MODEL = "claude-opus-5-5"
CHUNK_CHARS = 6_000
WORKERS = 4


def chunk_paragraphs(text: str, max_chars: int) -> list[str]:
    chunks: list[str] = []
    current = ""
    for paragraph in (p.strip() for p in text.split("\n\n")):
        if not paragraph:
            continue
        for piece in (paragraph[i:i + max_chars] for i in range(0, len(paragraph), max_chars)):
            candidate = f"{current}\n\n{piece}" if current else piece
            if len(candidate) <= max_chars:
                current = candidate
            else:
                chunks.append(current)
                current = piece
    if current:
        chunks.append(current)
    return chunks


def text_of(message) -> str:
    return "".join(block.text for block in message.content if block.type == "text").strip()


def summarize_part(index: int, total: int, chunk: str) -> str:
    message = client.messages.create(
        model=MODEL,
        max_tokens=1024,
        messages=[{"role": "user", "content": [
            {"type": "document", "title": f"Part {index} of {total}",
             "source": {"type": "text", "media_type": "text/plain", "data": chunk}},
            {"type": "text", "text": "Summarize this part in at most 120 words. List every decision, "
                                     "named person, and number, with the section it appears in."},
        ]}],
    )
    return text_of(message)


def summarize_document(text: str) -> str:
    chunks = chunk_paragraphs(text, CHUNK_CHARS)
    total = len(chunks)
    with ThreadPoolExecutor(max_workers=WORKERS) as pool:
        partials = list(pool.map(lambda pair: summarize_part(pair[0], total, pair[1]),
                                 enumerate(chunks, start=1)))

    message = client.messages.create(
        model=MODEL,
        max_tokens=1024,
        messages=[{"role": "user", "content":
                   "Combine these partial summaries, in order, into one summary of at most 300 words. "
                   "Preserve every number and named person. If something seems missing, say so.\n\n"
                   + "\n\n".join(f"Part {i}: {p}" for i, p in enumerate(partials, start=1))}],
    )
    return text_of(message)


if __name__ == "__main__":
    # Offline check of the chunker: no API calls
    sample = "\n\n".join(["A" * 40, "B" * 40, "C" * 40])
    chunks = chunk_paragraphs(sample, 100)
    print("chunks:", len(chunks), "all within limit:", all(len(c) <= 100 for c in chunks))

    # A long sample document: 300 sections, roughly 26,000 characters, so several parts
    sections = [f"Section {n}. The team reviewed item {n} and recorded the budget of {n * 1000} rupees."
                for n in range(1, 301)]
    document = "\n\n".join(sections)
    print(f"document: {len(document):,} characters, {len(chunk_paragraphs(document, CHUNK_CHARS))} parts")
    print(summarize_document(document))

Expected output (the part count is exact for this input; the summary wording varies):

chunks: 2 all within limit: True
document: 26,412 characters, 5 parts
The document is a review of 300 sections, each covering one item and its budget. Budgets rise steadily from 1,000 rupees in Section 1 to 300,000 rupees in Section 300.

The character count depends on the exact text, so treat the figure as approximate. The number of parts follows from it: each part holds at most 6,000 characters.

How It Works

  1. chunk_paragraphs() packs paragraphs into chunks of at most CHUNK_CHARS characters. The three-paragraph test shows that two 40-character paragraphs fit in one 100-character chunk with the separator, and the third starts a new chunk.
  2. Oversized paragraphs are sliced as a fallback. The slicing is the only place where a chunk can end mid-sentence, which is why the comment flags it.
  3. The sample is long enough to split. Its 300 sections add up to roughly 26,000 characters, so the map step runs several parts in parallel, and the reduce step combines them.
  4. summarize_part() sends each chunk as a titled document block, with a request for the facts the reduce step needs.
  5. ThreadPoolExecutor and pool.map() summarize the parts in parallel, with at most WORKERS requests at a time, and return the results in their original order (Lesson 80).
  6. The reduce request combines the partial summaries in order, and asks for omissions to be stated, not guessed.
  7. The offline check runs first, so chunking mistakes are caught before any tokens are spent.

Common Mistakes

Cutting Chunks Mid-Sentence

What happens: the summary of each chunk begins or ends with broken context, and details are misread.

Fix: split at paragraph boundaries, and slice only when unavoidable.

Summarizing Parts Without Their Position

What happens: Claude treats a chunk as if it were the whole document.

Fix: title each part, for example "Part 3 of 12".

Asking for Generic Partial Summaries

What happens: names, numbers, and decisions are lost, and the reduce step cannot recover them.

Fix: ask each part to list the specific facts the final summary needs.

Compressing Too Early

What happens: the final summary is vague, because each part was reduced to a single line.

Fix: give each part enough room for its key facts, and keep the reduce step's length limit realistic.

Unbounded Concurrency

What happens: rate-limit errors for very long documents with many chunks.

Fix: limit workers, as in Lesson 75.


Best Practices

  • Split at natural boundaries, and slice only as a fallback.
  • Title every part with its position.
  • Ask partial summaries for specific facts, not general impressions.
  • Preserve order when reducing.
  • Use a hierarchical reduce when partial summaries are still too long.
  • Limit concurrency and handle failures per part (Lesson 77).
  • Test the chunker offline before running the pipeline.

Exercise

  1. Change CHUNK_CHARS to 500 and then to 20,000. How do the number of requests and the quality of the final summary change?
  2. Add a failure handler so that a part that fails is recorded as [part N could not be summarized], and the reduce step is told about the gap.
  3. Write a hierarchical reduce that combines partial summaries in groups of 10 until one summary remains.
  4. Explain why the reduce step receives the partial summaries in their original order, rather than sorted or shuffled.
Show answers for steps 2 and 4

Step 2: wrap summarize_part() in a try block, returning a placeholder on anthropic.APIError, and include the placeholder text in the reduce prompt. Telling the reduce step about the gap prevents the final summary from presenting a partial picture as complete. Without the placeholder, the missing part would silently disappear, and the summary would look authoritative while omitting a section.

Step 4: the source document has a sequence, with earlier sections setting context for later ones, and summaries that reference "the previous decision" need the order to make sense. Keeping the order also lets the reduce step describe the document's progression. Sorting or shuffling would destroy the structure that the summary is meant to convey.

0 Comments

Reviewed before they appear

No comments yet.

Anthropic SDK Using Python
Anthropic SDK Intro Claude API Intro SDK Requirements Install Anthropic SDK Upgrade the SDK Create an API Key Set API Keys Use Environment Variables Import Anthropic Create a Client Send Your First Message Choose a Claude Model Set Max Tokens Read a Response Print Response Text Understand Message Blocks Use System Prompts Send User Messages Set Temperature Handle Stop Reasons Messages API Intro Create Messages Use Message Roles Send Conversation History Continue a Conversation Build a Chat Loop Reset Chat History Use Multiple Turns Inspect Message IDs Read Usage Data Write Clear Prompts Set a System Prompt Add Context to Prompts Use Prompt Templates Add Examples to Prompts Request JSON Output Use XML Tags Control Response Length Improve Prompt Quality Avoid Prompt Injection Read Text Blocks Read Content Blocks Check Stop Reasons Count Input Tokens Count Output Tokens Count Tokens Before Sending Limit Output Tokens Use Stop Sequences Read Request IDs Parse API Responses Streaming Intro Stream Text Output Use Stream Events Use Stream Helpers Get Final Messages Stream Async Responses Handle Stream Errors Build a Live Chat Stream Long Responses Close Streams Safely Build a Chatbot Keep Chat History Manage Chat Sessions Add Chat Memory Build a CLI Chatbot Build a Web Chatbot Use Flask with Claude Use FastAPI with Claude Return JSON from an API Add Chat Error Messages Async SDK Intro Create an Async Client Send Async Messages Use asyncio Run Parallel Requests Stream Async Text Handle Async Errors Set Async Timeouts Close Async Clients Choose Sync or Async Tool Use Intro Define a Tool Create Tool Schemas Send Tools to Claude Read Tool Calls Run Python Tools Return Tool Results Continue After Tool Use Control Tool Choice Handle Tool Errors Use the beta_tool Decorator Create a Tool Runner Add Tool Docstrings Validate Tool Inputs Use Multiple Tools Limit Tool Calls Log Tool Activity Build a Weather Tool Build a Search Tool Build a Calculator Tool Structured Output Intro Request JSON Data Validate JSON Output Use Pydantic Models Handle Invalid JSON Extract Fields from Text Generate Typed Data Use Image URLs Send Base64 Images Read PDFs with Claude Use Output Schemas Build Data Extraction Test Structured Responses Send Image Inputs Upload Files Use File IDs Process Text Files Summarize Documents Extract Table Data Handle File Errors Read Cache Usage Reduce Repeated Input Costs Debug Cache Misses Prompt Caching Intro Enable Prompt Caching Cache System Prompts Cache Long Context Test Cache Performance Use Cache Breakpoints Cache Best Practices Message Batches Intro Create a Message Batch Set Batch IDs Check Batch Status List Message Batches Get Batch Results Process Batch Outputs Handle Failed Batch Items Use Batches for Bulk Tasks Compare Batches and Requests Handle API Errors Handle Rate Limits Retry Failed Requests Set Request Timeouts Handle Network Errors Use Exponential Backoff Build Reliable API Calls Use Extended Thinking Log API Errors Track Request IDs Paginate API Results Set Thinking Budgets Read Thinking Blocks Use Tool Search Use Web Search Tools Use Computer Tools Use Citations Use Token Counting Read Raw Responses Set Custom Headers Use Claude on AWS Bedrock Use Claude on Vertex AI Use Claude on Microsoft Foundry Configure Cloud Credentials Choose a Cloud Provider Compare API Integrations Configure Provider Clients Handle Cloud Errors Use Cloud SDK Extras Deploy Cloud Apps Protect API Keys Use a .env File Ignore Secrets in Git Validate User Input Reduce Prompt Injection Limit Tool Permissions Avoid Unsafe Tool Calls Sanitize Tool Results Set Usage Limits Review Security Logs Test SDK Functions Mock API Responses Test Error Handling Test Streaming Code Measure Token Usage Track API Costs Configure Logging Deploy with Docker Deploy a FastAPI App Monitor Production Apps Build a Claude Chatbot Build a Text Summarizer Build a Code Explainer Build a JSON Extractor Build a PDF Assistant Build a Streaming Chat App Build a Tool-Calling Agent Build a Batch Processor Build a Research Assistant Build a Claude API Dashboard
Ask about this post
AI Ask about this post

Ask questions about Summarize Documents and get answers drawn from it.

Signed-in readers only.