Проблема: точность поиска против полноты контекста

Представьте документацию, где раздел «Аутентификация» занимает 3 000 символов и описывает OAuth2, JWT и API-ключи. Пользователь спрашивает: «как обновить истёкший JWT?»

ВАРИАНТ А: крупный чанк (весь раздел, 3000 символов)
  Embedding: усреднённый вектор всего раздела — «аутентификация вообще»
  Запрос «обновить JWT» → похожесть ~0.62
  Рядом по вектору: другие общие статьи про auth → RANK #4

  Плюс: если чанк всё же нашёлся — LLM видит весь нужный контекст.
  Минус: часто не находится, потому что embedding слишком общий.

ВАРИАНТ Б: мелкий чанк (только абзац про JWT, 400 символов)
  Embedding: точный вектор — «JWT refresh token flow»
  Запрос «обновить JWT» → похожесть ~0.91
  RANK #1 ← легко находится!

  Плюс: точный поиск.
  Минус: 400 символов без контекста — LLM не знает, с каким сервисом,
         какие заголовки нужны, куда отправлять запрос.

ВАРИАНТ В: parent-child
  Ищем по мелкому чанку (400 симв.) → находим точно.
  Возвращаем LLM родительский чанк (3000 симв.) → богатый контекст.
  Лучшее из двух миров.
        

Суть в том, что embedding-модель «устроена» так: чем длиннее текст, тем больше тем в нём смешивается, и тем дальше его вектор от любого конкретного запроса. 400-символьный абзац про JWT и запрос «обновить JWT» лежат в похожих точках пространства — их косинусное сходство высокое. 3 000-символьный раздел про «аутентификацию вообще» лежит в другой точке — дальше от любого конкретного запроса.

Стратегия
Точность поиска
Контекст для LLM
Мелкие чанки≈ 300–600 символов
★★★★★
★★☆☆☆
Крупные чанки≈ 1 500–3 000 символов
★★☆☆☆
★★★★★
Parent-childпоиск по мелким → контекст крупных
★★★★★
★★★★★

Теория: двухуровневая индексация

Parent-child chunking строит двухуровневую структуру:

  • Родительский чанк (parent) — крупный, 1 500–3 000 символов. Хранится в отдельном хранилище (doc store). В векторную базу не попадает.
  • Дочерние чанки (children) — мелкие, 300–600 символов. Каждый содержит в метаданных parent_id. Именно они индексируются в векторной БД.

При индексировании один родительский чанк порождает 3–6 дочерних — в зависимости от размеров. Дочерние могут перекрываться (overlap), чтобы не пропустить смысл на стыке.

Ключ связи: parent_id

Каждому родительскому чанку при создании присваивается UUID — doc_id. Все дочерние чанки, нарезанные из этого родителя, получают metadata["parent_id"] = doc_id. Это единственная связь между уровнями.

Родитель P2  (doc_id = "a3f8...")
  ┌──────────────────────────────────────────────────────────┐
  │  OAuth2 flow: клиент отправляет code → сервер возвращает │
  │  access_token и refresh_token. Access token действует    │
  │  15 минут. При истечении клиент использует...            │
  │                           ← 2 800 символов →             │
  └──────────────────────────────────────────────────────────┘
         ↓ нарезаем на дочерние (child_splitter)
  ┌───────────────┐  ┌───────────────┐  ┌───────────────┐
  │ C2.1          │  │ C2.2          │  │ C2.3          │
  │ parent_id:    │  │ parent_id:    │  │ parent_id:    │
  │ "a3f8..."     │  │ "a3f8..."     │  │ "a3f8..."     │
  │ ← 480 симв. → │  │ ← 510 симв. → │  │ ← 460 симв. → │
  └───────────────┘  └───────────────┘  └───────────────┘
         ↓                  ↓                  ↓
      В векторную БД (поиск)      Родитель — в doc store (контекст)
        

Архитектура: индексирование и retrieval

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
ИНДЕКСИРОВАНИЕ Исходный документ Родитель P1 doc_id: "b1c2..." ≈ 2 400 символов → doc store Родитель P2 doc_id: "a3f8..." ≈ 2 800 символов → doc store Родитель P3 doc_id: "c7d4..." ≈ 1 900 символов → doc store C1.1 parent_id: "b1c2..." C1.2 parent_id: "b1c2..." C1.3 parent_id: "b1c2..." C2.1 parent_id: "a3f8..." C2.2 ← НАЙДЕН parent_id: "a3f8..." C2.3 parent_id: "a3f8..." C3.1 parent_id: "c7d4..." C3.2 parent_id: "c7d4..." C3.3 parent_id: "c7d4..." ① Векторная БД: только дочерние чанки (9 шт.) Doc Store (P1, P2, P3) RETRIEVAL Запрос embed + search → Найден C2.2 480 символов parent_id: "a3f8..." lookup parent_id → Родитель P2 2 800 символов полный контекст LLM ② Запрос эмбеддируется, ищем в детских чанках → находим C2.2 ③ Читаем parent_id из метаданных C2.2 → "a3f8..." ④ Достаём P2 из doc store по ключу "a3f8..." → отдаём LLM

Три стратегии создания пар parent-child

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

Fixed-size пара
Самый простой
Параметры
Родитель: chunk_size=2000
Ребёнок: chunk_size=400
Overlap: 50–80 символов
~4–5 детей на родителя
Когда использовать
Однородный текст без явной структуры. Новостные статьи, транскрипты, художественные тексты. Быстрый старт без настройки.
Document-aware пара
Структурный
Параметры
Родитель: H2-секция (целый раздел)
Ребёнок: H3-подсекция или абзац
Используется MarkdownHeaderSplitter
~2–4 детей на родителя
Когда использовать
Структурированная документация, книги, вики. Когда структура документа совпадает со смысловыми единицами. Лучшее качество для технических docs.
Sentence window
Максимальная точность
Параметры
Ребёнок: 1 предложение
Родитель: окно ±3–5 предложений вокруг ребёнка
Высокая точность поиска, но много родителей
~1 ребёнок на родителя (скользящее окно)
Когда использовать
Научные и юридические документы, где важна точная цитата + её контекст. Запросы типа «найди определение X» или «процитируй пункт Y».

Retrieval: как работает поиск с подменой

Retrieval-процесс в parent-child — четыре чётких шага. Важно, что на каждом шаге работает разный компонент системы.

Поиск по дочерним чанкам
Запрос пользователя эмбеддируется и ищется в векторной БД, которая содержит только дочерние чанки. Возвращаем top-k × 3 результатов — с запасом, потому что несколько детей могут принадлежать одному родителю.
children = await vectorstore.asimilarity_search(query, k=k*3)
2
Сбор уникальных parent_id
Из метаданных каждого найденного дочернего чанка читаем parent_id. Дедуплицируем: если два ребёнка указывают на одного родителя — берём родителя только раз. Порядок сохраняем: первый найденный ребёнок = наиболее релевантный родитель.
parent_ids = list(dict.fromkeys(c.metadata["parent_id"] for c in children))
3
Загрузка родительских чанков из docstore
По списку уникальных parent_id обращаемся к хранилищу родителей. Docstore — это простой key-value store (dict, Redis, MongoDB). Операция быстрая: нет семантического поиска, только lookup по ключу.
parents = docstore.mget(parent_ids[:k])
4
Возврат родителей как контекст для LLM
Возвращаем список родительских документов. Каждый из них значительно крупнее дочернего — LLM получает полный контекст раздела, а не вырванный из него фрагмент. Родители уже отфильтрованы по релевантности через дочерние.
return [p for p in parents if p is not None]

Реализация: ParentChildSplitter

Сплиттер принимает два сплиттера — для родителей и детей — и документы. Возвращает пары: список дочерних чанков (для векторной БД) и словарь родительских (для docstore).

from __future__ import annotations

import uuid
from dataclasses import dataclass, field
from typing import Protocol


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


# ─────────────────────────────────────────────────────────────
# Протокол сплиттера — любой класс с методом split_documents
# ─────────────────────────────────────────────────────────────

class Splitter(Protocol):
    def split_documents(self, docs: list[Document]) -> list[Document]: ...


# ─────────────────────────────────────────────────────────────
# ParentChildSplitter
# ─────────────────────────────────────────────────────────────

class ParentChildSplitter:
    """
    Создаёт двухуровневую структуру чанков.

    Args:
        parent_splitter: сплиттер для создания родительских чанков.
            Определяет размер единицы контекста для LLM.
        child_splitter: сплиттер для нарезки детей из каждого родителя.
            Определяет размер единицы поиска в векторной БД.

    Returns (split_documents):
        child_docs: список дочерних чанков с parent_id в metadata.
            → индексировать в векторную БД.
        parent_store: dict {parent_id: Document}.
            → хранить в docstore для lookup при retrieval.
    """

    def __init__(
        self,
        parent_splitter: Splitter,
        child_splitter: Splitter,
    ) -> None:
        self.parent_splitter = parent_splitter
        self.child_splitter = child_splitter

    def split_documents(
        self,
        documents: list[Document],
    ) -> tuple[list[Document], dict[str, Document]]:
        """
        Возвращает (child_docs, parent_store).
        child_docs  — загружать в vectorstore.
        parent_store — загружать в docstore.
        """
        child_docs: list[Document] = []
        parent_store: dict[str, Document] = {}

        for doc in documents:
            # Шаг 1: нарезаем документ на родительские чанки
            parents = self.parent_splitter.split_documents([doc])

            for parent in parents:
                # Шаг 2: присваиваем родителю уникальный ID
                parent_id = str(uuid.uuid4())
                parent.metadata["doc_id"] = parent_id
                parent_store[parent_id] = parent

                # Шаг 3: нарезаем родителя на дочерние чанки
                children = self.child_splitter.split_documents([parent])

                for child in children:
                    # Шаг 4: каждый ребёнок ссылается на родителя
                    child.metadata["parent_id"] = parent_id
                    # Сохраняем source из исходного документа
                    if "source" in doc.metadata:
                        child.metadata["source"] = doc.metadata["source"]
                    child_docs.append(child)

        return child_docs, parent_store

Хранилища для родительских чанков

Docstore — простой key-value store. Интерфейс минимальный: mset(pairs) для записи и mget(keys) для чтения. Выбор зависит от масштаба и персистентности.

InMemoryDocStore
Python dict в памяти
Нет персистентности
0 зависимостей
Lookup за O(1)
Для разработки, прототипов и малых корпусов (<10k документов)
Redis / Valkey
Персистентный (AOF/RDB)
Сериализация: JSON или pickle
TTL для автоочистки
Production-ready
Для production: быстро, надёжно, поддерживает кластеризацию
MongoDB / PostgreSQL
Поддержка сложных запросов
Фильтрация по метаданным
ACID-транзакции
Медленнее Redis
Когда нужна дополнительная фильтрация родителей или аудит изменений
class InMemoryDocStore:
    """Простое in-memory хранилище документов."""

    def __init__(self) -> None:
        self._store: dict[str, Document] = {}

    def mset(self, pairs: list[tuple[str, Document]]) -> None:
        """Записать несколько документов сразу."""
        for key, doc in pairs:
            self._store[key] = doc

    def mget(self, keys: list[str]) -> list[Document | None]:
        """Получить документы по ключам. None если ключ не найден."""
        return [self._store.get(k) for k in keys]

    def __len__(self) -> int:
        return len(self._store)

    def __contains__(self, key: str) -> bool:
        return key in self._store


class RedisDocStore:
    """
    Redis-based docstore для production.
    Сериализует Document в JSON; родители доступны между перезапусками.
    """

    def __init__(self, redis_url: str = "redis://localhost:6379", ttl: int | None = None) -> None:
        import redis, json
        self._r = redis.from_url(redis_url)
        self._ttl = ttl
        self._json = json

    def mset(self, pairs: list[tuple[str, Document]]) -> None:
        pipe = self._r.pipeline()
        for key, doc in pairs:
            payload = self._json.dumps({
                "page_content": doc.page_content,
                "metadata": doc.metadata,
            })
            if self._ttl:
                pipe.setex(key, self._ttl, payload)
            else:
                pipe.set(key, payload)
        pipe.execute()

    def mget(self, keys: list[str]) -> list[Document | None]:
        raw_list = self._r.mget(keys)
        result = []
        for raw in raw_list:
            if raw is None:
                result.append(None)
            else:
                data = self._json.loads(raw)
                result.append(Document(
                    page_content=data["page_content"],
                    metadata=data["metadata"],
                ))
        return result

Retriever: от дочернего к родительскому

class ParentChildRetriever:
    """
    Ищет по дочерним чанкам, возвращает родительские.

    Args:
        vectorstore: хранилище с дочерними чанками (поддерживает asimilarity_search).
        docstore: хранилище с родительскими чанками (mget по parent_id).
        k: сколько уникальных родителей вернуть.
        search_k_multiplier: ищем k * multiplier дочерних чанков,
            чтобы после дедупликации набрать k уникальных родителей.
    """

    def __init__(
        self,
        vectorstore,
        docstore: InMemoryDocStore | RedisDocStore,
        k: int = 4,
        search_k_multiplier: int = 3,
    ) -> None:
        self.vectorstore = vectorstore
        self.docstore = docstore
        self.k = k
        self._search_k = k * search_k_multiplier

    async def aretrieve(self, query: str) -> list[Document]:
        """Async retrieval: query → дочерние → родительские."""
        # 1. Ищем дочерние чанки
        child_results = await self.vectorstore.asimilarity_search(
            query, k=self._search_k
        )

        # 2. Собираем уникальные parent_id (порядок = релевантность)
        seen: dict[str, None] = {}
        for doc in child_results:
            pid = doc.metadata.get("parent_id")
            if pid and pid not in seen:
                seen[pid] = None
            if len(seen) >= self.k:
                break

        parent_ids = list(seen.keys())

        # 3. Загружаем родителей из docstore
        parents = self.docstore.mget(parent_ids)
        return [p for p in parents if p is not None]

    def retrieve(self, query: str) -> list[Document]:
        """Sync wrapper для использования вне async-контекста."""
        import asyncio
        try:
            loop = asyncio.get_event_loop()
            if loop.is_running():
                import concurrent.futures
                with concurrent.futures.ThreadPoolExecutor() as pool:
                    future = pool.submit(asyncio.run, self.aretrieve(query))
                    return future.result()
            return loop.run_until_complete(self.aretrieve(query))
        except RuntimeError:
            return asyncio.run(self.aretrieve(query))

Полная сборка: индексирование и запрос

"""
Полный пример: индексируем документацию FastAPI через parent-child chunking,
затем задаём вопрос и получаем ответ с богатым контекстом.

Зависимости: chromadb, openai
"""

import asyncio
from pathlib import Path

import chromadb
from chromadb.utils.embedding_functions import OpenAIEmbeddingFunction
from openai import AsyncOpenAI


# ── Собственные сплиттеры (из предыдущих уроков) ──

class RecursiveCharacterSplitter:
    DEFAULT_SEPS = ["\n\n", "\n", ". ", " ", ""]

    def __init__(self, chunk_size=2000, chunk_overlap=100, separators=None):
        self.chunk_size = chunk_size
        self.chunk_overlap = chunk_overlap
        self.seps = separators or self.DEFAULT_SEPS

    def split_documents(self, docs):
        result = []
        for doc in docs:
            for chunk in self._split(doc.page_content, self.seps):
                result.append(Document(page_content=chunk, metadata=dict(doc.metadata)))
        return result

    def _split(self, text, seps):
        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, 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)
                    buf = buf[max(0, len(buf) - self.chunk_overlap):] + (sep if buf else "") + part
                else:
                    sub_seps = seps[seps.index(sep)+1:] if sep in seps[1:] else [""]
                    chunks.extend(self._split(part, sub_seps))
        if buf.strip():
            chunks.append(buf)
        return chunks


# ── Инициализация компонентов ──

embedding_fn = OpenAIEmbeddingFunction(
    api_key="sk-...",
    model_name="text-embedding-3-small",
)

chroma_client = chromadb.Client()
child_collection = chroma_client.create_collection(
    name="children",
    embedding_function=embedding_fn,
)

docstore = InMemoryDocStore()

parent_splitter = RecursiveCharacterSplitter(chunk_size=2000, chunk_overlap=200)
child_splitter  = RecursiveCharacterSplitter(chunk_size=400,  chunk_overlap=50)

splitter = ParentChildSplitter(
    parent_splitter=parent_splitter,
    child_splitter=child_splitter,
)


# ── Адаптер Chroma для retriever'а ──

class ChromaVectorStore:
    def __init__(self, collection):
        self.col = collection

    def add_documents(self, docs: list[Document]) -> None:
        self.col.add(
            ids=[str(i) for i in range(len(docs))],
            documents=[d.page_content for d in docs],
            metadatas=[d.metadata for d in docs],
        )

    async def asimilarity_search(self, query: str, k: int = 10) -> list[Document]:
        results = self.col.query(query_texts=[query], n_results=k)
        docs = []
        for content, meta in zip(results["documents"][0], results["metadatas"][0]):
            docs.append(Document(page_content=content, metadata=meta))
        return docs


# ── Индексирование ──

def index_documents(file_paths: list[str]) -> None:
    raw_docs = []
    for path in file_paths:
        content = Path(path).read_text(encoding="utf-8")
        raw_docs.append(Document(page_content=content, metadata={"source": path}))

    child_docs, parent_dict = splitter.split_documents(raw_docs)

    # Сохраняем родителей в docstore
    docstore.mset(list(parent_dict.items()))

    # Индексируем детей в Chroma
    vectorstore = ChromaVectorStore(child_collection)
    vectorstore.add_documents(child_docs)

    print(f"Проиндексировано:")
    print(f"  Родительских чанков: {len(parent_dict)}")
    print(f"  Дочерних чанков:     {len(child_docs)}")
    print(f"  Среднее детей/родитель: {len(child_docs)/len(parent_dict):.1f}")


# ── Retrieval + генерация ──

async def answer_question(question: str) -> str:
    vectorstore = ChromaVectorStore(child_collection)
    retriever = ParentChildRetriever(
        vectorstore=vectorstore,
        docstore=docstore,
        k=3,
    )

    # Получаем крупные родительские чанки
    parent_docs = await retriever.aretrieve(question)

    context = "\n\n---\n\n".join(d.page_content for d in parent_docs)

    client = AsyncOpenAI()
    response = await client.chat.completions.create(
        model="claude-sonnet-4-6",
        messages=[
            {"role": "system", "content": "Ты помощник по документации. Отвечай только на основе предоставленного контекста."},
            {"role": "user", "content": f"Контекст:\n{context}\n\nВопрос: {question}"},
        ],
    )
    return response.choices[0].message.content


# ── Запуск ──

if __name__ == "__main__":
    # Индексируем
    index_documents(list(Path("docs/").rglob("*.md")))

    # Задаём вопрос
    answer = asyncio.run(answer_question("Как обновить истёкший JWT токен?"))
    print(answer)

Вариант: document-aware родители

Вместо fixed-size родителей используем структуру документа: родитель — это H2-секция целиком, дочерние — абзацы внутри. Так родитель всегда совпадает со смысловой единицей, которую задумал автор.

from document_aware_chunking import MarkdownHeaderSplitter

# Родитель — H2-секция (весь раздел целиком)
parent_splitter = MarkdownHeaderSplitter(
    headers_to_split_on=[("##", "h2")],
    max_chunk_size=5000,      # допускаем большие родители
    inject_context=True,      # breadcrumb в начало
    strip_headers=False,      # заголовок остаётся в тексте родителя
)

# Ребёнок — H3-подсекция или абзацы (если нет H3)
child_splitter = MarkdownHeaderSplitter(
    headers_to_split_on=[("###", "h3")],
    max_chunk_size=600,
    inject_context=True,
)

splitter = ParentChildSplitter(
    parent_splitter=parent_splitter,
    child_splitter=child_splitter,
)

# Если у H2-секции нет H3-подсекций, MarkdownHeaderSplitter вернёт
# весь текст как один чанк — он станет одновременно и родителем, и ребёнком.
# В этом случае ParentChildSplitter создаёт одну пару parent=child,
# что нормально: retrieval всё равно работает корректно.

# Пример: документация с глубокой иерархией
docs = [Document(page_content=Path("fastapi-docs.md").read_text())]
child_docs, parent_store = splitter.split_documents(docs)

print(f"Родителей (H2-секции): {len(parent_store)}")
print(f"Детей (H3-подсекции):  {len(child_docs)}")

# Выведем первый родитель и его детей
first_parent_id = next(iter(parent_store))
first_parent = parent_store[first_parent_id]
print(f"\nРодитель: {first_parent.metadata.get('h2', 'н/д')}")
print(f"Размер:   {len(first_parent.page_content)} символов")

its_children = [c for c in child_docs if c.metadata.get("parent_id") == first_parent_id]
print(f"Дочерних: {len(its_children)}")
for c in its_children:
    print(f"  — [{c.metadata.get('h3', 'абзац')}] {len(c.page_content)} символов")

Вариант: sentence window

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

import re
from dataclasses import dataclass, field


class SentenceWindowSplitter:
    """
    Создаёт пары: ребёнок = одно предложение,
    родитель = окно window_size предложений вокруг него.

    Каждое предложение становится и ребёнком, и центром своего родителя.
    Это означает, что родители перекрываются — нормально для docstore.
    """

    _ABBREV = re.compile(r"\b(?:т\.е|и\.е|т\.к|ст|п|гл|рис|табл|др|пр|проф|д-р)\.")

    def __init__(self, window_size: int = 3) -> None:
        self.window_size = window_size  # предложений с каждой стороны

    def split_documents(
        self,
        docs: list[Document],
    ) -> tuple[list[Document], dict[str, Document]]:
        child_docs: list[Document] = []
        parent_store: dict[str, Document] = {}

        for doc in docs:
            sentences = self._split_sentences(doc.page_content)

            for i, sentence in enumerate(sentences):
                # Окно: берём window_size предложений с каждой стороны
                start = max(0, i - self.window_size)
                end   = min(len(sentences), i + self.window_size + 1)
                window_text = " ".join(sentences[start:end])

                parent_id = str(uuid.uuid4())
                parent = Document(
                    page_content=window_text,
                    metadata={
                        "doc_id": parent_id,
                        "source": doc.metadata.get("source", ""),
                        "center_sentence": i,
                        "window_start": start,
                        "window_end": end,
                    },
                )
                parent_store[parent_id] = parent

                child = Document(
                    page_content=sentence,
                    metadata={
                        "parent_id": parent_id,
                        "source": doc.metadata.get("source", ""),
                        "sentence_idx": i,
                    },
                )
                child_docs.append(child)

        return child_docs, parent_store

    def _split_sentences(self, text: str) -> list[str]:
        """Разбивает текст на предложения, защищая аббревиатуры."""
        protected = self._ABBREV.sub(lambda m: m.group().replace(".", "‼"), text)
        parts = re.split(r"(?<=[.!?])\s+", protected)
        return [p.replace("‼", ".").strip() for p in parts if p.strip()]


# Пример использования
window_splitter = SentenceWindowSplitter(window_size=2)
docs = [Document(page_content=Path("contract.txt").read_text(), metadata={"source": "contract.txt"})]

child_docs, parent_store = window_splitter.split_documents(docs)

print(f"Предложений (дети):    {len(child_docs)}")
print(f"Окон контекста (родители): {len(parent_store)}")

# Пример пары:
child = child_docs[5]
parent = parent_store[child.metadata["parent_id"]]
print(f"\nДочерний чанк (ищем по нему):")
print(f"  «{child.page_content}»")
print(f"\nРодительское окно (отдаём LLM):")
print(f"  «{parent.page_content}»")

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

Длинные документы с многоуровневой структурой. Техническая документация, книги, стандарты — здесь каждый вопрос относится к конкретному подразделу, но ответить без контекста раздела невозможно. Parent-child даёт и точность поиска, и полноту контекста.
Идеально
Юридические и нормативные документы. Пункт договора или статья закона — это маленький ребёнок для точного поиска. Но чтобы понять его смысл, LLM нужен весь раздел с определениями и предшествующим контекстом.
Подходит
Когда retrieval даёт нерелевантные ответы при крупных чанках. Если пользователи жалуются «находит не то», но контекст обрезан при мелких — parent-child устраняет оба недостатка.
Подходит
⚠️
Простые FAQ и короткие корпусы. Если документы короткие (300–500 символов) и уже хорошо ищутся — добавление второго уровня создаёт сложность без выигрыша. Оставайтесь на обычном chunking.
Не нужно
Ограниченный context window LLM. Если модель принимает только 4k токенов, а родительские чанки весят 3k — в контекст войдёт лишь один родитель. Либо уменьшайте parent_size, либо используйте модель с большим окном (128k+).
Осторожно

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

Индексировать родителей вместо детей (или вместе с детьми)
Суть техники в том, что в векторной БД — только дети. Если добавить туда и родителей, их «размытые» embeddings будут возвращаться наряду с точными детьми и снижать качество поиска. Docstore и vectorstore должны содержать разные данные.
✓ Vectorstore ← child_docs. Docstore ← parent_store. Никакого пересечения.
Слишком крупный ребёнок убирает преимущество
Если child_size=1500, а parent_size=2000 — дочерние чанки лишь немного меньше родительских. Embedding у них тоже «размытый», и точность поиска не улучшается по сравнению с обычным chunking. Разрыв должен быть ощутимым: минимум 3–5x.
✓ Рекомендуемые пропорции: parent ≈ 1500–3000, child ≈ 300–500 (разница 4–6x).
Docstore не персистируется между перезапусками
InMemoryDocStore живёт только в RAM. После перезапуска сервиса parent_id в векторной БД указывают в никуда — retriever возвращает пустые результаты. Часто эта ошибка обнаруживается только в production.
✓ В production используйте RedisDocStore или другой персистентный store. Синхронизируйте векторную БД и docstore — они обновляются вместе.
search_k слишком мало для дедупликации
При поиске k=4 родителей и search_k=4 детей — если все 4 найденных ребёнка из одного родителя, вернётся только 1 родитель вместо 4. Нужно искать больше детей с запасом.
✓ Устанавливайте search_k = k × 3–5. При k=4 ищите 12–20 дочерних чанков, потом дедуплицируйте.
Не обновлять оба хранилища при изменении документа
Если документ изменился и вы обновили только векторную БД — дочерние чанки указывают на устаревшие родительские через старые parent_id. Или наоборот: docstore обновлён, но vectorstore ещё хранит старые дочерние чанки.
✓ Атомарное обновление: delete_by_source(source) из обоих хранилищ, потом заново add. Реализуйте это как одну транзакционную операцию.

Шпаргалка

Быстрый старт (fixed-size):
  1. parent: RecursiveCharacterSplitter(chunk_size=2000, chunk_overlap=200)
  2. child: RecursiveCharacterSplitter(chunk_size=400, chunk_overlap=50)
  3. child_docs, parent_store = ParentChildSplitter(parent, child).split_documents(docs)
  4. Добавить child_docs в vectorstore
  5. Добавить parent_store.items() в docstore через mset()
  6. Retrieval: ParentChildRetriever(vectorstore, docstore, k=4).retrieve(query)
АРХИТЕКТУРА ХРАНИЛИЩ:

  Vectorstore (Chroma / Qdrant)      Docstore (Redis / Dict)
  ─────────────────────────────      ──────────────────────────
  child_1  → embedding              "b1c2..." → parent_doc_1
  child_2  → embedding              "a3f8..." → parent_doc_2
  child_3  → embedding              "c7d4..." → parent_doc_3
  ...                               ...

  Поиск: query → [child_2, child_5, child_3, ...]
  Lookup: child_2.metadata.parent_id = "a3f8..." → parent_doc_2
  Возврат LLM: [parent_doc_2, parent_doc_1, ...]

ВЫБОР СТРАТЕГИИ:

  Тип документа            Стратегия          parent / child
  ───────────────────────────────────────────────────────────
  Неструктурированный      Fixed-size          2000 / 400
  Markdown / DOCX / HTML   Document-aware      H2-секция / H3 или абзац
  Юридика / наука          Sentence-window     окно 5 предл. / 1 предл.

РАЗМЕРЫ (эмпирика):
  child_size: 300–500 символов → точный embedding
  parent_size: 1500–3000 символов → богатый контекст
  Разница: минимум 4–6x

ДЕДУПЛИКАЦИЯ: search_k = k × 3–5
  k=4  → search 12–20 дочерних чанков
  k=8  → search 24–40 дочерних чанков
        

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

  1. Сравнение стратегий. Возьмите документацию любого open-source проекта (FastAPI, Django, SQLAlchemy). Проиндексируйте её тремя способами: обычный fixed-size chunking (chunk_size=800), parent-child fixed-size (parent=2000, child=400), parent-child document-aware (H2/H3). Для каждого задайте 5 вопросов и вручную оцените качество найденного контекста по шкале 1–5.
  2. Детектор деградации docstore. Напишите функцию verify_integrity(child_docs, docstore), которая проверяет: для каждого дочернего чанка из vectorstore существует ли его родитель в docstore. Выводит % «осиротевших» детей и список отсутствующих parent_id. Полезно запускать после обновления индекса.
  3. Адаптивный parent_size. Реализуйте сплиттер, который автоматически подбирает размер родителя в зависимости от размера документа: для документов до 3 000 символов — весь документ как один родитель; для 3 000–10 000 — parent_size=2 000; для больших — parent_size=3 000. Сравните качество retrieval с фиксированным parent_size=2 000.