Проєктування ефективної системи RAG: часткове розділення, фільтрація та стрімінг
Огляд фундаменту RAG з орієнтацією на реальні дані: частковий розбір з урахуванням структури, безпечне розділення токенів, трьохетапна фільтрація результатів пошуку, збирання інформації з братських елементів, проектування запитів та стрімінг даних.
Моделі мов загального призначення дуже добре розуміють, пишуть та пояснюють, поки ви не запитуєте про систему, яку вони ніколи не бачили: вашу внутрішню документацію, робочі процеси вашої галузі, точну поведінку корпоративного додатку, який ваша команда налаштовує щодня. Ці знання відсутні у даних для навчання, і жоден трюк з запитом не може їх додати. Ще гірше те, що модель рідко визнає цю прогалину; вона формулює переконливі та логічні припущення. Нижче наведено основи системи з підсиленням генерації через пошук інформації (RAG): як підготовлюються документи, як створюються фрагменти, як фільтруються кандидатські варіанти, як формується запит та як відповіді надходять до користувача, разом із обґрунтуванням кожного рішення, щоб ви могли застосувати це до своєї системи.
Чому наявність контексту змінює завдання моделі
Принцип роботи RAG легко пояснити. Замість того, щоб покладатися на модель у зберіганні відповіді, ви даєте їй саму відповідь для читання. Ваша справжня документація розділяється на частини, які можна шукати; коли надходить запит, система знаходить уривки з найбільшою ймовірністю дати на нього відповідь та включає їх у контекст моделі. Завдання змінюється з „згадати цей факт“ на „прочитати цей уривок та добре його пояснити“, що саме є тим типом роботи, яку мовні моделі виконують надійно.
Концепція проста, але зробити її ефективною — не так вже й просто. Знаходження потрібних даних, підтримання їхньої послідовності та швидка відповідь, щоб ніхто не дивився на індикатор завантаження, — усе це вимагає рішень, які легко недооцінити. Описана тут система є свідомо на початковому етапі: спочатку знаходження інформації, потім відповідь. Більш амбітні розширення, такі як графи знань чи агенти, що використовують інструменти, будуються саме на цих елементах, і вони працюватимуть лише за умови міцної основи.
Markdown як джерело істини
Усі знання, на основі яких відповідає система, зберігаються у файлах Markdown. Цей вибір є практичним, а не естетичним. Markdown містить достатню структуру для збереження сенсу, включаючи заголовки, ієрархію, списки та таблиці, проте залишається звичайним текстом, який можна розділяти на частини та вбудовувати без необхідності використання більш складних форматів маркування.
Файли створюються так, як люди зазвичай пишуть технічну документацію: заголовок для кожної теми, підзаголовки для деталей під нею, таблиці там, де важлива структура, та скріншоти там, де зображення пояснює ситуацію швидше, ніж проза. Жодні аспекти стилю написання не підлаштовуються під процес обробки. Це важливо, адже документація, яку потрібно створювати для пошукових систем, часто взагалі не пишеться.
Уникнення скріншотів у шляху вбудовування
Скріншоти потребують окремого дизайнерського рішення. У документації до програм вони не є лише декором: значна кількість запитань типу «як це зробити» насправді стосуються того, яку кнопку чи меню використати, а проза сама по собі погано впорається з цим завданням.
Замість вбудовування зображень у файли документації вони розміщуються окремо, і кожен файл Markdown посилається на них за URL. Посилання є просто текстом, тому воно проходить через процеси розділення на частини, вбудовування та отримання так само, як і будь-який інший текст. Коли отримана частина, що містить таке посилання, потрапляє до відповіді, фронтенд читає URL та завантажує зображення під час відображення. Користувач бачить документацію такою, якою вона була написана, включаючи зображення, тоді як процеси обробки та отримання даних зовсім не обробляють дані зображень.
Цей принцип варто назвати: зберігайте контент у вигляді тексту на кожному етапі, де потрібен лише текст, а багатомедійний контент обробляйте у останню можливу мить, коли щось ось-ось буде показано користувачеві.
Стрімування відповіді у момент її створення
Такий самий підхід „останнього кроку“ застосовується й під час надання відповідей. Як тільки починається генерація, немає причин змушувати користувача чекати на повну відповідь. Токени надсилаються назад через постійне з’єднання у міру їх створення моделлю, тож відповідь формується по кілька слів за раз, схоже на те, як хтось пише пояснення. У поєднанні з зображеннями, які завантажуються асинхронно у міру появи їхніх URL у потоці, це створює враження розмови, хоча насправді це скоріше запит до бази даних, за яким слідує генерація.
Ієрархічне розділення на частини: дотримання структури документа
Перш ніж можна буде щось шукати, документ потрібно розділити на одиниці, кожну з яких можна вбудувати та отримати окремо. Саме на цьому етапі багато систем RAG приховано ускладнюють собі роботу, і наслідки цього легко пропустити.
Чому фіксоване розділення на частини приховано провалюється
Наївний підхід є механічним: обирається певна кількість токенів, документ розділяється на фрагменти приблизно такого розміру, і процес продовжується. Його легко створити, але він є неправильним у спосіб, який ніколи не призводить до появи помилок. Вікно фіксованого розміру не має уявлення про межі речень, а тим більше про концептуальні межі. Воно без проблем розділяє нумеровану процедуру навпіл, відокремлює таблицю від заголовка, який надає їй сенсу, або залишає визначення в одному фрагменті, тоді як приклад потрапляє в інший.
Такі фрагменти мають правильну довжину, але неправильну форму, і якість отримання інформації залежить від форми. Якщо отриманий текст не містить повноцінної думки, ретельне керування на наступних етапах не може її виправити. Модель міркує на основі фрагмента, і відповідь відображає це.
Читання дерева заголовків замість цього
Ієрархічне розділення починається з усвідомлення того, що документація вже має структуру, і що ця структура є перевагою. Добре написані технічні документи утворюють дерево заголовків: основні розділи, підрозділи, розташовані під ними, та основний текст, приєднаний на кожному рівні. Замість того, щоб ігнорувати це дерево, інструмент розділення просувається по ньому:
- Кожен основний розділ стає окремим блоком.
- Кожен підрозділ також стає окремим блоком, але з записом імені його батьківського розділу.
- Якщо підрозділ достатньо короткий, щоб краще читатися разом із своїм батьківським розділом, обидва зливаються в один спільний блок замість того, щоб створювати маленький фрагмент без контексту.
- Контент, який не належить до жодного заголовка, такий як вступи, окремі абзаци чи примітки, все одно фіксується як окремий тип блоку, замість того, щоб його проігнорувати чи незграбно вставити до сусіднього блоку.
Метадані, які уможливлюють наступні етапи
Кожен фрагмент містить більше, ніж просто текст:
- Сліди навігації: ланцюжок заголовків-попередників. Фрагмент, присвятований одному параметру конфігурації, все одно знає, що належить до ширшого процесу роботи, навіть коли його отримують окремо.
- Етикет типу: допомагає розрізняти розділи верхнього рівня, вкладені деталі, об’єднані фрагменти батьківського та дочірнього рівнів та окремі примітки.
- Стабільний ідентифікатор: дозволяє пов’язати фрагмент із його точним походженням у вихідному файлі.
Ці метадані — це не декорація. Саме вони уможливлюють пізніше переранжування, знову об’єднання розділів та створення послідовних, прив’язаних до джерела відповідей. Простий список текстових блоків однакового розміру не може підтримати нічого з цього; фрагмент, який знає своє місце в структурі, — може.
Основний компроміс полягає у дотриманні структури, яка вже є в документі, замість того, щоб нав’язати якусь довільну. Це швидко приносить користь: фрагменти читаються як повнісінькі думки, тому що це справді повнісінькі думки, сформульовані саме так, як це зробив автор документації.
Адаптивний другий етап для обмеження кількості токенів
Ієрархічне розділення на фрагменти забезпечує правильну структуру, але не враховує приховану жорстку обмеження: модель ембеддингів приймає лише обмежену кількість токенів на вхід. Структура документа не зважає на це обмеження. Довгий FAQ, величезна таблиця посилань чи підрозділ, який просто є дуже довгим, можуть бути цілком послідовними фрагментами, проте все одно перевищувати ліміт, який дозволяє модель.
Ця проблема проявляється небезпечно тихо. Залежно від клієнта, занадто великий фрагмент або відхиляється, або мовчки обрізається перед вставкою, і в обох випадках контент зникає без жодних ознак під час обробки. Обрізання є більш підступним наслідком, оскільки фрагмент все ще існує та продовжує отримуватися; його вектор просто більше не відображає текст у тому вигляді, яким ви його уявляєте.
Рішенням є другий етап обробки після ієрархічного. Кожен фрагмент перевіряється за певним порогом безпеки, розташованим значно нижче справжнього максимуму моделі, щоб існував буфер замість крайньої межі. Кількість токенів варіюється залежно від інструментів токенізації, і запас компенсує цю невизначеність. Фрагменти, що перебувають нижче порога, проходять без змін. Фрагменти, що перевищують його, далі розділяються, і кожна отримана частина:
- очікувано позначається як частина більшого цілого, щоб на наступних етапах можна було знайти її сусідні елементи
Те, де саме робити розріз та чому наївне розділення тут також є недостатнім, є важливою темою проектування сама по собі. Основна ідея полягає у тому, що ієрархічний етап гарантує правильну форму, а адаптивний — її відповідність контексту, тож гарна форма ніколи не коштує вам контенту.
Зберігання векторів разом із їхньою метаданими
Вбудовані фрагменти потребують системи, створеної для одного запитання: які з цих численних векторів є найближчими до цього нового, з відповіддю, яка має надаватися швидко та у великих масштабах. Системи реляційних баз даних не створені для таких запитів, тому для цього використовуються спеціалізовані сховища векторів.
База даних працює в контейнері, а не встановлюється безпосередньо на хості. Причини цього практичні: інстанція в контейнері легко запускається, може бути видалена чи переміщена на іншу машину, і не створює жодних залежностей у системі хоста.
Разом із кожним вектором сховище зберігає повний набір метаданих із текстом фрагмента, „посиланнями-слідами“, типом та ідентифікатором джерела. Таким чином, кожен результат пошуку схожості надходить із усім необхідним контекстом для негайного використання, а не як анонімний вектор. Поєднання швидкого пошуку схожості з багатими метаданими, приєднаними до кожного результату, дозволяє ефективно фільтрувати, переранжувати та об’єднувати результати без необхідності додаткового пошуку в інших джерелах інформації.
Життєвий цикл запиту: від запитання до потокової відповіді
Саме тут система виконує свою справжню роботу, тому варто описати її достатньо детально, щоб логіку можна було повторно використати.
Розділення процесу надсилання та стрімінгу
API надає два кінцеві точки з різними завданнями. Перша отримує запитання та негайно повертає ідентифікатор розмови. Друга — це кінцева точка стрімінгу, до якої фронтенд під’єднується за допомогою цього ідентифікатора. Прийняття запитання та надання відповіді — це різні завдання, і саме їх розділення дозволяє отримувати відповіді поступово, замість того щоб утримувати один запит відкритим до моменту готовності єдиної відповіді. Це також надає клієнту зручний спосіб для повторного під’єднання чи поєднання журналів подій для конкретної розмови.
Між цими двома кінцевими точками виконуються наступні кроки у певному порядку.
Крок 1: вбудовування запитання за допомогою моделі обробки даних
Текст користувача інкорпорується за допомогою точно того самого моделі, що використовувався під час обробки даних. Це деталь, яку неможливо змінити. Порівняння схожості мають сенс лише тоді, коли вектор запиту та збережені вектори знаходяться у одному векторному просторі. Якщо модель інкорпорації коли-небудь зміниться, кожен збережений фрагмент потрібно буде переінкорпорувати, оскільки вектори з двох різних моделей не піддаються порівнянню навіть у разі опису однакового тексту.
Крок 2: широке пошукове поле
Вектор запиту порівнюється з векторами збережених фрагментів, і повертається приблизно дванадцять найбільш близьких результатів. Мірою є косинусова схожість, яка по суті вимірює кут між двома векторами: чим менший кут, тим більша схожість значень. Цей етап є навмисно широким — його мета полягає у забезпеченні повного охоплення всього релевантного контенту перед початком фінального вибору.
Крок 3: відсів кандидатів через три фільтри
Потім послідовно застосовуються три фільтри, щоб скоротити цю дюжину кандидатів до тих небагатьох, які мають значення:
- Мінімальний рівень схожості. Кандидати зі значенням нижче мінімального балу видаляються. Це фрагменти, які потрапили до списку лише через відсутність кращих варіантів, а їх збереження призведе до додаткового «шуму».
- Повторна оцінка рангу. Ті, хто залишився, переоцінюються за допомогою моделі, яка працює інакше, ніж під час першого пошуку. Замість того, щоб окремо інкорпорувати запитання та кожен фрагмент та порівнювати вектори, модель бере запитання та один фрагмент як спільний вхідний даний та визначає, чи справді цей фрагмент відповідає на запитання. Це допомагає усунути певні хибно позитивні результати: уривки, які знаходяться поблизу запитання у векторному просторі, але насправді не містять відповіді, якщо читати їх разом із запитанням.
Кожен етап виконує певну функцію: перший захищає результат від шуму, другий забезпечує точність, а третій встановлює сувору межу щодо кількості контексту, який потрапляє до моделі. Те, що залишається, — це найкращий доступний матеріал, а не просто верхня частина довгого списку.
Причиною такої послідовності є вартість. Метод переурочування на основі парного читання є набагато точнішим за порівняння векторів, але водночас значно дорожчим, оскільки йому потрібно обробляти кожну пару фрагментів запитання разом. Його використання з десятком попередньо відфільтрованих кандидатів замість усього корпусу дозволяє досягти потрібної точності без повної вартості.
Крок 4: відновлення розділених секцій
Деякі збережені фрагменти є лише частинами розділу, який адаптивний процес довелося розділити. Якщо модель отримує, скажімо, другу з трьох частин без жодних підказок про існування інших, її відповідь ґрунтується на неповній інформації. Тому перед формуванням запиту кожен збережений фрагмент перевіряється на наявність «братів» — інших частин того самого початкового розділу. Якщо вони існують, їх завантажують та розміщують у правильному порядку, кожна з них позначається своїм положенням. Після цього модель читає весь розділ, а не лише його частину. Саме тут проявляється користь від позначок частин та стабільних ідентифікаторів, доданих під час обробки даних.
Крок 5: сформувати запит
Усі отримані фрагменти разом із їхніми відновленими аналогами утворюють основний контекст. Питання користувача передається разом із ним, а системний запит, описаний у наступному розділі, пояснює моделі її роль та спосіб використання матеріалу.
Крок 6: передача відповіді у потоці
Сформований текст надсилається назад через той самий кінцевий пункт потокової передачі, до якого спочатку підключився клієнт, у невеликих порціях замість однієї повної відповіді. Користувач бачить, як формується відповідь, у реальному часі.
Підсумовуючи: векторизуйте запит, отримуйте інформацію широко, фільтруйте її через три етапи, доповнюйте відсутні частини, надайте моделі інструкції та передавайте її результат у потоці. Жоден з етапів не є складним. Якість забезпечується чіткою передачею даних між етапами та призначенням кожному етапу лише однієї функції.
Розробка запиту: формат, модель та тон
Коли запит формується, проблеми зі складним пошуком даних вже вирішені: знайдені потрібні фрагменти, відфільтровані та знову об’єднані. Те, що залишається, так само легко можна зробити неправильно – потрібно правильно представити цей матеріал, щоб модель дала гарну відповідь, а не просто технічно правильну.
Написання запиту у форматі Markdown
Сам запит написаний у форматі Markdown. Причина знову ж таки практична: моделі бачили величезну кількість тексту у форматі Markdown у документації, файлах README та технічних текстах, тому інструкції у знайомому форматі обробляються надійніше, ніж інструкції у власноруч створеній структурі, яку моделі доводиться розшифровувати. Заголовки розділяють секції запиту, отриманий контекст чітко відокремлений від навколишніх інструкцій, а будь-який контент, зібраний з розділених частин, має видимий позначник, тож модель може відрізнити „одну цілісну ідею, складену з частин“ від звичайного отриманого тексту.
Вибір меншої та швидшої моделі для синтезу
Модель, яка формулює остаточну відповідь, є меншою та швидшою, а не найбільшою з доступних. Це може здатися суперечливим, поки ви не подивитеся на ту роботу, яку вона насправді виконує. Від неї не вимагають щось створювати з нуля, пам’ятати рідкісні деталі чи компенсувати брак знань; важка робота вже виконувалася раніше, під час пошуку та фільтрації. Її залишковим завданням є взяти ретельно відібраний, акуратно організований контекст та перетворити його на зрозумілу, швидку відповідь у правильному стилі.
Це завдання на синтез. Коли контекст вже є, компактна модель досягає майже такої ж якості відповідей, що й значно більша модель, причому працює швидше та коштує набагато менше. Здатність до логічних міркувань більшої моделі є цінною, коли їй потрібно щось розрахувати. Вона значно менш корисна, коли відповідь вже є перед нею, а завдання полягає у її належному поясненні. Цей баланс залежить від якості отримання інформації: чим слабкіший контекст, тим важливішою стає здатність більшої моделі справлятися з неоднозначностями, що є ще однією причиною інвестувати у етапи фільтрації.
Складна структура запиту
Системний запит має фіксовану структуру, а не імпровізовану:
- Роль. Спочатку вказується, який це асистент та в якій галузі він працює, тож тон та припущення вже з першого рядка налаштовуються правильно.
Нічого не залишається на розсуд стандартного уявлення моделі про те, як має звучати корисний асистент. Кожна дія описана детально, подібно до інтродукції нового колеги, коли пояснюються норми комунікації в команді перед тим, як доручити будь-яку роботу.
Результатом є асистент, який впевнено відповідає, коли має підстави, відверто зізнається, коли їх немає, та зберігає послідовний тон у кожній розмові. Ця послідовність не походить від самої моделі; вона залежить від запиту, який їй надається.
Маршрутизація за наміром: звичайні фрагменти та фрагменти екрана
Питання, які виглядають схожими, можуть стосуватися зовсім різних речей. Питання на кшталт «Як обчислюється ціна в цьому процесі?» є концептуальним та вимагає пояснення. Питання на кшталт «Яке поле на цьому екрані використовується для введення ціни?» є навігаційним та потребує вказати конкретне поле, кнопку чи ділянку інтерфейсу. Обидва можуть походити з однієї частини документації, проте ідеальні відповіді майже не мають спільного. Якщо ставитися до них однаково, це призведе до довгого концептуального пояснення для того, хто просто потребував інформації про місцезнаходження, або, ще гірше, до опису елемента інтерфейсу в абстрактних термінах, коли користувач потребував його точного розташування.
Розділення знань під час отримання інформації
Розрізнення вводиться на рівні частин тексту, а не лише під час формування відповіді. Окрім звичайних частин тексту у вигляді прозової документації, існує ще одна категорія, яка містить інформацію про екрани та їхні поля: як називається кожне поле, що воно робить та де воно розташоване відносно інших полів на тому самому екрані. Ці частини не позначаються згодом; класифікація відбувається під час обробки контенту, тож до моменту надходження запитання вже існують два чітко розділені набори знань, а не один нерозрізнений масив.
Вибір способу відповіді під час обробки запиту
Коли надходить запитання, його оцінюють щодо того, якого типу частини тексту він насправді потребує: концептуальної документації чи деталей конкретних екранів та полів. Ця класифікація впливає на те, які частини тексту будуть мати пріоритет під час пошуку та яку відповідь отримає модель:
- До запитання, орієнтованого на екран, використовується підказка, налаштована на точність щодо елементів інтерфейсу: буквальна, конкретна та зосереджена саме на місці та об’єкті.
- До концептуального запитання використовується підказка, налаштована на пояснення: більш широка та схильна до поєднання ідей.
У обох випадках процес отримання інформації та модель генерації залишаються однаковими; змінюється лише підхід до відповіді, який обирається залежно від потреб запитання, а не шляхом примусового використання єдиного універсального шаблону.
Це має більше значення, ніж здається. Асистент із єдиним стилем відповідей звучить універсально, незалежно від якості пошуку інформації. Розпізнавання наміру, а не лише теми, дозволяє асистенту здаватися уважним до того, що насправді було запитано. Якщо ви будете дотримуватися цього підходу, мати запасний варіант для неоднозначних запитань — наприклад, використовувати загальну концептуальну структуру та включати всі сильно відповідні фрагменти інформації — це допоможе уникнути проблем із класифікацією та забезпечить правильний стиль відповіді.
Контроль пам’яті
Акуратне отримання та генерація даних марні, якщо сервіс поступово вичерпує свою пам’ять. Процес, який вбудовує текст, зберігає його фрагменти в пам’яті, складає запити та передає результати, повторюючи ці дії іноді одночасно, поступово створює тиск на пам’ять, якщо її ніхто активно не керує. Об’єкти, потрібні лише для одного запиту, схильні залишатися в пам’яті довше, ніж слід, якщо ніхто їх не прибирає.
Тому очищення пам’яті не залишається на волю випадку. У природних межах, таких як кінець запиту чи завершення обробки пакету даних, пам’ять активно звільняється, замість того щоб сподіватися на її автоматичне очищення з часом. На практиці це означає видалення посилань на великі проміжні об’єкти, очищення кешу для кожного запиту та, у випадку локально розміщених моделей, звільнення всієї пам’яті, яку утримує система виконання.
Це неприваблива робота, яка ніколи не з’являється у демо-версії та помічається лише тоді, коли її бракує: це сервіс, продуктивність якого знижується протягом тривалої роботи, замість того щоб на тисячний запит відповідати так само швидко, як і на перший. Щоб усе було правильно, важливіша не кмітлива інженерія, а дисципліна — ставлення до пам’яті як до чогось, що контролюється на кожному етапі, а не як до чогось, що обов’язково вирішить система під час виконання. Спостереження за використанням пам’яті протягом тривалого тесту, а не лише короткого бенчмарку, — це найпростіший спосіб переконатися, що дисципліна діє ефективно.
Ключові висновки
Описана тут основа — це повний, обґрунтований підхід RAG: розбиття на частини з урахуванням структури, безпечне вставлення токенів, багатоетапна фільтрація, збирання розділених частин, дисципліноване формулювання запитів, яке змушує модель бути чесною щодо своїх знань, та доставка результатів у реальному часі. Найважливішими є наступні рішення:
- Зберігайте документи у структурованому форматі простого тексту та обробляйте зображення та інші медіа лише під час відображення.
- Розділяйте контент за деревом заголовків та до кожного фрагмента додавайте «хлібні крихти», мітки типу та стабільні ідентифікатори.
- Додайте другий етап обробки, який дотримується ліміту токенів моделі з запасом, використовує мітковані частини та невеликі перекриття.
- Завжди використовуйте для обробки запитів ту саму модель, що й під час вхідних даних, та переобробляйте все при зміні цієї моделі.
- Спочатку отримуйте велику кількість результатів, а потім фільтруйте їх за допомогою порогу схожості, алгоритму переранкінгу та суворого кінцевого обмеження.
- Знову об’єднуйте розділені частини перед формулюванням запиту, щоб модель ніколи не аналізувала немаркований фрагмент.
- Дозвольте меншій, швидшій моделі виконувати синтез після того, як модель для пошуку виконає складну роботу, та чітко визначте її роль, завдання, спосіб обробки прогалин та тон.
Кожен з цих варіантів сам по собі є простим. Разом вони створюють систему, чиї відповіді є обґрунтованими, послідовними та швидкими, а також забезпечують міцну основу для більш складних технік пошуку в майбутньому.
Пов’язана література
- Beyond Top-K: Relevance Thresholds, Hybrid Search and Reranking in RAG — Дізнайтеся, чому база даних векторів разом із LLM не є готовою системою RAG, та як чанкінг, пороги схожості, гібридний пошук, переранкінг та оцінка допомагають подолати цей недолік.
- Чому системи RAG у продакшені виходять за межі спеціалізованих баз даних векторів — Розглядається причина того, чому самостійні бази даних векторів втрачають позиції у системах RAG у продакшені, та як гібридні методи пошуку, фільтрації та об’єднані шари даних змінюють структуру цих систем.
- Карта систем RAG: етапи пайплайну, основні компоненти та різновиди — Пояснюється, як працює процес генерації з підсиленням за допомогою пошуку від початку до кінця, які компоненти потрібні системі RAG та як багато різновидів цих систем уміщуються в одну структуру.