💡 Полный рабочий пример доступен на GitHub:
document-to-markdown-rag-chunks-java
Проблема отдела претензий: почему процессинг RAG ломается на реальных файлах
Он‑премисный конвейер процессинга RAG — это слой парсинга документов, который превращает уже имеющиеся у команды файлы в поисковый, цитируемый текст без отправки их третьей стороне. Операции в страховании показывают, почему это важно. Обработчик претензий спрашивает внутреннего помощника: «что сказал оценщик о повреждении водой по этой полису?», а ответ находится в письме от прошлого марта, внутри прикреплённого PDF‑отчёта, под заголовком «Findings». Помощник сможет найти его только если процессинг прочитал вложение, сохранил заголовок и запомнил, к какому письму относится отчёт.
Большинство задач процессинга делают одну из трёх ошибок. Экстрактор простого текста возвращает только тело письма и игнорирует вложения. Фиксированный разрезатель на 512 токенов разрезает отчёт посередине «Findings». А облачный парсер решает обе проблемы, но только после того, как файл претензии с личными и медицинскими данными загружен на чужие серверы и оплачивается за каждую страницу.
То, что нужно команде, узко и конкретно: читать Word, PDF, EML и ZIP‑файлы внутри Java‑службы, уже владеющей данными, сохранять их структуру в виде Markdown, разрезать по заголовкам автора и прикреплять источник и родителя к каждому фрагменту.
Обзор рынка: руководства 2026 года по «парсингу документов для RAG», которые мы изучали, сравнивают открытые парсеры на Python с облачными API, оплачиваемыми за страницу. Java‑библиотека, работающая на вашем собственном оборудовании, почти полностью отсутствует в этом обсуждении.
Почему конвейерам RAG нужен Markdown, а не простой текст?
Markdown сохраняет те части документа, которые подсказывают поисковику, где заканчивается одна идея и начинается следующая. Заголовки становятся строками #, таблицы остаются строками и столбцами, списки сохраняют свои пункты. Разрезатель может делить по этим заголовкам, и LLM, читая фрагмент, видит тот же план, что и человек. Простой текст убирает этот план, оставляя размер единственным сигналом для разрезания.
Почему типовые решения здесь не работают
Команды обычно пробуют одно из трёх, прежде чем построить полноценный слой процессинга:
- Извлечение простого текста + разрезатель токенов: быстро настроить, но фрагменты смешивают разделы и теряют заголовок, который вы бы цитировали.
- Облачный API парсинга: хорошая структура, но файлы покидают сеть, а счёт растёт с каждой переиндексируемой страницей.
- Самодельный парсер для каждого формата: одна библиотека для DOCX, другая для PDF, MIME‑парсер для писем и
java.util.zipдля архивов, каждая со своими краевыми случаями.
GroupDocs.Parser для Java покрывает все четыре формата через один класс Parser и работает в том же процессе.
Решение: GroupDocs.Parser в рабочем процессе процессинга претензий
GroupDocs.Parser для Java читает документ и может вернуть его текст в виде Markdown через getFormattedText. Письма и архивы представлены как контейнеры, элементы которых открываются как новые экземпляры Parser, поэтому PDF‑вложение в письме обрабатывается точно так же, как PDF‑файл на диске. Примерный проект оборачивает это в шесть статических методов и записывает результат в JSON Lines, которые любой процесс эмбеддинга или векторное хранилище могут загрузить. Ни один RAG‑фреймворк не задействован; формат вывода — точка интеграции.
Для работы вам понадобится:
- JDK 11 или новее для поставляемого
pom.xml(сам код ограничен возможностями Java 8) - GroupDocs.Parser для Java 26.10 (получить временную лицензию)
- Небольшой набор файлов DOCX, PDF, EML или ZIP
pom.xml проекта объявляет репозиторий https://releases.groupdocs.com/java/repo/ и единственную зависимость, поэтому сборка — одна команда:
mvn compile exec:java
Реализация слоя процессинга шаг за шагом
Нижеописанные шаги проходят один файл претензии от входящего ящика до индекса. Каждый шаг соответствует одному‑двум методам в RagChunksDemo.java. Мы оставили их простыми статическими методами, чтобы вы могли взять любой из них в существующий воркер без необходимости принимать остальные.
Шаг 1 — Открыть каждый файл и направить его
Каждому загруженному файлу назначается свой Parser. Имя файла становится идентификатором источника, а у файла верхнего уровня нет родителя.
// One call per uploaded claim document
String sourceId = new File(path).getName();
try (Parser parser = new Parser(path)) {
return chunkParser(parser, sourceId, null, 0); // null parent: top-level file
} catch (UnsupportedDocumentFormatException ex) {
// An unknown format is logged and skipped, the batch keeps going
System.out.println("skipped (unsupported format): " + sourceId);
return new ArrayList<>();
}
chunkParser затем извлекает тело, разбивает его и спускается во вложения, пока глубина меньше 2.
Шаг 2 — Запросить Markdown, принять простой текст
Этап извлечения проверяет, что поддерживает формат, вместо жёсткого списка расширений.
if (parser.getFeatures().isFormattedText()) {
// Headings, lists and tables survive as Markdown
FormattedTextOptions options =
new FormattedTextOptions(FormattedTextMode.Markdown);
try (TextReader reader = parser.getFormattedText(options)) {
if (reader != null) {
return new ExtractedText("markdown", reader.readToEnd());
}
} catch (IOException ex) {
throw new RuntimeException(ex);
}
}
try (TextReader reader = parser.getText()) {
if (reader != null) { // fallback for formats without formatted text
return new ExtractedText("plain", reader.readToEnd());
}
} catch (IOException ex) {
throw new RuntimeException(ex);
}
return new ExtractedText("none", ""); // e.g. a photo with no OCR
Почему эти настройки важны для данных претензий:
FormattedTextMode.Markdown: заголовок оценщика «Findings» попадает к разрезателю как## Findings, поэтому может стать точкой разреза и цитатой.isFormattedText(): в примере DOCX и письмо возвращаются в виде Markdown, а PDF — в виде простого текста; полеformatфиксирует, что произошло.- Результат
none: фотографии претензий внутри ZIP не дают фрагментов без OCR, что честно и легко отслеживается.
Шаг 3 — Разрезать по заголовкам оценщика
Разрезатель хранит стек текущего заголовка для каждого из шести уровней Markdown. Новый заголовок закрывает текущий раздел и сбрасывает всё ниже своего уровня.
for (String line : markdown.split("\\r?\\n", -1)) {
Matcher heading = HEADING.matcher(line); // "#" to "######" + title
if (heading.matches()) {
// Close the previous section under the old path
addSection(chunks, section.toString(), sourceId, parentId, path, format);
section.setLength(0);
int level = heading.group(1).length();
stack[level - 1] = heading.group(2).trim();
for (int i = level; i < stack.length; i++) {
stack[i] = null; // forget deeper headings
}
path = joinPath(stack); // "Heading 1 > Heading 1.2"
}
section.append(line).append('\n');
}
Разделы более 1500 символов обрезаются на границах абзацев, поэтому длинный раздел «Findings» превращается в несколько фрагментов, которые делят один путь заголовка. В прилагаемом Word‑файле это даёт пути вроде Heading 1 > Heading 1.2 > Heading 1.2.1.
Шаг 4 — Обрабатывать вложения и записывать JSON Lines
Это шаг, который наиболее важен для отдела претензий. getContainer() перечисляет вложения писем и записи ZIP; каждый элемент открывается как дочерний документ и наследует идентификатор родителя.
Iterable<ContainerItem> items = parser.getContainer();
if (items == null) {
return chunks; // not an email or archive
}
for (ContainerItem item : items) {
// "claim-0412.eml/report.pdf" style ids keep the lineage readable
String childId = sourceId + "/" + item.getFilePath();
try (Parser child = item.openParser()) {
chunks.addAll(chunkParser(child, childId, sourceId, depth + 1));
} catch (UnsupportedDocumentFormatException ex) {
System.out.println("skipped (unsupported format): " + childId);
}
}
writeJsonl затем записывает одну UTF‑8 строку на каждый фрагмент с полями id, source, parent, heading_path, format и text. Для четырёх образцов входных данных получаем 22 строки. Фрагмент из вложения примерного письма имеет идентификатор email-with-attachment.eml/attachment.txt#0, а его parent — email-with-attachment.eml. Эта пара позволяет помощнику сказать «из отчёта, вложенного в письмо от 14 марта», а не «из какого‑то места в архиве».
Реальный рабочий процесс: еженедельная переиндексация претензий
Представьте команду операций по претензиям, которая каждый пятничный день экспортирует закрытые ящики претензий в виде EML‑файлов и ZIP‑пакетов сопроводительных документов. Java‑воркер уже перемещает эти экспорты в хранилище документов. Добавление процессинга означает вызов chunkDocument для каждого файла и добавление вывода JSON Lines в очередь эмбеддинга.
Шаг 4 сохраняет резервный отчёт, прикреплённый к письму, которое его доставило. Шаг 3 гарантирует, что когда помощник цитирует «Findings», ссылка указывает файл и заголовок, а не просто номер фрагмента. Мой практический совет для такого индекса: если фрагмент нельзя сослаться обратно на файл и заголовок, он не должен находиться в индексе.
Влияние на бизнес: что изменилось
| Аспект | Обычная конфигурация до | С конвейером GroupDocs.Parser |
|---|---|---|
| Где парсятся файлы | Сторонняя конечная точка | Внутри вашего собственного JVM |
| Модель стоимости парсинга | За страницу, при каждой переиндексации | Нет облачной платы за страницу |
| Границы фрагментов | Фиксированные окна токенов | Заголовки автора, ограничение 1500 символов |
| Вложения писем | Часто пропускаются или «сплющиваются» | Парсятся как дочерние документы |
| Данные для цитирования | Максимум — имя файла | source, parent и heading_path |
| Зависимости во время выполнения | SDK + сетевой доступ | Один Maven‑артефакт |
В совокупности получается индекс, который команда комплаенса может утвердить, и помощник, чьи ответы указывают на реальный документ и раздел.
Другие страховые сценарии, где подходит GroupDocs.Parser
После создания слоя процессинга тот же код обслуживает другие задачи, связанные с большим объёмом документов:
- Поиск по формулировкам полисов: Word‑документы полисов разбиваются по заголовкам пунктов, поэтому ответы цитируют точный раздел.
- Подачи брокеров: ZIP‑загрузки со смешанными PDF и таблицами индексируются по каждому элементу, каждый привязан к конкретной подаче.
- Обработка жалоб: письма клиентов и их вложения индексируются вместе, при этом письмо является родителем для всех фрагментов вложений.
Как начать в своей среде претензий
Склонируйте пример, запустите его на нескольких собственных экспортированных письмах и откройте output/chunks.jsonl, чтобы проверить пути заголовков и идентификаторы родителей перед тем, как подключать его к задаче эмбеддинга. Архитектурные заметки по этапам находятся в документации сценария использования этого конвейера, а справка по выводу Markdown перечисляет, какие элементы форматирования сохраняет режим.