AI News / 2026-05-25

AI Document Summarization for Long PDFs: Map-Reduce Pipeline

XycAi
AI Document Summarization for Long PDFs: Map-Reduce Pipeline

The Problem: Long PDFs Hit a Hard Ceiling

A 200-page legal contract. A 500-page technical white paper. Drop either one directly into a model and you'll hit a wall fast.

Context windows are larger than ever — Claude Opus 4.8 handles 200K tokens, GPT-5.4 tops out at 128K, and Gemini 3 pushes to 1M. That sounds generous until you factor in what actually happens to real documents: OCR artifacts from scanned pages, double-column layouts, appendices stuffed with tables. A 100-page PDF can easily clear 80K tokens before you've processed anything interesting.

Then there's cost. Feeding a 300-page report into Opus 4.8 in a single request can run over $2 in input tokens alone. Do that at scale and the economics collapse immediately.

The sustainable engineering answer is chunking plus aggregation — specifically, a map-reduce summarization pipeline. Slice the PDF into chunks, summarize each one independently (map), then synthesize all the chunk summaries into a final output (reduce). This piece walks through a production-ready implementation in Python and LangChain that works with any OpenAI-compatible API.


Chunking Strategy: Cut at Semantic Boundaries, Not Arbitrary Lengths

Chunk quality is what determines summary quality. The most common mistake is splitting on fixed character counts, which tears paragraphs apart and bisects tables mid-row.

Here's a practical hierarchy of chunking strategies:

Strategy Best for Recommended size
Heading / section boundaries Reports and papers with clear structure Natural section size, no forced limit
Recursive paragraph splitting General-purpose PDFs with unknown structure 1,500–2,500 tokens, 200-token overlap
Semantic similarity clustering Documents that jump between topics frequently Dynamic size, higher compute cost
Fixed token splitting Fallback only ~2,000 tokens, not a first choice

Overlap is the parameter most people underestimate. Setting a 200-token overlap between consecutive chunks preserves cross-boundary context — causal relationships, pronoun references, conditional clauses that span a paragraph break. In testing on legal documents, increasing overlap from 0 to 150 tokens improved logical coherence scores (via human evaluation) by roughly 18%.

Here's the core extraction and chunking code using PyMuPDF and LangChain:

import fitz  # PyMuPDF
from langchain.text_splitter import RecursiveCharacterTextSplitter

def extract_and_chunk(pdf_path: str, chunk_size: int = 2000, overlap: int = 200):
    doc = fitz.open(pdf_path)
    full_text = ""
    for page in doc:
        full_text += page.get_text("text")

    splitter = RecursiveCharacterTextSplitter(
        chunk_size=chunk_size,
        chunk_overlap=overlap,
        separators=["\n\n", "\n", ".", " "],
        length_function=len,  # swap in tiktoken for accurate token counts
    )
    chunks = splitter.split_text(full_text)
    print(f"Document split into {len(chunks)} chunks")
    return chunks

Building the Map-Reduce Pipeline

Map phase: Hit each chunk concurrently with a lightweight model and get back a local summary. This is where you save money. GPT-5.4-mini or Claude Haiku 4.5 cost less than a tenth of their flagship counterparts per million tokens, and chunk-level summarization doesn't need flagship-level reasoning.

Reduce phase: Concatenate all chunk summaries and pass them to a flagship model — Opus 4.8 or GPT-5.5. This is where global synthesis matters: resolving contradictions between sections, eliminating redundancy, and constructing a coherent narrative from fragments.

import asyncio
from openai import AsyncOpenAI

client = AsyncOpenAI(
    api_key="YOUR_API_KEY",
    base_url="https://api.xyc.ai/v1"  # any OpenAI-compatible endpoint
)

MAP_PROMPT = """Summarize the following document excerpt in 100 words or fewer.
Preserve key data points, conclusions, and proper nouns:

{chunk}

Summary:"""

REDUCE_PROMPT = """The following are summaries of individual sections from a long document.
Synthesize them into a single coherent summary (300 words or fewer).
Retain all key figures and conclusions, remove repetition, and organize logically:

{summaries}

Final summary:"""

async def map_chunk(chunk: str, semaphore: asyncio.Semaphore) -> str:
    async with semaphore:
        response = await client.chat.completions.create(
            model="claude-haiku-4-5",  # lightweight model for map phase
            messages=[{"role": "user", "content": MAP_PROMPT.format(chunk=chunk)}],
            max_tokens=300,
            temperature=0.3,
        )
        return response.choices[0].message.content

async def run_map_reduce(chunks: list[str], concurrency: int = 8) -> str:
    semaphore = asyncio.Semaphore(concurrency)  # throttle to avoid rate limit 429s

    # Map: process all chunks concurrently
    chunk_summaries = await asyncio.gather(
        *[map_chunk(chunk, semaphore) for chunk in chunks]
    )

    combined = "\n\n---\n\n".join(
        f"[Section {i+1}]\n{s}" for i, s in enumerate(chunk_summaries)
    )

    # Reduce: flagship model for final synthesis
    final_response = await client.chat.completions.create(
        model="claude-opus-4-8",
        messages=[{"role": "user", "content": REDUCE_PROMPT.format(summaries=combined)}],
        max_tokens=800,
        temperature=0.2,
    )
    return final_response.choices[0].message.content

concurrency=8 is a reasonable starting point for most APIs with ~60 RPM limits. At that rate, processing 50 chunks runs in about 40 seconds without triggering rate limit errors.


Hierarchical Reduce for Very Long Documents

When chunk count exceeds 50, the first reduce pass may itself overflow the context window. The fix is hierarchical reduction: group chunk summaries in batches of 10–15, reduce each group independently, then reduce the group summaries — repeat until you're down to one layer.

def hierarchical_reduce(summaries: list[str], group_size: int = 12) -> list[str]:
    """Recursively group and reduce until the summary list fits in one pass."""
    if len(summaries) <= group_size:
        return summaries

    groups = [summaries[i:i+group_size] for i in range(0, len(summaries), group_size)]
    reduced = [sync_reduce(group) for group in groups]  # async version follows same pattern
    return hierarchical_reduce(reduced, group_size)

Quality Control

A few practical checkpoints worth building into any production pipeline:

For high-stakes documents — legal, medical, regulatory — it's worth adding an explicit instruction in the reduce prompt asking the model to distinguish between what the document directly states versus what it infers. That single prompt change reduces undetected confidence errors noticeably.


Model Selection and What It Actually Costs

For a typical 200-page PDF (~80K tokens), here's what the three main strategies cost per document:

Strategy Map model Reduce model Estimated cost
All-flagship Opus 4.8 Opus 4.8 ~$1.60
Mixed (recommended) Haiku 4.5 Opus 4.8 ~$0.28
All-lightweight Haiku 4.5 Haiku 4.5 ~$0.06

The mixed strategy wins for most workloads. Map-phase tasks are simple enough for a lightweight model. The reduce phase — global synthesis, contradiction resolution, logical restructuring — is exactly where flagship reasoning pays off.

If you're running this pipeline through Claude Code, Codex, or Gemini CLI, the unified OpenAI-compatible format means you maintain one SDK and change a single parameter to switch models.


I've been running this pipeline on XycAi, which gives access to 200+ models — Claude, GPT-5.x, Gemini 3, and more — through a single OpenAI-compatible endpoint. The code above works as-is with just a base_url swap. For batch document processing in particular, the pricing (from 14% of official list price) makes the cost difference between strategies even more pronounced, and the CN2 direct-connect nodes keep latency low enough that high-concurrency runs stay stable.

One API for 200+ global AI models

GPT · Claude · Gemini official models from 14% of list price. Licensed LLM filing, CN2 direct connect at ~5ms, compliant global invoicing.

Try XycAi →