volver al blog

Recuperación visual de documentos: cuando dejas de extraer texto del PDF

ragmultimodalembeddings

El fallo casi nunca está donde lo buscas

Una consulta sobre una tabla de precios devuelve el párrafo de al lado. Cambias el modelo de embeddings. Sigue fallando. Metes un reranker. Mejora un poco. Ajustas el tamaño de los chunks. Nada.

El problema estaba tres pasos antes: el parser convirtió una tabla de siete columnas en una tira de números sin cabecera. Ningún componente posterior puede recuperar lo que ya no está en el texto.

La recuperación visual invierte el planteamiento. En vez de arrancarle texto al PDF, renderiza cada página como imagen y la indexa tal cual. El documento nunca se aplana.

Qué hace exactamente

El enfoque se popularizó con ColPali, en 2024. La idea encaja en tres piezas:

  1. La página entra como imagen. Un modelo de visión y lenguaje —PaliGemma en la versión original, Qwen2-VL en las siguientes— la trocea en parches, unos 1024 para una imagen de 448 × 448.
  2. Cada parche genera su propio vector. No hay un embedding por página, hay un embedding por parche, proyectado a 128 dimensiones.
  3. La comparación es late interaction. La consulta también se representa como varios vectores, uno por token. Para puntuar, cada token de la consulta busca su parche más parecido y se suman esos máximos.

Ese último punto es el mismo mecanismo MaxSim que ya vimos en ColBERT y la interacción tardía, aplicado a píxeles en lugar de a palabras. La consecuencia práctica es que “IVA reducido” puede encajar con la celda concreta de una tabla, y no con el promedio de una página entera.

Nada de esto requiere OCR, detección de layout ni reconstrucción de tablas. La mitad frágil del pipeline desaparece.

Cómo se ve en código

La librería colpali-engine deja el flujo bastante limpio:

import torch
from pdf2image import convert_from_path
from colpali_engine.models import ColQwen2, ColQwen2Processor

modelo = ColQwen2.from_pretrained(
    "vidore/colqwen2-v1.0",
    torch_dtype=torch.bfloat16,
    device_map="cuda:0",
).eval()
processor = ColQwen2Processor.from_pretrained("vidore/colqwen2-v1.0")

# 1. Indexación: la página se renderiza a imagen y ya. Sin OCR, sin parser.
#    150 DPI suele bastar; subir a 300 multiplica el coste sin ganar recall.
paginas = convert_from_path("tarifas_2026.pdf", dpi=150)

with torch.no_grad():
    lote = processor.process_images(paginas).to(modelo.device)
    # Forma resultante: (n_paginas, n_parches, 128).
    # Ojo: es un vector POR PARCHE, no uno por página. Aquí nace el coste.
    emb_paginas = modelo(**lote)

# 2. Consulta: mismo modelo, otra rama del procesador.
with torch.no_grad():
    q = processor.process_queries(["¿Qué IVA se aplica al tramo industrial?"])
    emb_query = modelo(**q.to(modelo.device))

# 3. Puntuación MaxSim: para cada token de la consulta, el parche que mejor
#    encaja; después se suman esos máximos. Un token puede "aterrizar" en una
#    celda concreta de la tabla en lugar de diluirse en la página.
puntuaciones = processor.score_multi_vector(emb_query, emb_paginas)
mejor = puntuaciones[0].argmax().item()
print(f"Página {mejor + 1}")

La página ganadora se pasa después al modelo generador como imagen. El texto de la respuesta lo produce el modelo mirando el documento, igual que haría una persona.

La comparación honesta

Pipeline OCR + chunkingRecuperación visual
PreparaciónParser, detección de layout, reconstrucción de tablasRenderizar a imagen
Tablas y gráficosSe degradan o se pierdenSe conservan
Índice por página~2 KB (un vector de 1024 dims)~250 KB (1024 vectores de 128 dims)
Latencia de indexadoAlta en CPU, paralelizable baratoRequiere GPU
DepuraciónLees el chunk recuperadoMiras la página recuperada
Filtrado por metadatosTrivialHay que mantenerlos aparte
Madurez del ecosistemaMuy altaEn construcción

La fila del índice es la que decide la mayoría de los proyectos. Con 100 000 páginas hablamos de unos 25 GB de vectores frente a 200 MB. No es un matiz: es la diferencia entre un índice que cabe en memoria y uno que no.

Cómo se domestica el coste

Tres palancas, de menos a más invasiva:

  • Pooling de parches. Agrupar parches contiguos por filas reduce el índice entre tres y cuatro veces con una pérdida de precisión pequeña. Es lo primero que hay que probar.
  • Cuantización binaria. Los vectores de interacción tardía toleran bien 1 bit por dimensión cuando se reordena después con los vectores completos. El índice cae otro orden de magnitud.
  • Dos etapas. Recuperar 100 candidatos con un embedding denso barato y reordenar solo esos con ColPali. Pagas la calidad visual únicamente donde importa, y el índice pesado puede vivir en disco.

La tercera es la que mejor funciona en producción, porque además te deja un sistema con el que ya sabes operar.

Cuándo compensa

Compensa cuando el valor está en la forma del documento: catálogos con fichas técnicas, informes con gráficos, facturas y albaranes, planos con leyendas, formularios escaneados, presentaciones. También cuando el corpus mezcla decenas de plantillas distintas y mantener reglas de extracción para cada una se ha convertido en un trabajo a tiempo parcial.

No compensa con documentación en texto plano bien estructurado, donde un pipeline clásico da el mismo recall por una fracción del coste. Tampoco cuando necesitas filtrar mucho por metadatos, citar a nivel de párrafo o responder sin GPU en el camino.

Y hay un detalle operativo que se pasa por alto: si tu control de acceso es por fragmento, aquí la unidad recuperable es la página entera. Conviene revisarlo antes, no después.

Por dónde empezar

No migres nada todavía. Coge las 50 consultas que hoy fallan, marca a mano la página correcta de cada una y mide recall@5 con tu pipeline actual y con ColQwen2 sobre ese mismo conjunto. Una tarde de trabajo.

Si la diferencia es de unos pocos puntos, tu problema es de chunking y ya sabes dónde mirar. Si es de veinte, tienes un caso claro para el enfoque visual, y entonces la conversación pasa a ser sobre almacenamiento, que es un problema mucho más agradable de resolver que la extracción de tablas.