Summarize Documents
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:
- Splits at natural boundaries, such as blank lines between paragraphs or headings.
- Packs paragraphs into chunks up to a size limit.
- 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
chunk_paragraphs()packs paragraphs into chunks of at mostCHUNK_CHARScharacters. 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.- 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.
- 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.
summarize_part()sends each chunk as a titled document block, with a request for the facts the reduce step needs.ThreadPoolExecutorandpool.map()summarize the parts in parallel, with at mostWORKERSrequests at a time, and return the results in their original order (Lesson 80).- The reduce request combines the partial summaries in order, and asks for omissions to be stated, not guessed.
- 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
- Change
CHUNK_CHARSto 500 and then to 20,000. How do the number of requests and the quality of the final summary change? - 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. - Write a hierarchical reduce that combines partial summaries in groups of 10 until one summary remains.
- Explain why the reduce step receives the partial summaries in their original order, rather than sorted or shuffled.
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.