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 токенов) для контекста. При поиске сначала находятся мелкие чанки, а затем расширяются до крупных.
Помните: не существует единственно правильной стратегии. Лучший чанкинг зависит от ваших данных, ваших запросов и вашей модели. Экспериментируйте и измеряйте результат.
Вопросы ещё не добавлены
Вопросы для этой подтемы ещё не добавлены.