💡 Повний робочий приклад доступний на GitHub: spring-ai-document-reader-using-groupdocs-parser-java

Старий спосіб: Вкладення, які ніколи не потрапляють в індекс

Spring AI DocumentReader — це перший етап ETL‑конвеєра Spring AI: він перетворює файл у об’єкти Document, які споживає розділювач і векторне сховище, тому все, що він викидає, зникає назавжди. Уявіть собі асистента, про якого просила ваша команда підтримки. Ви експортуєте спільну поштову скриньку, індексуєте тіла повідомлень у файлах .eml, розбиваєте, вбудовуєте та надсилаєте. Перший реальний запит надходить, і відповідь посилається на електронний лист, усього тіла якого — «Будь ласка, перегляньте прикріплену політику повернення». Сама політика, PDF‑вкладення, ніколи не була прочитана.

Ніщо нижче по конвеєру не може це виправити. TokenTextSplitter розбиває одне речення, яке він отримав, а векторне сховище повертає його як найбільш підходящий результат на запит «яке у нас вікно повернення?». Кожен компонент виконав свою роботу. Проблема виникає на самому початку конвеєра, у рішенні, що вважається документом. Експорт поштової скриньки — це дерево: повідомлення, вкладення та іноді архіви всередині вкладень. Читач, який бачить лише верхній рівень, індексує лише обгортку.

Моя перша версія робила саме це. Я виправив це очевидним способом: розпакував випускові пакети в одному допоміжному класі, витягнув вкладення з повідомлень в іншому, листав PDF‑файли третім, і придумав назви метаданих у кожному. Це працювало, доки не з’явився четвертий формат, і допоміжні засоби не змогли погодитися, як називати сторінку.

Справжня вартість: у демонстраційному проєкті 2 з 4 вхідних файлів — електронний лист і ZIP — містять текст, який читання лише тіла ніколи не індексує. У корпусі підтримки цей коефіцієнт визначає, чи є асистент корисним.

Кращий спосіб: Один читач для кожного вхідного файлу

GroupDocs.Parser для Java читає понад 65 форматів файлів в процесі. Він визначає формат за вмістом потоку, повертає текст по сторінках і перераховує вкладення електронного листа або елементи ZIP‑архіву через один API контейнера. Обгорніть ці виклики у інтерфейс DocumentReader Spring AI, і решта конвеєра не помітить різниці, окрім того, що документи тепер містять номери сторінок і походження.

Власні читачі Spring AI залишаються хорошим вибором для своїх випадків: довідка щодо ETL‑конвеєра описує TikaDocumentReader на базі Apache Tika та PagePdfDocumentReader на базі Apache PDFBox. Тут мова про більш вузьке застосування. Коли вкладення та елементи архіву мають бути першокласними джерелами, читач, який обходить контейнери, заощаджує вам зоопарк допоміжних засобів.

Перш ніж почати, вам знадобиться:

  • Java 17 або новіша, базова версія для Spring AI 2.x
  • GroupDocs.Parser для Java 26.10 (отримати тимчасову ліцензію) та org.springframework.ai:spring-ai-commons 2.0.2
  • Експортований електронний лист з вкладенням або чотири зразкові файли з репозиторію

Клонуйте зразок і запустіть його однією командою:

mvn compile exec:java

Що має повертати Spring AI DocumentReader для RAG‑цитувань?

Необхідний List<Document>, де кожен елемент достатньо малий для цитування і достатньо позначений для відстеження. На практиці це означає один документ на сторінку з page_number, source — назвою файлу, і, якщо дані прийшли всередині електронного листа або архіву, parent_source — назвою контейнера. TokenTextSplitter копіює ці метадані у кожен фрагмент, тому цитата зберігається до самого векторного сховища.

Новий спосіб: Вкладення як документи першого класу

Ось те саме завдання в одному класі GroupDocsParserDocumentReader. Три кроки охоплюють його: відкрити ресурс, прочитати сторінки, потім пройти по контейнерам і передати все розділювачу.

Крок 1 - Відкрити будь‑який ресурс як потік

Читач приймає Spring Resource, тому файл, запис у classpath та завантаження всі проходять однаково. Parser читає байти, що означає, що блоб без розширення все одно розбирається.

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

Крок 2 - Читати сторінка за сторінкою

Режим сторінки — за замовчуванням. Я залишив би його увімкненим для будь‑якого матеріалу довший, ніж одна‑дві сторінки, бо рецензент, що перевіряє відповідь, хоче одразу перейти до сторінки 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
    }
}

Ключові моменти:

  • pagePerDocument: true дає кожному фрагменту page_number; конструктор з трьома аргументами вимикає це для коротких нотаток.
  • isTextPage(): захищає формати без сторінок; для них читач один раз викликає getText() і все одно записує source, file_type і page_count.

Крок 3 - Пройти по контейнерах, потім розділити

Кожне вкладення або елемент архіву отримує власний Parser і проходить той самий логічний процес сторінок, позначений шляхом всередині батька.

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

Етап розділювача — стандартний Spring AI, і він зберігає метадані читача у кожному фрагменті:

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

На чотирьох зразкових файлах читач повертає 12 Document, а розділювач створює 20 фрагментів. Демонстрація записує їх у output/spring-ai-documents.jsonl замість vectorStore.add(chunks), тож ви можете переглянути source, parent_source, page_number і chunk_index розділювача в кожному рядку перед створенням реального індексу.

Цей файл вартий п’яти хвилин вашого часу. Шукайте у ньому parent_source, і ви точно побачите, які фрагменти походять з електронного листа чи ZIP; шукайте page_number, і ви побачите, де розташовані межі сторінок. Коли я вперше запустив це проти реального експорту поштової скриньки, ці два пошуки відповіли на більше питань про якість пошуку, ніж день налаштувань підказок.

Порівняння: До vs Після

Ось що змінилося між моєю версією з допоміжними засобами для кожного формату та одним читачем:

До (читання лише тіла + допоміжні засоби для кожного формату) Після (GroupDocs.Parser DocumentReader)
Вкладення електронних листів втрачені або витягнуті окремим допоміжним засобом читаються як Document з parent_source
Елементи ZIP спочатку розпаковуються на диск читаються на місці, source = sample.zip/sample.pdf
Цитати лише ім’я файлу ім’я файлу + page_number
Код для підтримки один допоміжний засіб на формат один клас DocumentReader
Нечитабельний файл залежить від допоміжного засобу залоговано, пропущено, пакет продовжується

Конвеєр після читача зовсім не змінився; змінився лише те, що в нього надходить.

Приклад з реального світу: Нічний індекс підтримки

Команда підтримки експортує свою спільну поштову скриньку щоночі і отримує ZIP з оновленими посібниками при кожному випуску продукту. Запланована задача у їхньому Spring Boot сервісі перелічує нові файли, читає їх за допомогою GroupDocsParserDocumentReader, розбиває та записує у векторне сховище позаду асистента‑агента. Коли агент запитує про повернення, відповідь тепер посилається на manuals-2026-10.zip/refunds.pdf, сторінка 4, а фільтр за parent_source може обмежити пошук останнім випусковим пакетом.

Код з трьох кроків точно відповідає цій задачі. Крок 1 приймає будь‑який результат нічного експорту, включаючи файли, у яких втрачено розширення. Крок 2 зберігає сторінки посібника окремо, щоб агент міг відкрити сторінку 4 перед відповіддю клієнту. Крок 3 робить ZIP і поштову скриньку видимими батьками замість непрозорих блобів, а виклик розділювача залишився незмінним у будь‑якому іншому конвеєрі Spring AI, який команда вже використовує.

Короткі внутрішні нотатки не потребують розбиття на сторінки, тому команда читає їх у режимі «цілий файл» і залишає вкладення:

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

Що ще можна зробити з GroupDocs.Parser?

Тепер, коли вкладення потрапляють у Spring AI, кілька пов’язаних можливостей доступні одним викликом:

Висновок

Виправлення «дивіться прикріплення» виявилося на етапі читача. З GroupDocs.Parser за DocumentReader Spring AI вкладення, елементи архіву та окремі сторінки стають джерелами, які асистент може цитувати, і заміна їх — це лише один конструктор.

Готові спробувати це на власному корпусі?

Додаткові ресурси