$ sudo teach IT

В прошлом уроке база знаний была списком аккуратных коротких строк. В реальной работе всё иначе: регламент на сорок страниц в PDF, выгрузка FAQ в текстовом файле, договоры, инструкции. Если превратить в один эмбеддинг целый сорокастраничный документ, вектор «усреднит» все темы сразу, и поиск перестанет находить конкретный ответ. А если отдать модели весь документ, вы потратите тысячи токенов на каждый вопрос.

Поэтому перед поиском документы загружают и режут на чанки — фрагменты по несколько сотен или тысяч символов. Хорошее разбиение — половина успеха RAG: от него зависит, найдёт ли поиск нужный абзац и хватит ли модели контекста для ответа. После урока вы сможете загрузить текстовые и PDF-файлы, разбить их на чанки с перекрытием и заранее оценить, сколько чанков получится.

Что уже умеем

Вы знаете, что такое эмбеддинг, умеете считать косинусное сходство и собрали простой семантический поиск top-k по списку строк. Теперь научимся готовить для него настоящие данные: загружать файлы и резать их на фрагменты удобного размера.

Что вы узнаете в рамках статьи

  • как устроен объект Document и зачем ему метаданные;
  • как загрузить текстовый файл и PDF стандартными загрузчиками LangChain;
  • что такое чанк, размер чанка и перекрытие;
  • как работает RecursiveCharacterTextSplitter;
  • как заранее оценить число чанков по формуле.

Ключевые термины

  • Document — объект LangChain с текстом (page_content) и словарём метаданных (metadata).
  • Загрузчик (loader) — класс, который читает файл и возвращает список объектов Document.
  • Чанк — фрагмент документа, который векторизуется и ищется как единое целое.
  • chunk_size — максимальная длина чанка; по умолчанию в символах.
  • chunk_overlap — сколько символов конца предыдущего чанка повторяется в начале следующего.
  • Метаданные — сведения о фрагменте: файл-источник, номер страницы, позиция в тексте.

Объект Document и метаданные

Все компоненты LangChain для работы с данными обмениваются объектами Document. Это простая обёртка: текст плюс словарь с любой дополнительной информацией.

from langchain_core.documents import Document

doc = Document(
    page_content="Возврат денег возможен в течение 14 дней после покупки.",
    metadata={"source": "faq.txt", "section": "returns"},
)

print(doc.page_content)
print(doc.metadata["source"])

Что увидим в консоли: текст документа и строку faq.txt. Метаданные кажутся мелочью, но именно они позволят в уроке 3.4 показать пользователю, из какого файла и с какой страницы взят ответ, а в уроке 3.3 — искать только среди документов нужного раздела. Правило простое: всё, что вы знаете об источнике фрагмента, кладите в metadata сразу при загрузке.

Загрузчики: текст и PDF

Писать чтение файлов вручную не нужно: в пакете langchain-community есть десятки готовых загрузчиков. Установим нужные пакеты:

pip install langchain-community langchain-text-splitters pypdf

Загрузим текстовый файл и PDF:

from langchain_community.document_loaders import TextLoader, PyPDFLoader

faq_docs = TextLoader("data/faq.txt", encoding="utf-8").load()
print(len(faq_docs), faq_docs[0].metadata)

pdf_docs = PyPDFLoader("data/regulations.pdf").load()
print(len(pdf_docs))
print(pdf_docs[3].metadata["page"], pdf_docs[3].page_content[:80])

Что увидим: TextLoader вернёт список из одного документа с метаданными {'source': 'data/faq.txt'}. PyPDFLoader вернёт по одному документу на каждую страницу, например 40, а в метаданных будут источник и номер страницы. Нумерация в page начинается с нуля, поэтому четвёртая страница имеет page равный 3.

Явно указывайте encoding="utf-8" для текстовых файлов с русским текстом: иначе на некоторых системах вместо букв появятся нечитаемые символы или ошибка декодирования. Если файлов много, загрузите их в цикле по pathlib.Path("data").glob("*.txt") и соберите все документы в один список.

Чанки и перекрытие

Чанк должен быть достаточно маленьким, чтобы говорить об одной теме, и достаточно большим, чтобы содержать законченную мысль. Типичный стартовый размер — 500–1000 символов.

Если резать текст встык, мысль может оборваться на границе: начало предложения окажется в одном чанке, конец — в другом, и ни один из них не ответит на вопрос. Перекрытие решает это: последние chunk_overlap символов каждого чанка повторяются в начале следующего. Обычно перекрытие берут равным 10–20 % от размера чанка.

Чтобы оценивать число чанков, рассмотрим простое посимвольное разбиение. Первый чанк начинается с позиции 0, каждый следующий — на step = chunk_size − chunk_overlap символов дальше, а разбиение заканчивается на чанке, который дошёл до конца текста. Для текста длиной L:

  • если L ≤ chunk_size, получится 1 чанк;
  • иначе n = ⌈(L − chunk_overlap) / (chunk_size − chunk_overlap)⌉, где ⌈ ⌉ — округление вверх.

Проверим формулу кодом:

import math

def simple_split(text: str, chunk_size: int, overlap: int) -> list[str]:
    step = chunk_size - overlap
    chunks, start = [], 0
    while True:
        chunks.append(text[start:start + chunk_size])
        if start + chunk_size >= len(text):
            return chunks
        start += step

text = "а" * 2600
chunks = simple_split(text, chunk_size=1000, overlap=200)
print(len(chunks), [len(c) for c in chunks])
print(math.ceil((2600 - 200) / (1000 - 200)))

Что увидим: 3 [1000, 1000, 1000] и 3. Чанки начинаются с позиций 0, 800 и 1600, последний заканчивается ровно на 2600. Формула и код дают одинаковый результат.

RecursiveCharacterTextSplitter

Посимвольная резка удобна для расчётов, но в реальном коде она рвёт слова и предложения. RecursiveCharacterTextSplitter действует умнее: сначала пытается делить текст по абзацам ("\n\n"), если кусок всё ещё велик — по строкам ("\n"), затем по пробелам и только в крайнем случае по символам. Так чанки по возможности заканчиваются на естественных границах.

from langchain_community.document_loaders import PyPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter

pages = PyPDFLoader("data/regulations.pdf").load()

splitter = RecursiveCharacterTextSplitter(
    chunk_size=800,
    chunk_overlap=100,
    add_start_index=True,
)
chunks = splitter.split_documents(pages)

print(len(pages), "страниц ->", len(chunks), "чанков")
print(chunks[0].metadata)
print(max(len(c.page_content) for c in chunks))

Что увидим: например, 40 страниц -> 118 чанков, затем метаданные вида {'source': 'data/regulations.pdf', 'page': 0, 'start_index': 0} и максимальную длину чанка не больше 800. Метод split_documents сохраняет метаданные исходной страницы в каждом чанке, а add_start_index=True добавляет позицию начала чанка в тексте страницы.

Поскольку сплиттер ищет удобные границы, число чанков у него обычно чуть больше, чем по формуле простого разбиения, а сами чанки бывают короче chunk_size. Формулой удобно пользоваться для оценки объёма индекса и стоимости эмбеддингов ещё до запуска кода. Для обычной строки без метаданных есть метод split_text, который возвращает список строк.

Частые ошибки

  • Слишком большие чанки. Чанк на 5000 символов смешивает несколько тем, его вектор становится размытым, и поиск промахивается. Начинайте с 500–1000 символов.
  • Перекрытие больше или равно размеру чанка. Шаг становится нулевым или отрицательным, разбиение теряет смысл; сплиттер в таком случае выбросит ошибку. Держите перекрытие в пределах 10–20 %.
  • Потеря метаданных. Если резать page_content вручную и создавать новые документы без metadata, вы не сможете показать источник ответа. Используйте split_documents.
  • Старый путь импорта. Сплиттеры давно живут в отдельном пакете: импортируйте их из langchain_text_splitters.

Наиболее частые вопросы

  • В чём измеряется chunk_size: в символах или токенах? — По умолчанию в символах. Для русского текста грубо можно считать, что 1 токен — это 3–4 символа.
  • Как выбрать размер чанка? — Начните с 800–1000 символов и перекрытия 100–200, затем проверьте поиск на реальных вопросах и подстройте.
  • Что делать с PDF-сканами? — PyPDFLoader извлекает только текстовый слой. Для сканов нужен отдельный этап распознавания текста.
  • Почему у меня чанков больше, чем по формуле? — Рекурсивный сплиттер режет по границам абзацев и предложений, поэтому некоторые чанки короче максимума.

Итоги

Поздравляем, вы освоили подготовку данных — этап, который многие недооценивают! Теперь вы умеете загружать текстовые файлы и PDF в объекты Document, понимаете, зачем нужны метаданные, и режете документы на чанки с перекрытием, сохраняя связь каждого фрагмента с источником. А ещё можете заранее прикинуть, сколько чанков получится.

Осталось где-то хранить эти чанки вместе с эмбеддингами, чтобы не пересчитывать их при каждом запуске и быстро искать даже среди тысяч фрагментов. В следующем уроке вы подключите векторное хранилище Chroma.

На следующий урок