Перейти к основному контенту
Tech Path Finder
КурсыИнтервьюКод-ревьюБлог
Tech Path Finder

Персонализированный путеводитель в IT. Квизы, мок-интервью, код ревью и аналитика прогресса.

@potapov_me

Платформа

  • Курсы
  • Прогресс
  • Мок-интервью
  • Код ревью
  • Живое ревью с ИИ
  • Тренажёр переговоров
  • Закладки

Контент

  • Блог
  • Главная
  • Обратная связь

Компания

  • О проекте
  • Тарифы
  • Условия использования
  • Конфиденциальность
  • Согласие на обработку данных
  • Cookie
  • Реквизиты

Аккаунт

  • Войти
  • Зарегистрироваться
  • Профиль

© 2026 Tech Path Finder. Все права защищены.

·ИП Потапов К.С.·Политика конфиденциальности·
Сделано с ❤️ в России
  1. Стратегии разделения текста
text_chunking

Стратегии разделения текста

Chunking: как правильно разбивать документы на фрагменты для эффективного поиска

Стратегии разбиения текста на чанки

#Зачем нужно разбивать текст

LLM-модели имеют ограниченное контекстное окно -- максимальное количество токенов, которое они могут обработать за один запрос. Даже модели с большим окном (128K токенов и более) не работают эффективно, когда в контекст загружается весь документ целиком.

Причина кроется в механизме внимания трансформеров. Модель уделяет больше внимания началу и концу контекста, а середина обрабатывается менее точно. Это явление называется "lost in the middle".

# Пример подсчёта токенов import tiktoken encoder = tiktoken.encoding_for_model("gpt-4") text = "Длинный документ из тысячи слов..." token_count = len(encoder.encode(text)) print(f"Количество токенов: {token_count}")

Библиотека tiktoken позволяет точно подсчитать токены для моделей OpenAI. Разные модели используют разные токенизаторы, поэтому подсчёт важно выполнять с правильным энкодером.

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

#Фиксированное разбиение по символам

Самый простой подход -- нарезать текст на фрагменты фиксированной длины по количеству символов.

def fixed_char_chunk(text: str, chunk_size: int = 500) -> list[str]: """Разбивает текст на чанки фиксированного размера по символам.""" chunks = [] for i in range(0, len(text), chunk_size): chunk = text[i:i + chunk_size] if chunk.strip(): chunks.append(chunk) return chunks document = "Это очень длинный текст..." * 100 result = fixed_char_chunk(document, chunk_size=200) print(f"Всего чанков: {len(result)}") print(f"Размер первого чанка: {len(result[0])}")

Этот метод прост в реализации, но имеет серьёзный недостаток: он может разрезать слова и предложения посередине. Чанк может закончиться на середине фразы, что лишит фрагмент смысла.

#Фиксированное разбиение по токенам

Поскольку LLM работают с токенами, а не с символами, имеет смысл разбивать текст по границам токенов.

import tiktoken def fixed_token_chunk(text: str, chunk_size: int = 256) -> list[str]: """Разбивает текст на чанки фиксированного размера по токенам.""" encoder = tiktoken.encoding_for_model("gpt-4") tokens = encoder.encode(text) chunks = [] for i in range(0, len(tokens), chunk_size): chunk_tokens = tokens[i:i + chunk_size] chunk = encoder.decode(chunk_tokens) if chunk.strip(): chunks.append(chunk) return chunks text = "Python -- мощный язык программирования. " * 50 result = fixed_token_chunk(text, chunk_size=50) print(f"Всего чанков: {len(result)}") print(f"Токенов в первом чанке: {len(tiktoken.encoding_for_model('gpt-4').encode(result[0]))}")

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

#Рекурсивное разбиение по символам

Рекурсивное разбиение -- наиболее популярная стратегия на практике. Алгоритм пытается разбить текст по "естественным" разделителям, начиная с самых крупных и переходя к мелким.

def recursive_character_chunk( text: str, chunk_size: int = 500, separators: list[str] | None = None ) -> list[str]: """Рекурсивное разбиение текста по разделителям.""" if separators is None: separators = ["\n\n", "\n", ". ", ", ", " ", ""] # Если текст помещается в один чанк -- возвращаем его if len(text) <= chunk_size: return [text] # Пробуем каждый разделитель for sep in separators: if not sep: # Последний вариант: просто режем по chunk_size return [text[i:i + chunk_size] for i in range(0, len(text), chunk_size)] parts = text.split(sep) # Если разделитель нашёлся и дал несколько частей if len(parts) > 1: chunks = [] current = "" for part in parts: part_with_sep = part + (sep if sep != "" else "") if len(current) + len(part_with_sep) <= chunk_size: current += part_with_sep else: if current.strip(): chunks.append(current.strip()) current = part_with_sep if current.strip(): chunks.append(current.strip()) # Рекурсивно обрабатываем чанки, которые всё ещё слишком большие final_chunks = [] for chunk in chunks: if len(chunk) > chunk_size: final_chunks.extend( recursive_character_chunk(chunk, chunk_size, separators[1:]) ) else: final_chunks.append(chunk) return final_chunks return [text] text = """Введение в машинное обучение. Машинное обучение -- это подраздел искусственного интеллекта, который изучает алгоритмы, способные обучаться на данных. Основные подходы включают обучение с учителем и без учителя. Классификация и регрессия -- задачи обучения с учителем. Кластеризация и снижение размерности -- задачи обучения без учителя.""" chunks = recursive_character_chunk(text, chunk_size=200) for i, chunk in enumerate(chunks): print(f"--- Чанк {i} ({len(chunk)} символов) ---") print(chunk)

Алгоритм работает в несколько проходов. Сначала он пытается разбить по двойным переносам строк (абзацы). Если части всё ещё слишком большие -- переходит к одиночным переносам строк, затем к предложениям (точка + пробел), затем к запятым и, наконец, к пробелам.

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

#Перекрытие между чанками

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

Перекрытие (overlap) решает эту проблему. Каждый чанк содержит часть текста предыдущего чанка, создавая "мостик" между фрагментами.

def chunk_with_overlap( text: str, chunk_size: int = 500, overlap: int = 50 ) -> list[str]: """Разбивает текст на чанки с перекрытием.""" if overlap >= chunk_size: raise ValueError("Перекрытие должно быть меньше размера чанка") chunks = [] step = chunk_size - overlap current = 0 while current < len(text): chunk = text[current:current + chunk_size] chunks.append(chunk) current += step # Прерываем цикл, если оставшийся текст уже включён if current >= len(text) - overlap: break return chunks text = "Важное предложение. " * 100 chunks = chunk_with_overlap(text, chunk_size=200, overlap=40) for i, chunk in enumerate(chunks): print(f"Чанк {i}: позиции {i * 160}-{i * 160 + len(chunk)}") # Чанк 0: позиции 0-200 # Чанк 1: позиции 160-360 (перекрывает 40 символов с чанком 0) # Чанк 2: позиции 320-520 (перекрывает 40 символа с чанком 1)

Типичное значение перекрытия -- 10-20% от размера чанка. Для чанка в 500 символов это 50-100 символов перекрытия.

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

#Семантическое разбиение

Семантическое разбиение учитывает смысловую структуру текста. Вместо фиксированных размеров алгоритм анализирует содержание и разбивает текст в местах смыслового перехода.

#Разбиение по предложениям

Простейший вид семантического разбиения -- группировка предложений в чанки до достижения лимита.

import re def chunk_by_sentences(text: str, max_chunk_size: int = 300) -> list[str]: """Разбивает текст, группируя предложения в чанки.""" # Простое разделение по предложениям sentences = re.split(r'(?<=[.!?])\s+', text) chunks = [] current = "" for sentence in sentences: if len(current) + len(sentence) <= max_chunk_size: current += sentence + " " else: if current.strip(): chunks.append(current.strip()) current = sentence + " " if current.strip(): chunks.append(current.strip()) return chunks text = """Машинное обучение -- раздел ИИ. Модели обучаются на данных. Рецепт борща включает свёклу. Варить нужно около часа. Градиентный бустинг популярен для табличных данных.""" chunks = chunk_by_sentences(text, max_chunk_size=120) for i, chunk in enumerate(chunks): print(f"Чанк {i}: {chunk}")

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

#Семантическое разбиение по эмбеддингам

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

import numpy as np from sentence_transformers import SentenceTransformer model = SentenceTransformer("all-MiniLM-L6-v2") def semantic_chunk_by_embeddings( sentences: list[str], threshold: float = 0.5 ) -> list[list[int]]: """Находит смысловые границы между предложениями через эмбеддинги. Возвращает списки индексов предложений, сгруппированных в чанки. """ if len(sentences) <= 1: return [list(range(len(sentences)))] # Получаем эмбеддинги всех предложений embeddings = model.encode(sentences) # Вычисляем косинусное сходство между соседними предложениями chunk_boundaries = [0] for i in range(len(embeddings) - 1): vec_a = embeddings[i] vec_b = embeddings[i + 1] # Косинусное сходство similarity = np.dot(vec_a, vec_b) / ( np.linalg.norm(vec_a) * np.linalg.norm(vec_b) + 1e-8 ) # Если сходство ниже порога -- это граница чанка if similarity < threshold: chunk_boundaries.append(i + 1) chunk_boundaries.append(len(sentences)) # Преобразуем границы в списки индексов chunks = [] for i in range(len(chunk_boundaries) - 1): start = chunk_boundaries[i] end = chunk_boundaries[i + 1] chunks.append(list(range(start, end))) return chunks sentences = [ "Машинное обучение -- раздел искусственного интеллекта.", "Модели обучаются на данных без явного программирования.", "Рецепт борща включает свёклу, капусту и картофель.", "Варить борщ нужно около часа на среднем огне.", "Градиентный бустинг -- популярный алгоритм для таблиц.", ] chunks = semantic_chunk_by_embeddings(sentences, threshold=0.5) for i, indices in enumerate(chunks): chunk_text = " ".join(sentences[j] for j in indices) print(f"Чанк {i}: {chunk_text}")

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

#Компромиссы при выборе размера чанка

Размер чанка -- один из главных гиперпараметров RAG-системы. От него зависит точность поиска, качество генерации и стоимость вычислений.

Слишком маленькие чанки (50-100 токенов) теряют контекст. Фрагмент может не содержать достаточно информации для ответа на вопрос. Запрос "какие этапы включает процесс деплоя" может найти чанк со словом "деплой", но без описания этапов.

# Пример маленького чанка -- контекст потерян small_chunk = "...затем запускается CI/CD пайплайн, который..." # Неясно: что запускает пайплайн? Какие шаги? Что после?

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

# Пример большого чанка -- слишком много шума large_chunk = """[2000 слов про всю архитектуру системы: базы данных, кеши, очереди, мониторинг, деплой, конфигурация...] """ # Запрос "как настроить кеш" найдёт этот чанк, но # полезная информация -- лишь 50 слов из 2000

Оптимальный размер зависит от типа документа и сценария. Для вопросов-ответов по документации хороши чанки по 200-500 токенов. Это достаточно для законченного ответа, но не слишком много шума. Для анализа длинных документов -- 500-1000 токенов. Для поиска конкретных фактов в больших массивах -- 100-300.

#Как выбрать стратегию для разных типов документов

Разные документы имеют разную структуру, и универсального подхода не существует.

Техническая документация и API-справки. Такие документы хорошо структурированы: заголовки, разделы, списки параметров. Рекурсивное разбиение с разделителями \n\n, \n, . работает лучше всего. Размер чанка 300-500 токенов, перекрытие 50-100 токенов.

def chunk_documentation(text: str) -> list[str]: """Стратегия для технической документации.""" return recursive_character_chunk( text, chunk_size=400, separators=["\n\n", "\n", ". ", ", ", " "] )

Научные статьи и длинные тексты. Статьи содержат абзацы с законченными мыслями. Семантическое разбиение по предложениям или абзацам сохраняет контекст каждой идеи. Размер чанка 500-800 токенов.

def chunk_research_paper(text: str) -> list[str]: """Стратегия для научных статей.""" return chunk_by_sentences(text, max_chunk_size=600)

Диалоги и чаты. В разговорах каждая реплика -- самостоятельная единица. Лучше всего разбивать по репликам или группам из нескольких сообщений. Размер чанка 200-400 токенов.

def chunk_conversations(messages: list[dict]) -> list[str]: """Группирует сообщения в чанки по 3-5 реплик.""" chunks = [] group_size = 4 for i in range(0, len(messages), group_size): group = messages[i:i + group_size] chunk = "\n".join( f"{m['role']}: {m['content']}" for m in group ) chunks.append(chunk) return chunks

Юридические документы и контракты. Юридические тексты требуют точности. Каждый пункт или статья должны быть в отдельном чанке. Разбиение по нумерованным пунктам -- лучший вариант.

import re def chunk_legal_document(text: str) -> list[str]: """Разбивает юридический документ по нумерованным пунктам.""" # Regex ищет паттерны типа "1.", "1.1", "Статья 5" pattern = r'(?m)(?:^|\n)(?=\d+[\.\)]|\bСтатья\b)' parts = re.split(pattern, text) return [p.strip() for p in parts if p.strip()]

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

import re def chunk_python_code(source: str) -> list[str]: """Разбивает Python-код по определениям функций и классов.""" pattern = r'(?m)^(?:def |class )' parts = re.split(pattern, source) chunks = [] for i, part in enumerate(parts): if i == 0 and not part.startswith(("def ", "class ")): # Модульные импорты и глобальные переменные if part.strip(): chunks.append(part.strip()) else: # Восстанавливаем ключевое слово if part.strip().startswith("class "): chunks.append("class " + part.strip()) else: chunks.append("def " + part.strip()) return chunks

#Практические рекомендации

Начинайте с рекурсивного разбиения по символам с размером чанка 500 токенов и перекрытием 100 токенов. Это работает "из коробки" для большинства сценариев.

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

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

Для критически важных систем рассмотрите гибридный подход: индексировать документы одновременно с мелкими чанками (100-200 токенов) для точного поиска и с крупными (1000-2000 токенов) для контекста. При поиске сначала находятся мелкие чанки, а затем расширяются до крупных.

Помните: не существует единственно правильной стратегии. Лучший чанкинг зависит от ваших данных, ваших запросов и вашей модели. Экспериментируйте и измеряйте результат.

Далее: Загрузка документов