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

Старый способ: вложения, которые никогда не попадают в индекс

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

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

Моя первая версия делала именно это. Я исправил её очевидным способом: распаковать релиз‑пакеты в одном помощнике, вытянуть вложения из сообщений в другом, постранично обрабатывать PDF в третьем и придумывать имена метаданных в каждом. Всё работало, пока не появился четвёртый формат, и помощники разошлись во мнениях, как назвать страницу.

Настоящая стоимость: в демонстрационном проекте 2 из 4 входных файлов — email и 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>, где каждая запись достаточно мала для цитирования и достаточно помечена для отслеживания. На практике это означает один 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 Documents, а сплиттер создаёт 20 фрагментов. Демонстрация записывает их в output/spring-ai-documents.jsonl вместо vectorStore.add(chunks), так что вы можете увидеть source, parent_source, page_number и chunk_index сплиттера в каждой строке перед построением реального индекса.

Этот файл стоит пяти минут вашего времени. Поиск по parent_source покажет, какие именно фрагменты пришли из письма и ZIP; поиск по page_number покажет, где находятся границы страниц. Когда я впервые запустил его на реальном экспорте почтовой коробки, эти два поиска ответили на больше вопросов о качестве извлечения, чем целый день настройки подсказок.

Сравнение: до и после

Вот что изменилось между моей версией с отдельными помощниками для каждого формата и единым читателем:

До (чтение только тела + вспомогательные программы для каждого формата) После (GroupDocs.Parser DocumentReader)
Email attachments потеряны, или извлечены отдельным вспомогательным модулем прочитаны как Documents с parent_source
ZIP members распакованы на диск сначала прочитаны на месте, source = sample.zip/sample.pdf
Citations только имя файла имя файла плюс page_number
Code to maintain один вспомогательный модуль на каждый формат один класс DocumentReader
Unreadable file зависит от вспомогательного модуля записано в журнал, пропущено, пакет продолжается

Конвейер после читателя не изменился; изменилось лишь то, что в него поступает.

Пример из реального мира: ночной индекс поддержки

Команда поддержки экспортирует общую почтовую коробку каждую ночь и получает 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 вложения, элементы архивов и отдельные страницы становятся источниками, которые помощник может цитировать, а замена требует лишь одного конструктора.

Готовы попробовать на собственном корпусе?

Дополнительные ресурсы