Найти тему
Pavel Zloi

Что я знал о чанках? Как нарезать текст с помощью LangChain для LLM и векторного поиска

Оглавление

Приветствую! Во время разработки проектов, которые в той или иной мере используют большие языковые модели, иногда возникает вопрос о том, какие методы нарезки текста (анг. text splitting) на кусочки (анг. chunks) существуют?

Изображение сгенерирвоанно при помощи DALL-E, промт: image of the mechanical robot in a drawn, artistic style, processing textual documents
Изображение сгенерирвоанно при помощи DALL-E, промт: image of the mechanical robot in a drawn, artistic style, processing textual documents

Вот и я задался подобным вопросом и стал гуглить. В процессе непродолжительного поиска мне на глаза попался прекрасный видеоролик под названием "The 5 Levels Of Text Splitting For Retrieval", в котором автор рассказывает и показывает различные методы нарезки текста при помощи LangChain и не только.

Потом я попробовал поискать не раскрывал ли кто данную тему в русскоязычном сегменте интернета, но найти чего-то подобного мне не удалось, поэтому я прочёл документацию про Text Splitters на сайте LangChain за вас, так что вам её теперь читать не обязательно :)

Кстати, под конец публикации будет представлен экспериментальный метод нарезки текста при помощи больших языковых моделей.

И так, поехали!

Зачем резать текст? Пару слов про RAG

Нарезка текста на чанки, ну или кусочки, имеет важное значение, особенно при работе с векторным поиском и большими языковыми моделями (БЯМ, или LLM).

Существует несколько причин из-за которых вам может потребоваться нарезать текст на небольшие кусочки:

  • Многие БЯМ имеют ограничения по количеству токенов, которые можно передать на вход. Например, самая лучшая foundation модель для работы с русским языком ai-forever/ruGPT-3.5-13B обрабатывает всего 2048 токенов, а у мультиязычной foundation модели meta-llama/Llama-2-7b-hf - 4096 токенов.
  • БЯМ, которым можно на вход передать большое количество токенов, как правило, не умеют адекватно обрабатывать такое количество данных. Например: mosaicml/mpt-7b-storywriter способную обрабатывать более 65k+ токенов, и NousResearch/Yarn-Mistral-7b-128k с лимитом до 128k токенов.
  • По-настоящему большие БЯМ (34B, 70B параметров и выше), способные принять большое количество токенов на вход, требуют значительного объема видеопамяти. Расход видеопамяти увеличивается квадратично с ростом количества токенов, передаваемых на вход модели и квантизация тут не очень сильно поможет.
  • Чем больше токенов модель может принять, тем дольше по времени будет происходить инференс ответа.

Самые сбалансированные модели (по сочетанию качества генерации и потребляемым ресурсам), такие как дообученная на датасетах Saiga модель ruGPT-3.5-13B, базовая Mixtral 8x22B или базовая LLaMA 3 8B Instruct, обычно требуют не очень много вычислительных ресурсов, однако, имеют ограниченный размер контекста (максимальное количество токенов, которые можно передать на вход).

Но благодаря высокому качеству своей работы применять эти модели часто предпочтительнее чем другие и чтобы сгладить их недостатки при реализации сложных проектов на больших текстовых базах их используют в паре с векторным поиском, формируя системы типа Retrieval-Augmented Generation (RAG) (arxiv:2005.11401).

Однако для работы векторного поиска требуется, при помощи специальных эмбеддинговых моделей, выполнить преобразование текстовых данных в эмбеддинги (векторные представления).

Упрощённое представление того как работает модель-эмбеддер
Упрощённое представление того как работает модель-эмбеддер

Тут возникает ещё одна проблема: модели-эмбеддеры так же как и БЯМ ограничены размером входного контекста, который они могут обработать за один раз (обычно не более 1024 токенов, что соответствует примерно 700-800 словам на английском или 600-700 словам на русском).

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

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

Собственно это и есть Retrieval-Augmented Generation (RAG) о которой так много шуму в последнее время.

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

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

В общем на вопрос «зачем?» мы ответили, так что далее рассмотрим какие алгоритмов нарезки на чанки существуют. Начнём с самых простых и постепенно будем повышать градус сложности :)

1. Чанки фиксированной длинны

Полный код юпитер-блокнота простой нарезки тут.

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

Пример простой нарезки текста на чанки
Пример простой нарезки текста на чанки

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

В проекте LangChain существует уже готовый класс CharacterTextSplitter который реализует примерно тоже самое, но в чуть более продвинутом исполнении.

Пример посимвольной нарезки текста при помощи проекта LangChain
Пример посимвольной нарезки текста при помощи проекта LangChain

В этом примере мы определили, что хотим собрать чанки длиной не более 80 символов каждый. Кроме того, между соседними чанками будет перекрытие в 10 символов, что может помочь сохранить контекст между чанками. Символ переноса строки \n используется в качестве разделителя, что позволяет более естественно разбивать текст, основываясь на его структуре.

Этот подход обеспечивает более умный способ разделения текста по сравнению с простой нарезкой на равные части, так как учитывает структурные границы в тексте (как, например, абзацы или строки), и способствует лучшему сохранению семантической связности текста.

2. Рекурсивное разделение на чанки

Полный код юпитер-блокнота рекурсивной нарезки тут.

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

2.1. Рекурсивная нарезка текста на чанки

Для демонстрации использования данного метода воспользуемся классом RecursiveCharacterTextSplitter из библиотеки LangChain. В этом примере мы установим маленький размер чанка, чтобы продемонстрировать возможности метода:

Рекурсивная нарезка через класс RecursiveCharacterTextSplitter
Рекурсивная нарезка через класс RecursiveCharacterTextSplitter

Для языков, в которых нет чётких границ слов, как, например, в китайском или японском, необходимо адаптировать список разделителей, чтобы обеспечить целостность слов при разделении:

Тонкая настройках границ разделения текста
Тонкая настройках границ разделения текста

Этот метод позволяет адаптировать процесс нарезки текста под конкретные языковые особенности, обеспечивая более точное и смысловое разделение текста.

2.2. Рекурсивная нарезка JSON-документов на чанки

При работе с большими JSON-структурами, особенно в случаях, когда необходимо их обрабатывать или отправлять по сети, может потребоваться их разделение на более мелкие части. Для этих целей можно использовать инструмент RecursiveJsonSplitter из библиотеки langchain-text-splitters.

Ниже будет пример кода на Python, который позволяет рекурсивно нарезать JSON на множество небольших кусочков, в качестве примера JSON был использован пример из спецификации OpenAPI 3.0.

Пример нарезки JSON документа при помощи RecursiveJsonSplitter
Пример нарезки JSON документа при помощи RecursiveJsonSplitter

Использование RecursiveJsonSplitter позволяет эффективно работать с большими JSON-структурами, делая их более удобными для передачи, хранения или обработки.

3. Нарезка с учётом структуры HTML и Markdown документов

Код юпитер-блокнота с примерами нарезки HTML и Markdown тут.

Когда дело доходит до создания систем Retrieval-Augmented Generation (RAG), часто приходится работать с данными в форматах, отличных от чисто текстовых или JSON, такими как HTML и Markdown. Эти форматы требуют особого подхода, так как могут содержать значимую структурную и семантическую информацию, которую важно сохранить.

3.1. Нарезка HTML по заголовкам

Рассмотрим использование HTMLHeaderTextSplitter для структурно-осознанной нарезки HTML-документов. Этот метод деления текста на части сохраняет связанный текст в группах и позволяет поддерживать контекст, кодируемый в структурах документа.

Представим что у нас имеется следующего вида HTML-документ:

Документ для наразки
Документ для наразки

Теперь давайте попробуем нарезать данный документ на структуры с учётом иерархии глав и подглав:

Результат работы класса HTMLHeaderTextSplitter
Результат работы класса HTMLHeaderTextSplitter

Результат работы класса HTMLHeaderTextSplitter показывает, как текст был разделен на чанки, каждый из которых содержит текст, относящийся к определенным главам и подглавам, например:

Содержимое: Foo
Заголовок: {'Header 1': 'Foo'}
Содержимое: Some intro text about Foo. \nBar main section Bar subsection 1 Bar subsection 2
Заголовок: {'Header 1': 'Foo', 'Header 2': 'Bar main section'}
Содержимое: Some intro text about Bar.

и так далее.

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

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

3.2. Нарезка Markdown по заголовкам

Для нарезки Markdown документов в LangChain имеется класс MarkdownHeaderTextSplitter. Если кратко, то это класс, аналогичный HTMLHeaderTextSplitter, но предназначенный для обработки документов, написанных в разметке Markdown. Он позволяет нарезать Markdown-документы, учитывая структуру и иерархию заголовков, что сохраняет контекст и семантическую связность текста.

Представим что у нас имеется следующего вида Markdown-документ:

Простой Markdown для нарезки
Простой Markdown для нарезки

Обработаем его следующим образом:

Обработка через класс MarkdownHeaderTextSplitter
Обработка через класс MarkdownHeaderTextSplitter

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

Заголовок: {'Header 1': 'Foo', 'Header 2': 'Bar'}
Содержимое: Hi this is Jim\nHi this is Joe
Заголовок: {'Header 1': 'Foo', 'Header 2': 'Bar', 'Header 3': 'Boo'}
Содержимое: Hi this is Lance
Заголовок: {'Header 1': 'Foo', 'Header 2': 'Baz'}
Содержимое: Hi this is Molly

Как видно метод нарезки по главам Markdown-документа лишён недостатков аналогичного решения через HTML, и именно поэтому я предпочитаю конвертировать все приходящие ко мне текстовые данные в формат Markdown.

4. Нарезка текста через токенизацию

Код юпитер-блокнота с примерами нарезки через токенизаторы тут.

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

Существуем несколько методов нарезки текста на чанки через токенизацию: tiktoken, spaCy, nltk (так же существует форк konlp с поддержкой CJK), sentence-transformers и обычные токенизаторы моделей доступные на HuggingFace. В данной главе мы рассмотрим только те методы нарезки текста, которыми пользовался лично, но в целом всё плюс минус одинаковое.

4.1. Разделение текста с помощью tiktoken

tiktoken — это быстрый токенизатор BPE, созданный OpenAI, который можно использовать для оценки количества использованных токенов. Это особенно актуально для моделей OpenAI.

Для разделения текста мы используем класс CharacterTextSplitter, который позволяет нарезать текст, учитывая размер токена:

Нарезка через класс CharacterTextSplitter и токенизатор tiktoken
Нарезка через класс CharacterTextSplitter и токенизатор tiktoken

Для гарантии, что размер чанков не превысит максимально допустимый размер в токенах, можно использовать RecursiveCharacterTextSplitter:

Нарезка через RecursiveCharacterTextSplitter и токенизатор tiktoken
Нарезка через RecursiveCharacterTextSplitter и токенизатор tiktoken

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

4.2. Разделение с помощью SentenceTransformers

Помимо tiktoken и CharacterTextSplitter, существуют другие полезные инструменты, которые можно использовать для нарезки текста. Например есть класс под названием SentenceTransformersTokenTextSplitter, специализированный сплиттер текста, который идеально подходит для работы с моделями семейства Sentence Transformer, которые традиционно используются для извлечения эмбеддингов из текста.

-14

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

4.3. Разделение через токенизаторы HuggingFace

HuggingFace предлагает обширную библиотеку токенизаторов, включая AutoTokenizer для автоматического определения токенизатора по названию модели.

Посимвольная нарезка через токенизатор GPT2TokenizerFast
Посимвольная нарезка через токенизатор GPT2TokenizerFast

Как видно из кода мы использовали класс GPT2TokenizerFast подгрузив токенизатор из модели gpt-2, но можно использовать любой другой и AutoTokenizer в том числе.

5. Семантическая нарезка текста с использованием эмбеддинговых моделей

Код семантической нарезки текста тут.

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

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

Семантическая нарезка при помощи эмбеддинг-модели intfloat/multilingual-e5-large
Семантическая нарезка при помощи эмбеддинг-модели intfloat/multilingual-e5-large

Сплиттер позволяет настроить способы определения точек разделения текста:

  • На основе перцентиля (percentile) - режим по умолчанию, в нём разделение происходит если различие между эмбеддингами предложений превышает заданный перцентиль.
text_splitter = SemanticChunker(text_embedder, breakpoint_threshold_type="percentile", breakpoint_threshold_amount=95)
  • На основе стандартного отклонения (standard_deviation) - используется для разделения, когда различие превышает заданное стандартное отклонение.
text_splitter = SemanticChunker(text_embedder, breakpoint_threshold_type="standard_deviation", breakpoint_threshold_amount=3)
  • Межквартильное расстояние (interquartile) - разделение по межквартильному расстоянию в пространстве эмбеддингов.
text_splitter = SemanticChunker(text_embedder, breakpoint_threshold_type="interquartile", breakpoint_threshold_amount=1.5)

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

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

6. Нарезка текста при помощи больших языковых моделей (БЯМ, или LLM)

Код нарезки при помощи языковой модели тут.

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

Для демонстрации использования LLM в нарезке текста, мы обратимся к модели LLaMA 3 8B Instruct работающей на локальном сервере через сервис Ollama, чтобы извлечь ключевые факты из главы "Введение" научной публикации о графовых нейросетях "Graph Neural Networks: A Review of Methods and Applications" (arxiv:1812.08434).

Введение публикации про графовые найросети https://arxiv.org/abs/1812.08434
Введение публикации про графовые найросети https://arxiv.org/abs/1812.08434

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

Приходится использовать английский, так как Llama 3 плохо справляется с русским
Приходится использовать английский, так как Llama 3 плохо справляется с русским

Далее передадим промт и текст на вход модели Llama 3 через простую цепочку LangChain, результат обработаем при помощи класса MarkdownListOutputParser:

Код цепочки LangChain для извелючения ключевых фактов из текста
Код цепочки LangChain для извелючения ключевых фактов из текста

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

Заключение

На этом мы подходим к завершению нашего путешествия по различным методам нарезки текста. Мы рассмотрели от самых базовых подходов, таких как нарезка фиксированной длины, до продвинутых методик, включая семантическую нарезку с использованием эмбеддингов и нарезку при помощи больших языковых моделей (LLM).

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

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

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

Благодарю за внимание и до скорых встреч!