Главная / Статьи / Проектирование эффективной системы RAG: разбиение на чанки, фильтрация и потоковая обработка

Проектирование эффективной системы RAG: разбиение на чанки, фильтрация и потоковая обработка

Пошаговое руководство по созданию надежной основы RAG: разбиение на фрагменты с учетом структуры, безопасное разделение токенов, трехэтапная фильтрация результатов поиска, воссоединение связанных фрагментов, проектирование промптов и потоковая обработка.

3895 слов

Универсальные языковые модели способны отлично рассуждать, писать и объяснять, пока вы не зададите вопрос о системе, которую они никогда раньше не видели: вашей внутренней документации, рабочих процессах вашей области, точном поведении корпоративного приложения, которое ваша команда настраивает ежедневно. Эти знания отсутствуют в данных обучения, и никакие трюки с запросами не могут их туда добавить. Что ещё хуже, модель редко признаёт этот пробел; она выдает убедительный и плавный ответ на основе догадок. Ниже приведена основа системы генерации с усилением с помощью поиска информации (RAG): как готовятся документы, как формируются фрагменты текста, как фильтруются кандидатские варианты, как формируется запрос и как ответы доходят до пользователя, а также обоснование каждого решения, чтобы вы могли применить это к своей разработке.

Почему использование внешних данных меняет задачу модели

Суть технологии RAG легко объяснить. Вместо того чтобы полагаться на способность модели запоминать ответы, вы предоставляете ей сам текст для прочтения. Ваша документация разбивается на поисковые фрагменты; когда поступает вопрос, система находит те участки, которые с наибольшей вероятностью могут на него ответить, и включает их в контекст модели. Задача меняется с «вспомните этот факт» на «прочитайте этот отрывок и хорошо его объясните», что как раз соответствует тому, что языковые модели умеют делать надежно.

Концепция проста, но сделать её эффективно работающей — не так-то просто. Получение нужных данных, их согласованность и достаточно быстрый ответ, чтобы никто не смотрел на индикатор загрузки, — всё это требует решений, которые легко недооценить. Описанная здесь система представляет собой намеренно начальный этап: сначала получение данных, затем ответ. Более амбициозные расширения, такие как графы знаний или агенты, использующие инструменты, строятся именно на этих элементах, и они будут работать только при прочном фундаменте.

Markdown как источник истины

Вся информация, на основе которой отвечает система, хранится в файлах Markdown. Этот выбор обусловлен практичностью, а не эстетикой. Markdown обеспечивает достаточную структуру для сохранения смысла, включая заголовки, иерархию, списки и таблицы, при этом остаётся простым текстом, который можно разбивать на части и встраивать без необходимости использования более сложных форматов разметки.

Файлы составляются так, как люди обычно пишут техническую документацию: заголовок для каждой темы, подзаголовки для деталей под ними, таблицы там, где важна структура, и скриншоты там, где изображение объясняет ситуацию быстрее, чем проза. Стиль написания совсем не подстраивается под процессы обработки документации. Это важно, потому что документация, которую необходимо создавать для поисковых систем, часто вообще не пишется.

Исключение скриншотов из пути встраивания

Скриншоты требуют отдельного дизайнерского решения. В документации к приложениям они не являются украшением: большинство вопросов типа «как это сделать» на самом деле касаются того, какой кнопкой или меню воспользоваться, а проза одна с трудом справляется с этой задачей.

Вместо встраивания изображений прямо в файлы документации они хранятся отдельно, и каждый файл Markdown ссылается на них по URL. Ссылка представляет собой просто текст, поэтому она проходит через процессы разбиения на части, встраивания и извлечения так же, как и любой другой текст. Когда полученный фрагмент с такой ссылкой попадает в ответ, фронтенд читает URL и загружает изображение в момент отрисовки. Пользователь видит документацию в том виде, в котором она была написана, включая изображения, тогда как система обработки данных никогда не обрабатывает сами данные изображений.

Этот принцип стоит назвать: сохраняйте контент в виде текста на всех этапах, где требуется только текст, а более сложные медиафайлы обрабатывайте в самый последний момент, когда они собираются быть показаны пользователю.

Стриминг ответа по мере его генерации

Тот же принцип «последнего шага» применим и при предоставлении ответов. Как только начинается генерация, нет причин заставлять пользователя ждать полного ответа. Токены поступают обратно через постоянное соединение по мере их генерации моделью, так что ответ формируется постепенно, слово за словом, примерно как если бы кто-то печатал объяснение. В сочетании с изображениями, которые загружаются асинхронно по мере появления их URL в потоке, весь процесс напоминает разговор, хотя на самом деле это скорее запрос к базе данных с последующей генерацией.

Иерархическое разделение на части: учет структуры документа

Прежде чем что-либо можно будет искать, документ необходимо разделить на единицы, каждая из которых может быть встроена и извлечена независимо. Именно на этом этапе многие системы RAG скрытным образом упускают важные детали, и последствия этого легко ускользнуть от внимания.

Почему фиксированное разделение на части работает неэффективно

Наивный подход является механическим: выбирается определенное количество токенов, документ разделяется на фрагменты примерно такого размера, и процесс продолжается. Его легко реализовать, но он ошибочен, причем эта ошибка никогда не приводит к возникновению ошибки. Окно фиксированного размера не учитывает границы предложений, а тем более концептуальные границы. Оно может разделить пронумерованную процедуру пополам, оторвать таблицу от заголовка, придающего ей смысл, или оставить определение в одном фрагменте, в то время как пример окажется в следующем.

Такие фрагменты имеют правильную длину, но неправильную структуру, и качество извлечения текста зависит от этой структуры. Если извлеченный текст не содержит полной мысли, тщательные указания на последующих этапах не смогут ее восстановить. Модель рассуждает на основе фрагмента, и ответ отражает это.

Вместо этого — чтение дерева заголовков

Иерархическое разбиение начинается с осознания того, что документация уже имеет структуру, и что эта структура является преимуществом. Хорошо написанные технические документы представляют собой дерево заголовков: основные разделы, вложенные под ними подразделы и текст основного содержания на каждом уровне. Вместо того чтобы игнорировать это дерево, инструмент разбиения проходит по нему:

  • Каждый основной раздел становится отдельным блоком.
  • Каждый подраздел также становится отдельным блоком, но с указанием своего родительского раздела.
  • Если подраздел достаточно короткий, чтобы лучше читаться вместе с родительским разделом, они объединяются в один общий блок вместо того, чтобы создаваться как крошечные фрагменты без контекста.
  • Контент, который не относится ни к какому заголовку, такой как введения, отдельные абзацы или примечания, также сохраняется как отдельный тип блока, вместо того чтобы быть удаленным или некорректно включенным в соседний блок.

Метаданные, обеспечивающие возможность последующих этапов

Каждый фрагмент содержит не только текст:

  • Очередь заголовков: цепочка родительских разделов. Фрагмент, посвящённый отдельному параметру конфигурации, всё равно понимает, что принадлежит более широкому рабочему процессу, даже при извлечении в отдельности.
  • Метка типа: позволяющая различать верхние уровни разделов, вложенные детали, объединённые фрагменты родителя и потомка и отдельные заметки.
  • Стабильный идентификатор: связывающий фрагмент с его точным источником в исходном файле.

Эти метаданные — не декорация. Именно они позволяют позже переустанавливать ранжирование, воссоздавать разделённые части и получать связные, привязанные к источнику ответы. Простой список текстовых блоков одинакового размера не может обеспечить ничего из этого; фрагмент, знающий своё место в структуре, может.

Основной компромисс заключается в соблюдении уже существующей структуры документа вместо применения произвольной. Это быстро окупается: фрагменты кажутся полноценными мыслями, потому что на самом деле являются ими, оформленными в соответствии с тем подходом, который использовал автор документации.

Адаптивный второй этап для ограничения количества токенов

Иерархическое разделение на фрагменты позволяет правильно сформировать структуру, но оно не учитывает скрытое строгое ограничение: модель генерации векторов принимает лишь ограниченное количество токенов на вход. Структура документа не учитывает это ограничение. Длинный раздел с часто задаваемыми вопросами, обширная таблица справок или подраздел, который просто слишком длинный, могут быть полностью связными фрагментами, но при этом превысить лимит, допускаемый моделью.

Эта ошибка проявляется крайне тихо и опасно. В зависимости от клиента слишком большой фрагмент либо отклоняется, либо молча обрезается перед вставкой, и в обоих случаях контент исчезает незаметно для пользователя в момент обработки. Обрезка является более коварным результатом, поскольку фрагмент всё ещё существует и продолжает загружаться; его вектор просто больше не отражает текст, которым он, по вашему мнению, является.

Решение заключается во втором этапе обработки после иерархического. Каждый фрагмент сравнивается с безопасным порогом, установленным значительно ниже истинного максимума модели, что обеспечивает запас прочности вместо резкого ограничения. Количество токенов различается в зависимости от инструментов токенизации, и запас компенсирует эту неопределённость. Фрагменты, находящиеся ниже порога, проходят без изменений. Фрагменты, превышающие его, делятся дальше, и каждый полученный кусок имеет:

  • явную метку как часть более крупного целого, чтобы последующие этапы могли найти его сопутствующие элементы
  • Поскольку текст с соседним фрагментом немного перекрывается, ни человек, ни модель, читающие его, не сталкиваются с резким прерыванием без учета контекста посреди мысли
  • Вопрос о том, где именно производить разрез и почему примитивное разделение здесь тоже недостаточно, сам по себе является важной темой проектирования. Основная идея заключается в том, что иерархический этап гарантирует правильную форму, а адаптивный — соответствие размерам, так что хорошая форма никогда не стоит вам контента.

    Хранение векторов с их метаданными

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

    База данных работает в контейнере, а не устанавливается непосредственно на хост-систему. Причины этого практические: инстанция в контейнере легко запускается, удаляется или перемещается на другой компьютер, при этом она не создает дополнительных зависимостей на хост-системе.

    Помимо каждого вектора, хранилище сохраняет полный набор метаданных, включающий текст фрагмента, информацию о структуре данных, тип и идентификатор источника. Таким образом, каждый результат поиска сходства поступает вместе со всеми необходимыми контекстными данными, что позволяет использовать его сразу, а не как анонимный вектор. Сочетание быстрого поиска сходства с подробными метаданными, привязанными к каждому результату, обеспечивает возможность фильтрации, переранжирования и объединения результатов без необходимости дополнительного поиска в другом источнике информации.

    Жизненный цикл запроса: от вопроса до потокового ответа

    Именно здесь система выполняет свою основную работу, поэтому важно описать её достаточно точно, чтобы логику можно было повторно использовать.

    Разделение процесса отправки и потоковой передачи

    API предоставляет два конца точек с разными задачами. Первый принимает вопрос и немедленно возвращает идентификатор разговора. Второй — это конец точки потоковой передачи, к которому фронтенд подключается с помощью этого идентификатора. Принятие вопроса и предоставление ответа — это разные задачи, и именно их разделение позволяет получать ответы постепенно, вместо того чтобы держать открытым один запрос до готовности единственного монолитного ответа. Это также даёт клиенту удобный способ для повторного подключения или сопоставления логов конкретного разговора.

    Между этими двумя концами точек последовательно выполняются следующие шаги.

    Шаг 1: встраивание вопроса с помощью модели обработки данных

    Текст пользователя эмбеддится с использованием точно того же модели, что и при его вводе. Это параметр, который нельзя изменить. Сравнения похожести имеют смысл только тогда, когда вектор запроса и сохраненные векторы находятся в одном векторном пространстве. Если модель эмбеддинга когда-либо изменится, каждый сохраненный фрагмент необходимо будет перэмбеддить, поскольку векторы из двух разных моделей несопоставимы даже в том случае, если они описывают одинаковый текст.

    Шаг 2: широкий поиск

    Вектор запроса сравнивается с векторами сохраненных фрагментов, и находятся примерно двенадцать самых близких результатов. Для измерения используется косинусная похожесть, которая по сути показывает угол между двумя векторами: чем меньше угол, тем выше степень схожести значений. На этом этапе применяется более щедкий подход. Его цель — обеспечение полноты восстановления информации: убедиться, что ничего значимого не будет проигнорировано до начала финального отбора.

    Шаг 3: отбор кандидатов через три этапа

    Затем последовательно применяются три фильтра, чтобы сократить десяток кандидатов до тех немногих, которые действительно важны:

    1. Порог сходства. Удаляются кандидаты с показателем ниже минимального значения. Это фрагменты, попавшие в список лишь потому, что лучших не было, и их сохранение привело бы к появлению лишней информации.
    2. Повторная оценка ранга. Оставшиеся кандидаты переоцениваются с помощью модели, работающей иначе, чем при первом поиске. Вместо того чтобы отдельно встраивать вопрос и каждый фрагмент, а затем сравнивать полученные векторы, модель использует вопрос и один фрагмент в качестве совместного входных данных и определяет, действительно ли этот фрагмент отвечает на вопрос. Это позволяет исключить определенные ложноположительные результаты: фрагменты, находящиеся рядом с запросом в векторном пространстве, но не содержащие ответа при сопоставлении с ним.
  • Строгий минимум и жесткий максимум. После переранжирования гораздо более строгий порог удаляет часть оставшихся элементов, а то, что выживает, ограничивается небольшим фиксированным максимумом.
  • У каждого этапа есть своя функция: первый защищает результаты от шума, второй обеспечивает точность, а третий устанавливает строгий лимит на объем контекста, доходящего до модели. В итоге остается лучший доступный материал, а не просто элементы в верхней части длинного списка.

    Причина такого порядка — стоимость. Метод переранжирования на основе сопоставления пар гораздо точнее, чем сравнение векторов, но в то же время значительно дороже, поскольку он должен обрабатывать каждую пару фрагментов вопроса совместно. Его применение к дюжине предварительно отфильтрованных вариантов вместо всего корпуса позволяет получить нужную точность без полной стоимости.

    Шаг 4: восстановление разделенных секций

    Некоторые сохранившиеся фрагменты представляют собой лишь части раздела, которые при адаптивной обработке пришлось разделить. Если модель получает, скажем, вторую из трех частей без каких-либо указаний на существование остальных, её ответ основывается на неполной информации. Поэтому перед сборкой запроса каждый сохранившийся фрагмент проверяется на наличие «братьев» — других частей того же исходного раздела. Если такие части существуют, их загружают и располагают в правильном порядке, каждая с указанием своей позиции. Затем модель читает весь раздел, а не лишь его фрагмент. Именно здесь оказываются полезными метки частей и стабильные идентификаторы, добавленные при обработке данных.

    Шаг 5: сборка запроса

    Все полученные фрагменты вместе с их восстановленными аналогами образуют основу контекста. Вопрос пользователя передается вместе с ним, а системное указание, описанное в следующем разделе, сообщает модели, какую роль ей играть и как использовать полученный материал.

    Шаг 6: передача ответа в потоковом режиме

    Сгенерированный текст возвращается через тот же точку входа в потоковом режиме, к которой сначала подключился клиент, причем поступает порциями вместо одного большого ответа. Пользователь может наблюдать, как формируется ответ, в реальном времени.

    Подводя итог: векторизуем запрос, выполняем поиск в широком диапазоне, фильтруем результаты через три этапа, заполняем отсутствующие части, задаем указания модели и передаем её вывод в потоковом режиме. Ни один из этих этапов не является чем-то особенным. Качество обусловлено четкой передачей данных между этапами и тем, что каждый этап выполняет только одну функцию.

    Разработка указаний для модели: формат, модель и тон

    К моменту сборки запроса проблемы сложного поиска данных уже решены: были найдены подходящие фрагменты, отфильтрованы и снова объединены. Остается лишь то, что так же легко можно сделать неправильно — представить материал таким образом, чтобы модель дала хороший ответ, а не просто технически корректный.

    Написание запроса в формате Markdown

    Сам запрос написан на Markdown. Причина здесь также практическая: модели видели огромное количество примеров Markdown в документации, файлах README и технических текстах, поэтому инструкции в знакомом формате обрабатываются более надежно, чем инструкции в нестандартной структуре, которую модели приходится расшифровывать. Заголовки разделяют секции запроса, полученный контекст четко отделен от окружающих инструкций, а любой контент, собранный из разделенных частей, имеет видимый маркер, что позволяет модели отличить «одну целостную идею, собранную из фрагментов», от обычного полученного текста.

    Выбор более маленькой и быстрой модели для синтеза

    Модель, которая формирует окончательный ответ, — это более мелкая и быстрая версия, а не самая мощная из доступных. Это может показаться противоречивым, пока вы не увидите, какую именно задачу она выполняет. От неё не требуется ничего создавать с нуля, запоминать редкие детали или компенсировать отсутствующие знания; тяжёлая работа уже была выполнена ранее, в процессе поиска и фильтрации. Её оставшаяся задача — взять тщательно отобранный и аккуратно организованный контекст и превратить его в чёткий, быстрый ответ правильным тоном.

    Это задание на синтез. Когда контекст уже имеется, компактная модель дает ответы примерно такого же качества, как и гораздо более большая модель, при этом работает быстрее и требует гораздо меньше ресурсов. Способность к логическим рассуждениям у более крупной модели ценна тогда, когда ей необходимо что-то вывести из данных. Она становится гораздо менее ценной, когда ответ уже доступен, и задача заключается лишь в его правильном объяснении. Баланс зависит от качества получения контекста: чем слабее контекст, тем важнее способность крупной модели справляться с неоднозначностями, что является ещё одной причиной для инвестиций в этапы фильтрации.

    Структура запроса

    Системный запрос следует фиксированной структуре, а не импровизированному варианту:

    • Роль. В начале указывается, какой помощник является моделью и в какой области она работает, так что тон и предпосылки задаются с первой же строки.
  • Задача. Здесь четко описывается работа: необходимо прочитать полученный контекст и отвечать исключительно на его основе, не обращаясь к общим знаниям или догадкам, даже если ответ кажется очевидным.
  • Уверенность и пробелы в знаниях. Краткое вступление указывает, насколько уверенно или осторожно должен звучать ответ, и что делать, когда в контексте действительно нет ответа: следует прямо об этом сказать, вместо того чтобы что-то выдумывать.
  • Тон и стиль. Указывается уровень формальности, типичная длина ответа, а также то, следует ли сразу давать прямой ответ или постепенно к нему подводить, вместо того чтобы оставлять это на усмотрение.
  • Ничего не оставляется на усмотрение стандартного представления модели о том, как должен звучать полезный помощник. Каждое поведение прописано подробно, точно как при ознакомлении нового коллеги с правилами коммуникации в команде перед поручением ему работы.

    Результатом является ассистент, который уверенно отвечает, когда у него есть основания для этого, честно признаёт отсутствие информации и сохраняет единый тон во всех разговорах. Такая последовательность не происходит от самой модели; она обусловлена формулировкой запроса к ней.

    Маршрутизация по намерению: обычные фрагменты и экранные фрагменты

    Вопросы, кажущиеся похожими, могут касаться совершенно разных вещей. Вопрос вроде «Как рассчитывается цена в этом рабочем процессе?» носит концептуальный характер и требует объяснения. Вопрос вроде «Какое поле на этом экране используется для указания цены?» имеет навигационную направленность и требует указания конкретного поля, кнопки или области интерфейса. Оба вопроса могут относиться к одной и той же части документации, но идеальные ответы на них практически не имеют общего. Равнозначное отношение к ним приводит либо к длинном концептуальном объяснении для того, кому нужно было лишь указание местоположения, либо, что ещё хуже, к абстрактному описанию элемента интерфейса, когда пользователю требовалось его точное местоположение.

    Разделение знаний при получении информации

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

    Выбор способа ответа в момент запроса

    При поступлении вопроса он анализируется с целью определения того, какого типа информации на самом деле требуется: концептуальная документация или подробные сведения о конкретных экранах и полях. Эта классификация влияет на то, какие блоки информации будут иметь приоритет в процессе поиска и какой ответ получит модель:

    • Для вопроса, ориентированного на экран, используется подсказка, направленная на получение точных ответов относительно элементов интерфейса: конкретные, чёткие и с акцентом на точное местоположение и характеристики.
    • Для концептуального вопроса используется подсказка, способствующая объяснению: более широкая по охвату и позволяющая связывать различные идеи.

    В обоих случаях одинаковы механизм поиска информации и модель генерации ответов; меняется лишь подход к ответу, который выбирается в зависимости от потребностей вопроса, а не применяется единый универсальный шаблон.

    Это важнее, чем кажется. Ассистент с одним стилем ответов звучит общим, как бы хорошо он ни справлялся с поиском информации. Именно распознавание намерения, а не просто темы, позволяет ассистенту казаться внимательным к тому, что на самом деле было спрошено. Если вы будете использовать эту модель, предусмотрите резервный вариант для неоднозначных вопросов — например, перейдите к концептуальному подходу и включите все сильно соответствующие фрагменты экрана, чтобы ошибочная классификация приводила к более гладкому результату, а не к неверному стилю ответа.

    Контроль памяти

    Аккуратное извлечение и генерация данных бесполезны, если сервис постепенно исчерпывает свою память. Процесс, который встраивает текст, сохраняет фрагменты данных в оперативной памяти, формирует запросы и передаёт результаты, делая это повторно и иногда одновременно, тихо накапливает нагрузку на память, если только кто-то активно её не управляет. Объекты, нужные только для одного запроса, склонны оставаться в памяти дольше, чем следует, если никто не удаляет их после использования.

    Поэтому очистка памяти не оставляется на усмотрение случая. В естественных моментах, таких как окончание запроса или завершение обработки пакета данных, память явно освобождается вместо того, чтобы полагаться на то, что она сама со временем освободится. На практике это означает устранение ссылок на крупные промежуточные объекты, очистку кэша для каждого запроса и, в случае локально размещённых моделей, освобождение всей памяти, которую хранит среда выполнения.

    Это непривлекательная работа, которая никогда не показывается в демо-версии и замечается лишь тогда, когда её нет: сервис, производительность которого снижается со временем длительной работы, вместо того чтобы отвечать на тысячный запрос так же быстро, как и на первый. Для её правильной реализации важнее дисциплина, чем изощрённая инженерия — необходимо рассматривать память как то, что управляется на каждом этапе, а не как что-то, с чем обязательно справится движок выполнения. Наблюдение за использованием памяти во время длительного теста, а не только короткого тестирования на производительность, — это самый простой способ подтвердить наличие такой дисциплины.

    Основные выводы

    Описанная здесь основа представляет собой полную и надёжную систему RAG: разбиение на части с учётом структуры, вкладка токенов без потерь данных, многоэтапная фильтрация, воссоединение разделённых частей, чёткие инструкции для модели, обеспечивающие точность её ответов, и доставка результата в реальном времени. Самые важные решения заключаются в следующем:

    • Храните документы в структурированном формате простого текста и загружайте изображения и другие медиафайлы только во время отрисовки.
    • Разделяйте содержимое по дереву заголовков и добавляйте к каждому фрагменту «маршрутную сетку», метки типа и стабильные идентификаторы.
    • Выполните второй проход, при котором будет соблюдаться лимит токенов модели включения с учетом запаса прочности, а также будут использоваться маркированные части и небольшие перекрытия.
    • Всегда используйте ту же модель для обработки запросов, что и при их вводе, и перезагружайте всё при изменении этой модели.
    • Сначала проведите массовый поиск, затем сузьте результаты с помощью порога сходства, алгоритма переранжирования на основе парного чтения и строгого окончательного лимита.
    • Пересоберите разделенные части перед отправкой запроса модели, чтобы она никогда не обрабатывала немаркированный фрагмент.
    • Позвольте более маленькой и быстрой модели выполнять синтез после того, как поиск выполнит основную работу, и четко определите ее роль, задачи, способы обработки пробелов в данных и тон ответа.
  • Классифицируйте намерение, а не только тему, и применяйте для каждого типа вопроса свой способ ответа.
  • Восстанавливайте данные из памяти по запросу и при изменении границ пакетов, чтобы сервис оставался таким же быстрым после многих часов работы, как и при запуске.
  • Каждый из этих подходов сам по себе незначителен. Вместе они создают систему, ответы которой основаны на фактах, являются последовательными и быстрыми, а также обеспечивают прочную основу для более сложных методов поиска в будущем.

    Связанная литература