Módulo 3: Comprensión de Documentos

6. Documentos Largos

Descripción

Los documentos de 50+ páginas (manuales técnicos, informes financieros, contratos extensos, papers académicos) no caben en un solo request a un LLM — o si caben, el costo y la latencia son prohibitivos. En esta cápsula aprenderás chunking (dividir documentos en fragmentos procesables), estimación de tokens, resumen jerárquico con map-reduce, y estrategias de Q&A sobre documentos largos. Estas técnicas son la base directa para RAG documental en el Módulo 6.

Por qué importa: Sin chunking, no puedes procesar manuales de 200 páginas ni responder preguntas sobre informes extensos. Enviar un documento completo a GPT-4o puede costar $5-15 por request. Las técnicas de esta cápsula te permiten procesar cualquier documento de forma eficiente, controlando costos y calidad. Se aplican directamente en el Document Analyzer del Módulo 8.

Conexión con el módulo: Esta cápsula conecta la extracción de texto (cápsula 05) con el procesamiento inteligente. El chunking que aprendes aquí es exactamente lo que usarás en RAG (Módulo 6) para indexar documentos en bases vectoriales.


Conceptos Clave

Chunking: dividir para conquistar

Chunking es el proceso de dividir un documento largo en fragmentos más pequeños que un LLM puede procesar individualmente. La elección de estrategia impacta directamente en la calidad de los resultados.

Comparación de estrategias de chunking

EstrategiaCuándo usarProsContrasTamaño típico
Por páginasPDFs con layout fijoSimple, preserva paginaciónCorta párrafos entre páginas3-5 páginas/chunk
Por párrafosTexto narrativo, artículosRespeta estructura naturalChunks de tamaño variable1-5 párrafos/chunk
Por tokensControl preciso de contextoTamaño uniforme, predeciblePuede cortar oraciones500-2000 tokens/chunk
Por seccionesDocs con headers clarosSemánticamente coherenteRequiere estructura detectableVaría por sección
Con overlapCualquiera (mejora calidad)No pierde contexto en bordesMás chunks, más costo10-20% overlap

Límites de contexto (2024-2026)

ModeloContexto máximoCosto input (1M tokens)Recomendación
GPT-4o128K tokens~$2.50Bueno para docs medianos
GPT-4o-mini128K tokens~$0.15Ideal para resúmenes
Claude 3.5 Sonnet200K tokens~$3.00Contexto amplio
Gemini 1.5 Pro1M tokens~$1.25Docs muy largos

Aun con contextos de 1M tokens, enviar 100 páginas completas es costoso y lento. Chunking + procesamiento selectivo es casi siempre más eficiente.


Chunking por Páginas

La estrategia más simple: agrupar N páginas consecutivas en cada chunk.

def chunk_by_pages(text_by_page: list[dict], chunk_size: int = 3) -> list[dict]:
    """
    Agrupa páginas consecutivas en chunks.
    text_by_page: [{"page": 1, "text": "..."}, ...]
    """
    chunks = []
    for i in range(0, len(text_by_page), chunk_size):
        group = text_by_page[i:i + chunk_size]
        combined = "\n\n".join(p["text"] for p in group)
        chunks.append({
            "chunk_id": len(chunks) + 1,
            "pages": [p["page"] for p in group],
            "text": combined,
            "token_estimate": len(combined) // 4
        })
    return chunks


pages = [{"page": i, "text": f"Contenido de página {i}..."} for i in range(1, 51)]
chunks = chunk_by_pages(pages, chunk_size=5)
print(f"50 páginas → {len(chunks)} chunks de 5 páginas cada uno")

Chunking por Párrafos

Respeta la estructura natural del texto. Agrupa párrafos consecutivos hasta alcanzar un límite de tokens, sin cortar nunca un párrafo a la mitad.

def chunk_by_paragraphs(text: str, max_tokens: int = 1500) -> list[dict]:
    """Agrupa párrafos consecutivos hasta max_tokens por chunk."""
    max_chars = max_tokens * 4
    paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
    chunks = []
    current_paragraphs = []
    current_len = 0

    for para in paragraphs:
        para_len = len(para)
        if current_len + para_len > max_chars and current_paragraphs:
            chunks.append({
                "chunk_id": len(chunks) + 1,
                "text": "\n\n".join(current_paragraphs),
                "paragraph_count": len(current_paragraphs),
                "token_estimate": current_len // 4
            })
            current_paragraphs = []
            current_len = 0
        current_paragraphs.append(para)
        current_len += para_len + 2

    if current_paragraphs:
        chunks.append({
            "chunk_id": len(chunks) + 1,
            "text": "\n\n".join(current_paragraphs),
            "paragraph_count": len(current_paragraphs),
            "token_estimate": current_len // 4
        })
    return chunks

Chunking por Tokens con tiktoken

Para control preciso, usa tiktoken — la librería oficial de OpenAI para contar tokens exactos en lugar de aproximaciones.

import tiktoken


def chunk_by_tokens(text: str, max_tokens: int = 1000, model: str = "gpt-4o") -> list[dict]:
    """Divide texto en chunks de exactamente max_tokens usando tiktoken."""
    enc = tiktoken.encoding_for_model(model)
    tokens = enc.encode(text)
    chunks = []

    for i in range(0, len(tokens), max_tokens):
        chunk_tokens = tokens[i:i + max_tokens]
        chunk_text = enc.decode(chunk_tokens)
        chunks.append({
            "chunk_id": len(chunks) + 1,
            "text": chunk_text,
            "token_count": len(chunk_tokens)
        })
    return chunks


text = "Este es un texto de ejemplo para comparar métodos de conteo de tokens."
enc = tiktoken.encoding_for_model("gpt-4o")
tokens_real = len(enc.encode(text))
tokens_aprox = len(text) // 4
print(f"Tokens reales: {tokens_real}, Aproximación (chars/4): {tokens_aprox}")

Chunking por Secciones (Headers)

Ideal para documentos estructurados. Cada sección se convierte en un chunk semánticamente coherente.

import re


def chunk_by_sections(text: str) -> list[dict]:
    """Detecta secciones por headers (##, 1., 1.1, etc.) y crea chunks."""
    header_pattern = re.compile(
        r'^(#{1,6}\s+.+|\d+\.\s+.+|\d+\.\d+\s+.+)$', re.MULTILINE
    )
    sections = []
    current_header = "Introducción"
    current_content = []

    for line in text.split("\n"):
        if header_pattern.match(line.strip()):
            if current_content:
                content = "\n".join(current_content).strip()
                sections.append({
                    "header": current_header,
                    "content": content,
                    "token_estimate": len(content) // 4
                })
            current_header = line.strip()
            current_content = []
        else:
            current_content.append(line)

    if current_content:
        content = "\n".join(current_content).strip()
        sections.append({
            "header": current_header,
            "content": content,
            "token_estimate": len(content) // 4
        })
    return sections

Chunking con Overlap

Por qué importa el overlap

Cuando divides un documento en chunks sin overlap, el contexto se pierde en los bordes. Si una oración importante queda al final del chunk 3 y su continuación al inicio del chunk 4, ninguno tiene la idea completa.

Sin overlap:
  Chunk 1: [A B C D E]
  Chunk 2: [F G H I J]
  → Se pierde contexto entre E y F

Con overlap (2 elementos):
  Chunk 1: [A B C D E]
  Chunk 2: [D E F G H]
  → D y E aparecen en ambos, preservando continuidad

El overlap típico es 10-20% del tamaño del chunk. Más overlap = más contexto preservado, pero también más chunks y más costo.

Implementación

import tiktoken


def chunk_with_overlap(
    text: str,
    max_tokens: int = 1000,
    overlap_tokens: int = 150,
    model: str = "gpt-4o"
) -> list[dict]:
    """Chunking por tokens con overlap configurable."""
    enc = tiktoken.encoding_for_model(model)
    tokens = enc.encode(text)
    total = len(tokens)
    step = max_tokens - overlap_tokens

    if step <= 0:
        raise ValueError("overlap_tokens debe ser menor que max_tokens")

    chunks = []
    for start in range(0, total, step):
        end = min(start + max_tokens, total)
        chunk_tokens = tokens[start:end]
        chunks.append({
            "chunk_id": len(chunks) + 1,
            "text": enc.decode(chunk_tokens),
            "token_count": len(chunk_tokens),
            "has_overlap": start > 0
        })
        if end == total:
            break
    return chunks


text_largo = "palabra " * 5000
chunks = chunk_with_overlap(text_largo, max_tokens=1000, overlap_tokens=150)
print(f"Total chunks: {len(chunks)}, Chunk 2 overlap: {chunks[1]['has_overlap']}")

Estimación de Tokens

Antes de procesar un documento, necesitas saber cuántos tokens tiene para elegir la estrategia correcta y estimar costos.

import tiktoken


def estimate_document_tokens(text_by_page: list[dict], model: str = "gpt-4o") -> dict:
    """Cuenta tokens, verifica contexto y recomienda estrategia."""
    enc = tiktoken.encoding_for_model(model)
    context_limits = {
        "gpt-4o": 128_000, "gpt-4o-mini": 128_000,
        "claude-3-5-sonnet": 200_000, "gemini-1.5-pro": 1_000_000}

    page_stats = []
    total_tokens = 0
    for page in text_by_page:
        page_tokens = len(enc.encode(page["text"]))
        total_tokens += page_tokens
        page_stats.append({"page": page["page"], "tokens": page_tokens})

    avg_tokens_per_page = total_tokens // max(len(text_by_page), 1)

    fits_in = {
        name: total_tokens < (limit * 0.8)
        for name, limit in context_limits.items()
    }

    if total_tokens < 4_000:
        strategy, reason = "direct", "Documento corto, enviar completo"
    elif total_tokens < 50_000:
        strategy, reason = "single_pass", "Cabe en contexto, pero considerar costo"
    else:
        strategy, reason = "chunking", "Documento largo, usar chunking"

    return {
        "total_tokens": total_tokens,
        "total_pages": len(text_by_page),
        "avg_tokens_per_page": avg_tokens_per_page,
        "fits_in_context": fits_in,
        "recommended_strategy": strategy,
        "reason": reason,
        "page_stats": page_stats
    }


import fitz

doc = fitz.open("informe_anual.pdf")
pages = [{"page": i + 1, "text": doc[i].get_text()} for i in range(len(doc))]
doc.close()

analysis = estimate_document_tokens(pages)
print(f"Tokens: {analysis['total_tokens']:,} | Estrategia: {analysis['recommended_strategy']}")
for model_name, fits in analysis["fits_in_context"].items():
    print(f"  {model_name}: {'✓ Cabe' if fits else '✗ No cabe'}")

Resumen Jerárquico (Map-Reduce)

Para documentos muy largos, el patrón map-reduce es la estrategia más robusta: resumir cada chunk individualmente (map), luego combinar los resúmenes en un resumen final (reduce).

Documento (200 páginas)
    ↓ chunking
[Chunk 1] [Chunk 2] [Chunk 3] ... [Chunk 20]
    ↓ map (resumir cada uno)
[Resumen 1] [Resumen 2] [Resumen 3] ... [Resumen 20]
    ↓ reduce (combinar resúmenes)
[Resumen Final]
from openai import OpenAI
client = OpenAI()

def summarize_chunk(chunk_text: str, chunk_id: int, context: str = "") -> str:
    """Map: resume un chunk individual."""
    prompt = f"""Resume el siguiente fragmento en 3-5 oraciones.
Mantén datos específicos (cifras, nombres, fechas).
{f"Contexto del documento: {context}" if context else ""}

Fragmento {chunk_id}:
{chunk_text[:6000]}"""

    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        max_tokens=300
    )
    return response.choices[0].message.content


def combine_summaries(summaries: list[str], doc_type: str = "documento") -> str:
    """Reduce: combina resúmenes parciales en resumen final."""
    combined = "\n\n".join(
        f"[Sección {i+1}]: {s}" for i, s in enumerate(summaries)
    )
    prompt = f"""A partir de estos resúmenes parciales de un {doc_type}, genera:
1. **Resumen ejecutivo** (1 párrafo, máximo 100 palabras)
2. **Puntos clave** (5-7 bullets)
3. **Datos relevantes** (cifras, fechas, nombres mencionados)

Resúmenes parciales:
{combined}"""

    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[{"role": "user", "content": prompt}],
        max_tokens=800
    )
    return response.choices[0].message.content


def hierarchical_summary(text: str, chunk_tokens: int = 1500, doc_type: str = "documento") -> dict:
    """Pipeline completo de resumen jerárquico map-reduce."""
    chunks = chunk_by_tokens(text, max_tokens=chunk_tokens)
    print(f"Procesando {len(chunks)} chunks...")

    chunk_summaries = []
    for i, chunk in enumerate(chunks):
        text_content = chunk["text"] if isinstance(chunk, dict) else chunk
        summary = summarize_chunk(text_content, i + 1)
        chunk_summaries.append(summary)
        print(f"  Chunk {i+1}/{len(chunks)} resumido")

    print("Generando resumen final...")
    final_summary = combine_summaries(chunk_summaries, doc_type)
    return {
        "chunk_count": len(chunks),
        "chunk_summaries": chunk_summaries,
        "final_summary": final_summary
    }

Q&A sobre Documentos Largos

Dos enfoques según el tamaño del documento:

Enfoque 1: Stuff (documento completo en contexto)

Si el documento cabe en la ventana de contexto, envíalo completo. Simple y efectivo.

def qa_stuff(document_text: str, question: str) -> str:
    """Q&A enviando el documento completo. Funciona si cabe en contexto."""
    enc = tiktoken.encoding_for_model("gpt-4o")
    doc_tokens = len(enc.encode(document_text))

    if doc_tokens > 100_000:
        raise ValueError(
            f"Documento tiene {doc_tokens:,} tokens. Usa qa_chunk_retrieve."
        )

    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[
            {"role": "system", "content": "Responde basándote SOLO en el documento. Si la respuesta no está, dilo."},
            {"role": "user", "content": f"Documento:\n{document_text}\n\nPregunta: {question}"}
        ],
        max_tokens=500
    )
    return response.choices[0].message.content

Enfoque 2: Chunk + Retrieve (Proto-RAG)

Para documentos que no caben en contexto: dividir en chunks, buscar los más relevantes, y responder solo con esos.

def qa_chunk_retrieve(chunks: list[dict], question: str, top_k: int = 3) -> dict:
    """Proto-RAG: puntúa chunks por relevancia y responde con los mejores."""
    scored_chunks = []
    for chunk in chunks:
        text = chunk["text"] if isinstance(chunk, dict) else chunk
        response = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": (
                f"¿Qué tan relevante es este fragmento para la pregunta?\n"
                f"Responde SOLO un número del 0 al 10.\n\n"
                f"Pregunta: {question}\n\nFragmento:\n{text[:2000]}"
            )}],
            max_tokens=5
        )
        try:
            score = int(response.choices[0].message.content.strip())
        except ValueError:
            score = 0
        scored_chunks.append({"chunk": chunk, "score": score})

    top_chunks = sorted(scored_chunks, key=lambda x: x["score"], reverse=True)[:top_k]
    context = "\n\n---\n\n".join(
        c["chunk"]["text"] if isinstance(c["chunk"], dict) else c["chunk"]
        for c in top_chunks
    )

    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[
            {"role": "system", "content": "Responde basándote SOLO en los fragmentos."},
            {"role": "user", "content": f"Fragmentos:\n{context}\n\nPregunta: {question}"}
        ],
        max_tokens=500
    )
    return {
        "answer": response.choices[0].message.content,
        "chunks_used": [c["chunk"].get("chunk_id", "?") for c in top_chunks],
        "relevance_scores": [c["score"] for c in top_chunks]
    }

Nota: Este enfoque "proto-RAG" usa el LLM para evaluar relevancia, lo cual es lento y costoso. En el Módulo 6 aprenderás a usar embeddings + búsqueda vectorial para hacer esto en milisegundos.


Pipeline: Documento Largo → Chunks → Procesamiento → Combinación

Un pipeline end-to-end que integra extracción, análisis, chunking y procesamiento.

import fitz
from openai import OpenAI
client = OpenAI()

def process_long_document(pdf_path: str, task: str = "summary") -> dict:
    """
    Pipeline completo para documentos largos.
    task: "summary" para resumen jerárquico, "extract" para chunks crudos
    """
    doc = fitz.open(pdf_path)
    text_by_page = []
    for i in range(len(doc)):
        page_text = doc[i].get_text()
        if page_text.strip():
            text_by_page.append({"page": i + 1, "text": page_text})
    doc.close()

    if not text_by_page:
        return {"error": "No se extrajo texto. El PDF puede ser escaneado (usar OCR)."}

    full_text = "\n\n".join(p["text"] for p in text_by_page)
    analysis = estimate_document_tokens(text_by_page)
    print(f"Páginas: {analysis['total_pages']} | Tokens: {analysis['total_tokens']:,}")

    if analysis["recommended_strategy"] == "direct":
        chunks = [{"chunk_id": 1, "text": full_text}]
    else:
        sections = chunk_by_sections(full_text)
        if len(sections) >= 3:
            chunks = [
                {"chunk_id": i + 1, "text": f"{s['header']}\n{s['content']}", "header": s["header"]}
                for i, s in enumerate(sections)
            ]
        else:
            chunks = chunk_with_overlap(full_text, max_tokens=1500, overlap_tokens=200)

    if task == "summary":
        result = hierarchical_summary(full_text, doc_type="documento PDF")
        return {
            "task": "summary", "pages": analysis["total_pages"],
            "tokens": analysis["total_tokens"],
            "chunks_processed": result["chunk_count"],
            "summary": result["final_summary"]
        }
    if task == "extract":
        return {
            "task": "extract", "pages": analysis["total_pages"],
            "tokens": analysis["total_tokens"],
            "chunks": chunks, "analysis": analysis
        }
    return {"error": f"Tarea no reconocida: {task}"}


result = process_long_document("manual_tecnico.pdf", task="summary")
print(result["summary"])

Optimización de Costos en Documentos Largos

Tres estrategias para reducir costos sin sacrificar calidad.

1. Estimar costo antes de procesar

def estimate_processing_cost(total_tokens: int, task: str = "summary") -> dict:
    """Estima costo de procesamiento. Precios aprox. por 1M tokens (2025)."""
    prices = {
        "gpt-4o": {"input": 2.50, "output": 10.00},
        "gpt-4o-mini": {"input": 0.15, "output": 0.60},
    }
    if task == "summary":
        input_tokens, output_tokens, model = total_tokens * 1.1, total_tokens * 0.15, "gpt-4o-mini"
    elif task == "qa":
        input_tokens, output_tokens, model = total_tokens * 0.3, 500, "gpt-4o"
    else:
        input_tokens, output_tokens, model = total_tokens, total_tokens * 0.1, "gpt-4o-mini"

    cost_input = (input_tokens / 1_000_000) * prices[model]["input"]
    cost_output = (output_tokens / 1_000_000) * prices[model]["output"]
    total_cost = cost_input + cost_output
    return {
        "model": model,
        "estimated_input_tokens": int(input_tokens),
        "estimated_output_tokens": int(output_tokens),
        "total_cost": round(total_cost, 4),
        "cost_display": f"${total_cost:.4f}"
    }


cost = estimate_processing_cost(150_000, task="summary")
print(f"Costo estimado: {cost['cost_display']} con {cost['model']}")

2. Procesamiento selectivo

No siempre necesitas procesar todo el documento. Filtra páginas relevantes primero.

def selective_processing(text_by_page: list[dict], topic: str, max_pages: int=20) -> list[dict]:
    """Selecciona solo las páginas relevantes al tema por keyword matching."""
    keywords = topic.lower().split()
    scored_pages = []
    for page in text_by_page:
        text_lower = page["text"].lower()
        score = sum(text_lower.count(kw) for kw in keywords)
        scored_pages.append({**page, "relevance": score})

    relevant = sorted(scored_pages, key=lambda x: x["relevance"], reverse=True)
    selected = [p for p in relevant[:max_pages] if p["relevance"] > 0]
    if not selected:
        selected = relevant[:5]
    selected.sort(key=lambda x: x["page"])
    return selected

3. Detalle progresivo

Resumen rápido primero; profundiza solo donde el usuario lo pida.

def progressive_detail(text_by_page: list[dict], question: str = None) -> dict:
    """Nivel 1: resumen rápido. Nivel 2: detalle sobre pregunta específica."""
    sample_text = "\n\n".join(p["text"] for p in text_by_page[:10])
    quick_summary = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": f"Resume en 5 bullets:\n{sample_text[:8000]}"}],
        max_tokens=300
    ).choices[0].message.content

    result = {"level_1_summary": quick_summary}
    if question:
        relevant_pages = selective_processing(text_by_page, question, max_pages=10)
        context = "\n\n".join(p["text"] for p in relevant_pages)
        detailed = client.chat.completions.create(
            model="gpt-4o",
            messages=[
                {"role": "system", "content": "Responde con detalle basándote en el documento."},
                {"role": "user", "content": f"Documento:\n{context[:20000]}\n\nPregunta: {question}"}
            ],
            max_tokens=800
        ).choices[0].message.content
        result["level_2_detail"] = detailed
        result["pages_used"] = [p["page"] for p in relevant_pages]
    return result

Troubleshooting

Problema: Context length exceeded

Síntoma: Error maximum context length exceeded al enviar texto al LLM. Solución: Usar estimate_document_tokens() antes de enviar. Si excede el 80% del contexto, aplicar chunking. Siempre dejar margen para el prompt del sistema y la respuesta.

Problema: Información perdida en bordes de chunks

Síntoma: Respuestas incompletas que ignoran datos en la frontera entre dos chunks. Solución: Usar chunk_with_overlap() con overlap de 10-20%. Aumentar el overlap o usar chunking por secciones si un dato crítico queda cortado.

Problema: Resúmenes inconsistentes entre chunks

Síntoma: El resumen final contiene contradicciones o repite información. Solución: En el paso de map, incluir contexto del documento (título, tipo, tema). En reduce, instruir al modelo a resolver contradicciones y eliminar redundancias.

Problema: Costo alto al procesar muchos documentos

Síntoma: Factura inesperadamente alta al procesar lotes de documentos largos. Solución: Usar gpt-4o-mini para resúmenes y scoring; reservar gpt-4o solo para la respuesta final. Implementar estimate_processing_cost() antes de cada ejecución.

Problema: Procesamiento lento en documentos grandes

Síntoma: Pipeline tarda minutos para documentos de 100+ páginas. Solución: Procesar chunks en paralelo con asyncio o concurrent.futures. Usar chunks más grandes (2000-3000 tokens) para reducir el total.


Ejercicios

Ejercicio 1: Analizador de documento con recomendación

Crea una función que reciba un texto, cuente tokens con tiktoken, y retorne: total de tokens, si cabe en GPT-4o/Claude, y la estrategia recomendada.

Ver solución
import tiktoken

def analyze_document(text: str) -> dict:
    enc = tiktoken.encoding_for_model("gpt-4o")
    total_tokens = len(enc.encode(text))
    fits_gpt4o = total_tokens < 100_000
    fits_claude = total_tokens < 160_000

    if total_tokens < 4_000:
        strategy = "direct"
    elif total_tokens < 50_000:
        strategy = "single_pass"
    else:
        strategy = "chunking"

    return {
        "total_tokens": total_tokens,
        "fits_gpt4o": fits_gpt4o,
        "fits_claude": fits_claude,
        "strategy": strategy
    }

result = analyze_document("Tu texto largo aquí..." * 1000)
print(f"Tokens: {result['total_tokens']:,}, Estrategia: {result['strategy']}")

Ejercicio 2: Chunking inteligente con fallback

Intenta chunking por secciones primero. Si hay menos de 3 secciones, haz fallback a chunking con overlap de 100 tokens.

Ver solución
def smart_chunking(text: str, max_tokens: int = 1500, overlap_tokens: int = 100) -> list[dict]:
    sections = chunk_by_sections(text)

    if len(sections) >= 3:
        return [
            {"chunk_id": i + 1, "text": f"{s['header']}\n{s['content']}",
             "strategy": "sections", "header": s["header"]}
            for i, s in enumerate(sections)
        ]

    chunks = chunk_with_overlap(text, max_tokens=max_tokens, overlap_tokens=overlap_tokens)
    for c in chunks:
        c["strategy"] = "paragraphs_with_overlap"
    return chunks

chunks = smart_chunking("# Intro\nTexto...\n\n# Método\nTexto...\n\n# Resultados\nTexto...")
print(f"Estrategia: {chunks[0]['strategy']}, Total: {len(chunks)}")

Ejercicio 3: Q&A con selección automática de enfoque

Si el documento tiene menos de 80K tokens, usa "stuff". Si tiene más, usa "chunk + retrieve" con los 3 chunks más relevantes.

Ver solución
def smart_qa(document_text: str, question: str) -> dict:
    enc = tiktoken.encoding_for_model("gpt-4o")
    total_tokens = len(enc.encode(document_text))

    if total_tokens < 80_000:
        answer = qa_stuff(document_text, question)
        return {"approach": "stuff", "tokens": total_tokens, "answer": answer}

    chunks = chunk_with_overlap(document_text, max_tokens=1500, overlap_tokens=200)
    result = qa_chunk_retrieve(chunks, question, top_k=3)
    return {"approach": "chunk_retrieve", "tokens": total_tokens, "chunks_total": len(chunks), **result}

result = smart_qa("Documento extenso aquí...", "¿Cuál es la conclusión principal?")
print(f"Enfoque: {result['approach']}, Respuesta: {result['answer']}")

Ejercicio 4: Estimador de costo para lote de PDFs

Crea una función que reciba una lista de rutas a PDFs y genere un reporte con tokens por documento, costo individual y costo total del lote.

Ver solución
import fitz
import tiktoken

def batch_cost_report(pdf_paths: list[str]) -> dict:
    enc = tiktoken.encoding_for_model("gpt-4o")
    documents = []
    total_cost = 0

    for path in pdf_paths:
        doc = fitz.open(path)
        text = "\n".join(doc[i].get_text() for i in range(len(doc)))
        page_count = len(doc)
        doc.close()

        tokens = len(enc.encode(text))
        cost = estimate_processing_cost(tokens, task="summary")
        total_cost += cost["total_cost"]
        documents.append({
            "path": path, "pages": page_count,
            "tokens": tokens, "cost": cost["cost_display"]
        })

    return {
        "documents": documents, "total_documents": len(documents),
        "total_cost": f"${total_cost:.4f}", "model": "gpt-4o-mini"
    }

report = batch_cost_report(["doc1.pdf", "doc2.pdf", "doc3.pdf"])
for d in report["documents"]:
    print(f"  {d['path']}: {d['pages']} págs, {d['tokens']:,} tokens → {d['cost']}")
print(f"Costo total: {report['total_cost']}")

Recursos Adicionales

  1. tiktoken — Tokenizer oficial de OpenAI
  2. LangChain Text Splitters
  3. OpenAI Context length por modelo
  4. Chunking strategies for RAG