Зачем вообще делить текст на части

Embedding-модели преобразуют текст в вектор фиксированной размерности (например, 1536 чисел для text-embedding-3-small). Задача вектора — закодировать смысл текста так, чтобы похожие по значению фрагменты оказывались близко в векторном пространстве. Но один вектор не может одновременно хорошо передать смысл 100-страничного документа: слишком много разных тем, и вектор усредняется в нечто размытое.

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

Документ (10 000 слов)
       │
       ▼
  ┌─────────┐   ┌─────────┐   ┌─────────┐   ┌─────────┐
  │ Chunk 1 │   │ Chunk 2 │   │ Chunk 3 │   │ Chunk N │
  │  ~300   │   │  ~300   │   │  ~300   │   │  ~300   │
  │  слов   │   │  слов   │   │  слов   │   │  слов   │
  └────┬────┘   └────┬────┘   └────┬────┘   └────┬────┘
       │              │              │              │
   Embedding      Embedding      Embedding      Embedding
    vector         vector         vector         vector
       │              │              │              │
       └──────────────┴──────────────┴──────────────┘
                              │
                       Vector Database
                              │
              Запрос → top-K похожих чанков → LLM
        

Как устроен fixed-size chunking

Идея предельно проста: берём строку текста и нарезаем её на куски фиксированной длины. Два параметра управляют процессом:

  • chunk_size — максимальная длина одного чанка (в символах или токенах).
  • chunk_overlap — сколько символов/токенов из конца предыдущего чанка повторяется в начале следующего.

Шаг сдвига между чанками: step = chunk_size − chunk_overlap. Если chunk_size = 500 и chunk_overlap = 50, то каждый следующий чанк начинается на 450 символов правее предыдущего.

Текст без overlap (chunk_size=200, overlap=0)
Первый чанк: символы 0–199. Полное предложение обрывается
Второй чанк: 200–399. Начало может быть в середине слова
Третий чанк: 400–599. Контекст между чанками потерян.
Chunk 1
Chunk 2
Chunk 3
С overlap (chunk_size=200, overlap=40)
Chunk 1: символы 0–199
overlap
overlap
Chunk 2: символы 160–359
overlap
overlap
Chunk 3: символы 320–519
Повторяющееся перекрытие (overlap)

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

Символы vs токены: в чём разница

Fixed-size chunking можно считать в символах (bytes/chars) или в токенах. Разница критична, потому что embedding-модели и LLM имеют лимиты именно в токенах, а не в символах.

По символам
Плюсы
Быстро — O(1) срез строки
Без зависимостей (нет токенизатора)
Предсказуемо для всех языков
Минусы
Кириллица = 2 байта, латиница = 1
chunk_size=500 chars ≠ 500 tokens
Может нарушить лимит модели
По токенам
Плюсы
Точно вписывается в контекст модели
1 токен ≈ 4 символа (EN) / 2–3 (RU)
Гарантия не превысить лимит
Минусы
Требует tiktoken / tokenizer
Медленнее: кодирование ≈ 10× дороже
Разный счёт для разных моделей
Правило большого пальца: если chunk_size ≤ 1000 символов — разница между символами и токенами некритична (RU-текст из 1000 символов даёт примерно 400–600 токенов — хорошо вписывается в 8192-токенный лимит большинства embedding-моделей). При chunk_size > 2000 символов — лучше считать в токенах.

Пайплайн чанкинга

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
Документ page_content N символов FixedSplitter chunk_size = 500 chunk_overlap = 50 step = 500 − 50 = 450 Chunk 1 chars[0 : 500] Chunk 2 chars[450 : 950] Chunk N chars[450·(N−1) : ...] list[Document] page_content = chunk_text metadata.chunk_index = i metadata.source = ... overlap 50 chars

Как выбрать chunk_size

Нет универсального значения — правильный размер зависит от типа контента, задачи и модели. Разберём логику выбора.

Размер чанка определяет «единицу смысла», которую будет искать ретривер. Слишком маленький чанк (50–100 слов) — каждый кусок слишком узкий, почти без контекста. Слишком большой (1000+ слов) — один чанк накрывает несколько тем, и вектор становится «средним по больнице».

Размер
Символов
≈ Токенов
Лучше всего подходит
Не подходит для
Tiny
100–200
40–80
FAQ, определения, короткие факты
Нарративный текст
Small
300–500
120–200
Чат-боты, Q&A, узкие факты, API-документация
Средняя сложность
Medium
500–1000
200–400
Статьи, регламенты, технические доки — дефолт
Универсальный
Large
1000–2000
400–800
Аналитика, академические тексты, где нужен широкий контекст
Узкие запросы
XLarge
> 2000
> 800
Summarization, очень длинные нарративы
Точечные факты
Практическое правило: начните с chunk_size=700, overlap=70 (≈280 токенов). Проверьте 20–30 тестовых запросов. Если ответы обрывочны — увеличьте размер. Если нерелевантны — уменьшите.

Как выбрать chunk_overlap

Overlap — это страховка от разрезания мысли на границе чанков. Он увеличивает количество чанков и размер индекса, но улучшает полноту поиска.

0%
Без перекрытия
Минимум дубликатов, максимальная компактность индекса. Работает только если текст хорошо структурирован по абзацам и каждая мысль самодостаточна. На произвольном тексте даёт потери: факт, разорванный на границе — не найдётся.
10%
Минимальный overlap (overlap = chunk_size × 0.1)
Хорошо для хорошо структурированных текстов (технические статьи, регламенты), где абзацы редко «переливаются» друг в друга. Практически не увеличивает размер индекса. Типичный базовый выбор.
20%
Стандартный overlap (overlap = chunk_size × 0.2)
Оптимальный баланс для большинства задач. Гарантирует, что предложение длиной до 20% от chunk_size попадёт в оба соседних чанка целиком. Увеличивает индекс примерно на 20–25%. Хороший дефолт.
50%
Большой overlap — обычно лишнее
Почти удваивает размер индекса. Ретривер возвращает много похожих чанков из одного документа. Имеет смысл только для очень коротких чанков (≤ 100 токенов) или когда документы насыщены терминами и каждое слово критично.

Реализация

Базовый CharacterSplitter

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

from dataclasses import dataclass, field
from typing import Iterator


@dataclass
class Document:
    page_content: str
    metadata: dict = field(default_factory=dict)


class CharacterSplitter:
    """
    Fixed-size chunking по символам с мягким разрывом по пробелу.

    chunk_size    — максимальная длина чанка в символах
    chunk_overlap — сколько символов из конца предыдущего чанка
                    повторяется в начале следующего
    """

    def __init__(self, chunk_size: int = 700, chunk_overlap: int = 70):
        if chunk_overlap >= chunk_size:
            raise ValueError("chunk_overlap должен быть меньше chunk_size")
        self.chunk_size    = chunk_size
        self.chunk_overlap = chunk_overlap
        self.step          = chunk_size - chunk_overlap

    def split_text(self, text: str) -> list[str]:
        """Нарезает строку на список чанков."""
        chunks = []
        start  = 0
        text_len = len(text)

        while start < text_len:
            end = min(start + self.chunk_size, text_len)

            # Откатываемся к пробелу, чтобы не резать слово
            if end < text_len:
                # Ищем последний пробел в последних 50 символах окна
                boundary = text.rfind(" ", max(start, end - 50), end)
                if boundary > start:
                    end = boundary

            chunk = text[start:end].strip()
            if chunk:
                chunks.append(chunk)

            start += self.step

        return chunks

    def split_documents(self, documents: list[Document]) -> list[Document]:
        """Разбивает список документов на чанки, сохраняя метаданные."""
        result = []
        for doc in documents:
            chunks = self.split_text(doc.page_content)
            for i, chunk_text in enumerate(chunks):
                result.append(Document(
                    page_content=chunk_text,
                    metadata={
                        **doc.metadata,           # сохраняем все исходные метаданные
                        "chunk_index": i,          # порядковый номер чанка
                        "chunk_total": len(chunks),# всего чанков в документе
                        "chunk_size":  len(chunk_text),
                    }
                ))
        return result


# Использование
splitter = CharacterSplitter(chunk_size=700, chunk_overlap=70)
docs = splitter.split_documents([
    Document(
        page_content="Длинный текст документа...",
        metadata={"source": "policy.pdf", "title": "Регламент"}
    )
])
print(f"Получено чанков: {len(docs)}")
print(f"Первый чанк ({len(docs[0].page_content)} символов):")
print(docs[0].page_content[:200])

TokenSplitter: счёт по токенам

Если важно точно вписаться в лимит embedding-модели, считаем токены через tiktoken — библиотеку от OpenAI, которую поддерживают большинство моделей (включая text-embedding-3-* и Claude через аппроксимацию).

import tiktoken


class TokenSplitter:
    """
    Fixed-size chunking по токенам.
    Использует tiktoken для подсчёта — токены совпадают с моделями OpenAI/Azure.
    Для Claude: используйте cl100k_base как приближение (≈95% точность).
    """

    def __init__(
        self,
        chunk_size: int = 256,     # токенов
        chunk_overlap: int = 32,   # токенов
        encoding_name: str = "cl100k_base",  # GPT-4 / text-embedding-3
    ):
        self.enc           = tiktoken.get_encoding(encoding_name)
        self.chunk_size    = chunk_size
        self.chunk_overlap = chunk_overlap
        self.step          = chunk_size - chunk_overlap

    def _encode(self, text: str) -> list[int]:
        return self.enc.encode(text)

    def _decode(self, tokens: list[int]) -> str:
        return self.enc.decode(tokens)

    def split_text(self, text: str) -> list[str]:
        tokens = self._encode(text)
        chunks = []
        start  = 0

        while start < len(tokens):
            end   = min(start + self.chunk_size, len(tokens))
            chunk = self._decode(tokens[start:end])
            if chunk.strip():
                chunks.append(chunk.strip())
            start += self.step

        return chunks

    def split_documents(self, documents: list[Document]) -> list[Document]:
        result = []
        for doc in documents:
            chunks = self.split_text(doc.page_content)
            for i, chunk_text in enumerate(chunks):
                token_count = len(self._encode(chunk_text))
                result.append(Document(
                    page_content=chunk_text,
                    metadata={
                        **doc.metadata,
                        "chunk_index":       i,
                        "chunk_total":       len(chunks),
                        "chunk_token_count": token_count,
                    }
                ))
        return result


# Пример: текст из 2000 слов → ~500 токенов → 2 чанка по 256
splitter = TokenSplitter(chunk_size=256, chunk_overlap=32)
docs = splitter.split_documents([
    Document(page_content="..." * 300, metadata={"source": "report.pdf"})
])
for d in docs:
    print(f"Chunk {d.metadata['chunk_index']}: {d.metadata['chunk_token_count']} tokens")

Асинхронный пайплайн для батч-обработки

При индексировании тысяч документов важно не блокировать event loop. Сам чанкинг — CPU-операция, поэтому выносим её в asyncio.get_event_loop().run_in_executor().

import asyncio
from concurrent.futures import ProcessPoolExecutor
from pathlib import Path


def _split_one(args: tuple) -> list[dict]:
    """Запускается в процессе — нет GIL, чистый CPU."""
    text, metadata, chunk_size, overlap = args
    splitter = CharacterSplitter(chunk_size=chunk_size, chunk_overlap=overlap)
    chunks = splitter.split_text(text)
    return [
        {"page_content": c, "metadata": {**metadata, "chunk_index": i, "chunk_total": len(chunks)}}
        for i, c in enumerate(chunks)
    ]


async def chunk_documents_async(
    documents: list[Document],
    chunk_size: int = 700,
    chunk_overlap: int = 70,
    max_workers: int = 4,
) -> list[Document]:
    """
    Параллельный чанкинг через ProcessPoolExecutor.
    1000 документов × 5000 символов ≈ 3–5 сек вместо 15–20 сек синхронно.
    """
    loop = asyncio.get_event_loop()
    args_list = [
        (doc.page_content, doc.metadata, chunk_size, chunk_overlap)
        for doc in documents
    ]

    with ProcessPoolExecutor(max_workers=max_workers) as pool:
        results = await asyncio.gather(*[
            loop.run_in_executor(pool, _split_one, args)
            for args in args_list
        ])

    return [
        Document(page_content=r["page_content"], metadata=r["metadata"])
        for batch in results
        for r in batch
    ]


# Бенчмарк
async def demo():
    import time

    # Генерируем 500 документов по ~3000 символов
    docs = [Document(
        page_content="Слово " * 500,
        metadata={"source": f"doc_{i}.pdf"}
    ) for i in range(500)]

    t = time.perf_counter()
    chunks = await chunk_documents_async(docs, chunk_size=700, chunk_overlap=70)
    elapsed = time.perf_counter() - t

    print(f"Входных документов: {len(docs)}")
    print(f"Получено чанков:    {len(chunks)}")
    print(f"Время:              {elapsed:.2f}с")


if __name__ == "__main__":
    asyncio.run(demo())

Измерение качества чанкинга

Прежде чем индексировать всё — проверьте, что параметры дают разумные результаты. Три метрики помогают поймать проблему до того, как RAG «поедет».

import statistics
from collections import Counter


def analyze_chunks(chunks: list[Document]) -> dict:
    """
    Анализирует распределение чанков:
    - Средний/медианный размер
    - Количество слишком маленьких (< 50 символов) — признак плохого текста
    - Количество слишком больших (> chunk_size × 1.1) — не должно быть
    """
    sizes = [len(c.page_content) for c in chunks]
    if not sizes:
        return {}

    tiny_threshold = 50
    tiny_count = sum(1 for s in sizes if s < tiny_threshold)

    return {
        "total_chunks":     len(chunks),
        "mean_size":        round(statistics.mean(sizes)),
        "median_size":      round(statistics.median(sizes)),
        "min_size":         min(sizes),
        "max_size":         max(sizes),
        "stdev":            round(statistics.stdev(sizes)) if len(sizes) > 1 else 0,
        "tiny_chunks":      tiny_count,           # подозрительно маленькие
        "tiny_pct":         round(tiny_count / len(sizes) * 100, 1),
    }


def print_chunk_report(chunks: list[Document], chunk_size: int) -> None:
    stats = analyze_chunks(chunks)
    print(f"{'─' * 40}")
    print(f"Всего чанков:       {stats['total_chunks']}")
    print(f"Средний размер:     {stats['mean_size']} символов")
    print(f"Медиана:            {stats['median_size']} символов")
    print(f"Мин / Макс:         {stats['min_size']} / {stats['max_size']}")
    print(f"Σ отклонение:       {stats['stdev']}")
    print(f"Tiny (< 50 симв):   {stats['tiny_chunks']} ({stats['tiny_pct']}%)")

    # Гистограмма размеров
    buckets = [0] * 5
    for c in chunks:
        size = len(c.page_content)
        idx  = min(int(size / chunk_size * 4), 4)
        buckets[idx] += 1

    print(f"\nРаспределение:")
    labels = ["0–25%", "25–50%", "50–75%", "75–100%", "100%+"]
    for label, count in zip(labels, buckets):
        bar = "█" * (count * 20 // max(buckets, default=1))
        print(f"  {label:8s} {bar} {count}")
    print(f"{'─' * 40}")


# Использование
splitter = CharacterSplitter(chunk_size=700, chunk_overlap=70)
chunks   = splitter.split_documents(my_documents)
print_chunk_report(chunks, chunk_size=700)

Когда fixed-size подходит, а когда нет

Однородный контент одного стиля. Регламенты, технические мануалы, юридические документы — текст плотный и примерно одинаковый по структуре. Fixed-size даёт предсказуемые чанки без сюрпризов.
Подходит
Быстрый прототип и эксперименты. Никакой зависимости от структуры документа. Запустил — получил чанки. Хорошо для первой итерации пайплайна, пока не известны реальные запросы.
Подходит
Данные без явной структуры. Если документы — это plain text без заголовков, абзацев и маркеров, смысловые методы (recursive, semantic) не дадут преимущества. Fixed-size тут не хуже.
Подходит
⚠️
Текст с таблицами и кодом. Таблица, разрезанная посередине, теряет смысл. Код без сигнатуры функции непонятен. Fixed-size может разбить их произвольно — нужен document-aware chunking.
С осторожностью
Многотемные документы с чёткими разделами. Статья с h2/h3, учебник с главами — смысловые границы уже заданы автором. Recursive или document-aware chunking использует эти границы и даёт значительно лучший recall.
Лучше другой метод
Диалоги и транскрипты. Реплики одного говорящего надо держать вместе. Fixed-size разрежет посередине фразы — семантика сломается.
Лучше другой метод

Типичные ошибки

chunk_overlap ≥ chunk_size
Шаг сдвига становится нулевым или отрицательным — бесконечный цикл или константное повторение одного и того же чанка.
✓ Проверяйте в конструкторе: assert chunk_overlap < chunk_size. Типично overlap = 10–20% от chunk_size.
Потеря метаданных при разбивке
Если просто нарезать page_content и создать новые Document-объекты без копирования metadata, каждый чанк теряет source, author, date. Ретривер не сможет показать, из какого документа пришёл чанк.
✓ Всегда: metadata={{**doc.metadata, "chunk_index": i}} — spread исходных метаданных плюс новые поля.
Не очищать пустые чанки
Граница чанка может попасть на серию переносов строк или пробелов. Пустой или почти пустой чанк (" \n\n ") создаёт шум в индексе — при поиске он может вернуться как «релевантный» и загрязнить контекст LLM.
✓ Фильтруйте: chunk = text[start:end].strip(); if len(chunk) > 20: chunks.append(chunk)
Одинаковый chunk_size для всех типов документов
FAQ с ответами по 50 слов и 50-страничный отчёт требуют разного chunk_size. Один параметр для всего — либо FAQ-ответы разрезаются, либо чанки отчёта слишком маленькие и теряют контекст.
✓ Используйте разные сплиттеры по doc_type из метаданных. Dispatch через словарь: SPLITTERS = {"faq": small, "report": large}
Не хранить позицию чанка в документе
Без chunk_index и chunk_total нельзя восстановить порядок чанков. При генерации ответа LLM получает фрагменты вразнобой и не понимает что за чем идёт.
✓ Добавляйте в metadata: chunk_index, chunk_total, а опционально — char_start/char_end для точного позиционирования в оригинале.

Шпаргалка

Быстрый выбор параметров:
  • Стартовые значения: chunk_size=700, chunk_overlap=70 (≈280 токенов)
  • Для коротких фактов (FAQ, API-доки): chunk_size=300, overlap=30
  • Для длинного нарратива (академика, отчёты): chunk_size=1200, overlap=120
  • Overlap = 10% от chunk_size — минимум; 20% — стандарт; >30% — только для коротких чанков
  • Измерять в токенах нужно только если chunk_size > 2000 символов
ФОРМУЛА: step = chunk_size − chunk_overlap

chunk 1: text[0 : chunk_size]
chunk 2: text[step : step + chunk_size]
chunk 3: text[2·step : 2·step + chunk_size]
chunk N: text[(N−1)·step : (N−1)·step + chunk_size]

КОЛИЧЕСТВО ЧАНКОВ ≈ ceil((len(text) − chunk_overlap) / step)

ИТОГО ДАННЫХ В ИНДЕКСЕ = len(text) × (chunk_size / step)
  при overlap=10%: ×1.11  (на 11% больше исходного текста)
  при overlap=20%: ×1.25
  при overlap=50%: ×2.0   ← неоправданно много

КОГДА НЕ ХВАТАЕТ FIXED-SIZE → используй:
  ├── Recursive splitting   — текст с абзацами и заголовками
  ├── Semantic chunking     — нет структуры, но нужны смысловые границы
  ├── Document-aware        — таблицы, код, HTML/Markdown
  └── Parent-child chunks   — нужен и точный поиск, и широкий контекст
        

Практические задания

  1. Сравнение параметров. Возьмите любой текст из ≥ 5000 символов. Создайте три набора чанков: (300, 30), (700, 70), (1500, 150). Для каждого выведите print_chunk_report(). Сформулируйте: для каких запросов каждый вариант будет работать лучше.
  2. Диспетчер по типу документа. Напишите класс SmartSplitter, который по полю doc_type в метаданных выбирает разные параметры: faq → (200, 20), report → (1000, 100), default → (700, 70). Протестируйте на трёх документах разных типов.
  3. Восстановление оригинала. Напишите функцию reconstruct_document(chunks: list[Document]) → str, которая склеивает чанки обратно в исходный текст, используя chunk_index для сортировки и убирая дублирующийся overlap между соседними чанками.