Когда бот RAG цитирует неверную сумму страхового франшиза
Фрагментатор фиксированного размера разделял сумму в долларах по строкам; необходимо было заново настроить обработку и пройти четыре уровня фрагментации — от рекурсивных базовых вариантов до извлечения информации из родительского документа — прежде чем можно было доверять полученным ответам.
Всё, что приведено ниже, представляет собой вымышленный пример для учебных целей. Названия компаний, имена людей и суммы в долларах — вымышленные; способы возникновения сбоев соответствуют тем, с которыми действительно сталкиваются системы RAG в производственной сфере в регулируемых областях, таких как страхование и аналогичные высокорисковые сектора.
Поздно во вторник руководитель отдела обработки претензий связался с инженерным отделом: ассистент сообщил клиенту, что сумма страхового взноса за наводнение составляет 500 долларов, хотя в полисе было указано 5000 долларов. Бот, усиленный системой поиска — «Атлас» — работал уже три недели, используя хранилище векторов, модель глубокого усаживания данных, мощную модель больших языковых моделей и удобный интерфейс. Однако у него отсутствовала эффективная стратегия разбиения данных на части.
Результат поиска выглядел следующим образом:
...applies to all covered perils. Deductible: $5
00 for flood events in Zone A, $500 for wind events, and $1,0
При извлечении данных из PDF цифра была разделена между несколькими строками. Затем инструмент фиксированного размера разбивал данные каждые 500 символов, учитывая это разделение. Модель глубокого усаживания данных создала индексы для фрагментов с текстом «5 долларов» и «500 долларов». Модель ответила уверенно — но неверно.
Далее описано, как была перестроена система обработки данных: сначала происходит этап парсинга, который устанавливает верхний предел, затем следуют четыре уровня разбиения на фрагменты, при которых стоимость растет по мере приближения к этому пределу.
Одна концепция позволяет сохранять честность в оценке затрат: почти вся стоимость разбиения на фрагменты связана с однократными вычислениями для индексации. Эти вычисления выполняются офлайн, до того как поступит любой запрос от пользователя. Единственная стоимость, связанная с разбиением на фрагменты и учитываемая при анализе запросов уровня p95, — это сама модель генерации эмбеддингов. То, что высшие уровни считаются «дорогими», означает дорогостоящие вычисления один раз на документ, а не за каждый запрос.
Этап 0: невозможно разбить на фрагменты структуру, которую было уничтожено во время парсинга
Первый вариант — более умный инструмент для разбиения на части. Лучший вопрос звучит так: показывать текст, который разбивается, а не PDF. В первых версиях Atlas текст представлял собой сплошной поток символов. Заголовки сливались с основным текстом, а таблицы с разделами на удержания превращались в единую строку, где значения из разных строк располагались рядом друг с другом. Указания страницы («Форма страховки HO-3 — страница 14 из 62») повторялись каждые несколько тысяч символов.
Ни один инструмент для разбиения не может восстановить границы, уже уничтоженные парсером. Если заголовки отсутствуют, у инструмента для разделения на заголовки в Markdown нет чего делить. Если таблицы распались, ничто не сохраняет строки в целостности.
Соотнесите каждый тип входных данных с парсером, который генерирует структуру, пригодную для использования инструментом разбиения:
Оригинальные файлы PDF и DOCX (формы для оформления полисов, подтверждения, руководства). Желательно использовать парсеры, учитывающие форматирование — LlamaParse, Unstructured.io, Docling, Azure Document Intelligence — вместо простых инструментов для извлечения текста. Требуется формат Markdown с указанием типов элементов (заголовок, таблица, список), а не простая строка текста.
Отсканированные файлы PDF, изображения и факсы. Необходимо применить технологии OCR (Tesseract — самый слабый вариант; PaddleOCR, Textract, Document AI, Azure — более эффективны). Требуется не только текст, но и информация о структуре формата, а также уровень уверенности для каждого блока. Уровень уверенности позже помогает определить, что тот или иной показатель неопределен, и в таких случаях не следует использовать его при расчетах.
HTML-документы и внутренние вики. Необходимо удалить лишний контент и выдать чистый формат Markdown с сохраненными заголовками.
Презентации и таблицы. Следует сохранять границы слайдов или ячеек, чтобы отдельные фрагменты не содержали несвязанных слайдов.
Аудио и видео (звонки по страховым претензиям, вебинары). Сервисы Whisper, Deepgram или AssemblyAI должны генерировать транскрипт с указанием того, кто говорил и когда. Если ораторы не разделены, противоположные заявления агента и клиента могут сливаться в один вводящий в заблуждение абзац.
После перепарсинга с учётом структуры формата раздел с информацией о вычетах выглядел следующим образом:
## Section 4 — Deductibles
### 4.2 Peril-specific deductibles
| Peril | Zone A | Zone B |
|----------------|---------|---------|
| Flood | $5,000 | $2,500 |
| Wind / hail | $500 | $500 |
| Named storm | $1,000 | $1,000 |
Таблица была восстановлена. Древо заголовков также было восстановлено. Сумма “$5,000” снова рассматривалась как один токен. Код на разбиение текста ещё не изменился, но процесс поиска уже стал более надёжным.
Уровень 1: Синтаксическое разбиение — дешёво, быстро и откуда следует начать
Фиксированный размер: прототип, который появился случайно
Разделяйте текст каждые N символов или токенов (CharacterTextSplitter, tiktoken). Затраты на индексацию практически равны нулю. Это подходит для временных ситуаций, но катастрофично в производственных условиях. Такой метод прерывает текст посреди предложения, таблицы или числа — именно так происходят инциденты с некорректной обработкой данных. Правило после той ночи: инструменты с фиксированным размером разделения никогда не должны использоваться в реальных проектах.
Команды постоянно обнаруживают одну и ту же закономерность: набор коротких постов из блогов выдерживает разделение по символам, но как только появляется первый многоколоночный PDF или документ, отсканированный с помощью OCR, качество ответов резко падает. Рассматривайте фиксированные окна обработки как временные решения для локальных экспериментов, обязательно включив пункт проверки о замене таких решений перед началом работы с внешними пользователями.
Перекрытие: регулируемый параметр, а не стратегия
chunk_overlap приводит в начало блока N+1 часть текста, заканчивающую блок N. Часто используется значение от 10 до 15 процентов (например, 50 из 500 токенов в блоке). Такое перекрытие служит недорогим средством защиты для уровней 1–2; оно не исправляет плохо определенные границы — лишь дублирует их. При нахождении совпадения в зоне перекрытия количество векторов и случаев дублирования результатов в методе top-k увеличивается примерно на 10 процентов.
Когда в списке соседних блоков преобладают дубликаты, инструменты переранжирования и большие языковые модели тратят ресурсы окна контекста дважды на одно и то же предложение. Для снижения этого эффекта можно использовать идентификаторы родителей или хэши текста, практически идентичные друг другу, после получения результатов поиска, если по другим причинам необходимо сохранить высокий уровень перекрытия.
Предложение/параграф: соблюдает грамматику, игнорирует структуру документа
Собирайте целые предложения в один блок (NLTK, spaCy, LlamaIndex SentenceSplitter). Никогда не разрезайте предложение на полуслове — иначе это могло бы помешать правильному разделению на строки, — но инструмент не умеет распознавать заголовки, таблицы или списки исключений. Отлично подходит для транскрипций звонков; удовлетворительно работает с структурированными формами полисов.
Рекурсивный разделитель: стандартный вариант, который используется в первую очередь
RecursiveCharacterTextSplitter пробует разделители в определенном порядке приоритета — пустая строка, символ новой строки, предложение, слово — и переходит к другим вариантам только тогда, когда фрагмент всё ещё слишком большой, после чего объединяет мелкие фрагменты до размера chunk_size. Типичными параметрами являются 500 токенов с наложением 50%
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=50,
separators=["\n\n", "\n", ". ", " ", ""],
)
chunks = splitter.split_text(policy_markdown)
В перепарсенном разделе с лимитом на вычеты было сохранено всё из 4.2, включая таблицу, поскольку пустые строки образовывали естественные группы объемом менее 500 токенов. Ошибка исчезла. Используйте recursive-500/50 в качестве базового показателя для сравнения, а не как готового решения.
Запишите этот базовый показатель на доске и откажитесь от предложений о более «умных» инструментах разбиения на части, которые не могут превзойти его при решении тех же эталонных задач. Многие дорогостоящие решения кажутся блестящими, пока их не сравнивают с простым рекурсивным инструментом разбиения на части в чистом формате Markdown.
Уровень 2: Разбиение с учетом структуры — разрезайте там, где в документе уже есть разделители
С использованием набора из примерно 80 реальных задач recursive-500/50 показал результат около 71%. Сбои возникали в длинных разделах и при ответах, где модель не могла определить, какая политика или раздел стала причиной правильного ответа.
Разбиение заголовков Markdown / HTML
MarkdownHeaderTextSplitter / HTMLHeaderTextSplitter разделяют текст в соответствии с иерархией заголовков и сохраняют путь к заголовку в метаданных:
from langchain_text_splitters import MarkdownHeaderTextSplitter
header_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=[("#", "h1"), ("##", "h2"), ("###", "h3")]
)
sections = header_splitter.split_text(policy_markdown)
# then apply the recursive splitter inside each section
Каждый фрагмент, полученный из подраздела 4.2, должен содержать «путь-маркер», например: форма HO-3 → ставки → таблица для конкретного риска. Этот путь-маркер необходимо приложить к тексту перед его вставкой, чтобы вектор отражал не только числовые данные, но и местоположение в документе. Из-за ошибок типа «Какой раздел?» такие маркеры удаляются; вместо этого можно указывать «согласно разделу 4.2». Расходы остаются практически нулевыми, но только в том случае, если на этапе 0 был сгенерирован настоящий markdown. В противном случае упрощенный текст бесшумно превращается в один огромный фрагмент. Этот метод наилучшим образом подходит для вики-страниц, технической документации и документов с заголовками.
Вместе с вектором должны передаваться метаданные: идентификатор исходного документа, путь к разделу, дата вступления в силу и юрисдикция, если правила различаются в разных штатах. Эти поля позволяют использовать фильтры («только HO-3, действующий с 2024-01-01»), которые невозможно реализовать с помощью чистого поиска по сходству.
Учитывающий макет / по заголовку
Неструктурированный метод chunk_by_title (и аналогичные в LlamaParse/Docling) соблюдает границы элементов парсера: таблицы остаются целыми, заголовки начинают блоки данных, списки сохраняют свои вводные предложения. Идеально подходит для контрактов и сложных PDF-документов. API для парсинга стоят денег только один раз при создании индекса. Дешевый парсер сверху устраняет необходимость в дорогих решениях — не стоит покупать роскошное рулевое управление для автомобиля без двигателя.
Учитывающий код (AST)
Неактуально для корпусов политик, но необходимо для Terraform/Python RAG. Разделение по строкам позволяет отделять подписи от основного текста. Инструменты вроде tree-sitter или рекурсивные разделители, учитывающие особенности языка, делают разрез на узлах функций/классов. Является стандартом, когда корпус состоит из репозиториев или инструментов IaC.
Смешивание правил обработки текста и исходного кода в одном индексе без использования разделителей обычно приводит к наихудшему из возможных вариантов: функции обрезаются на середине, а таблицы правил разрушаются из-за гиерархических правил обработки.
Уровень 3: Разбиение на части на основе модели — оплачивайте принятие решений только там, где отсутствует структура
На участке с золотыми примерами результат составил около 84%, и появился новый класс сбоев: неструктурированные транскрипции звонков без заголовков. Рекурсивные блоки по 500 токенов охватывали смену тем — сначала информация о страховом случае, затем адрес для доставки — в результате эмбеддинги отражали среднее значение двух тем и плохо соответствовали ни одной из них.
Семантическое разбиение на части
Встраивайте текст по предложениям; прерывайте процесс там, где последовательное сходство по косинусу падает ниже установленного порога (SemanticChunker, любой приемлемый инструмент для генерации эмбеддингов). Одна процедура генерации эмбеддингов на этапе обработки индекса. Метод применим к речи; пороговые значения зависят от конкретного корпуса. Порог для телефонных разговоров привел к разделению длинных текстов правил на мелкие фрагменты — поэтому используйте семантическое разбиение только для неструктурированной речи, а правила оставьте на втором уровне обработки.
Разметка документов по классам — речь, формы, вики — эффективнее поиска универсального инструмента для разбиения текста. Затраты на организацию обработки сводятся к использованию классификатора или простого механизма определения типа файла при загрузке; в результате получается меньше нечетких эмбеддингов.
Разбиение на предложения/атомарные факты
Небольшая нейросеть переписывает отрывки в отдельные утверждения («Лимит ответственности за наводнение для зоны A по полису HO-3 составляет 5 000 долларов»). Точность повышается, поскольку каждое утверждение представляет собой отдельный факт с встроенным контекстом. Стоимость составляет один вызов нейросети на отрывок — что целесообразно для небольших корпусов с высокой точностью, таких как 40-страничный FAQ. Однако существует риск: формулировки могут отклоняться от оригинальных. Когда клиенты оспаривают ответы, дословное цитирование лучше парафраза. Используйте утверждения в качестве помощи при поиске и возвращайте оригинальный отрывок для цитирования.
Организации, занимающиеся соблюдением норм и обработкой претензий, в конечном итоге будут требовать изображение страницы или выделенный фрагмент в формате PDF к ответу. Заранее определите идентификаторы цитат, чтобы индексы утверждений оставались инструментом ускорения работы, а не основным источником данных.
Агентное разбиение на части
Передайте целый документ флагманской модели для определения границ. Этот подход работает, но плохо масштабируется: затраты растут линейно с размером корпуса, в то время как польза — нет. Подходит для нескольких сотен важных документов; не подходит для миллионов.
Уровень 4: Разбиение на части во время поиска — поиск небольших единиц, возвращение более крупных
На уровнях 1–3 предполагается, что индексируемая единица совпадает с возвращаемой. Это приводит к ложному компромиссу: маленькие части обеспечивают чистую структуру эмбеддингов, но лишают большие языковые модели контекста; крупные части дают контекст, но искажают эмбеддинги. Настройка параметра chunk_size никогда не позволяет найти универсально оптимальное решение.
На уровне 4 разделяются функции: единица поиска, размер которой соответствует требованиям модуля генерации эмбеддингов, и единица передачи, размер которой соответствует требованиям большой языковой модели. Критерий оценки: если вам нужен дополнительный хранилище (docstore, карта родителей, дерево), вы находитесь на уровне 4.
Родительский документ / от малого к большому
Индекс маленьких дочерних элементов (150–200 токенов). При поиске возвращается более крупный родительский элемент (весь раздел 4.2 или подраздел длиной около 1500 токенов):
from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryStore
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=1500)
child_splitter = RecursiveCharacterTextSplitter(chunk_size=200)
retriever = ParentDocumentRetriever(
vectorstore=vectorstore,
docstore=InMemoryStore(), # the "second store"
child_splitter=child_splitter,
parent_splitter=parent_splitter,
)
retriever.add_documents(policy_docs)
LlamaIndex AutoMergingRetriever предлагает аналогичную иерархию. Дополнительные затраты на индексацию практически равны нулю — один и тот же текст включается в более мелкие фрагменты. Уже благодаря этому изменению на «золотом наборе» точность результатов Atlas выросла с примерно 84% до ~91%; запрос «what’s my flood deductible» находил точную строку таблицы, в то время как большая языковая модель всё ещё учитывала сопутствующие примечания (включая определение зоны A). Рабочие затраты сводятся к использованию хранилища документов, ключом к которому служит ID родительского элемента, обновляемого при любых изменениях.
Здесь важны процедуры удаления и версионирования: при редактировании раздела 4.2 дочерние и родительские элементы должны обновляться одновременно, иначе система может вернуть точный дочерний элемент, связанный с устаревшим родительским. Процесс ввода данных следует рассматривать как транзакционную операцию по публикации родительского элемента, его дочерних элементов и встроенных данных — а не как три независимых задания.
Контекстуальный поиск
Перед встраиванием попросите небольшую модель LLM сгенерировать одно-два предложения для контекстуализации и добавьте их в начало; сочетайте это с алгоритмом BM25. Фрагменты вроде «Зона А: $5,000 / Зона B: $2,500» становятся доступными для поиска. Стоимость составляет один вызов модели LLM на каждый фрагмент — это дорого без кэширования промптов; с кэшированием префикс документа остается в памяти, и стоимость вызовов на каждый фрагмент снижается.
Позднее разделение на фрагменты
Встраивайте весь документ с помощью инструмента для генерации эмбеддингов с длинным контекстом, затем объединяйте векторы токенов внутри каждого фрагмента. Векторы фрагментов сохраняют контекст документа без необходимости вызова модели LLM на каждый фрагмент. Этот метод конкурентоспособен с методами поиска с учетом контекста, но требует использования моделей эмбеддингов с длинным контекстом.
Иерархический подход / RAPTOR
Группируются блоки информации, формируются краткие сводки, которые затем снова группируются в виде дерева; индексируется каждый уровень. Общие вопросы направляются на сводки, а конкретные — на отдельные элементы дерева. Высокая стоимость обработки на четвертом уровне делает процесс сложным при изменении документов — пересоздание структуры требует значительных затрат. Ежеквартальные обновления политик сделали RAPTOR подходящим решением для Atlas.
Статические базы знаний — внутренние энциклопедии, которые обновляются ежегодно — могут выдерживать пересоздание дерева структуры. Динамические формы страхования этого не выдерживают. Выбирайте иерархические методы только тогда, когда учтены скорость изменений и бюджет на пересоздание.
Что говорит обновленная инструкция
Шесть недель после учений по чрезвычайным ситуациям в Slack система Atlas показала результат около 93% на наборе из примерно 300 вопросов. Краткая версия с результатами анализа дизайна:
Начните с рекурсивного разделения текста на символы в сочетании с разрезами, учитывающими структуру, примерно на уровне 500 токенов и 50% перекрытия — это почти не требует затрат и позволяет получить измеримую базовую оценку. Сначала обновите систему до возможности поиска в родительском документе, чтобы размеры результатов поиска и передаваемого контента могли отличаться. Только после этого инвестируйте средства в контекстуальный поиск, если при оценке всё ещё наблюдается проблема с воспроизведением информации.
Ничто из вышесказанного не сможет исправить плохую обработку текста. Сначала отремонтируйте процесс извлечения данных, прежде чем настраивать инструменты разделения текста.
Версия в одной странице
Этап 0 — Обработка текста. Подберите подходящие инструменты обработки к исходным данным: структурированный Markdown для оригинальных документов; данные в формате текст+макет+уровень уверенности от OCR для сканов; чистый Markdown с заголовками для HTML-документов; границы слайдов или листов для презентаций; транскрипции аудио в формате дневника событий. Это определяет верхний предел качества обработки.
Уровень 1 — синтаксический. Фиксированный размер только для прототипов. Допускается перекрытие в диапазоне (10–15%), что приводит к появлению дублирующихся результатов типа top-k. Разделение предложений осуществляется с учетом грамматики, без учета логики документа. Рекурсивный подход с коэффициентом 500/50 является стандартной отправной точкой, но не конечной целью.
Уровень 2 — учитывающий структуру. Разделение заголовков включает пути к разделам; качество зависит от парсера. Режим расположения by_title сохраняет целостность таблиц; дешевые парсеры нарушают это. Для хранилищ кода используется разделение по дереву синтаксического анализа.
Уровень 3 — основанный на моделях. Семантическое разделение производится на основе степени сходства; настройки варьируются в зависимости от корпуса данных. Предложения повышают точность при работе с небольшими корпусами, но могут отклоняться из-за особенностей формулировок. Агентные методы редко оправдывают себя в масштабных задачах.
Уровень 4 — Время извлечения. Соответствие на небольших единицах; доставка крупных. Родительский документ представляет собой оптимизацию с наивысшей отдачей. Для контекстного поиска необходим кэширование. Разбиение на части позже стоит дешевле, но ограничивает возможности инструмента встраивания данных. Инструмент RAPTOR устаревает при обновлениях. Если требуется второе хранилище, это относится к уровню 4.
Проблема, которая вызвала споры, на самом деле касалась не выбора между LangChain и LlamaIndex. Речь шла о необходимости соблюдения структуры документа прежде чем применять математические операции, о тестировании инструментов разбиения на части на реальных вопросах клиентов и о отказе от использования фрагментов модели, которые вряд ли могли содержать полную числовую информацию.
Создавайте «золотой набор» на основе тех данных, которые уже приводили к проблемам в продукте — неверные суммы страховых выплат, отсутствующие исключения, неясные условия страхования, — а не на основе синтетических данных. Оценивайте правильность ответов и точность цитирования отдельно, чтобы случайная ошибка никогда не выглядела как победа. Когда появляется новый вариант решения, требуйте от него превзойти базовые показатели по обоим критериям перед тем, как он начнет использоваться в реальных условиях.
Наконец, обязательно предусмотрите механизм отключения: если уровень уверенности OCR в числовых данных низкий или версии родительского и дочернего элементов не совпадают, ассистент должен отклонить вопрос о страховых выплатах и передать его на более высокий уровень обработки, вместо того чтобы выдумывать значение уверенности. Пользователи гораздо быстрее прощают фразу «невозможно проверить по предоставленной форме», чем случайный неверный ответ на сумму в пятьсот долларов. Такой механизм отклонения должен быть указан в требованиях к продукту, а не только в презентации с анализом ошибок, созданной после того, как ущерб уже нанесен.