В прошлом уроке база знаний была списком аккуратных коротких строк. В реальной работе всё иначе: регламент на сорок страниц в 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.