Бюджети токенів проти семантичних меж: як правильно розділяти частини тексту в RAG
Дізнайтеся, у чому різниця між токенізацією та чанкінгом, яке місце вони займають у процесі RAG, та як розділяти документи за змістом, водночас вимірюючи їхній розмір за допомогою справжнього токенайзера.
Джерела: Chunking vs Tokenization: The Two Splits Every AI Engineer Keeps Confusing
Пайплайн RAG може здаватися справним на кожному видимому рівні: з розумними ембеддингами, правдоподібними показниками схожості та добре заповненою базою векторів, проте все одно передавати моделі фрагменти тексту, які обриваються посеред речення. У багатьох таких випадках проблема полягає у коді обробки даних, де алгоритм розділення тексту на частини будувався на хибній припущенні щодо того, як текст перетворюється на токени. Цей посібник чітко розділяє ці два підходи, показує, де кожен з них знаходиться в пайплайні, та описує метод розділення, який спочатку враховує сенс тексту, зберігаючи при цьому точний контроль над кількістю токенів.
Інженери часто кажуть щось на кшталт „розділіть текст на 512 токенів“ або „розділення за принципом токенів“, і така формулювання поєднує дві різні операції в одну. Вони вирішують різні проблеми, виконуються на різних етапах та функціонують по-різному. Точне розуміння того, де закінчується одна операція та починається інша, дозволяє ефективно працювати з системою пошуку, замість того щоб просто налаштовувати параметри до покращення результатів оцінки.
Дві операції, два власники
Найкоротші корисні визначення є такими:
- Токенізація — це перетворення тексту на окремі цілочисельні одиниці, які фактично використовує модель. Вона належить до моделі.
- Розділення на частини — це поділ документів на самодостатні фрагменти, які можна вбудовувати, індексувати та знаходити. Вона належить до вашої процесуальної схеми та ваших даних.
Токенізація відбувається щоразу, коли будь-який рядок потрапляє до моделі, незалежно від того, усвідомлюєте ви це чи ні. Розділення на фрагменти — це рішення, яке ви приймаєте заздалегідь щодо того, як структурувати корпус перед тим, як модель його побачить. Перший процес є фіксованим та майже непомітним; другий — це щось, що ви проектуєте, оцінюєте та вдосконалюєте протягом тижнів.
Ці два процеси постійно взаємодіють. Розділювач, який ігнорує поведінку токенізатора, буде створювати низькоякісні фрагменти. Проте це окремі кроки, і сприйняття їх як одного й того самого є причиною значної кількості тонких помилок у пошуку інформації.
Як модель читає текст
Жоден трансформерний LLM, чи то GPT-4o, Claude, Llama чи будь-що інше у продакшені, не читає слів. Він читає послідовність цілих чисел, кожне з яких вказує на фіксований словник, навчений під час тренування. Токенайзер — це детерміністичний алгоритм, який перетворює необроблений текст на таку послідовність. Його обирають один раз під час тренування моделі, і він постачається як частина її артефактів. Ви не можете змінити його за кожним запитом.
Словники підслів: BPE, WordPiece, SentencePiece
Більшість сучасних моделей використовують токенізацію підслів, зазвичай з родин BPE, WordPiece чи SentencePiece. Спільний принцип полягає у тому, щоб почати з символів або необроблених байтів та постійно об’єднувати найчастіші сусідні пари у нові записи словника, поки не досягнеться бажаний розмір словника, який зазвичай становить від 32 к до 128 к записів або більше.
Що це означає у повсякденній роботі:
- Часто вживані англійські слова, такі як
the,isчиmodel, зазвичай перетворюються по одному токену кожне. - Рідше зустрічаються слова розбиваються на підслов’яні частини, тож
tokenizationможе статиtokenплюсization. - Незвичайні рядки, включаючи ідентифікатори коду, нелатинські писемності, емодзі та вигадані назви продуктів, можуть розділитися на багато токенів на одне слово, а іноді — майже на один токен на кожен символ.
Саме тому речення мовою хінді чи тамільською може вимагати у два-три рази більше токенів, ніж англійське речення з таким самим значенням, і чому ідентифікатор на кшталт xgboost_lstm_ensemble_v2 може спожити близько восьми токенів, якщо планувалося два. Точні співвідношення залежать від токенайзера, а новіші словники зменшили розбіжності для деяких мов, тому вимірюйте з використанням конкретної моделі. Для будь-якого продукту, який працює на ринках, де не використовується англійська мова, наприклад шару ШІ, який узагальнює інформацію з індійських фондових бірж чи журналів замовлень брокерів, це є реальною витратою та елементом бюджету, а не просто цікавістю.
Чому кількість токенів має значення поза межами рахунку
Кількість токенів впливає не лише на ціну запиту. Вона визначає три аспекти:
- Яку частину вікна контексту ви використовуєте. Вікно розміром 128 кб вимірюється у токенах, а не у словах чи символах. Частина з 1 000 слів не має фіксованої вартості; кількість її токенів змінюється залежно від словника, мови та типу контенту, оскільки код, проза та числа токенізуються дуже по-різному.
- Над чим працює механізм уваги. Трансформери сприймають інформацію між позиціями токенів, тому спосіб розділення тексту на токени впливає на те, наскільки послідовно модель може пов’язувати його частини. Патологічні розділення, які ускладнюють розуміння, трапляються рідко, але певні формати кодування можуть їх створювати.
- Де відбувається обрізання. Коли вхідний текст перевищує обсяг вікна, його обрізають на рівні токенів, а не на межі речення чи частини, якщо ви самі не вирішите інакше. Недбале обрізання може залишити слово неповним.
Один раз перегляньте власні рядки
Якщо ви ніколи не пропускали свої справжні продакшн-тексти через інструмент tiktoken від OpenAI чи токенайзер від Anthropic, варто зробити це хоча б один раз. Бачення власної шаблони запиту, розділеної на несподівані фрагменти, перетворює абстракцію на щось конкретне. Синтаксис Markdown, дужки JSON та рамки коду є особливо проблемними: вони токенізуються менш ефективно, ніж звичайний прозовий текст.
У наведеному нижче фрагменті використовується кодування cl100k_base, і підраховується кількість токенів у типовій команді шелу. Зверніть увагу, що прапорці та розширення файлів зазвичай займають по два-три токени, що саме такий тип контенту призводить до збільшення обсягу обробки без усвідомлення цього.
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
tokens = enc.encode("pip-compile requirements.in --output-file requirements.txt")
print(len(tokens), tokens)
# CLI flags and file extensions often split into 2-3 tokens each —
# this is exactly the kind of string that silently inflates your token budget
Як ви вирішуєте, що буде бачити модель
Чанкінг працює на наступному рівні, у конвеєрі обробки даних, а не в самій моделі. Він бере великий документ — наприклад, PDF, сторінку у Confluence, репозиторій коду чи 40-сторінковий нормативний циркуляр — та розділяє його на менші одиниці, які можна вбудовувати, індексувати та отримувати окремо. Чанк є основною одиницею у шарі пошуку даних.
Два фактори роблять чанкінг необхідним, і жоден з них насправді не стосується токенізації:
- Довгі ембеддинги стають нечіткими. Якщо помістити занадто багато непов’язаних ідей у один вектор, він стане середнім значенням усіх них та не буде точно відповідати нічому. Хороший чанк містить одну послідовну ідею, а не цілий документ.
Стратегії чанкінгу та їхні недоліки
Вікна фіксованого розміру
Найпростіший підхід полягає у розділенні тексту кожні N токенів або символів, зазвичай із певним перекриттям між сусідніми частинами. Більшість команд починають саме з цього, оскільки для реалізації потрібно лише кілька рядків коду. Наведена нижче функція кодує весь текст, ділить список токенів на вікна розміром chunk_size, декодує кожне вікно назад у текст та просувається на відстань chunk_size - overlap, щоб послідовні чанки мали спільний маржинальний простір.
def fixed_chunk(text, chunk_size=512, overlap=50):
tokens = enc.encode(text)
chunks = []
start = 0
while start < len(tokens):
end = start + chunk_size
chunk_tokens = tokens[start:end]
chunks.append(enc.decode(chunk_tokens))
start += chunk_size - overlap
return chunks
Слабкістю є те, що ця функція не розуміє поняття речення. Вона ставить межу посеред частини речення, розділяючи твердження від даних, які його підтримують, або зразок коду від речення, яке його пояснює. Ці неповні фрагменти речень потрапляють у вектори, які знаходяться між темами, і погано підходять один до одного, що саме і є симптомом, описаним на початку. Ще два нюанси: значення overlap має залишатися нижчим за chunk_size, інакше цикл ніколи не буде просуватися далі, а декодування довільного фрагмента токенів може розділити багатобайтовий символ навпіл, що призведе до появи замінника символу на краю фрагмента.
Рекурсивне розділення за природними межами
Рекурсивний розділювач віддає перевагу розривам абзаців, якщо це можливо; у разі неможливості використовує розриви речень, а потім — розриви слів. Лише тоді, коли ніщо інше не підходить, він робить жорсткий розрив. RecursiveCharacterTextSplitter з бібліотеки LangChain та парсери вузлів у LlamaIndex дотримуються цього підходу. Для корпусів, що складаються переважно з прози, це набагато кращий стандартний варіант, оскільки спочатку враховується власна структура документа, а вже потім — обмеження за розміром.
Семантичне членування
Замість встановлення фіксованого розміру, семантичне членування включає окремі речення або невеликі групи з них та ставить межу там, де косинусова схожість між сусідніми ембеддингами знижується — саме там змінюється тема. Отримані частини пов’язані між собою за змістом, а не за довжиною. Недоліком є додатковий процес створення ембеддингів для кожного документа під час обробки.
Членування з урахуванням структури
Контент із вбудованою структурою, такий як Markdown із заголовками, вихідні файли, специфікації API чи документація схем, слід розділяти відповідно до цієї структури. У коді очевидним межовим елементом є визначення функції. У базі знань у форматі Markdown такою межею є розділ ##. У випадку README багатоагентної системи розділення за заголовками розділів забезпечує набагато кращу здатність до пошуку значень, ніж будь-який підрахунок токенів. Більш детальний опис способів збереження підтримуючих доказів наведено у розділі про розбиття на частини зі збереженням доказів.
Розбиття на частини за допомогою LLM
У дорогому сегменті можна попросити ШІ прочитати документ та самостійно запропонувати межі частин, щоб кожна з них була самостійною та відповідала на певне запитання. Цей процес є повільним та дорогим, але він є ефективним для роботи з високоякісними корпусами даних, такими як регуляторні звіти чи контракти, де неправильне пошукове знаходження може мати серйозні наслідки.
Де зустрічаються ці дві ідеї
Цікавою є точка зустрічі цих концепцій, яку можна сформулювати одним реченням: розмір частин зазвичай виражається у токенах, але межі частин мають відповідати значенню, причому ці дві мети спрямовані в протилежних напрямках.
Жорстке обрізання на рівні 512 токенів за допомогою chunk_size=512 дозволяє дотриматися ліміту, але при цьому ігнорує зміст. Розділення лише за межами абзаців без жодних обмежень зберігає зміст, але не враховує того, що один абзац може містити 40 токенів, а наступний — 4 000. Великий фрагмент може перевищити максимальну довжину послідовності моделі ембеддингу, через що отриманий контекст буде дуже нерівномірним щодо кількості інформації в кожному фрагменті.
Тому надійний інструмент для розділення враховує обидві проблеми у фіксованій послідовності:
- Спочатку знаходяться семантичні межі: розділи, абзаци, речення, блоки коду.
- Об’єднуються невеликі сусідні одиниці, поки додавання наступної не призведе до перевищення ліміту токенів.
Функція нижче реалізує цю логіку. Вона очікує параметр sections, які вже були розділені на абзаци чи заголовки. Для кожного з них вимірюється справжня довжина токену; якщо його додавання призведе до переповнення поточного блоку, накопичені розділи виводяться як окремий блок. Розділ, розмір якого сам по собі перевищує max_tokens, розділяється за межами токенів; усе інше додається до поточного блоку. Те, що залишиться в кінці, стає остаточним блоком.
def chunk_by_tokens_with_semantic_bias(sections, max_tokens=512):
chunks, current, current_len = [], [], 0
for section in sections: # pre-split by paragraph/header
section_len = len(enc.encode(section))
if current_len + section_len > max_tokens and current:
chunks.append("\n".join(current))
current, current_len = [], 0
if section_len > max_tokens:
# single section too big — hard split on token boundaries, not chars
section_tokens = enc.encode(section)
for i in range(0, len(section_tokens), max_tokens):
chunks.append(enc.decode(section_tokens[i:i+max_tokens]))
else:
current.append(section)
current_len += section_len
if current:
chunks.append("\n".join(current))
return chunks
Ключове рішення щодо проектування полягає у тому, що розмір вимірюється за допомогою токенайзера моделі ембеддингу-цілі, а не оцінюється за кількістю символів, і що примусове розділення може відбуватися лише на межі токенів, яку контролює код. Одне удосконалення, яке варто розглянути: новий рядок, який використовується у "\n".join, додає приблизно один токен на кожну секцію, який не враховується у поточному підрахунку, тож слід залишити невеликий запас під жорстку межу моделі.
Місце кожного кроку у потоці обробки
З точки зору архітектури процес чанкінгу є проблемою на ранньому етапі. Він відбувається під час отримання даних, ще до того, як буде використано будь-яку модель, а його результат зберігається. Токенізація є проблемою на пізньому етапі. Вона відбувається у вхідному шарі моделі під час кожного запиту, включаючи операції ембеддингу під час отримання даних та операції генерації під час виконання запиту. Якщо мати це на увазі, можна зрозуміти, який код потрібно відкрити у разі виникнення проблем.
Розрізнення двох способів збою
При аналізі поганого результату пошуку два симптоми можуть здаватися схожими, але вказують на різні етапи обробки:
- Отриманий фрагмент є неповним або закінчується посеред речення. Це є помилкою чанкінгу. Логіка визначення меж розділила семантичну одиницю, тож виправлення має бути у самому інструменті розділення, а не у виборі моделі ембеддингу.
len(text) / 4, тоді як фактична кількість виявляється вищою.Їх обидва часто звинувачують у „поганій моделі ембеддингу“. Це рідко є справжньою причиною; майже завжди проблема полягає у логіці обробки кордонів чи підрахунку токенів.
Чек-лист для виробництва
Під час створення чи перевірки конвеєра RAG або агента обов’язково перевірте наступні моменти:
- Підрахуйте токени за допомогою справжнього токенайзера моделі, яку ви використовуєте. Для моделей OpenAI використовуйте
tiktoken, а для моделей з відкритим кодом — токенайзер, притаманний саме цій моделі. Ніколи не використовуйтеlen(text) // 4як кількісний показник у продакшені. - Вимірюйте розмір частин у токенах моделі ембеддингу. Моделі ембеддингу часто використовують інший токенайзер, ніж моделі генерації, та встановлюють більш сувору максимальну довжину послідовності.
- Явно враховуйте перекриття. Перекриття споживає частину ефективного вікна, тому включайте його до обчислень межі вхідних даних моделі ембеддингу замість того, щоб дізнаватися про це з попередження про обрізку.
- Ставте структуру вище за розмір. Використовуйте автоматичне розділення з урахуванням структури або рекурсивне розділення, а фіксовані за розміром вікна призначайте для справді неструктурованого тексту.
- Фіксуйте кількість токенів та рішення щодо меж під час обробки даних. Коли якість погіршується, вам потрібно негайно бачити, чи була частина обрізана через розмір, чи через структуру.
- Поновлюйте токенізацію після зміни моделей ембеддингу. Стратегія, налаштована під певний словниковий запас, не працює ефективно в інших умовах; 512 токенів охоплюють різну кількість тексту залежно від використовуваного токенайзера.
Підсумки
Токенізація відповідає на запитання моделі про те, як узагалі обробляти рядок. Розділення на частини відповідає на запитання про те, якою є найменша самодостатня одиниця, яку варто отримати. Перше фіксується моменту вибору моделі; друге є аспектом проєктування, який повністю знаходиться під вашим контролем, і саме тут зазвичай полягає справжня інженерна перевага в системі RAG.
Ці два типи помилок проявляються по-різному. Помилки обліку токенів проявляються у дивному обрізанні тексту та перевищенні бюджету, що принаймні є помітним. Помилки розбиття на частини призводять до ситуації, коли система працює без жодних помилок, але все одно отримує неправильну половину правильного речення, що значно складніше помітити. Вимірюйте за допомогою справжнього токенайзера, спочатку розділяйте текст за значенням та фіксуйте причини розташування кожної межі саме там, де вона знаходиться.