Módulo 6: RAG Multimodal

5. Documentos con Imágenes

Descripción

Los documentos del mundo real no son texto puro. Un manual técnico tiene diagramas de arquitectura. Un reporte financiero tiene gráficos de tendencia. Un paper académico tiene figuras con resultados experimentales. Un catálogo de productos tiene fotos. El RAG multimodal solo funciona si puedes procesar estos documentos preservando la relación entre texto e imágenes.

En esta cápsula vas a aprender estrategias para chunking de documentos que contienen figuras, cómo asociar cada imagen con el texto que la rodea, y cómo construir un pipeline completo que tome un PDF con contenido mixto y lo prepare para indexación.

Por qué importa: Si extraes texto e imágenes por separado sin preservar su relación, pierdes contexto crítico. Un chunk de texto que dice "como muestra la Figura 3" es inútil si no sabes qué es la Figura 3. Y una descripción de imagen que dice "gráfico de barras mostrando datos" es vaga si no incluyes el título y el texto que la explica.

Conexión con el módulo: Esta cápsula alimenta el pipeline de indexación de la cápsula 03. Los chunks que generas aquí se buscan con el retrieval de la cápsula 04. Y el proyecto de la cápsula 08 procesa documentos reales con esta lógica.


El Problema: Documentos Mixtos

Qué contiene un documento típico

Manual técnico (PDF, 50 páginas):
├── Texto corrido: explicaciones, descripciones, procedimientos
├── Diagramas: arquitectura, flujo, entidad-relación
├── Tablas: configuraciones, comparaciones, especificaciones
├── Capturas de pantalla: interfaz, logs, configuración
├── Figuras referenciadas: "ver Figura 3", "como muestra el Diagrama 2"
└── Headers/footers: títulos de sección, números de página

Qué pasa si ignoras las imágenes

Pregunta: "¿Cómo se conecta el servicio de pagos con la base de datos?"

Solo texto:
  → Recupera: "El servicio de pagos se conecta a PostgreSQL vía connection pool"
  → Respuesta: parcial, sin el diagrama que muestra el flujo completo

Texto + imágenes:
  → Recupera: párrafo + "Diagrama mostrando servicio de pagos conectado
     a PostgreSQL vía PgBouncer con pool de 20 conexiones"
  → Respuesta: completa, incluye detalles del diagrama

Qué pasa si no preservas la relación

Chunk de texto: "Como muestra la Figura 3, el flujo de autenticación
  pasa por tres etapas."

Chunk de imagen (sin relación): "Diagrama de flujo con tres cajas
  conectadas por flechas."

→ El retrieval puede encontrar el texto O la imagen, pero no sabe
  que van juntos. La respuesta pierde coherencia.

Estrategias de Chunking para Documentos Mixtos

Estrategia 1: Chunk por página

Cada página del PDF es un chunk. El chunk incluye todo el texto de la página más referencias a las imágenes de esa página.

import fitz
from pathlib import Path


def chunk_by_page(pdf_path: str) -> list[dict]:
    doc = fitz.open(pdf_path)
    chunks = []

    for page_num in range(len(doc)):
        page = doc[page_num]
        text = page.get_text().strip()
        images = page.get_images()

        if not text and not images:
            continue

        chunks.append({
            "text": text,
            "page": page_num + 1,
            "chunk_index": page_num,
            "has_images": len(images) > 0,
            "image_count": len(images),
            "strategy": "page",
        })

    doc.close()
    return chunks

Ventajas: Simple. Las imágenes de una página están naturalmente asociadas con el texto de esa página.

Desventajas: Páginas largas producen chunks grandes que diluyen la señal. Páginas con solo una imagen producen chunks vacíos de texto.

Estrategia 2: Chunk por sección con imágenes asociadas

Divide por secciones (headers) y asocia las imágenes de esas páginas con la sección correspondiente.

import re


def chunk_by_section(pdf_path: str, max_chunk_words: int = 500) -> list[dict]:
    doc = fitz.open(pdf_path)
    full_text = ""
    page_boundaries = {}
    current_pos = 0

    for page_num in range(len(doc)):
        page = doc[page_num]
        page_text = page.get_text()
        page_boundaries[page_num + 1] = (current_pos, current_pos + len(page_text))
        full_text += page_text
        current_pos += len(page_text)

    doc.close()

    sections = re.split(r'\n(?=[A-Z][^\n]{3,50}\n)', full_text)

    chunks = []
    for i, section in enumerate(sections):
        section = section.strip()
        if not section:
            continue

        section_page = 1
        section_start = full_text.find(section)
        for page, (start, end) in page_boundaries.items():
            if start <= section_start < end:
                section_page = page
                break

        words = section.split()
        if len(words) > max_chunk_words:
            sub_chunks = split_text_with_overlap(section, max_chunk_words, overlap=50)
            for j, sub in enumerate(sub_chunks):
                chunks.append({
                    "text": sub,
                    "page": section_page,
                    "chunk_index": len(chunks),
                    "strategy": "section",
                    "section_part": j,
                })
        else:
            chunks.append({
                "text": section,
                "page": section_page,
                "chunk_index": len(chunks),
                "strategy": "section",
            })

    return chunks


def split_text_with_overlap(
    text: str,
    max_words: int,
    overlap: int = 50
) -> list[str]:
    words = text.split()
    chunks = []
    start = 0

    while start < len(words):
        end = start + max_words
        chunk = " ".join(words[start:end])
        chunks.append(chunk)
        start += max_words - overlap

    return chunks

Estrategia 3: Chunk enriquecido con contexto de imagen

El chunk de texto incluye la descripción de las imágenes como parte del contenido. Así el embedding captura tanto el texto como el contenido visual.

from openai import OpenAI
import base64

client = OpenAI()


def describe_image_bytes(image_bytes: bytes, ext: str = "png") -> str:
    b64 = base64.b64encode(image_bytes).decode("utf-8")
    mime = f"image/{ext}" if ext != "jpg" else "image/jpeg"

    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{
            "role": "user",
            "content": [
                {
                    "type": "text",
                    "text": "Describe esta imagen en 1-2 oraciones. Incluye tipo de contenido y elementos principales."
                },
                {
                    "type": "image_url",
                    "image_url": {"url": f"data:{mime};base64,{b64}"}
                }
            ]
        }],
        max_tokens=100
    )
    return response.choices[0].message.content


def enrich_chunks_with_image_context(
    chunks: list[dict],
    images_by_page: dict[int, list[dict]]
) -> list[dict]:
    enriched = []

    for chunk in chunks:
        page = chunk.get("page", 0)
        page_images = images_by_page.get(page, [])

        if page_images:
            descriptions = []
            for img in page_images:
                desc = describe_image_bytes(img["data"], img.get("ext", "png"))
                descriptions.append(desc)

            image_context = "\n\n".join(
                [f"[Figura en página {page}: {d}]" for d in descriptions]
            )
            enriched_text = f"{chunk['text']}\n\n{image_context}"

            enriched.append({
                **chunk,
                "text": enriched_text,
                "has_images": True,
                "image_descriptions": descriptions,
            })
        else:
            enriched.append({
                **chunk,
                "has_images": False,
                "image_descriptions": [],
            })

    return enriched

Comparación de estrategias

EstrategiaMejor paraChunks típicosCalidad de relación
Por páginaPDFs cortos, documentos bien maquetados~1 por páginaMedia — toda la página va junta
Por secciónDocumentos con headers claros (markdown, HTML)VariableAlta — secciones temáticas
EnriquecidoCualquier documentoMismo que base + contextoAlta — texto + descripción de imagen

Extraer Imágenes de PDF

Extracción con PyMuPDF

def extract_images_by_page(pdf_path: str) -> dict[int, list[dict]]:
    doc = fitz.open(pdf_path)
    images_by_page = {}

    for page_num in range(len(doc)):
        page = doc[page_num]
        img_list = page.get_images()

        if not img_list:
            continue

        page_images = []
        for img_ref in img_list:
            xref = img_ref[0]
            try:
                base_image = doc.extract_image(xref)
                if base_image and base_image.get("image"):
                    page_images.append({
                        "data": base_image["image"],
                        "ext": base_image.get("ext", "png"),
                        "width": base_image.get("width", 0),
                        "height": base_image.get("height", 0),
                        "xref": xref,
                    })
            except Exception:
                continue

        if page_images:
            images_by_page[page_num + 1] = page_images

    doc.close()
    return images_by_page

Guardar imágenes extraídas

def save_extracted_images(
    images_by_page: dict[int, list[dict]],
    output_dir: str,
    pdf_name: str
) -> dict[int, list[dict]]:
    output = Path(output_dir)
    output.mkdir(parents=True, exist_ok=True)

    saved = {}
    for page, images in images_by_page.items():
        saved[page] = []
        for i, img in enumerate(images):
            filename = f"{pdf_name}_p{page}_img{i}.{img['ext']}"
            filepath = output / filename

            with open(filepath, "wb") as f:
                f.write(img["data"])

            saved[page].append({
                **img,
                "path": str(filepath),
                "filename": filename,
            })

    return saved

Filtrar imágenes irrelevantes

No todas las imágenes en un PDF son útiles. Logos, iconos pequeños, separadores y fondos son ruido.

MIN_IMAGE_WIDTH = 100
MIN_IMAGE_HEIGHT = 100
MIN_IMAGE_BYTES = 5000


def filter_relevant_images(
    images_by_page: dict[int, list[dict]]
) -> dict[int, list[dict]]:
    filtered = {}

    for page, images in images_by_page.items():
        relevant = []
        for img in images:
            width = img.get("width", 0)
            height = img.get("height", 0)
            size = len(img.get("data", b""))

            if width < MIN_IMAGE_WIDTH or height < MIN_IMAGE_HEIGHT:
                continue
            if size < MIN_IMAGE_BYTES:
                continue

            relevant.append(img)

        if relevant:
            filtered[page] = relevant

    return filtered

Pipeline Completo: PDF → Chunks con Contexto de Imagen

El pipeline integrado

def process_document_with_images(
    pdf_path: str,
    strategy: str = "enriched",
    chunk_size: int = 500,
    output_dir: str = "./extracted_images",
    describe_images: bool = True
) -> list[dict]:
    pdf_name = Path(pdf_path).stem

    if strategy == "page":
        chunks = chunk_by_page(pdf_path)
    elif strategy == "section":
        chunks = chunk_by_section(pdf_path, max_chunk_words=chunk_size)
    else:
        chunks = chunk_by_section(pdf_path, max_chunk_words=chunk_size)

    images_by_page = extract_images_by_page(pdf_path)
    images_by_page = filter_relevant_images(images_by_page)

    saved_images = save_extracted_images(images_by_page, output_dir, pdf_name)

    if strategy == "enriched" and describe_images:
        chunks = enrich_chunks_with_image_context(chunks, images_by_page)

    image_chunks = []
    for page, images in saved_images.items():
        for i, img in enumerate(images):
            if describe_images:
                desc = describe_image_bytes(img["data"], img.get("ext", "png"))
            else:
                desc = f"Imagen de página {page}"

            image_chunks.append({
                "text": desc,
                "page": page,
                "chunk_index": len(chunks) + len(image_chunks),
                "type": "image",
                "image_path": img.get("path", ""),
                "has_images": True,
            })

    for chunk in chunks:
        if "type" not in chunk:
            chunk["type"] = "text"

    all_chunks = chunks + image_chunks

    return all_chunks


# chunks = process_document_with_images("manual_tecnico.pdf")
# print(f"Total chunks: {len(chunks)}")
# text_chunks = [c for c in chunks if c["type"] == "text"]
# image_chunks = [c for c in chunks if c["type"] == "image"]
# print(f"  Texto: {len(text_chunks)}, Imágenes: {len(image_chunks)}")

Indexar el resultado

import chromadb
from chromadb.utils import embedding_functions
import os

openai_ef = embedding_functions.OpenAIEmbeddingFunction(
    api_key=os.getenv("OPENAI_API_KEY"),
    model_name="text-embedding-3-small"
)


def index_processed_document(
    chunks: list[dict],
    collection,
    source: str
) -> dict:
    stats = {"text": 0, "image": 0, "errors": 0}

    documents = []
    ids = []
    metadatas = []

    for chunk in chunks:
        chunk_type = chunk.get("type", "text")
        chunk_id = f"{source}_{chunk_type}_{chunk['chunk_index']}"

        documents.append(chunk["text"])
        ids.append(chunk_id)
        metadatas.append({
            "type": chunk_type,
            "source": source,
            "page": chunk.get("page", 0),
            "has_images": chunk.get("has_images", False),
            "image_path": chunk.get("image_path", ""),
        })

        stats[chunk_type] = stats.get(chunk_type, 0) + 1

    if documents:
        try:
            collection.add(
                documents=documents,
                ids=ids,
                metadatas=metadatas
            )
        except Exception as e:
            print(f"Error indexando: {e}")
            stats["errors"] += 1

    return stats

Manejar Referencias a Figuras

Detectar referencias en el texto

Muchos documentos técnicos usan "ver Figura X", "como muestra el Diagrama Y", etc. Detectar estas referencias permite asociar chunks de texto con las imágenes correctas.

import re


def find_figure_references(text: str) -> list[dict]:
    patterns = [
        r'(?:ver|see|véase)\s+(?:la\s+)?(?:Figura|Figure|Fig\.?)\s*(\d+)',
        r'(?:Figura|Figure|Fig\.?)\s*(\d+)',
        r'(?:Diagrama|Diagram)\s*(\d+)',
        r'(?:Tabla|Table)\s*(\d+)',
        r'(?:Gráfico|Graph|Chart)\s*(\d+)',
    ]

    references = []
    for pattern in patterns:
        for match in re.finditer(pattern, text, re.IGNORECASE):
            references.append({
                "type": "figure",
                "number": int(match.group(1)),
                "span": match.span(),
                "context": text[max(0, match.start()-50):match.end()+50],
            })

    return references


sample = "Como muestra la Figura 3, el servicio de pagos se conecta vía Diagrama 1 al gateway."
refs = find_figure_references(sample)
for r in refs:
    print(f"  Referencia: {r['type']} #{r['number']} → ...{r['context']}...")

Resolver referencias: asociar texto con imagen

def resolve_figure_references(
    text_chunks: list[dict],
    image_chunks: list[dict],
    images_by_page: dict
) -> list[dict]:
    figure_number_to_image = {}
    sorted_images = []
    for page in sorted(images_by_page.keys()):
        for img in images_by_page[page]:
            sorted_images.append({"page": page, **img})

    for i, img in enumerate(sorted_images):
        figure_number_to_image[i + 1] = img

    resolved_chunks = []
    for chunk in text_chunks:
        refs = find_figure_references(chunk["text"])

        associated_images = []
        for ref in refs:
            fig_num = ref["number"]
            if fig_num in figure_number_to_image:
                associated_images.append(figure_number_to_image[fig_num])

        resolved_chunks.append({
            **chunk,
            "figure_references": refs,
            "associated_images": associated_images,
        })

    return resolved_chunks

Enriquecer chunks con figuras resueltas

def enrich_with_resolved_figures(
    resolved_chunks: list[dict]
) -> list[dict]:
    enriched = []

    for chunk in resolved_chunks:
        if chunk["associated_images"]:
            image_descriptions = []
            for img in chunk["associated_images"]:
                desc = describe_image_bytes(img["data"], img.get("ext", "png"))
                image_descriptions.append(desc)

            fig_context = "\n".join(
                [f"[Figura referenciada: {d}]" for d in image_descriptions]
            )
            enriched_text = f"{chunk['text']}\n\n{fig_context}"

            enriched.append({
                **chunk,
                "text": enriched_text,
                "image_descriptions": image_descriptions,
            })
        else:
            enriched.append(chunk)

    return enriched

Manejar Tablas en Documentos

Extraer tablas con PyMuPDF

def extract_tables_from_page(page) -> list[dict]:
    tables = page.find_tables()
    extracted = []

    for table in tables:
        rows = table.extract()
        if not rows:
            continue

        headers = rows[0] if rows else []
        data_rows = rows[1:] if len(rows) > 1 else []

        markdown = format_table_as_markdown(headers, data_rows)

        extracted.append({
            "markdown": markdown,
            "headers": headers,
            "rows": data_rows,
            "row_count": len(data_rows),
        })

    return extracted


def format_table_as_markdown(
    headers: list,
    rows: list[list]
) -> str:
    if not headers:
        return ""

    clean_headers = [str(h).strip() if h else "" for h in headers]
    header_line = "| " + " | ".join(clean_headers) + " |"
    separator = "| " + " | ".join(["---"] * len(clean_headers)) + " |"

    lines = [header_line, separator]
    for row in rows:
        clean_cells = [str(c).strip() if c else "" for c in row]
        while len(clean_cells) < len(clean_headers):
            clean_cells.append("")
        lines.append("| " + " | ".join(clean_cells[:len(clean_headers)]) + " |")

    return "\n".join(lines)

Incorporar tablas al chunking

def extract_tables_by_page(pdf_path: str) -> dict[int, list[dict]]:
    doc = fitz.open(pdf_path)
    tables_by_page = {}

    for page_num in range(len(doc)):
        page = doc[page_num]
        tables = extract_tables_from_page(page)

        if tables:
            tables_by_page[page_num + 1] = tables

    doc.close()
    return tables_by_page


def enrich_chunks_with_tables(
    chunks: list[dict],
    tables_by_page: dict[int, list[dict]]
) -> list[dict]:
    enriched = []

    for chunk in chunks:
        page = chunk.get("page", 0)
        page_tables = tables_by_page.get(page, [])

        if page_tables:
            table_context = "\n\n".join(
                [f"[Tabla en página {page}:\n{t['markdown']}]" for t in page_tables]
            )
            enriched_text = f"{chunk['text']}\n\n{table_context}"
            enriched.append({**chunk, "text": enriched_text, "has_tables": True})
        else:
            enriched.append({**chunk, "has_tables": False})

    return enriched

Pipeline Avanzado: Texto + Imágenes + Tablas

def process_document_full(
    pdf_path: str,
    chunk_size: int = 500,
    output_dir: str = "./extracted_images",
    describe_images: bool = True
) -> dict:
    pdf_name = Path(pdf_path).stem

    text_chunks = chunk_by_section(pdf_path, max_chunk_words=chunk_size)

    images_by_page = extract_images_by_page(pdf_path)
    images_by_page = filter_relevant_images(images_by_page)
    saved_images = save_extracted_images(images_by_page, output_dir, pdf_name)

    tables_by_page = extract_tables_by_page(pdf_path)

    if describe_images:
        text_chunks = enrich_chunks_with_image_context(text_chunks, images_by_page)

    text_chunks = enrich_chunks_with_tables(text_chunks, tables_by_page)

    image_only_chunks = []
    for page, images in saved_images.items():
        for i, img in enumerate(images):
            desc = describe_image_bytes(img["data"], img.get("ext", "png")) if describe_images else f"Imagen p.{page}"
            image_only_chunks.append({
                "text": desc,
                "page": page,
                "chunk_index": len(text_chunks) + len(image_only_chunks),
                "type": "image",
                "image_path": img.get("path", ""),
            })

    for c in text_chunks:
        c["type"] = "text"

    all_chunks = text_chunks + image_only_chunks

    stats = {
        "total_chunks": len(all_chunks),
        "text_chunks": len(text_chunks),
        "image_chunks": len(image_only_chunks),
        "pages_with_images": len(images_by_page),
        "pages_with_tables": len(tables_by_page),
        "total_images": sum(len(imgs) for imgs in images_by_page.values()),
        "total_tables": sum(len(tbls) for tbls in tables_by_page.values()),
    }

    return {"chunks": all_chunks, "stats": stats}

Troubleshooting

PyMuPDF no extrae imágenes de ciertos PDFs

Algunos PDFs tienen imágenes embebidas como vectores (SVG) o como parte del renderizado de página, no como imágenes raster. PyMuPDF solo extrae raster.

def extract_page_as_image(
    pdf_path: str,
    page_num: int,
    dpi: int = 150
) -> bytes:
    doc = fitz.open(pdf_path)
    page = doc[page_num]
    mat = fitz.Matrix(dpi / 72, dpi / 72)
    pix = page.get_pixmap(matrix=mat)
    image_bytes = pix.tobytes("png")
    doc.close()
    return image_bytes

Chunks demasiado largos por tablas grandes

Si una tabla tiene 100 filas, el chunk resultante es enorme.

MAX_TABLE_ROWS = 20

def truncate_table(table: dict, max_rows: int = MAX_TABLE_ROWS) -> dict:
    if table["row_count"] <= max_rows:
        return table

    truncated_rows = table["rows"][:max_rows]
    markdown = format_table_as_markdown(table["headers"], truncated_rows)
    markdown += f"\n... ({table['row_count'] - max_rows} filas más)"

    return {**table, "markdown": markdown, "rows": truncated_rows}

Descripciones de imagen duplicadas

Si la misma imagen aparece en múltiples páginas (logos, headers), se describe múltiples veces.

import hashlib


def deduplicate_images(
    images_by_page: dict[int, list[dict]]
) -> dict[int, list[dict]]:
    seen_hashes = set()
    deduped = {}

    for page, images in images_by_page.items():
        unique = []
        for img in images:
            img_hash = hashlib.md5(img["data"]).hexdigest()
            if img_hash not in seen_hashes:
                seen_hashes.add(img_hash)
                unique.append(img)
        if unique:
            deduped[page] = unique

    return deduped

Referencias a figuras que no existen

El texto dice "ver Figura 5" pero solo hay 3 imágenes en el documento.

Solución: Log la referencia no resuelta y continúa sin enriquecer ese chunk.
No falles el pipeline completo por una referencia rota.

Ejercicios

Ejercicio 1: Asociar imagen con chunk de texto por proximidad

Dado un chunk de texto y una lista de imágenes por página, retorna las imágenes de la misma página o de páginas adyacentes (± 1).

Ver solución
def get_nearby_images(
    chunk: dict,
    images_by_page: dict[int, list[dict]],
    range_pages: int = 1
) -> list[dict]:
    page = chunk.get("page", 0)
    nearby = []

    for p in range(page - range_pages, page + range_pages + 1):
        if p in images_by_page:
            for img in images_by_page[p]:
                nearby.append({**img, "from_page": p})

    return nearby


chunk = {"text": "El flujo de autenticación...", "page": 5}
images_by_page = {
    4: [{"data": b"...", "ext": "png"}],
    5: [{"data": b"...", "ext": "png"}, {"data": b"...", "ext": "jpg"}],
    7: [{"data": b"...", "ext": "png"}],
}

nearby = get_nearby_images(chunk, images_by_page, range_pages=1)
print(f"Imágenes cercanas al chunk p.{chunk['page']}: {len(nearby)}")
for img in nearby:
    print(f"  - Página {img['from_page']}, formato {img['ext']}")

Ejercicio 2: Pipeline de procesamiento con estadísticas

Implementa un pipeline que procese un PDF y devuelva estadísticas detalladas: chunks por tipo, páginas con imágenes, tamaño promedio de chunk, etc.

Ver solución
def process_with_stats(pdf_path: str) -> dict:
    result = process_document_full(pdf_path, describe_images=False)
    chunks = result["chunks"]

    text_chunks = [c for c in chunks if c.get("type") == "text"]
    image_chunks = [c for c in chunks if c.get("type") == "image"]

    text_lengths = [len(c["text"].split()) for c in text_chunks]
    avg_text_length = sum(text_lengths) / len(text_lengths) if text_lengths else 0

    pages_with_images = set()
    for c in chunks:
        if c.get("has_images") or c.get("type") == "image":
            pages_with_images.add(c.get("page", 0))

    stats = {
        "file": pdf_path,
        "total_chunks": len(chunks),
        "text_chunks": len(text_chunks),
        "image_chunks": len(image_chunks),
        "avg_text_words": round(avg_text_length),
        "min_text_words": min(text_lengths) if text_lengths else 0,
        "max_text_words": max(text_lengths) if text_lengths else 0,
        "pages_with_images": len(pages_with_images),
        "enriched_text_chunks": sum(1 for c in text_chunks if c.get("has_images")),
    }

    return stats

Ejercicio 3: Comparar estrategias de chunking

Procesa el mismo PDF con las tres estrategias (page, section, enriched) y compara el número de chunks, tamaño promedio, y cobertura de imágenes.

Ver solución
def compare_chunking_strategies(pdf_path: str) -> dict:
    strategies = {}

    for strategy_name in ["page", "section", "enriched"]:
        if strategy_name in ("page", "section"):
            if strategy_name == "page":
                chunks = chunk_by_page(pdf_path)
            else:
                chunks = chunk_by_section(pdf_path)
            for c in chunks:
                c["type"] = "text"
        else:
            result = process_document_full(pdf_path, describe_images=False)
            chunks = result["chunks"]

        text_chunks = [c for c in chunks if c.get("type") == "text"]
        word_counts = [len(c["text"].split()) for c in text_chunks]

        strategies[strategy_name] = {
            "total_chunks": len(chunks),
            "text_chunks": len(text_chunks),
            "avg_words": round(sum(word_counts) / len(word_counts)) if word_counts else 0,
            "with_image_context": sum(1 for c in text_chunks if c.get("has_images")),
        }

    print(f"Comparación para: {pdf_path}\n")
    print(f"{'Estrategia':<15} {'Chunks':<10} {'Texto':<10} {'Avg Words':<12} {'Con Img':<10}")
    print("-" * 57)
    for name, stats in strategies.items():
        print(f"{name:<15} {stats['total_chunks']:<10} {stats['text_chunks']:<10} "
              f"{stats['avg_words']:<12} {stats['with_image_context']:<10}")

    return strategies

Resumen

  • Los documentos reales mezclan texto, imágenes, tablas y referencias cruzadas.
  • Tres estrategias de chunking: por página (simple), por sección (semántica), enriquecido (texto + descripción de imagen).
  • Extraer imágenes con PyMuPDF y filtrar las irrelevantes (logos, iconos pequeños).
  • Asociar imágenes con su texto circundante preserva el contexto que el RAG necesita.
  • Detectar referencias ("ver Figura 3") y resolverlas contra las imágenes extraídas.
  • Las tablas se extraen y convierten a markdown para incluir en los chunks.
  • Deduplicar imágenes repetidas (logos en cada página) reduce ruido y costo.
  • El pipeline completo: PDF → extraer texto + imágenes + tablas → chunking → enriquecer → indexar.

Recursos Adicionales

  1. PyMuPDF Documentation — Extracción de texto, imágenes y tablas
  2. Unstructured.io — Librería especializada en procesamiento de documentos
  3. Document Chunking Strategies — Guía de estrategias de chunking
  4. LangChain Document Loaders — Loaders para diferentes formatos