Почему структура документа лучше символьного счёта

Возьмём типичную документацию с разделами «Установка», «Конфигурация», «Примеры использования». При chunk_size=1000 recursive splitter разобьёт её механически — и высока вероятность, что последний абзац раздела «Конфигурация» окажется в одном чанке с первым абзацем «Примеров».

ДОКУМЕНТ (4 раздела):
  [== Установка (200 символов) ==][== Конфигурация (800 символов) ==][== Примеры (900 символов) ==][== Деплой ==]

Recursive splitter (chunk_size=1000):
  ┌──────────────────────────────────────────────────────────────┐
  │ Установка (200) + Конфигурация (800) = 1000 ✓               │
  └──────────────────────────────────────────────────────────────┘
  ┌──────────────────────────────────────────────────────────────┐
  │ Примеры (900) + Деплой (100) = 1000 ✓                       │
  └──────────────────────────────────────────────────────────────┘
  → Каждый чанк смешивает два несвязанных раздела

Document-aware splitter (split on ##):
  ┌──────────────────┐ ┌────────────────────┐ ┌────────────────┐
  │ ## Установка     │ │ ## Конфигурация    │ │ ## Примеры     │
  │ (200 символов)   │ │ (800 символов)     │ │ (900 символов) │
  └──────────────────┘ └────────────────────┘ └────────────────┘
  → Каждый чанк = один раздел, как задумал автор
        

Три сценария, где structural splitting кардинально важен:

  • Технические документы. В API-документации каждый endpoint описан в своём разделе. Смешать описание POST /users с DELETE /users/{id} в один чанк — значит гарантированно получать галлюцинации при вопросах об одном из них.
  • Юридические документы. Статья закона или пункт договора — самодостаточная единица смысла. Их нельзя делить пополам и нельзя смешивать с соседними.
  • Книги и руководства. Читатель задаёт вопрос «как настроить X» — и ждёт ответа из раздела «Настройка X», а не из чанка, который начинается в середине этого раздела.
Принцип: если у документа есть явная структура — используй её. Автор уже разметил смысловые границы. Переделывать его работу символьным счётчиком бессмысленно.

Теория: структурные маркеры и иерархия заголовков

Структура документа — это дерево разделов. Каждый раздел состоит из заголовка и тела (текст, картинки, таблицы, вложенные подразделы). Разные форматы кодируют это дерево по-разному, но идея одна:

MD Markdown
# H1 Заголовок 1-го уровня
## H2 Заголовок 2-го уровня
### H3 Заголовок 3-го уровня
#### до H6
Библиотека: встроенный парсер
HTML HTML
<h1> Заголовок 1
<h2> Заголовок 2
<section> Секция
<article> Статья
Библиотека: BeautifulSoup4
DOCX Word
Heading 1 Стиль абзаца
Heading 2 Стиль абзаца
Heading 3 Стиль абзаца
Normal Обычный текст
Библиотека: python-docx
PDF PDF
Outline Закладки/оглавление
FontSize Крупный шрифт
Bold Жирный текст
Нет единого стандарта
Библиотека: pdfplumber / pypdf

Что такое «секция»

Секция — это заголовок плюс весь контент до следующего заголовка того же или более высокого уровня. Именно секция является единицей чанка при document-aware splitting.

# FastAPI: полное руководство
├── ## Установка
│ └── [текст: pip install fastapi...] ← секция 1
├── ## Первый маршрут
│ ├── ### Декоратор @app.get
│ │ └── [текст: регистрирует...] ← секция 2
│ └── ### Параметры пути
│ └── [текст: /items/{id}...] ← секция 3
└── ## Развёртывание
└── [текст: uvicorn main:app...] ← секция 4

Ключевой вопрос: что делать, если секция оказалась слишком большой? Например, раздел «Конфигурация» занимает 5 000 символов, а max_chunk_size = 2 000. Ответ: применяем fallback splitting — дробим большую секцию через RecursiveCharacterSplitter, сохраняя заголовок в метаданных каждого получившегося подчанка.

Пайплайн document-aware chunking

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
Документ (MD / HTML / DOCX) # FastAPI Guide ## Установка pip install fastapi uvicorn[standard] ## Маршруты @app.get("/items") def get_items(): return items ### Параметры /items/{item_id} ## Деплой uvicorn main:app --host 0.0.0.0 Парсер структуры Находит заголовки for line in lines: if line.startswith ("# "): h1 found ("## "): h2 found ("### "): h3 found else: накапливаем Дерево секций path: [FastAPI Guide] content: (пусто) len: 0 path: [Guide > Установка] content: "pip install..." len: 320 path: [Guide > Маршруты > Параметры] content: "/items/{id}..." len: 890 path: [Guide > Деплой] content: "uvicorn..." len: 450 Нормализация Проверка размера len <= max_size? ✓ оставляем чанк len > max_size? ✗ fallback split: RecursiveChar Splitter Чанки с breadcrumb [Guide > Установка] pip install fastapi... [Guide > Маршруты > Параметры] /items/{id}... [Guide > Деплой] uvicorn main:app... ① Входной документ ② Парсинг заголовков ③ Группировка по секциям ④ Fallback для больших ⑤ Чанки + breadcrumb

Распространение контекста заголовков

Самое важное, что отличает document-aware chunking от простой разбивки по заголовкам — это инъекция пути заголовков в начало каждого чанка. Без этого извлечённый текст теряет контекст.

✗ Без контекста заголовков
В этом разделе рассматривается установка зависимостей. Выполните команду в терминале и убедитесь, что версия соответствует...
Запрос: «как установить FastAPI»
Rank: #11 — embedding не знает, что это про установку FastAPI
✓ С breadcrumb-путём
[FastAPI Guide > Установка]
В этом разделе рассматривается установка зависимостей. Выполните команду в терминале и убедитесь, что версия соответствует...
Запрос: «как установить FastAPI»
Rank: #1 — заголовок раскрывает контекст текста

Путь заголовков сохраняется двумя способами:

  1. Препенд в текст чанка — добавляем строку [H1 > H2 > H3] в начало page_content. Это влияет на embedding: вектор чанка «знает» о его месте в документе.
  2. Метаданные — сохраняем отдельные поля h1, h2, h3, heading_path в metadata. Это позволяет фильтровать при поиске: «только чанки из раздела Конфигурация».
Когда не добавлять путь в текст: если у вас очень длинные цепочки заголовков (5+ уровней), препенд может занять значительную долю context window embedding-модели. В таком случае добавляйте только 2–3 последних уровня из пути.

Markdown: разбивка по заголовкам

Markdown — самый распространённый формат для технической документации, README и вики. Его структура однозначно определяется символами # в начале строки.

Алгоритм парсинга

1
Разбить текст на строки
Разделяем по \n. Обрабатываем построчно — это единственный проход по документу.
lines = text.split("\n")
2
Для каждой строки: заголовок или контент?
Строка является заголовком уровня N, если начинается ровно с N символов #, после которых идёт пробел. Важно: ##heading (без пробела) и #tag — не заголовки.
line.startswith("## ") and not line.startswith("## #")
3
При нахождении заголовка: сохранить текущую секцию
Если уже накоплен контент — сохраняем его в список секций с текущим путём заголовков. Сбрасываем буфер контента.
4
Обновить путь заголовков
Путь — список (уровень, название). При встрече заголовка уровня N: удалить из пути все элементы с уровнем ≥ N, добавить новый. Это поддерживает правильную иерархию.
path = [(l, t) for l, t in path if l < level] + [(level, title)]
5
Продолжать накапливать строки контента
Все не-заголовочные строки добавляются в буфер текущей секции.
6
В конце: сохранить последнюю секцию
После обхода всех строк — добавить накопленный контент как финальную секцию. Пустые секции (только заголовок без текста) фильтруем.

Реализация MarkdownHeaderSplitter

from __future__ import annotations

import re
from dataclasses import dataclass, field
from typing import NamedTuple


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


class MarkdownHeaderSplitter:
    """
    Разбивает Markdown-документ на чанки по иерархии заголовков.

    Каждый чанк — это содержимое одного раздела (от заголовка до
    следующего заголовка того же или более высокого уровня).
    В начало каждого чанка добавляется breadcrumb-путь заголовков.

    Args:
        headers_to_split_on: список пар (маркер, имя_поля), например
            [("#", "h1"), ("##", "h2"), ("###", "h3")].
            По умолчанию — все уровни H1–H6.
        max_chunk_size: максимальный размер чанка в символах.
            Секции больше этого значения дробятся fallback-сплиттером.
        strip_headers: не включать строку заголовка в текст чанка.
        inject_context: добавлять breadcrumb в начало page_content.
    """

    DEFAULT_HEADERS: list[tuple[str, str]] = [
        ("#",      "h1"),
        ("##",     "h2"),
        ("###",    "h3"),
        ("####",   "h4"),
        ("#####",  "h5"),
        ("######", "h6"),
    ]

    def __init__(
        self,
        headers_to_split_on: list[tuple[str, str]] | None = None,
        max_chunk_size: int = 2000,
        strip_headers: bool = True,
        inject_context: bool = True,
    ) -> None:
        raw = headers_to_split_on or self.DEFAULT_HEADERS
        # Сортируем от длинного к короткому: "###" должен проверяться раньше "##"
        self.headers = sorted(raw, key=lambda x: -len(x[0]))
        self.max_chunk_size = max_chunk_size
        self.strip_headers = strip_headers
        self.inject_context = inject_context
        self._fallback: "RecursiveCharacterSplitter | None" = None

    # ──────────────────────────────────────────────
    # Публичный API
    # ──────────────────────────────────────────────

    def split_text(self, text: str) -> list[Document]:
        """Разбить markdown-строку на список Document."""
        lines = text.split("\n")
        sections = self._parse_sections(lines)
        return self._sections_to_documents(sections)

    def split_documents(self, docs: list[Document]) -> list[Document]:
        """Обработать список документов (сохраняет исходные metadata)."""
        result = []
        for doc in docs:
            chunks = self.split_text(doc.page_content)
            for chunk in chunks:
                chunk.metadata = {**doc.metadata, **chunk.metadata}
            result.extend(chunks)
        return result

    # ──────────────────────────────────────────────
    # Внутренние методы
    # ──────────────────────────────────────────────

    def _parse_sections(self, lines: list[str]) -> list[dict]:
        """
        Один проход по строкам документа.
        Возвращает список словарей:
          {"path": [(level, field_name, title), ...], "content": "..."}
        """
        current_path: list[tuple[int, str, str]] = []  # (level, name, title)
        current_lines: list[str] = []
        sections: list[dict] = []

        for line in lines:
            matched_header = self._detect_header(line)
            if matched_header:
                level, field_name, title = matched_header
                # Сохраняем накопленный контент как секцию
                if current_lines or current_path:
                    content = "\n".join(current_lines).strip()
                    if content:
                        sections.append({
                            "path": list(current_path),
                            "content": content,
                        })
                # Обновляем иерархию: убираем заголовки того же и глубже уровня
                current_path = [
                    (l, n, t) for l, n, t in current_path if l < level
                ]
                current_path.append((level, field_name, title))
                # Если не вырезаем заголовки — добавляем строку в контент
                current_lines = [] if self.strip_headers else [line]
            else:
                current_lines.append(line)

        # Финальная секция
        if current_lines:
            content = "\n".join(current_lines).strip()
            if content:
                sections.append({
                    "path": list(current_path),
                    "content": content,
                })

        return sections

    def _detect_header(
        self, line: str
    ) -> tuple[int, str, str] | None:
        """
        Возвращает (level, field_name, title) если строка — заголовок,
        иначе None.
        Условие: строка начинается с N символов '#', затем ровно один пробел.
        """
        for marker, field_name in self.headers:
            prefix = marker + " "
            if line.startswith(prefix):
                # Убедимся, что это не "### нет" вместо "## нет"
                # (уже гарантировано сортировкой по длине маркера)
                title = line[len(prefix):].strip()
                level = len(marker)
                return (level, field_name, title)
        return None

    def _build_path_string(self, path: list[tuple]) -> str:
        """'FastAPI Guide > Установка > Конфигурация'"""
        return " > ".join(title for _, _, title in path)

    def _sections_to_documents(self, sections: list[dict]) -> list[Document]:
        docs: list[Document] = []

        for section in sections:
            path_str = self._build_path_string(section["path"])
            content = section["content"]

            # Препендим breadcrumb в текст (влияет на embedding)
            if self.inject_context and path_str:
                full_text = f"[{path_str}]\n\n{content}"
            else:
                full_text = content

            metadata: dict = {"heading_path": path_str}
            # Также сохраняем отдельные уровни для фильтрации
            for _, field_name, title in section["path"]:
                metadata[field_name] = title

            if len(full_text) <= self.max_chunk_size:
                docs.append(Document(page_content=full_text, metadata=metadata))
            else:
                # Fallback: дробим большую секцию recursive splitter'ом
                sub_chunks = self._fallback_split(content)
                for sub in sub_chunks:
                    sub_text = f"[{path_str}]\n\n{sub}" if (self.inject_context and path_str) else sub
                    docs.append(Document(
                        page_content=sub_text,
                        metadata={**metadata, "sub_split": True},
                    ))

        return docs

    def _fallback_split(self, text: str) -> list[str]:
        """Рекурсивный сплиттер как fallback для больших секций."""
        if self._fallback is None:
            self._fallback = RecursiveCharacterSplitter(
                chunk_size=self.max_chunk_size,
                chunk_overlap=200,
            )
        return self._fallback.split_text(text)


# ─────────────────────────────────────────────────────────────
# Минимальный RecursiveCharacterSplitter для fallback
# ─────────────────────────────────────────────────────────────

class RecursiveCharacterSplitter:
    """Упрощённый recursive splitter для использования как fallback."""

    DEFAULT_SEPS = ["\n\n", "\n", ". ", " ", ""]

    def __init__(
        self,
        chunk_size: int = 2000,
        chunk_overlap: int = 200,
        separators: list[str] | None = None,
    ) -> None:
        self.chunk_size = chunk_size
        self.chunk_overlap = chunk_overlap
        self.seps = separators or self.DEFAULT_SEPS

    def split_text(self, text: str) -> list[str]:
        return self._split(text, self.seps)

    def _split(self, text: str, seps: list[str]) -> list[str]:
        if len(text) <= self.chunk_size:
            return [text] if text.strip() else []
        sep = next((s for s in seps if s in text), seps[-1])
        parts = text.split(sep)
        chunks: list[str] = []
        buf = ""
        for part in parts:
            candidate = buf + (sep if buf else "") + part
            if len(candidate) <= self.chunk_size:
                buf = candidate
            else:
                if buf:
                    chunks.append(buf)
                    # Overlap: сохраняем конец предыдущего чанка
                    overlap_start = max(0, len(buf) - self.chunk_overlap)
                    buf = buf[overlap_start:] + (sep if buf[overlap_start:] else "") + part
                else:
                    # Часть сама по себе слишком велика — рекурсия
                    sub_seps = seps[seps.index(sep) + 1:] if sep in seps[1:] else [""]
                    chunks.extend(self._split(part, sub_seps))
                    buf = ""
        if buf.strip():
            chunks.append(buf)
        return chunks

Пример работы

SAMPLE = """
# FastAPI: полное руководство

## Установка

Установите FastAPI и uvicorn через pip:

```
pip install fastapi uvicorn[standard]
```

## Первый маршрут

### Декоратор @app.get

Декоратор регистрирует функцию как обработчик GET-запросов.
Путь передаётся первым аргументом.

### Параметры пути

FastAPI автоматически извлекает параметры из URL:

```python
@app.get("/items/{item_id}")
def get_item(item_id: int):
    return {"id": item_id}
```

## Развёртывание

Запустите сервер командой:

    uvicorn main:app --host 0.0.0.0 --port 8000
"""

splitter = MarkdownHeaderSplitter(
    headers_to_split_on=[("#", "h1"), ("##", "h2"), ("###", "h3")],
    max_chunk_size=2000,
    inject_context=True,
)

chunks = splitter.split_text(SAMPLE)

for i, chunk in enumerate(chunks, 1):
    print(f"=== Chunk {i} ===")
    print(f"Metadata: {chunk.metadata}")
    print(f"Content:\n{chunk.page_content[:200]}")
    print()

# Вывод:
# === Chunk 1 ===
# Metadata: {'heading_path': 'FastAPI: полное руководство > Установка', 'h1': 'FastAPI: полное руководство', 'h2': 'Установка'}
# Content:
# [FastAPI: полное руководство > Установка]
#
# Установите FastAPI и uvicorn через pip:
# ...
#
# === Chunk 2 ===
# Metadata: {'heading_path': '... > Первый маршрут > Декоратор @app.get', 'h1': ..., 'h2': ..., 'h3': ...}
# Content:
# [FastAPI: полное руководство > Первый маршрут > Декоратор @app.get]
# ...

HTML: разбивка по тегам

HTML имеет как явные заголовки (h1–h6), так и семантические элементы (<section>, <article>). Алгоритм аналогичен Markdown: идём по DOM, находим заголовки, группируем текстовые блоки (p, li, blockquote) под текущий заголовок.

Важно для веб-страниц: перед chunking'ом HTML нужно удалить навигацию, футеры, рекламные блоки. Используйте soup.find("main") или soup.find("article") как точку входа, а не весь body.
from bs4 import BeautifulSoup, NavigableString, Tag


class HtmlStructureSplitter:
    """
    Разбивает HTML-документ на чанки по заголовкам h1–h3.

    Args:
        split_on_headings: теги, которые считаются разделителями секций.
        max_chunk_size: максимальная длина чанка (символы).
        content_selector: CSS-селектор корневого элемента (e.g. "main", "article").
    """

    BLOCK_TAGS = {"p", "li", "blockquote", "pre", "td", "th", "dd", "dt"}

    def __init__(
        self,
        split_on_headings: list[str] | None = None,
        max_chunk_size: int = 2000,
        content_selector: str | None = None,
    ) -> None:
        self.split_tags = split_on_headings or ["h1", "h2", "h3"]
        self.max_chunk_size = max_chunk_size
        self.content_selector = content_selector
        self._fallback = RecursiveCharacterSplitter(chunk_size=max_chunk_size)

    def split_html(self, html: str) -> list[Document]:
        soup = BeautifulSoup(html, "html.parser")

        # Находим корневой элемент с контентом
        if self.content_selector:
            root = soup.select_one(self.content_selector) or soup
        else:
            root = soup.find("main") or soup.find("article") or soup.body or soup

        return self._extract_sections(root)

    def _extract_sections(self, root) -> list[Document]:
        docs: list[Document] = []
        current_headings: dict[str, str] = {}  # {"h1": "...", "h2": "..."}
        current_parts: list[str] = []

        def flush():
            if not current_parts:
                return
            path = self._build_path(current_headings)
            content = "\n\n".join(p for p in current_parts if p)
            if not content.strip():
                return
            full = f"[{path}]\n\n{content}" if path else content
            if len(full) <= self.max_chunk_size:
                docs.append(Document(page_content=full, metadata={"heading_path": path, **current_headings}))
            else:
                for sub in self._fallback.split_text(content):
                    sub_text = f"[{path}]\n\n{sub}" if path else sub
                    docs.append(Document(page_content=sub_text, metadata={"heading_path": path, **current_headings, "sub_split": True}))

        # Обходим только прямые дочерние элементы верхнего уровня
        # (не рекурсируем вглубь — текст берём через get_text)
        for el in root.children:
            if not isinstance(el, Tag):
                continue
            tag = el.name.lower() if el.name else ""

            if tag in self.split_tags:
                flush()
                current_parts = []
                level = int(tag[1])
                title = el.get_text(" ", strip=True)
                current_headings[tag] = title
                # Удаляем более глубокие заголовки
                for i in range(level + 1, 7):
                    current_headings.pop(f"h{i}", None)

            elif tag in self.BLOCK_TAGS or tag in {"ul", "ol", "table", "figure"}:
                text = el.get_text(" ", strip=True)
                if text:
                    current_parts.append(text)

        flush()
        return docs

    def _build_path(self, headings: dict[str, str]) -> str:
        return " > ".join(
            headings[f"h{i}"]
            for i in range(1, 7)
            if f"h{i}" in headings
        )


# Использование
html = """

Python asyncio

Зачем нужен async

Синхронный код блокирует поток...

Coroutines

Корутина объявляется через async def...

Ключевое слово await

await приостанавливает корутину до завершения...

""" splitter = HtmlStructureSplitter(split_on_headings=["h1", "h2", "h3"]) chunks = splitter.split_html(html) for chunk in chunks: print(chunk.metadata["heading_path"]) print(chunk.page_content[:100]) print()

DOCX: разбивка по стилям абзацев

В Word-документах структура кодируется не символами, а стилями абзацев. Заголовки применяются через встроенные стили «Заголовок 1», «Заголовок 2», «Heading 1», «Heading 2» и т.д. — в зависимости от языка интерфейса Word. Библиотека python-docx даёт доступ к стилю каждого абзаца.

from docx import Document as DocxFile


class DocxStructureSplitter:
    """
    Разбивает DOCX-файл на чанки по стилям абзацев Heading 1–3
    (и их русскоязычным эквивалентам).
    """

    # Маппинг стилей → уровень (учитываем оба языка)
    HEADING_STYLES: dict[str, int] = {
        "Heading 1": 1, "Heading 2": 2, "Heading 3": 3,
        "Heading 4": 4, "Heading 5": 5, "Heading 6": 6,
        "Заголовок 1": 1, "Заголовок 2": 2, "Заголовок 3": 3,
        "Заголовок 4": 4, "Заголовок 5": 5, "Заголовок 6": 6,
    }

    def __init__(
        self,
        max_chunk_size: int = 2000,
        inject_context: bool = True,
        extra_heading_styles: dict[str, int] | None = None,
    ) -> None:
        self.max_chunk_size = max_chunk_size
        self.inject_context = inject_context
        self._styles = {**self.HEADING_STYLES, **(extra_heading_styles or {})}
        self._fallback = RecursiveCharacterSplitter(chunk_size=max_chunk_size)

    def split_file(self, path: str) -> list[Document]:
        doc = DocxFile(path)
        return self._split_paragraphs(doc.paragraphs)

    def split_paragraphs(self, paragraphs) -> list[Document]:
        """Принимает список Paragraph-объектов python-docx."""
        return self._split_paragraphs(paragraphs)

    def _split_paragraphs(self, paragraphs) -> list[Document]:
        docs: list[Document] = []
        current_path: list[tuple[int, str]] = []  # (level, title)
        current_parts: list[str] = []

        def flush():
            if not current_parts:
                return
            content = "\n\n".join(p for p in current_parts if p)
            if not content.strip():
                return
            path_str = " > ".join(t for _, t in current_path)
            full = f"[{path_str}]\n\n{content}" if (self.inject_context and path_str) else content
            metadata = {
                "heading_path": path_str,
                **{f"h{l}": t for l, t in current_path},
            }
            if len(full) <= self.max_chunk_size:
                docs.append(Document(page_content=full, metadata=metadata))
            else:
                for sub in self._fallback.split_text(content):
                    sub_text = f"[{path_str}]\n\n{sub}" if (self.inject_context and path_str) else sub
                    docs.append(Document(page_content=sub_text, metadata={**metadata, "sub_split": True}))

        for para in paragraphs:
            style_name = para.style.name if para.style else ""
            level = self._styles.get(style_name)
            text = para.text.strip()

            if level and text:
                flush()
                current_parts = []
                # Обновляем иерархию: убираем уровни ≥ текущего
                current_path = [(l, t) for l, t in current_path if l < level]
                current_path.append((level, text))
            elif text:
                current_parts.append(text)

        flush()
        return docs


# Использование
splitter = DocxStructureSplitter(max_chunk_size=2000)
chunks = splitter.split_file("technical_spec.docx")

print(f"Всего чанков: {len(chunks)}")
for chunk in chunks[:3]:
    print(f"\n[{chunk.metadata['heading_path']}]")
    print(chunk.page_content[:150] + "...")

Нормализация размера: что делать с большими секциями

Реальные документы неравномерны: один раздел — 200 символов, другой — 8 000. Вот как это выглядит в типичной технической документации:

## Установка (350 симв.)
350 / 2000
OK
## Конфигурация (820 симв.)
820 / 2000
OK
## Справочник API (5 400 симв.)
5 400 / 2000
Слишком большой
↓ fallback: RecursiveCharacterSplitter(chunk_size=2000, overlap=200)
[Guide > Справочник API] чанк 1 (1980 симв.)
[Guide > Справочник API] чанк 2 (1850 симв.)
[Guide > Справочник API] чанк 3 (1570 симв.)
## Примеры (1 400 симв.)
1 400 / 2000
OK

Все подчанки от fallback-разбивки наследуют метаданные родительской секции: heading_path, h1, h2 и т.д. плюс флаг sub_split: True — чтобы можно было отличить «чистые» секционные чанки от дроблёных.

Унифицированный DocumentAwareChunker

В реальном pipeline документы приходят в разных форматах. Оборачиваем все три сплиттера в единый интерфейс с автоопределением формата по расширению.

from pathlib import Path


class DocumentAwareChunker:
    """
    Единая точка входа для document-aware chunking.
    Автоматически выбирает сплиттер по расширению файла или
    по явно указанному формату.

    Поддерживаемые форматы: markdown, html, docx.
    """

    def __init__(
        self,
        max_chunk_size: int = 2000,
        inject_context: bool = True,
        md_headers: list[tuple[str, str]] | None = None,
        html_split_on: list[str] | None = None,
        html_selector: str | None = None,
    ) -> None:
        self._md = MarkdownHeaderSplitter(
            headers_to_split_on=md_headers,
            max_chunk_size=max_chunk_size,
            inject_context=inject_context,
        )
        self._html = HtmlStructureSplitter(
            split_on_headings=html_split_on,
            max_chunk_size=max_chunk_size,
            content_selector=html_selector,
        )
        self._docx = DocxStructureSplitter(
            max_chunk_size=max_chunk_size,
            inject_context=inject_context,
        )

    def split_file(self, path: str | Path) -> list[Document]:
        """Автоопределение формата по расширению файла."""
        p = Path(path)
        suffix = p.suffix.lower()
        text = None

        if suffix in (".md", ".markdown"):
            text = p.read_text(encoding="utf-8")
            return self._md.split_text(text)

        elif suffix in (".html", ".htm"):
            text = p.read_text(encoding="utf-8")
            return self._html.split_html(text)

        elif suffix == ".docx":
            return self._docx.split_file(str(p))

        else:
            raise ValueError(
                f"Неподдерживаемый формат: {suffix}. "
                f"Используйте split_text() или split_html() напрямую."
            )

    def split_text(self, text: str, format: str = "markdown") -> list[Document]:
        """Разбить текст явно указанного формата."""
        if format == "markdown":
            return self._md.split_text(text)
        elif format == "html":
            return self._html.split_html(text)
        else:
            raise ValueError(f"Неподдерживаемый формат: {format}")


# ─────────────────────────────────────────────────────────────
# Пример использования в RAG-пайплайне
# ─────────────────────────────────────────────────────────────

from pathlib import Path

chunker = DocumentAwareChunker(
    max_chunk_size=2000,
    inject_context=True,
)

# Обработка директории с документацией
docs_dir = Path("docs/")
all_chunks: list[Document] = []

for file_path in docs_dir.rglob("*.md"):
    try:
        chunks = chunker.split_file(file_path)
        # Добавляем имя файла в метаданные
        for chunk in chunks:
            chunk.metadata["source"] = str(file_path)
        all_chunks.extend(chunks)
    except Exception as e:
        print(f"Ошибка при обработке {file_path}: {e}")

print(f"Всего чанков: {len(all_chunks)}")

# Статистика по размерам
sizes = [len(c.page_content) for c in all_chunks]
print(f"Средний размер: {sum(sizes) / len(sizes):.0f} символов")
print(f"Максимальный:   {max(sizes)} символов")
print(f"Минимальный:    {min(sizes)} символов")

# Фильтрация при поиске: только чанки из раздела "API Reference"
api_chunks = [
    c for c in all_chunks
    if "API Reference" in c.metadata.get("heading_path", "")
]
print(f"\nЧанков в разделе API Reference: {len(api_chunks)}")

Когда применять document-aware chunking

Структурированная документация: Markdown, HTML, DOCX. README, wiki-страницы, технические спецификации, книги с главами — везде, где автор сам разметил структуру. Это самый точный и бесплатный способ разбивки.
Идеально
Вопросно-ответные системы по документации продукта. Пользователь спрашивает про конкретный раздел («как настроить аутентификацию»). Чанки с breadcrumb-путём дают точный retrieval по названию раздела.
Подходит
Юридические и регуляторные документы. Статьи законов, пункты договоров, разделы стандартов — самодостаточные единицы с чёткими границами. Смешивать их нельзя.
Подходит
⚠️
Академические статьи и новостные тексты. У них есть заголовки разделов (Abstract, Introduction, Methods), но смысловые переходы внутри раздела не отмечены структурно. Используйте document-aware для первичной разбивки + semantic chunking внутри каждой секции.
Частично
Неструктурированный текст без заголовков. Транскрипты переговоров, чаты, нарративный текст — нет структурных маркеров. Document-aware не даст преимущества перед recursive splitting; лучше использовать semantic chunking.
Не подходит

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

Не учитывать порядок сортировки маркеров
Если проверять маркеры в порядке ["#", "##", "###"], строка ### Заголовок сначала сматчится на # и будет распознана как H1 вместо H3. Всегда сортируйте по убыванию длины маркера.
✓ headers = sorted(headers, key=lambda x: -len(x[0]))
Игнорировать пустые секции
Если заголовок идёт за заголовком без контента между ними («## Введение\n## Что такое X\n...»), создаются пустые чанки. Они занимают место в векторной БД и ничего не несут при retrieval.
✓ Фильтруйте: [s for s in sections if s["content"].strip()]
Не добавлять breadcrumb в текст — только в метаданные
Метаданные не участвуют в создании embedding. Если breadcrumb только в metadata, но не в page_content, вектор чанка «не знает» о его месте в документе — retrieval по названию раздела не работает.
✓ Добавляйте "[H1 > H2]" в начало page_content И сохраняйте в metadata для фильтрации.
Разбивать на уровне # H1, когда это заголовок всего документа
Большинство Markdown-файлов имеют один H1 — заголовок документа. Включение H1 в список разделителей создаёт один огромный «чанк» для каждого документа вместо нормальной разбивки по H2/H3.
✓ Ориентируйтесь на реальную структуру файлов: если один H1 — начинайте с H2.
Не обрабатывать DOCX с нестандартными стилями
Корпоративные DOCX-шаблоны часто переименовывают стили: «Раздел 1», «Подзаголовок», «Title Bold». Стандартный маппинг «Heading 1» не найдёт ни одного заголовка — все параграфы попадут в один чанк.
✓ Перед запуском проверьте стили: set(p.style.name for p in doc.paragraphs). Передайте extra_heading_styles с нужными именами.

Шпаргалка

Быстрый старт для Markdown:
  1. Создайте MarkdownHeaderSplitter(headers_to_split_on=[("##","h2"),("###","h3")])
  2. Вызовите split_text(md_content)
  3. Проверьте: нет ли чанков с len(page_content) > max_chunk_size
  4. Проверьте: у каждого чанка есть heading_path в metadata
  5. Убедитесь, что breadcrumb [H1 > H2] стоит в начале page_content
ВЫБОР УРОВНЕЙ РАЗБИВКИ:

  Документ с одним H1 (типично для README):
    headers = [("##", "h2"), ("###", "h3")]   ← H1 пропускаем

  Книга с частями и главами:
    headers = [("#", "h1"), ("##", "h2")]     ← H1 = часть, H2 = глава

  Вики с глубокой иерархией:
    headers = [("##", "h2"), ("###", "h3"), ("####", "h4")]

РАЗМЕР ЧАНКОВ ПО ФОРМАТУ:

  Markdown / DOCX:
    max_chunk_size = 1500–2000  (обычно разделы небольшие)

  HTML (веб-страницы):
    max_chunk_size = 2000–3000  (разделы бывают длиннее)

INJECT_CONTEXT: ВСЕГДА True для retrieval-задач
  False — только если чанки пойдут в LLM без поиска (summarization)

BREADCRUMB ДЛИНА:
  [H1 > H2 > H3] — до 3 уровней, длиннее занимает слишком много токенов
  Можно брать только последние 2: path_str = " > ".join(titles[-2:])

СРАВНЕНИЕ СТРАТЕГИЙ:

  Метод             Стоимость   Точность   Требует embedding?
  ─────────────────────────────────────────────────────────
  Fixed-size        ⚡ мгновенно  ★★☆☆☆    Нет
  Recursive split   ⚡ мгновенно  ★★★☆☆    Нет
  Document-aware    ⚡ мгновенно  ★★★★☆    Нет  ← sweet spot
  Semantic          💰 API вызовы  ★★★★★    Да
        

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

  1. Аудит структуры репозитория. Возьмите любой публичный GitHub-репозиторий с документацией в Markdown. Обойдите все *.md файлы через MarkdownHeaderSplitter и выведите статистику: среднее число чанков на файл, распределение размеров, топ-10 самых больших секций (кандидаты на fallback-разбивку).
  2. Фильтрация по разделу. Постройте простой RAG с chromadb: проиндексируйте документацию FastAPI, сохраняя heading_path в метаданных. Реализуйте два режима поиска: обычный семантический и с фильтром where={"h2": "Advanced User Guide"}. Сравните качество ответов на узкоспециализированные вопросы.
  3. Гибридная стратегия. Реализуйте сплиттер, который: (1) сначала делит Markdown по H2 через MarkdownHeaderSplitter, (2) секции короче 300 символов объединяет с соседними (эвристика: «слишком мелкий раздел — это подсказка или заглушка»), (3) секции длиннее max_chunk_size дополнительно дробит через SemanticSplitter вместо recursive fallback. Сравните с baseline (только recursive splitter) на 10 тестовых вопросах.