Главная / Статьи

Бюджет токенов против семантических границ: как правильно делить документы на кластеры в RAG

Узнайте, в чем разница между токенизацией и кластеризацией, какое место они занимают в процессе RAG и как разделять документы по смыслу, одновременно измеряя размер с помощью реального токенизатора.

Издатель: REACTAPP.TOP11 мин чтения2515 слов

Источники: Chunking vs Tokenization: The Two Splits Every AI Engineer Keeps Confusing

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

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

Два процесса, два ответственных

Самые краткие и понятные определения следующие:

  • Токенизация преобразует текст в отдельные целочисленные единицы, которые фактически использует модель. Она относится к модели.
  • Чанкинг делит документы на самодостаточные фрагменты, которые можно встраивать, индексировать и извлекать. Он относится к вашей процессной цепочке и данным.

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

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

Как модель читает текст

Ни один трансформерный LLM, будь то GPT-4o, Claude, Llama или любой другой в эксплуатации, не читает слова. Он обрабатывает последовательность целых чисел, каждое из которых указывает на элемент фиксированного словаря, выученного во время обучения. Токенизатор — это детерминистический алгоритм, преобразующий необработанный текст в такую последовательность. Он выбирается один раз при обучении модели и поставляется в составе её артефактов. Его нельзя заменять по мере необходимости.

Словари субслов: BPE, WordPiece, SentencePiece

Большинство современных моделей используют токенизацию субслов, как правило, на основе методов BPE, WordPiece или SentencePiece. Общий принцип заключается в том, чтобы начать с символов или необработанных байтов и постоянно объединять наиболее часто встречающиеся соседние пары в новые элементы словаря до достижения желаемого размера словаря, который обычно составляет от 32 к до 128 к элементов или больше.

Что это означает в повседневной работе:

  • Часто встречающиеся английские слова, такие как the, is или model, обычно соответствуют по одному токену каждое.
  • Реже встречающиеся слова разделяются на подчастицы, поэтому tokenization может превратиться в token плюс ization.
  • Необычные строки, включая идентификаторы кода, нелатинские алфавиты, эмодзи и вымышленные названия продуктов, могут привести к появлению множества токенов на одно слово, а иногда — почти по одному токену на каждый символ.

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

Почему количество токенов важно не только для расчета стоимости

Количество токенов влияет не только на цену запроса. Оно определяет три аспекта:

  1. Какой объем окна контекста вы используете. Размер окна в 128 кб измеряется в токенах, а не в словах или символах. Фрагмент из 1000 слов не имеет фиксированной стоимости; количество токенов в нем меняется в зависимости от словарного запаса, языка и типа контента, поскольку код, проза и числа токенизируются сильно по-разному.
  2. Над чем работает механизм внимания. Трансформеры учитывают позиции токенов, поэтому способ разбиения текста на токены влияет на степень связности, с которой модель может соединять его части. Патологическое разделение, затрудняющее понимание, встречается редко, но некоторые форматы кодирования могут его вызывать.
  3. Где происходит обрезка. Когда объем вводимого текста превышает размер окна, он обрезается на уровне токенов, а не на границе предложения или фрагмента, если только вы сами этого не управляете. Небрежная обрезка может привести к тому, что слово останется незавершенным.

Один раз посмотрите на собственные строки

Если вы никогда раньше не пропускали свои реальные производственные запросы через инструмент 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-страничное нормативное распоряжение — и разделяет его на меньшие единицы, которые можно встраивать, индексировать и извлекать независимо. Чанк является основной единицей в слое поиска.

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

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

    Окна фиксированного размера

    Самый простой подход заключается в разделении текста каждые 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. Большой фрагмент может превысить максимальную длину последовательности модели эмбеддингов, в результате чего количество информации в каждом фрагменте становится крайне неравномерным.

    Поэтому надежный инструмент для разделения текста учитывает оба этих аспекта в определенном порядке:

    1. Сначала определяются семантические границы: разделы, абзацы, предложения, блоки кода.
    2. Маленькие соседние фрагменты объединяются до тех пор, пока добавление следующего из них не приведет к превышению лимита токенов.
  • Разделяйте единицу на отдельные токены только в том случае, если её размер сам по себе превышает установленный лимит, и при этом делайте разделение сами, чтобы определить место разреза, вместо того чтобы полагаться на оценку количества символов, которая не учитывает границы токенов.
  • Ниже приведена функция, реализующая этот порядок действий. Она ожидает параметр 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.

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