💡 Ejemplo completo funcionando disponible en GitHub: spring-ai-document-reader-using-groupdocs-parser-java

El método antiguo: Adjuntos que nunca llegan al índice

Un Spring AI DocumentReader es la primera etapa del pipeline ETL de Spring AI: convierte un archivo en los objetos Document que un splitter y un vector store consumen, de modo que lo que descarta se pierde para siempre. Imagina el asistente que tu equipo de soporte pidió. Exportas el buzón compartido, indexas los cuerpos de los mensajes de los archivos .eml, divides, incrustas y envías. Llega la primera pregunta real, y la respuesta cita un correo cuyo cuerpo completo es “Please see the attached refund policy.” La política en sí, un adjunto PDF, nunca se leyó.

Nada aguas abajo puede reparar eso. TokenTextSplitter fragmenta la única oración que recibió, y el vector store lo devuelve como la coincidencia más cercana a “what is our refund window?”. Cada componente hizo su trabajo. La brecha está al inicio del pipeline, en la decisión de qué cuenta como documento. Una exportación de buzón es un árbol: mensajes, adjuntos y, a veces, archivos dentro de los adjuntos. Un lector que solo ve el nivel superior indexa el papel de envolver.

Mi propia primera versión hacía exactamente eso. Lo arreglé de la manera obvia: descomprimir los paquetes de lanzamiento en un helper, extraer los adjuntos de los mensajes en otro, pasar página por página los PDFs con un tercero, e inventar nombres de metadatos en cada uno. Funcionó, hasta que llegó el cuarto formato y los helpers no coincidían en cómo llamar a una página.

El costo real: en el proyecto de ejemplo, 2 de 4 archivos de entrada, el correo y el ZIP, contienen texto que una lectura solo del cuerpo nunca indexa. En un corpus de soporte esa proporción decide si el asistente es útil.

Un método mejor: Un lector para cada entrada

GroupDocs.Parser para Java lee 65+ formatos de archivo en proceso. Detecta el formato a partir del contenido del flujo, devuelve texto por página y enumera los adjuntos de un correo o las entradas de un ZIP mediante una única API de contenedor. Envuelve esas llamadas en la interfaz DocumentReader de Spring AI y el resto del pipeline no puede notar la diferencia, salvo que los Documents ahora llevan números de página y linaje.

Los lectores propios de Spring AI siguen siendo buenas opciones para sus casos: la referencia del pipeline ETL documenta TikaDocumentReader sobre Apache Tika y PagePdfDocumentReader sobre Apache PDFBox. El punto aquí es más estrecho. Cuando los adjuntos y los miembros de archivos comprimidos deben ser fuentes de primera clase, un lector que recorre contenedores te ahorra el zoológico de helpers.

Antes de comenzar, necesitarás:

  • Java 17 o posterior, la base para Spring AI 2.x
  • GroupDocs.Parser para Java 26.10 (obtener una licencia temporal) y org.springframework.ai:spring-ai-commons 2.0.2
  • Un correo exportado con un adjunto, o los cuatro archivos de ejemplo del repositorio

Clona el ejemplo y ejecútalo con un solo comando:

mvn compile exec:java

¿Qué necesita devolver un Spring AI DocumentReader para las citas RAG?

Necesita una List<Document> donde cada entrada sea lo suficientemente pequeña para citar y lo suficientemente etiquetada para rastrear. En la práctica eso significa un Document por página con un page_number, un source que nombre el archivo y, para cualquier cosa que haya llegado dentro de un correo o archivo comprimido, un parent_source que nombre el contenedor. TokenTextSplitter copia estos metadatos a cada fragmento, de modo que la cita sobreviva hasta el vector store.

El método nuevo: Adjuntos como documentos de primera clase

Aquí está el mismo trabajo con una única clase, GroupDocsParserDocumentReader. Tres pasos lo cubren: abrir el recurso, leer páginas y luego seguir los contenedores y pasar todo al splitter.

Paso 1 - Abrir cualquier recurso como flujo

El lector acepta un Resource de Spring, así que un archivo, una entrada del classpath y una carga pueden tomar el mismo camino. El Parser lee bytes, lo que significa que un blob sin extensión aún se analiza.

// Format detection works on content, so the file name is only a label
try (InputStream stream = resource.getInputStream();
     Parser parser = new Parser(stream)) {
    List<Document> documents = new ArrayList<>(readBody(parser, source, null));
    if (includeAttachments) {
        documents.addAll(readAttachments(parser, source)); // the fix for "see attached"
    }
    return documents;
} catch (UnsupportedDocumentFormatException ex) {
    // Log the unreadable file and return an empty list; the batch keeps going
    System.out.println("skipped (unsupported format): " + source);
    return new ArrayList<>();
}

Paso 2 - Leer página por página

El modo página es el predeterminado. Lo dejaría activado para cualquier cosa más larga que una o dos páginas, ya que un revisor que verifica una respuesta quiere saltar directamente a la página 31.

if (pagePerDocument && pageCount > 0 && parser.getFeatures().isTextPage()) {
    for (int page = 0; page < pageCount; page++) {
        String text = readText(parser.getText(page));
        Map<String, Object> metadata =
                metadata(source, parent, fileType, pageCount);
        metadata.put(METADATA_PAGE, page + 1); // people count pages from 1
        addIfNotBlank(documents, text, metadata); // blank pages never get embedded
    }
}

Puntos clave:

  • pagePerDocument: true asigna a cada fragmento un page_number; el constructor de tres argumentos lo desactiva para notas cortas.
  • isTextPage(): protege formatos sin páginas; para esos el lector llama a getText() una vez y aún registra source, file_type y page_count.

Paso 3 - Seguir los contenedores, luego dividir

Cada adjunto o miembro de archivo comprimido obtiene su propio Parser y vuelve a pasar por la misma lógica de página, etiquetado con su ruta dentro del padre.

Iterable<ContainerItem> items = parser.getContainer(); // null for plain files
if (items == null) {
    return documents;
}
for (ContainerItem item : items) {
    String childSource = source + "/" + item.getFilePath(); // "mail.eml/policy.pdf"
    try (Parser child = item.openParser()) {
        documents.addAll(readBody(child, childSource, source)); // parent_source = source
    } catch (UnsupportedDocumentFormatException ex) {
        System.out.println("skipped (unsupported format): " + childSource);
    }
}

La etapa splitter es Spring AI estándar, y mantiene los metadatos del lector en cada fragmento:

TokenTextSplitter splitter = TokenTextSplitter.builder()
        .withChunkSize(300) // tokens per chunk in the sample run
        .build();
List<Document> chunks = splitter.apply(documents);

En los 4 archivos de ejemplo el lector devuelve 12 Documents y el splitter genera 20 fragmentos. La demo los escribe en output/spring-ai-documents.jsonl en lugar de vectorStore.add(chunks), de modo que puedes leer source, parent_source, page_number y el chunk_index del splitter en cada línea antes de crear un índice real.

Ese archivo vale cinco minutos de tu tiempo. Búscalo por parent_source y verás exactamente qué fragmentos provienen del interior del correo y del ZIP; búscalo por page_number y verás dónde cayó cada límite de página. Cuando lo ejecuté por primera vez contra una exportación real de buzón, esas dos búsquedas respondieron más preguntas sobre la calidad de recuperación que un día de ajustes de prompts.

Comparación lado a lado: Antes vs. Después

Aquí está lo que cambió entre mi versión con helper por formato y el lector único:

Antes (lectura solo del cuerpo más ayudantes por formato) Después (GroupDocs.Parser DocumentReader)
Adjuntos de correo electrónico perdidos, o extraídos por un ayudante separado leídos como Documents con parent_source
Miembros ZIP descomprimidos a disco primero leídos en el lugar, source = sample.zip/sample.pdf
Citas solo nombre de archivo nombre de archivo más page_number
Código a mantener un ayudante por formato una clase DocumentReader
Archivo ilegible depende del ayudante registrado, omitido, el lote continúa

El pipeline después del lector no cambió en absoluto; solo lo que fluye dentro de él cambió.

Ejemplo del mundo real: Un índice de soporte nocturno

Un equipo de soporte exporta su buzón compartido cada noche y recibe un ZIP con manuales actualizados en cada lanzamiento de producto. Un trabajo programado en su servicio Spring Boot lista los archivos nuevos, los lee con GroupDocsParserDocumentReader, los divide y los escribe en el vector store detrás del asistente agente. Cuando un agente pregunta sobre reembolsos, la respuesta ahora cita manuals-2026-10.zip/refunds.pdf, página 4, y un filtro en parent_source puede restringir la búsqueda al último paquete de lanzamiento.

El código de los tres pasos se mapea a ese trabajo uno a uno. El Paso 1 acepta lo que produzca la exportación nocturna, incluidos los archivos cuya extensión se perdió en el camino. El Paso 2 mantiene las páginas del manual separadas, de modo que el agente pueda abrir la página 4 antes de responder al cliente. El Paso 3 hace que el ZIP y el buzón sean padres visibles en lugar de blobs opacos, y la llamada al splitter permanece sin cambios respecto a cualquier otro pipeline de Spring AI que el equipo ya ejecuta.

Las notas internas cortas no necesitan dividir por página, así que el equipo las lee en modo de archivo completo y mantiene los adjuntos activados:

// One Document per note; attachments still become their own Documents
DocumentReader reader = new GroupDocsParserDocumentReader(resource, false, true);
List<Document> documents = reader.read();

¿Qué más puedes hacer con GroupDocs.Parser?

Ahora que los adjuntos fluyen a Spring AI, algunas capacidades relacionadas están a una llamada de distancia:

Conclusión

La solución para “see attached” resultó estar en la etapa del lector. Con GroupDocs.Parser detrás del DocumentReader de Spring AI, los adjuntos, los miembros de archivos comprimidos y las páginas individuales se convierten en fuentes que el asistente puede citar, y sustituirlo es tan simple como un constructor.

¿Listo para probarlo en tu propio corpus?

Recursos adicionales