💡 Полный рабочий пример доступен на 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 перечисляет, какие элементы форматирования сохраняет режим.

Ресурсы