💡 Полный рабочий пример доступен на 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-commons2.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, несколько связанных возможностей находятся на расстоянии одного вызова:
- Читать из потоков любого происхождения: руководство загрузить документ из потока охватывает конструктор
Parser(InputStream), используемый читателем. - Работать с контейнерами напрямую: извлечь данные из вложений и ZIP‑архивов объясняет
getContainerиopenParser. - Читать страницы PDF вне Spring AI: извлечь текст из PDF‑документов демонстрирует постраничное извлечение текста самостоятельно.
- Обрабатывать email напрямую: извлечь вложения из электронных писем показывает, как извлекать вложения из сообщений без слоя Spring AI.
Заключение
Исправление проблемы «см. вложение» оказалось на этапе читателя. С GroupDocs.Parser за DocumentReader Spring AI вложения, элементы архивов и отдельные страницы становятся источниками, которые помощник может цитировать, а замена требует лишь одного конструктора.
Готовы попробовать на собственном корпусе?
- Получить временную лицензию для работы без ограничений оценки
- Следуйте руководству по интеграции в документации для шаблонов, настроек и контрольного списка для продакшна
- Клонируйте пример проекта на GitHub и укажите путь к копии вашего экспорта почтовой коробки