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
| Estrategia | Cuándo usar | Pros | Contras | Tamaño típico |
|---|---|---|---|---|
| Por páginas | PDFs con layout fijo | Simple, preserva paginación | Corta párrafos entre páginas | 3-5 páginas/chunk |
| Por párrafos | Texto narrativo, artículos | Respeta estructura natural | Chunks de tamaño variable | 1-5 párrafos/chunk |
| Por tokens | Control preciso de contexto | Tamaño uniforme, predecible | Puede cortar oraciones | 500-2000 tokens/chunk |
| Por secciones | Docs con headers claros | Semánticamente coherente | Requiere estructura detectable | Varía por sección |
| Con overlap | Cualquiera (mejora calidad) | No pierde contexto en bordes | Más chunks, más costo | 10-20% overlap |
Límites de contexto (2024-2026)
| Modelo | Contexto máximo | Costo input (1M tokens) | Recomendación |
|---|---|---|---|
| GPT-4o | 128K tokens | ~$2.50 | Bueno para docs medianos |
| GPT-4o-mini | 128K tokens | ~$0.15 | Ideal para resúmenes |
| Claude 3.5 Sonnet | 200K tokens | ~$3.00 | Contexto amplio |
| Gemini 1.5 Pro | 1M tokens | ~$1.25 | Docs 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
- tiktoken — Tokenizer oficial de OpenAI
- LangChain Text Splitters
- OpenAI Context length por modelo
- Chunking strategies for RAG