💡 Pełny działający przykład dostępny na GitHub: spring-ai-document-reader-using-groupdocs-parser-java

Stara metoda: Załączniki, które nigdy nie trafiają do indeksu

Spring AI DocumentReader jest pierwszym etapem potoku ETL Spring AI: zamienia plik w obiekty Document, które konsumuje splitter i magazyn wektorów, więc to, co zostanie odrzucone, znika na dobre. Wyobraź sobie asystenta, o którego poprosił Twój zespół wsparcia. Eksportujesz wspólną skrzynkę pocztową, indeksujesz treść wiadomości z plików .eml, dzielisz, osadzisz i wysyłasz. Pojawia się pierwsze prawdziwe pytanie, a odpowiedź cytuje e‑mail, którego cała treść brzmi „Proszę zobaczyć załączoną politykę zwrotów”. Sama polityka, załącznik PDF, nigdy nie została odczytana.

Nic w dół strumienia nie może to naprawić. TokenTextSplitter dzieli jedyne zdanie, które otrzymał, a magazyn wektorów zwraca je jako najbliższe dopasowanie do „jaki jest nasz okres zwrotu?”. Każdy komponent wykonał swoją pracę. Luka znajduje się na samym początku potoku, w decyzji, co liczy się jako dokument. Eksport skrzynki pocztowej to drzewo: wiadomości, załączniki i czasem archiwa wewnątrz załączników. Czytnik, który widzi tylko poziom najwyższy, indeksuje jedynie opakowanie.

Moja pierwsza wersja robiła dokładnie to. Naprawiłem to w oczywisty sposób: rozpakowałem pakiety wydania w jednym pomocniku, wyciągnąłem załączniki z wiadomości w drugim, przeglądałem PDF‑y trzecim i wymyśliłem nazwy metadanych w każdym. Działało, dopóki nie pojawił się czwarty format i pomocnicy nie zgadzali się, jak nazwać stronę.

Rzeczywisty koszt: w projekcie przykładowym, 2 z 4 plików wejściowych, e‑mail i ZIP, zawierają tekst, którego odczyt tylko z treści nigdy nie indeksuje. W korpusie wsparcia ten stosunek decyduje, czy asystent jest użyteczny.

Lepsze rozwiązanie: Jeden czytnik dla każdego wejścia

GroupDocs.Parser for Java odczytuje ponad 65 formatów plików w procesie. Wykrywa format z zawartości strumienia, zwraca tekst per strona i wymienia załączniki e‑maila lub wpisy ZIP‑a poprzez jedno API kontenera. Owiń te wywołania w interfejs DocumentReader Spring AI, a reszta potoku nie odróżni różnicy, oprócz tego, że Dokumenty teraz zawierają numery stron i pochodzenie.

Własne czytniki Spring AI pozostają dobrymi wyborami dla swoich przypadków: [odniesienie do ETL pipeline] dokumentuje TikaDocumentReader na Apache Tika i PagePdfDocumentReader na Apache PDFBox. Punkt jest węższy. Gdy załączniki i elementy archiwów muszą być źródłami pierwszej klasy, czytnik, który przegląda kontenery, oszczędza Ci zoo pomocników.

Zanim zaczniemy, będziesz potrzebować:

  • Java 17 lub nowsza, podstawa dla Spring AI 2.x
  • GroupDocs.Parser for Java 26.10 (uzyskaj tymczasową licencję) oraz org.springframework.ai:spring-ai-commons 2.0.2
  • Wyeksportowany e‑mail z załącznikiem lub cztery przykładowe pliki z repozytorium

Sklonuj przykład i uruchom go jednym poleceniem:

mvn compile exec:java

Co musi zwracać Spring AI DocumentReader dla cytowań RAG?

Potrzebuje List<Document>, w której każdy wpis jest wystarczająco mały, by go cytować, i odpowiednio oznaczony, by go śledzić. W praktyce oznacza to jeden Dokument na stronę z page_number, source nazwą pliku oraz, dla wszystkiego, co przybyło wewnątrz e‑maila lub archiwum, parent_source nazwą kontenera. TokenTextSplitter kopiuje te metadane do każdego fragmentu, więc cytat przetrwa aż do magazynu wektorów.

Nowe podejście: Załączniki jako dokumenty pierwszej klasy

Oto to samo zadanie w jednej klasie, GroupDocsParserDocumentReader. Trzy kroki to obejmują: otwórz zasób, odczytaj strony, a potem przejdź przez kontenery i przekaż wszystko splitterowi.

Krok 1 – Otwórz dowolny zasób jako strumień

Czytnik przyjmuje Spring Resource, więc plik, wpis classpath i upload wszystkie idą tą samą drogą. Parser odczytuje bajty, co oznacza, że blob bez rozszerzenia nadal się parsuje.

// 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<>();
}

Krok 2 – Czytaj strona po stronie

Tryb stron jest domyślny. Zostawiłbym go włączony dla wszystkiego dłuższego niż jedna‑dwie strony, ponieważ recenzent sprawdzający odpowiedź chce od razu przejść do strony 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
    }
}

Kluczowe punkty:

  • pagePerDocument: true daje każdemu fragmentowi page_number; konstruktor z trzema argumentami wyłącza to dla krótkich notatek.
  • isTextPage(): chroni formaty bez stron; w takich przypadkach czytnik wywołuje getText() raz i nadal zapisuje source, file_type oraz page_count.

Krok 3 – Przejdź przez kontenery, a potem podziel

Każdy załącznik lub element archiwum otrzymuje własny Parser i wraca przez tę samą logikę stron, oznaczony swoją ścieżką wewnątrz rodzica.

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);
    }
}

Etap splittera to standardowy Spring AI i zachowuje metadane czytnika w każdym fragmencie:

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

Na 4 przykładowych plikach czytnik zwraca 12 Dokumentów, a splitter tworzy 20 fragmentów. Demo zapisuje je do output/spring-ai-documents.jsonl zamiast vectorStore.add(chunks), więc możesz odczytać source, parent_source, page_number oraz chunk_index splittera w każdej linii przed zbudowaniem prawdziwego indeksu.

Ten plik jest wart pięciu minut Twojego czasu. Przeszukaj go pod kątem parent_source, a zobaczysz dokładnie, które fragmenty pochodziły z e‑maila i ZIP‑a; przeszukaj pod kątem page_number, a zobaczysz, gdzie skończyła się każda strona. Gdy po raz pierwszy uruchomiłem to na rzeczywistym eksporcie skrzynki, te dwa wyszukiwania odpowiedziały na więcej pytań o jakość wyszukiwania niż dzień dopasowywania promptów.

Porównanie: Przed vs. Po

Oto, co się zmieniło między wersją z pomocnikami per format a pojedynczym czytnikiem:

Przed (odczyt tylko treści + pomocnicy dla każdego formatu) Po (GroupDocs.Parser DocumentReader)
Załączniki e‑mail zgubione lub wyodrębnione przez oddzielnego pomocnika odczytywane jako Dokumenty z parent_source
Elementy ZIP rozpakowywane najpierw na dysk odczytywane w miejscu, source = sample.zip/sample.pdf
Cytaty tylko nazwa pliku nazwa pliku plus page_number
Kod do utrzymania jeden pomocnik na format jedna klasa DocumentReader
Nieczytelny plik zależy od pomocnika zalogowany, pominięty, partia kontynuuje

Potok po czytniku nie zmienił się wcale; zmieniło się jedynie to, co do niego wpływa.

Przykład z rzeczywistości: Nocny indeks wsparcia

Zespół wsparcia codziennie w nocy eksportuje wspólną skrzynkę pocztową i otrzymuje ZIP z zaktualizowanymi podręcznikami przy każdym wydaniu produktu. Zaplanowane zadanie w ich usłudze Spring Boot wymienia nowe pliki, odczytuje je przy pomocy GroupDocsParserDocumentReader, dzieli i zapisuje do magazynu wektorów za asystentem. Gdy agent pyta o zwroty, odpowiedź teraz cytuje manuals-2026-10.zip/refunds.pdf, strona 4, a filtr na parent_source może ograniczyć wyszukiwanie do najnowszego pakietu wydania.

Kod z trzech kroków mapuje się na to zadanie jeden‑do‑jeden. Krok 1 przyjmuje cokolwiek produkuje nocny eksport, w tym pliki, które utraciły rozszerzenie po drodze. Krok 2 utrzymuje strony podręcznika oddzielnie, więc agent może otworzyć stronę 4 przed odpowiedzią klientowi. Krok 3 czyni ZIP i skrzynkę pocztową widocznymi rodzicami zamiast nieprzezroczystych blobów, a wywołanie splittera pozostaje niezmienione w stosunku do każdego innego potoku Spring AI, który zespół już uruchamia.

Krótkie notatki wewnętrzne nie potrzebują podziału na strony, więc zespół odczytuje je w trybie całego pliku i zachowuje załączniki:

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

Co jeszcze możesz zrobić z GroupDocs.Parser?

Teraz, gdy załączniki płyną do Spring AI, kilka powiązanych możliwości jest na wyciągnięcie ręki:

Wnioski

Naprawa „zobacz załącznik” okazała się leżeć w etapie czytnika. Z GroupDocs.Parser za Spring AI DocumentReader, załączniki, elementy archiwów i poszczególne strony stają się źródłami, które asystent może cytować, a wymiana polega na jednym konstruktorze.

Gotowy, aby wypróbować to na własnym korpusie?

Dodatkowe zasoby