💡 Esempio completo funzionante disponibile su GitHub: spring-ai-document-reader-using-groupdocs-parser-java

Il vecchio metodo: allegati che non raggiungono mai l’indice

Un Spring AI DocumentReader è la prima fase della pipeline ETL di Spring AI: trasforma un file negli oggetti Document che uno splitter e un vector store consumano, quindi tutto ciò che elimina è perso per sempre. Immagina l’assistente che il tuo team di supporto ha richiesto. Esporti la casella di posta condivisa, indicizzi i corpi dei messaggi dei file .eml, dividi, incorpori e spedisci. Arriva la prima domanda reale, e la risposta cita un’email il cui intero corpo è “Please see the attached refund policy.” La politica stessa, un allegato PDF, non è mai stata letta.

Niente a valle può riparare questo. TokenTextSplitter suddivide l’unica frase che gli è stata data, e il vector store la restituisce come la corrispondenza più vicina a “what is our refund window?”. Ogni componente ha svolto il proprio compito. Il divario si trova all’inizio della pipeline, nella decisione su cosa conta come documento. Un’esportazione della casella di posta è un albero: messaggi, allegati e talvolta archivi dentro gli allegati. Un lettore che vede solo il livello superiore indicizza la carta di imballaggio.

La mia prima versione ha fatto esattamente questo. L’ho risolta nel modo ovvio: decomprimere i bundle di rilascio in un helper, estrarre gli allegati dai messaggi in un altro, scorrere le pagine dei PDF con un terzo, e inventare nomi di metadati in ciascuno. Ha funzionato, finché è arrivato il quarto formato e gli helper non sono più d’accordo su come chiamare una pagina.

Il costo reale: nel progetto di esempio, 2 dei 4 file di input, l’email e lo ZIP, contengono testo che una lettura solo corpo non indicizza mai. In un corpus di supporto quel rapporto decide se l’assistente è utile.

Un modo migliore: un lettore per ogni input

GroupDocs.Parser per Java legge 65+ formati di file in-process. Rileva il formato dal contenuto di uno stream, restituisce il testo per pagina e elenca gli allegati di un’email o le voci di uno ZIP tramite una singola API di contenitore. Avvolgi queste chiamate nell’interfaccia DocumentReader di Spring AI e il resto della pipeline non può distinguere la differenza, tranne per il fatto che i Document ora portano numeri di pagina e lineage.

I lettori nativi di Spring AI rimangono buone scelte per i loro casi: il riferimento della pipeline ETL documenta TikaDocumentReader su Apache Tika e PagePdfDocumentReader su Apache PDFBox. Il punto qui è più ristretto. Quando gli allegati e i membri di archivio devono essere fonti di prima classe, un lettore che attraversa i contenitori ti salva lo zoo di helper.

Prima di iniziare, avrai bisogno di:

  • Java 17 o successivo, la baseline per Spring AI 2.x
  • GroupDocs.Parser per Java 26.10 (ottieni una licenza temporanea) e org.springframework.ai:spring-ai-commons 2.0.2
  • Un’email esportata con un allegato, o i quattro file di esempio dal repository

Clona l’esempio e eseguilo con un solo comando:

mvn compile exec:java

Cosa deve restituire un Spring AI DocumentReader per le citazioni RAG?

Serve una List<Document> dove ogni voce è sufficientemente piccola da citare e sufficientemente etichettata da tracciare. In pratica ciò significa un Document per pagina con un page_number, un source che nomina il file e, per qualsiasi cosa arrivata dentro un’email o un archivio, un parent_source che nomina il contenitore. TokenTextSplitter copia questi metadati su ogni chunk, così la citazione sopravvive fino al vector store.

Il nuovo modo: allegati come documenti di prima classe

Ecco lo stesso lavoro con una singola classe, GroupDocsParserDocumentReader. Tre passaggi lo coprono: apri la risorsa, leggi le pagine, poi segui i contenitori e passa tutto allo splitter.

Step 1 - Apri qualsiasi risorsa come stream

Il lettore accetta una Spring Resource, quindi un file, una voce di classpath e un upload seguono tutti lo stesso percorso. Il Parser legge i byte, il che significa che un blob senza estensione viene comunque analizzato.

// Il rilevamento del formato funziona sul contenuto, quindi il nome file è solo un'etichetta
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)); // la correzione per "vedi allegato"
    }
    return documents;
} catch (UnsupportedDocumentFormatException ex) {
    // Registra il file illeggibile e restituisce una lista vuota; il batch continua
    System.out.println("skipped (unsupported format): " + source);
    return new ArrayList<>();
}

Step 2 - Leggi pagina per pagina

La modalità pagina è quella predefinita. La lascerei attiva per qualsiasi cosa più lunga di una o due pagine, poiché un revisore che controlla una risposta vuole saltare direttamente alla pagina 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); // le persone contano le pagine a partire da 1
        addIfNotBlank(documents, text, metadata); // le pagine vuote non vengono mai incorporate
    }
}

Punti chiave:

  • pagePerDocument: true assegna a ogni chunk un page_number; il costruttore a tre argomenti lo disattiva per note brevi.
  • isTextPage(): protegge i formati senza pagine; per questi il lettore chiama getText() una volta e registra comunque source, file_type e page_count.

Step 3 - Segui i contenitori, poi dividi

Ogni allegato o membro di archivio ottiene il proprio Parser e ritorna attraverso la stessa logica di pagina, etichettato con il suo percorso all’interno del genitore.

Iterable<ContainerItem> items = parser.getContainer(); // null per file semplici
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);
    }
}

Lo stage splitter è lo Spring AI standard, e mantiene i metadati del lettore su ogni chunk:

TokenTextSplitter splitter = TokenTextSplitter.builder()
        .withChunkSize(300) // token per chunk nell'esecuzione di esempio
        .build();
List<Document> chunks = splitter.apply(documents);

Sui 4 file di esempio il lettore restituisce 12 Document e lo splitter ne crea 20. La demo li scrive in output/spring-ai-documents.jsonl al posto di vectorStore.add(chunks), così puoi leggere source, parent_source, page_number e il chunk_index dello splitter su ogni riga prima di costruire un indice reale.

Quel file vale cinque minuti del tuo tempo. Cercalo per parent_source e vedrai esattamente quali chunk provengono dall’interno dell’email e dello ZIP; cercalo per page_number e vedrai dove cadeva ogni confine di pagina. Quando l’ho eseguito per la prima volta su un’esportazione reale di casella di posta, quelle due ricerche hanno risposto a più domande sulla qualità del recupero rispetto a un giorno di aggiustamenti dei prompt.

Confronto: Prima vs. Dopo

Ecco cosa è cambiato tra la mia versione helper-per-formato e il lettore unico:

Prima (lettura solo corpo più helper per formato) Dopo (GroupDocs.Parser DocumentReader)
Allegati email persi, o estratti da un helper separato letti come Documenti con parent_source
Membri ZIP decompressi su disco prima letti in loco, source = sample.zip/sample.pdf
Citazioni solo nome file nome file più page_number
Codice da mantenere un helper per formato una classe DocumentReader
File illeggibile dipende dall’helper registrato, saltato, il batch continua

La pipeline dopo il lettore non è cambiata affatto; solo ciò che vi fluisce dentro è cambiato.

Esempio reale: un indice di supporto notturno

Un team di supporto esporta la sua casella di posta condivisa ogni notte e riceve uno ZIP di manuali aggiornati con ogni rilascio di prodotto. Un job programmato nel loro servizio Spring Boot elenca i nuovi file, li legge con GroupDocsParserDocumentReader, li divide e li scrive nel vector store dietro l’assistente agente. Quando un agente chiede informazioni sui rimborsi, la risposta ora cita manuals-2026-10.zip/refunds.pdf, pagina 4, e un filtro su parent_source può limitare la ricerca al bundle di rilascio più recente.

Il codice dei tre step si mappa su quel job uno a uno. Lo Step 1 accetta qualsiasi cosa produca l’esportazione notturna, inclusi file la cui estensione è andata persa lungo il percorso. Lo Step 2 mantiene separate le pagine dei manuali, così l’agente può aprire la pagina 4 prima di rispondere al cliente. Lo Step 3 rende lo ZIP e la casella di posta genitori visibili invece di blob opachi, e la chiamata allo splitter rimane invariata rispetto a qualsiasi altra pipeline Spring AI che il team già utilizza.

Le note interne brevi non hanno bisogno di una divisione per pagina, quindi il team le legge in modalità file intero e mantiene gli allegati attivi:

// Un Document per nota; gli allegati diventano comunque i propri Documenti
DocumentReader reader = new GroupDocsParserDocumentReader(resource, false, true);
List<Document> documents = reader.read();

Cos’altro puoi fare con GroupDocs.Parser?

Ora che gli allegati fluiscono in Spring AI, alcune funzionalità correlate sono a una chiamata di distanza:

Conclusione

La correzione per “vedi allegato” si è rivelata nel livello del lettore. Con GroupDocs.Parser dietro il DocumentReader di Spring AI, gli allegati, i membri di archivio e le pagine individuali diventano fonti che l’assistente può citare, e sostituirlo è una sola costruzione.

Pronto a provarlo sul tuo corpus?

Risorse aggiuntive