RAG — Módulo 2: ingesta y parsing, el eslabón que todos subestiman
Segundo módulo del Curso de RAG: por qué el PDF es un formato hostil para las máquinas, comparación de herramientas de parsing (pypdf, PyMuPDF, Unstructured, Docling, LlamaParse), cuándo hace falta OCR y por qué la metadata que captures aquí decide filtros y permisos.
☰ Índice del contenido
- La tesis del módulo
- Por qué esta etapa se subestima
- El zoo de formatos
- Por qué el PDF es un infierno
- Comparación de herramientas de parsing
- OCR: cuándo y con qué
- Limpieza: quitar ruido sin destruir señal
- Metadata: el activo más subestimado
- El flujo completo de ingesta
- Código: pipeline de ingesta con metadata
- Idempotencia: no reindexar lo que no cambió
- Errores comunes
- La frase para llevarse
- Qué viene
- Material relacionado
En el módulo anterior quedó establecido que la mayor parte de la calidad de un RAG se juega en el retrieval. Ahora vamos un paso más atrás, porque hay algo que ocurre incluso antes: el retrieval nunca va a ser mejor que el texto que indexaste. Si tu parser convirtió una tabla de precios en una sopa de números sin columnas, ningún embedding, ningún reranker y ningún modelo van a recuperar el precio correcto. Este módulo trata del eslabón menos glamoroso y más determinante del pipeline: sacar texto limpio y bien etiquetado de documentos que nunca fueron diseñados para ser leídos por una máquina.
La tesis del módulo
“Garbage in, garbage out” no es un cliché en RAG: es la ley que gobierna el sistema completo. El parsing es la única etapa donde la información se puede perder de forma irreversible — todo lo que viene después solo puede reordenar lo que el parser dejó pasar. Y la metadata que captures en este momento es lo que después habilita filtros, citas y permisos por usuario.
Por qué esta etapa se subestima
Es la parte aburrida. No hay embeddings, no hay vectores, no hay prompt engineering: hay encoding, saltos de línea y headers repetidos. La consecuencia previsible es que la mayoría de los equipos escribe tres líneas con pypdf, ve que “sale texto” y sigue adelante.
El problema aparece dos meses después, cuando el sistema responde mal y nadie entiende por qué. Se cambia el modelo de embeddings, se agrega un reranker, se prueba otro LLM. Y la respuesta correcta estaba en una tabla que el parser aplastó en una sola línea ilegible el primer día.
Hay una asimetría dura aquí: un error de retrieval es recuperable (mejoras la búsqueda y aparece el chunk); un error de parsing es irrecuperable sin reindexar todo, porque la información ya no está en el índice.
El zoo de formatos
Un corpus empresarial real nunca es homogéneo. Cada formato tiene su propia trampa.
Markdown es el mejor caso: la estructura es explícita (#, ##, listas, tablas), es texto plano y se puede aprovechar directamente para el chunking del módulo 3. Si puedes elegir el formato de origen, elige este.
HTML tiene estructura semántica real (<h1>, <table>, <article>), pero viene envuelto en navegación, banners de cookies, footers y scripts. El trabajo aquí es de extracción de contenido principal: quedarse con el artículo y botar el chrome del sitio. Herramientas como trafilatura o readability-lxml hacen exactamente eso.
DOCX es un ZIP de XML con estilos declarados. Se puede recuperar la jerarquía de títulos leyendo los estilos de párrafo (Heading 1, Heading 2), lo que lo hace bastante amable con python-docx.
Confluence, Notion, SharePoint se atacan por API, no bajando archivos. Ventaja enorme: la API te entrega el contenido y la metadata (autor, fecha de modificación, espacio, permisos) en el mismo payload. Es la fuente más rica que puedes tener.
CSV y tablas no son texto y tratarlos como texto es un error clásico. Una fila suelta de un CSV no significa nada sin sus encabezados de columna. Hay que decidir explícitamente cómo representarlas.
Imágenes escaneadas no tienen texto: hay que generarlo con OCR (más abajo).
PDF merece su propia sección, porque es el formato dominante en las empresas y el más hostil de todos.
Por qué el PDF es un infierno
La clave para entender el problema es esta: un PDF no es un documento, es una descripción de impresión. El formato fue diseñado para que una página se vea idéntica en cualquier impresora. Internamente no dice “esto es un título, esto es un párrafo, esto es una tabla”: dice “pon el glifo ‘H’ en la coordenada (72, 640) con la fuente Helvetica 14”.
No hay estructura semántica. Toda la estructura que ves cuando lo lees la reconstruye tu cerebro a partir de la posición visual. El parser tiene que hacer esa misma reconstrucción, y ahí es donde se rompe todo:
- Columnas que se mezclan. Un paper a dos columnas se extrae leyendo horizontalmente de lado a lado, intercalando la columna izquierda con la derecha y produciendo frases sin sentido.
- Headers y footers repetidos. El nombre de la empresa y “Página 12 de 47” aparecen en cada una de las 47 páginas. Sin limpieza, eso contamina cada chunk y compite en el retrieval.
- Tablas aplanadas. Una tabla con seis columnas se convierte en una tira de números consecutivos donde ya no se sabe qué valor pertenece a qué columna. Esta es la pérdida de información más grave y más frecuente.
- Saltos de línea falsos. Cada línea visual termina con
\n, así que una frase de tres líneas se parte en tres. Peor: palabras cortadas con guion al final de línea (“propor-\nción”) que hay que volver a pegar. - Orden de lectura arbitrario. Los objetos de texto se guardan en el orden en que el generador los escribió, que no tiene por qué ser el orden en que se leen.
- PDFs escaneados. No contienen texto en absoluto: son imágenes de páginas dentro de un contenedor PDF.
pypdfdevuelve string vacío y el equipo pasa una tarde entera pensando que hay un bug.
Ojo: siempre valida que el parsing produjo algo. Un PDF escaneado devuelve texto vacío o casi vacío sin lanzar ninguna excepción. Si no revisas la cantidad de caracteres por página, vas a indexar cientos de documentos fantasma y tu RAG va a responder “no tengo esa información” sobre archivos que sí subiste.
Comparación de herramientas de parsing
No existe la mejor herramienta: existe la adecuada a tus documentos. Esta es la tabla de decisión.
| Herramienta | Costo | Velocidad | Tablas | OCR | Layout | Cuándo usarla |
|---|---|---|---|---|---|---|
| pypdf | Gratis | Muy rápida | Malas | No | No | PDFs digitales simples, texto corrido, prototipos |
| pdfplumber | Gratis | Lenta | Buenas | No | Coordenadas | Cuando hay tablas y el volumen es moderado |
| PyMuPDF (fitz) | Gratis (AGPL) | La más rápida | Regulares | No | Sí, bloques | Alto volumen; extraer imágenes; base sólida general |
| Unstructured | Gratis + cloud | Media | Buenas | Sí | Sí, por elemento | Corpus multiformato (PDF+DOCX+HTML+PPTX) con una sola API |
| Docling (IBM) | Gratis | Media | Muy buenas | Sí | Sí, con modelos | Salida Markdown de calidad manteniendo estructura, sin pagar API |
| LlamaParse | Pago por página | Lenta (API) | Excelentes | Sí | Sí | Documentos complejos donde la tabla ES el dato: financieros, científicos |
| Azure Document Intelligence | Pago por página | Media | Excelentes | Excelente | Sí | Producción con formularios, facturas, escaneos; ya estás en Azure |
La estrategia sensata en producción no es elegir una: es enrutar por tipo de documento. PyMuPDF para la mayoría de los PDFs digitales de texto corrido, y una herramienta cara solo para la minoría que tiene tablas críticas. Pagar por página en un corpus de 100.000 páginas cuando 80.000 son texto simple es tirar plata.
Y la licencia importa: PyMuPDF es AGPL, lo que obliga a liberar tu código o comprar licencia comercial. En una empresa eso se revisa antes, no después.
OCR: cuándo y con qué
OCR (Optical Character Recognition, reconocimiento óptico de caracteres) es convertir la imagen de un texto en texto real. Lo necesitas cuando el PDF es un escaneo, cuando hay fotos de documentos, o cuando el PDF tiene una capa de texto corrupta.
La detección es simple: si extraes una página y salen menos de ~50 caracteres pero la página tiene una imagen grande, es un escaneo.
Tesseract es el motor open source clásico. Gratis, corre local (importante si los documentos son confidenciales), soporta español. Su debilidad es que funciona bien con escaneos limpios y rectos, y se degrada rápido con fotos torcidas, sellos, manuscritos o tablas.
Servicios cloud (Azure Document Intelligence, Google Document AI, AWS Textract) son notoriamente mejores: manejan rotación, ruido, tablas y formularios, y devuelven estructura además de texto. Cuestan por página y los documentos salen de tu infraestructura, lo que puede ser un bloqueador legal.
Modelos multimodales son la alternativa moderna y la que más ha cambiado el juego: renderizas la página como imagen y le pides a un modelo de visión que la transcriba a Markdown. La ventaja es que el modelo entiende el layout — reconstruye la tabla como tabla, mantiene los encabezados, ignora el footer. Las desventajas son reales: es más caro y lento que el OCR clásico, y el modelo puede alucinar contenido que no está en la imagen, lo que en OCR es un pecado mortal.
import base64
import fitz # PyMuPDF
from openai import OpenAI
client = OpenAI()
def pagina_a_markdown(pdf_path: str, num_pagina: int) -> str:
"""Renderiza una página como imagen y pide su transcripción en
Markdown. Útil para páginas con tablas o escaneos donde el parser
tradicional falla. Caro: usarlo solo en las páginas problemáticas."""
doc = fitz.open(pdf_path)
pagina = doc[num_pagina]
# 200 dpi es el punto dulce: menos pierde detalle en letra chica,
# más solo sube el costo de tokens sin mejorar la lectura.
pix = pagina.get_pixmap(dpi=200)
b64 = base64.b64encode(pix.tobytes("png")).decode()
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "user",
"content": [
{"type": "text", "text": (
"Transcribe esta página a Markdown. Mantén los "
"encabezados con # y las tablas en formato tabla "
"Markdown. NO agregues ni interpretes nada que no "
"esté literalmente en la imagen. Ignora headers y "
"footers de página."
)},
{"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{b64}"}},
],
}],
temperature=0, # cero creatividad: esto es transcripción
)
return resp.choices[0].message.content
Limpieza: quitar ruido sin destruir señal
Ya tienes texto. Ahora hay que limpiarlo, y la regla es ser conservador: es más barato indexar un poco de ruido que borrar contenido real.
Headers y footers repetidos. El método robusto no es una regex: es estadístico. Junta la primera y la última línea de cada página; si una línea aparece en más del 50% de las páginas, es header o footer. Así detectas cualquier plantilla sin escribir reglas por documento.
Espacios y saltos. Colapsar espacios múltiples, unir palabras cortadas con guion al final de línea, y convertir saltos simples dentro de un párrafo en espacios — pero preservar los dobles saltos, porque marcan separación de párrafos y son la señal principal para el chunking del módulo siguiente.
Deduplicación. Los corpus reales tienen el mismo documento tres veces con nombres distintos. Un hash del contenido normalizado los agrupa. Duplicados en el índice desperdician espacio y, peor, copan el top-k con el mismo texto repetido.
Tablas. La decisión: convertirlas a Markdown. Los LLM leen tablas Markdown muy bien y el formato mantiene la relación fila-columna. Si la tabla es enorme, conviene además generar una descripción en prosa que se indexe junto a ella, porque una pregunta en lenguaje natural se parece más a la prosa que a una grilla de números.
Imágenes. Un gráfico no aporta nada al índice. Pásalo por un modelo de visión, pide una descripción de lo que muestra, y guarda esa descripción como texto en el lugar donde estaba la imagen.
Lo que NO hay que hacer: pasar todo a minúsculas, quitar puntuación o eliminar stopwords. Son costumbres heredadas de la búsqueda léxica de los 2000 que destruyen el rendimiento de los embeddings modernos, que fueron entrenados con texto natural completo. Y la puntuación es señal para el chunking.
Metadata: el activo más subestimado
Aquí está la decisión de mayor retorno de todo el módulo. Mientras extraes el texto, estás además viendo información contextual que después es imposible de recuperar. Captúrala.
| Campo | De dónde sale | Para qué sirve después |
|---|---|---|
doc_id | Hash del contenido | Idempotencia, deduplicación |
fuente | Ruta o URL | Citas en la respuesta (módulo 9) |
titulo | Nombre o <h1> | Prependerlo al chunk (módulo 3) |
seccion | Encabezado activo | Chunking estructural, contexto |
pagina | Del parser | Cita precisa: “manual.pdf, p. 34” |
fecha | Metadata del archivo | Filtro por vigencia (módulo 5) |
autor / area | Sistema de origen | Filtro y atribución |
tenant / permisos | Sistema de origen | Aislamiento por usuario (módulo 12) |
hash | Del contenido | Detectar cambios sin reindexar todo |
Los dos últimos son los que separan un demo de un sistema real. El campo de permisos tiene que viajar con cada chunk hasta la base vectorial, porque el filtro se aplica en la búsqueda: el usuario nunca debe recibir un vecino cercano de un documento que no tiene derecho a ver. Si no lo capturaste en la ingesta, agregarlo después significa reprocesar el corpus completo.
El flujo completo de ingesta
FUENTES
│
├──► PDF ──────┐
├──► HTML ─────┤
├──► DOCX ─────┼──► ┌───────────────┐
├──► Notion ───┤ │ router de │
└──► CSV ──────┘ │ formato │
└───────┬───────┘
│
▼
┌───────────────┐
│ parser │
│ texto + meta │
└───────┬───────┘
│
▼
┌───────────────┐ sí ┌───────────────┐
│ OCR │ ◄──│ ¿vacío o │
│ o multimodal │ │ escaneado? │
└───────┬───────┘ └───────┬───────┘
│ │ no
└────────┬───────────┘
▼
┌───────────────┐
│ limpieza │
│ headers, dedup│
└───────┬───────┘
▼
┌───────────────┐
│ DOCUMENTO │ ──► al chunking
│ NORMALIZADO │
└───────────────┘
Código: pipeline de ingesta con metadata
Este es un pipeline real y completo: extrae por página, detecta headers repetidos de forma estadística, limpia y devuelve una lista de dicts lista para el chunking del módulo 3.
import hashlib
import re
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
import pdfplumber
def _hash(texto: str) -> str:
"""Hash del contenido normalizado. Sirve para dos cosas: detectar
duplicados y saber si un documento CAMBIÓ sin comparar texto."""
normalizado = re.sub(r"\s+", " ", texto).strip().lower()
return hashlib.sha256(normalizado.encode("utf-8")).hexdigest()[:16]
def _detectar_repetidas(paginas: list[str], umbral: float = 0.5) -> set[str]:
"""Encuentra headers y footers de forma ESTADÍSTICA, no con regex:
junta la primera y última línea de cada página y se queda con las
que aparecen en más del `umbral` de las páginas. Así funciona con
cualquier plantilla sin escribir reglas específicas."""
candidatas = Counter()
for texto in paginas:
lineas = [l.strip() for l in texto.split("\n") if l.strip()]
# Dos primeras y dos últimas: los headers a veces son de 2 líneas.
for linea in lineas[:2] + lineas[-2:]:
if 3 < len(linea) < 100: # ni ruido ni párrafos completos
candidatas[linea] += 1
minimo = max(2, int(len(paginas) * umbral))
return {l for l, n in candidatas.items() if n >= minimo}
def _limpiar(texto: str, repetidas: set[str]) -> str:
"""Limpieza conservadora: saca ruido estructural pero NO toca
mayúsculas, puntuación ni stopwords (los embeddings los necesitan)."""
lineas = [l for l in texto.split("\n") if l.strip() not in repetidas]
texto = "\n".join(lineas)
# "propor-\nción" -> "proporción": palabra cortada por fin de línea.
texto = re.sub(r"(\w+)-\n(\w+)", r"\1\2", texto)
# Números de página sueltos en su propia línea.
texto = re.sub(r"\n\s*\d{1,4}\s*\n", "\n", texto)
# Salto simple dentro de un párrafo -> espacio. El doble salto SE
# PRESERVA: marca fin de párrafo y es la señal clave del chunking.
texto = re.sub(r"(?<!\n)\n(?!\n)", " ", texto)
texto = re.sub(r"[ \t]+", " ", texto)
texto = re.sub(r"\n{3,}", "\n\n", texto)
return texto.strip()
def _tabla_a_markdown(tabla: list[list]) -> str:
"""Una tabla aplanada como texto pierde la relación fila-columna.
En Markdown la conserva, y los LLM leen Markdown muy bien."""
filas = [[(c or "").replace("\n", " ").strip() for c in fila]
for fila in tabla if any(fila)]
if len(filas) < 2:
return ""
encabezado, *cuerpo = filas
salida = ["| " + " | ".join(encabezado) + " |",
"|" + "---|" * len(encabezado)]
salida += ["| " + " | ".join(f) + " |" for f in cuerpo]
return "\n".join(salida)
def ingestar_pdf(ruta: str, area: str, permisos: list[str]) -> list[dict]:
"""Devuelve un dict por página con texto limpio + metadata completa.
El chunking (módulo 3) recibe esto y hereda la metadata en cada chunk."""
path = Path(ruta)
crudas, tablas_por_pagina = [], []
with pdfplumber.open(path) as pdf:
for pagina in pdf.pages:
crudas.append(pagina.extract_text() or "")
tablas_por_pagina.append(
[_tabla_a_markdown(t) for t in (pagina.extract_tables() or [])]
)
# ALERTA de escaneo: texto casi vacío en la mayoría de las páginas.
vacias = sum(1 for t in crudas if len(t.strip()) < 50)
if crudas and vacias / len(crudas) > 0.5:
raise ValueError(
f"{path.name}: {vacias}/{len(crudas)} páginas sin texto. "
"Probable PDF escaneado: hay que pasarlo por OCR."
)
repetidas = _detectar_repetidas(crudas)
doc_id = _hash("".join(crudas))
stat = path.stat()
documentos = []
for i, cruda in enumerate(crudas):
limpio = _limpiar(cruda, repetidas)
# Las tablas se anexan como Markdown al final de su página.
tablas = [t for t in tablas_por_pagina[i] if t]
if tablas:
limpio += "\n\n" + "\n\n".join(tablas)
if len(limpio) < 30: # página en blanco o solo con una imagen
continue
documentos.append({
"texto": limpio,
"metadata": {
"doc_id": doc_id,
"chunk_hash": _hash(limpio),
"fuente": str(path),
"titulo": path.stem.replace("_", " "),
"pagina": i + 1,
"total_paginas": len(crudas),
"tiene_tabla": bool(tablas),
"area": area,
"permisos": permisos, # crítico para el módulo 12
"fecha_doc": datetime.fromtimestamp(
stat.st_mtime, tz=timezone.utc
).date().isoformat(),
"ingestado_en": datetime.now(timezone.utc).isoformat(),
},
})
return documentos
if __name__ == "__main__":
docs = ingestar_pdf(
"manual_operaciones.pdf",
area="operaciones",
permisos=["rol:operaciones", "rol:admin"],
)
print(f"{len(docs)} páginas con contenido")
print(docs[0]["metadata"])
print(docs[0]["texto"][:300])
Idempotencia: no reindexar lo que no cambió
Idempotente significa que correr el pipeline dos veces con la misma entrada produce el mismo resultado, sin duplicar nada. Sin esto, cada ejecución mete copias del mismo documento en el índice y el top-k se llena de repeticiones.
La solución es el chunk_hash que ya vienes calculando. Antes de indexar, comparas el hash de cada pieza contra lo que hay guardado:
def sincronizar(nuevos: list[dict], hashes_actuales: dict[str, str]) -> dict:
"""Compara hashes para decidir qué indexar de verdad.
hashes_actuales: {chunk_hash: id_en_el_indice} de lo ya guardado."""
entrantes = {d["metadata"]["chunk_hash"]: d for d in nuevos}
a_insertar = [d for h, d in entrantes.items() if h not in hashes_actuales]
# Lo que estaba y ya no viene: el documento se editó o se borró.
a_borrar = [i for h, i in hashes_actuales.items() if h not in entrantes]
sin_cambios = len(entrantes) - len(a_insertar)
return {
"insertar": a_insertar,
"borrar": a_borrar,
"sin_cambios": sin_cambios,
}
En un corpus de 50.000 páginas donde cambian 20 al día, esto es la diferencia entre pagar embeddings de 50.000 páginas diarias y pagar 20. Con hash a nivel de página o de chunk (no del documento completo), editar un párrafo reindexa una pieza, no el manual entero.
Errores comunes
- Asumir que el texto salió bien porque no hubo excepción. Los parsers de PDF fallan en silencio: devuelven string vacío o basura sin error. Siempre valida caracteres por página y revisa 10 documentos a mano antes de indexar el corpus.
- Usar una sola herramienta para todo el corpus. pypdf en un financiero con tablas destruye el dato; LlamaParse en 100.000 páginas de texto simple destruye el presupuesto. Enruta por tipo de documento.
- Aplanar las tablas como texto corrido. Se pierde la relación fila-columna y con ella el significado. Conviértelas a Markdown, y si son grandes agrega una descripción en prosa que se indexe junto.
- Limpiar de más: minúsculas, sin puntuación, sin stopwords. Es una herencia de la búsqueda de los 2000 que degrada los embeddings modernos, entrenados con texto natural completo.
- No capturar la metadata en la ingesta. Fuente, página, fecha y permisos solo están disponibles en este momento. Agregarlos después significa reprocesar todo el corpus.
- Pipeline no idempotente. Sin hash de contenido, cada corrida duplica documentos en el índice y el top-k se llena del mismo texto repetido.
- Ignorar los headers y footers repetidos. “Confidencial — Página 12 de 47” en cada chunk agrega ruido, gasta tokens y compite en la búsqueda con el contenido real.
La frase para llevarse
“El parsing es la única etapa donde la información se pierde para siempre. Todo lo que viene después solo puede reordenar lo que el parser dejó pasar — nunca inventar lo que dejó afuera.”
Qué viene
Módulo 3: chunking, cortar los documentos normalizados que acabas de producir. Ahí aparece el trade-off central del curso: chunks chicos dan retrieval preciso pero contexto insuficiente para responder, chunks grandes dan contexto de sobra pero un embedding difuso lleno de ruido. Vamos a recorrer cinco estrategias de corte con código — de la ingenua por caracteres al chunking semántico —, qué es el overlap y cuánto conviene, tamaños recomendados por tipo de documento, y tres trucos que suben la calidad del retrieval sin cambiar ni el modelo ni una línea de la búsqueda.
Material relacionado
Preguntas sobre este curso
Regístrate gratis para hacer preguntas y guardar tu progreso.
✉ Entérate de lo nuevo
Recibe un aviso cuando publique nuevos cursos, workshops o investigaciones. Sin spam.