Учебник

Как загрузить нормативку и договоры в чат-бота

У стройфирмы 200 страниц нормативки, типовые договоры подряда и прайс на работы. Менеджер должен помнить всё наизусть. Реально помнит процентов 30. Чтобы чат-бот работал точно, документы нужно нарезать правильно - это называется chunking. Разбираем все стратегии с конкретными параметрами: от простой нарезки по 512 токенов с перекрытием 10-20% до смысловой, которая даёт +9% к точности поиска.

Макс Космов··12 мин чтения

У стройфирмы 200 страниц нормативки - СП по бетонам, СП по фундаментам, типовые узлы. Плюс типовые договоры подряда, прайс на работы, регламенты по приёмке. Менеджер по продажам или прораб должен помнить всё это наизусть, чтобы быстро ответить клиенту или сметчику. Реально помнит процентов 30, остальное гуглит при клиенте или обещает «уточнить».

Чат-бот с этой базой мог бы закрыть вопрос за 10 секунд. Но только если документы загружены правильно. Если нарезать их как попало - бот будет находить случайные куски, давать обрывочные ответы и в итоге окажется бесполезнее менеджера.

Нарезка документов (chunking) - самая недооценённая часть при сборке такого бота. Поменяли размер фрагмента с 1024 на 512 токенов - качество поиска выросло на 8%. Добавили перекрытие 10% - ещё плюс 4%. Правильная нарезка решает больше, чем замена модели.

Почему нарезка влияет на деньги

Чтобы бот находил нужный фрагмент в базе документов, весь текст сначала переводится в числовое представление (это называется embedding - каждое слово и фраза превращаются в набор чисел, по которым компьютер умеет искать похожее по смыслу). Потом, когда прораб спрашивает «какой класс бетона для плиты перекрытия по СП 63?», система ищет числовые представления, наиболее близкие к вопросу, и возвращает нужные куски текста.

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

Для бизнеса это конкретно: бот либо находит нужный пункт договора подряда за 10 секунд, либо отвечает невпопад и менеджер всё равно идёт смотреть вручную. Стоимость переделки нулевая - это настройка параметров, а не замена инфраструктуры.

Fixed-size chunking: базовый метод и с чего начинать

Простая нарезка фиксированного размера (fixed-size chunking) режет текст по заданному числу символов или токенов, игнорируя смысловые границы. Токен - это примерно 3/4 слова в русском тексте, то есть 512 токенов это около 350-400 слов. Это отправная точка, с которой начинают все.

Основные параметры:

  • chunk_size=512 токенов - эмпирически оптимально для большинства задач. Достаточно контекста для анализа, достаточно мало для точного поиска.
  • overlap=50-100 токенов (10-20%) - перекрытие соседних фрагментов. Нужно, чтобы ключевая фраза не «потерялась» на границе двух кусков.

Представьте такую ситуацию в базе нормативных документов:

...арматура класса А500С диаметром 12 мм с шагом 200 мм.
[--- ГРАНИЦА ФРАГМЕНТА ---]
Узлы опирания плиты на стену выполняются согласно...

Если прораб спрашивает «как армировать плиту перекрытия?», правильный ответ разрезан пополам. Без перекрытия поиск вернёт неполный результат. С перекрытием в 50 токенов второй фрагмент начнётся раньше и захватит оба куска - ответ будет найден.

Этот код создаёт базовый объект для нарезки текста. Задаём размер фрагмента в символах и перекрытие между соседними кусками, чтобы важный контекст не потерялся на границе.

from langchain.text_splitter import CharacterTextSplitter

splitter = CharacterTextSplitter(
 chunk_size=512, # в символах (не токенах)
 chunk_overlap=100,
 separator="\n"
)

chunks = splitter.split_text(document_text)
print(f"Документ {len(document_text)} символов -> {len(chunks)} чанков")

Ограничения: метод режет предложения посередине, игнорирует структуру (заголовки, абзацы), одинаково обрабатывает плотный технический текст СНиПа и короткий FAQ по оплате.

RecursiveCharacterTextSplitter: умная нарезка по иерархии

Плохая нарезка документа - как плохое оглавление в книге. Читатель нашёл главу, но внутри всё перемешано. RecursiveCharacterTextSplitter (рекурсивный разделитель) пробует нарезать по иерархии естественных границ, а не слепо по числу символов. Результат - фрагменты, которые обрываются на границе абзаца, а не посередине фразы.

Алгоритм пробует разбить текст по такому приоритету:

["\n\n", "\n", " ", ""]

Сначала - по двойному переносу строки (граница абзаца). Если кусок всё ещё слишком большой - по одинарному переносу. Если и это не помогает - по пробелам. В крайнем случае - посимвольно.

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

Этот код создаёт разделитель для обычного текста. Параметр length_function определяет, в чём считать размер - в символах или токенах.

from langchain.text_splitter import RecursiveCharacterTextSplitter

text_splitter = RecursiveCharacterTextSplitter(
 chunk_size=512,
 chunk_overlap=64,
 length_function=len, # символы
)

chunks = text_splitter.create_documents(
 texts=[document_text],
 metadatas=[{"source": "SP_63_13330.pdf", "page": 1}]
)

Для точного подсчёта в токенах (модели работают именно с токенами, а не символами) подключают библиотеку tiktoken. База нормативов стройфирмы на 400 страниц, нарезанная по токенам, даст на 15-20% меньше фрагментов по сравнению с нарезкой по символам - база компактнее и поиск быстрее.

Этот код подключает счётчик токенов и передаёт его как функцию измерения размера фрагмента. Теперь chunk_size=512 означает ровно 512 токенов, а не символов.

import tiktoken

enc = tiktoken.get_encoding("cl100k_base")

def tiktoken_len(text: str) -> int:
 return len(enc.encode(text))

splitter = RecursiveCharacterTextSplitter(
 chunk_size=512, # теперь это токены
 chunk_overlap=64,
 length_function=tiktoken_len
)

Semantic chunking: нарезка по смыслу, +9% к точности поиска

Смысловая нарезка (semantic chunking) - это прямое вложение в качество ответов бота. Если стандартная нарезка даёт 80% попадания при поиске нужного фрагмента, смысловая поднимает показатель до 87-89%. Для корпоративного AI-ассистента это разница между «почти всегда находит нужное» и «иногда отвечает мимо».

Как это работает: алгоритм анализирует смысловые границы между предложениями через числовые представления текста. Если похожесть между соседними предложениями резко падает - это граница фрагмента.

Представьте СП 63.13330 на 400 страниц. Первые 50 страниц - общие требования и термины. Потом раздел про армирование. Потом про опалубку. При обычной нарезке фрагмент может захватить хвост раздела про термины и начало раздела про армирование - это мусор. Смысловая нарезка разрезает строго между темами.

Алгоритм:

  1. Разбиваем текст на предложения.
  2. Переводим каждое предложение в набор чисел (числовое представление).
  3. Считаем похожесть между соседними предложениями.
  4. Там, где похожесть резко падает - граница фрагмента.

Этот код создаёт смысловой разделитель. Параметр breakpoint_threshold_amount=95 означает: граница фрагмента там, где похожесть падает сильнее, чем в 95% остальных случаев - то есть только в самых резких смысловых переходах.

from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings

embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

splitter = SemanticChunker(
 embeddings=embeddings,
 breakpoint_threshold_type="percentile",
 breakpoint_threshold_amount=95
)

chunks = splitter.create_documents([document_text])
print(f"{len(chunks)} смысловых фрагментов")

Прирост: по данным независимых экспериментов на корпусах технической документации - +7-11% Recall@5 (доля случаев, когда нужный фрагмент попал в топ-5 результатов поиска) против обычной нарезки. Медиана прироста: +9%.

Стоимость: каждое предложение требует одного вызова к API числовых представлений. Документ из 10 тысяч токенов (70-90 предложений) - это 70-90 дополнительных вызовов при индексации. При text-embedding-3-small это примерно $0.002 на документ. Для базы из 500 документов нормативки - около $1 на всю индексацию. Приемлемо.

Альтернатива без платного API: использовать локальную модель BGE-M3 для смысловой нарезки при индексации, и основную модель - для поиска.

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

Parent-child chunking: маленькие куски для поиска, большие для ответа

Иерархическая нарезка (parent-child chunking) решает фундаментальное противоречие: маленькие фрагменты точнее попадают в запрос при поиске, но большие дают модели больше контекста для качественного ответа.

Представьте: ассистент ищет ответ в базе нормативки. Маленький фрагмент точно находит нужный пункт про армирование. Но для полного ответа прорабу нужен весь подраздел - иначе ответ будет обрезанным и прораб задаст уточняющий вопрос. Parent-child решает это автоматически.

Схема работы:

  • Дочерние фрагменты (child chunks): 128-256 токенов - используются для поиска по базе.
  • Родительские фрагменты (parent chunks): 512-1024 токена - передаются модели для генерации полного ответа.

При поиске: ищем по маленьким фрагментам, а в модель отдаём их родительские большие - так ответ получается точным и полным.

Этот код строит иерархию трёх уровней: 1024 - большой родительский блок (например, весь подраздел про армирование), 512 - средний, 128 - маленький для поиска. AutoMergingRetriever автоматически заменяет найденные маленькие фрагменты на их родителей перед тем, как передать в модель.

from llama_index.core.node_parser import HierarchicalNodeParser
from llama_index.core.retrievers import AutoMergingRetriever
from llama_index.core import VectorStoreIndex, StorageContext

# Создаём иерархию: 1024 -> 512 -> 128 токенов
node_parser = HierarchicalNodeParser.from_defaults(
 chunk_sizes=[1024, 512, 128]
)

nodes = node_parser.get_nodes_from_documents(documents)

storage_context = StorageContext.from_defaults()
base_index = VectorStoreIndex(nodes, storage_context=storage_context)

base_retriever = base_index.as_retriever(similarity_top_k=12)

retriever = AutoMergingRetriever(
 base_retriever,
 storage_context,
 verbose=True
)

nodes = retriever.retrieve("Класс бетона для монолитной плиты перекрытия по СП 63")

В LangChain та же логика реализована через ParentDocumentRetriever. Этот код настраивает два уровня нарезки - маленький для поиска (128 символов) и большой для передачи в модель (512 символов). InMemoryStore подходит для теста; в рабочей среде его заменяют на Redis или MongoDB.

from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryStore
from langchain_community.vectorstores import Qdrant
from langchain.text_splitter import RecursiveCharacterTextSplitter

# маленький для поиска
child_splitter = RecursiveCharacterTextSplitter(chunk_size=128)
# большой для передачи в модель
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=512)

docstore = InMemoryStore() # или Redis/MongoDB для рабочей среды
vectorstore = Qdrant.from_documents([], embeddings, location=":memory:")

retriever = ParentDocumentRetriever(
 vectorstore=vectorstore,
 docstore=docstore,
 child_splitter=child_splitter,
 parent_splitter=parent_splitter,
)

retriever.add_documents(documents)
results = retriever.invoke("вопрос прораба или клиента")
# Возвращает родительские большие фрагменты, не маленькие

Late chunking: новая техника 2025 года

Поздняя нарезка (late chunking) переворачивает классический подход. Вместо нарезки до перевода в числовые представления, сначала пропускаем весь документ через модель, а потом нарезаем уже полученные числа.

Результат: каждый фрагмент «помнит» контекст всего документа. Фраза «по этому методу» в пятом фрагменте получает числовое представление, которое «знает», что метод назывался во введении. Это устраняет главную проблему классической нарезки - потерю контекста при переходе между разделами.

Ограничение: нужна модель с длинным контекстным окном (контекстное окно - это сколько текста модель удерживает в памяти за один раз). Jina Embeddings v3 поддерживает 8192 токена - это покрывает большинство стандартных документов. Для длинных технических руководств на 400+ страниц всё равно нужна предварительная нарезка на разделы.

Пример: у стройфирмы база регламентов и СП. Слово «нагрузка» встречается в каждом втором документе. Поздняя нарезка позволяет поиску понять: этот «нагрузка» - про ветровые нагрузки на фасад, а тот «нагрузка» - про нагрузку на перекрытие. Без этого контекста векторный поиск путается и выдаёт смешанные результаты.

Нарезка документов с заголовками: структура как граница

Документация в формате Markdown имеет естественную иерархию через заголовки. MarkdownHeaderTextSplitter использует её как границы фрагментов - это особенно удобно, если загружаете документы, которые уже структурированы по разделам.

Этот код разбивает Markdown-документ по заголовкам первого, второго и третьего уровня. Каждый фрагмент автоматически получает в метаданных путь заголовков - пользователь видит, из какого раздела документа пришёл ответ.

from langchain.text_splitter import MarkdownHeaderTextSplitter

headers_to_split_on = [
 ("#", "H1"),
 ("##", "H2"),
 ("###", "H3"),
]

splitter = MarkdownHeaderTextSplitter(
 headers_to_split_on=headers_to_split_on,
 strip_headers=False
)

chunks = splitter.split_text(markdown_text)
# Метаданные фрагмента: {"H1": "Раздел 5", "H2": "5.3 Армирование"}

Для стройфирмы это работает так: если конвертировать СП 63 в Markdown с заголовками по разделам, бот будет отвечать «согласно разделу 5.3 СП 63» - прораб сразу знает, куда смотреть в бумажном документе для проверки.

Метаданные фрагментов: откуда взят ответ

Метаданные - это то, что позволяет не просто «найти похожий текст», а вернуть ссылку на источник. Или отфильтровать по дате, по типу документа, по объекту. Без метаданных бот может найти нужную фразу, но не скажет, из какого договора подряда она взята и какой это номер СП.

Этот код создаёт объект с текстом и полным набором метаданных. Поле chunk_id особенно важно: без него повторная загрузка того же документа создаёт дубликаты в базе, которые портят результаты поиска. С chunk_id работает логика «обновить, если уже есть».

from langchain.schema import Document

chunk = Document(
 page_content="текст фрагмента",
 metadata={
 "source": "documents/SP_63_13330_2012.pdf",
 "page_num": 47,
 "section": "5.3 Армирование монолитных конструкций",
 "chunk_id": "SP_63_p47_0",
 "doc_type": "normativ",
 "language": "ru",
 "created_at": "2025-11-01",
 "department": "боевая среда"
 }
)

Для стройфирмы стоит добавить поля doc_type со значениями normativ, dogovor, prajis, reglament - это позволит фильтровать поиск: клиентскому боту отдавать только прайс и условия договоров, прорабскому - нормативку и регламенты.

Как выбрать размер фрагмента: тест за 2 часа

Вместо догадок - измерение. Берёте 100 реальных вопросов из практики фирмы (что спрашивают клиенты, что уточняет прораб), тестируете 3-4 размера фрагментов, смотрите на Recall@5 - нашёл ли поиск нужный фрагмент в топ-5 результатов. За 2 часа получаете данные, а не ощущения.

Этот код автоматически перебирает несколько комбинаций размера и перекрытия. Для каждой комбинации нарезает документы, строит векторную базу и проверяет, в скольких случаях из 100 правильный ответ попал в топ-5. На выходе - таблица с числами.

import json
from typing import List, Dict

def evaluate_chunking_strategy(
 documents: List[str],
 questions: List[Dict],
 chunk_size: int,
 chunk_overlap: int
) -> float:
 splitter = RecursiveCharacterTextSplitter(
 chunk_size=chunk_size,
 chunk_overlap=chunk_overlap,
 length_function=tiktoken_len
 )
 chunks = splitter.create_documents(documents)

 vectorstore = Qdrant.from_documents(
 chunks, OpenAIEmbeddings(model="text-embedding-3-small"),
 location=":memory:"
 )

 recalls = []
 for item in questions:
 retrieved = vectorstore.similarity_search(item["q"], k=5)
 retrieved_ids = [r.metadata["chunk_id"] for r in retrieved]
 hit = any(gid in retrieved_ids for gid in item["golden_chunk_ids"])
 recalls.append(float(hit))

 return sum(recalls) / len(recalls)

# Тестируем несколько вариантов
for chunk_size in [256, 512, 1024]:
 for overlap_pct in [0.1, 0.2]:
 overlap = int(chunk_size * overlap_pct)
 recall = evaluate_chunking_strategy(docs, questions, chunk_size, overlap)
 print(f"size={chunk_size}, overlap={overlap}: Recall@5={recall:.3f}")

100 вопросов, 3 размера фрагмента, 2 варианта перекрытия - это 6 экспериментов по 5-10 минут каждый. Итого 2 часа и конкретный ответ вместо ощущений.

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

Выбирать размер фрагмента на глаз. Без измерения на реальных вопросах нет данных для решения. Потратьте 2 часа на тест - сэкономите недели дебага.

Не добавлять перекрытие. Без overlap важный факт регулярно оказывается разрезан ровно на границе двух фрагментов. Перекрытие 10% закрывает эту проблему почти полностью.

Игнорировать метаданные. Без source и chunk_id невозможно сказать пользователю, откуда взята информация. И невозможно обновить базу без дублей.

Применять одну стратегию ко всем документам. Прайс с короткими позициями - один фрагмент на позицию. Технический регламент на 200 страниц - parent-child. Типовой договор подряда - нарезка по разделам. Разные документы требуют разного подхода.

Нарезать таблицы так же, как текст. Таблицу нельзя резать - теряется смысл строк и столбцов. Для прайса: индексировать каждую строку отдельно с заголовком таблицы в метаданных. Для таблиц в СП: конвертировать в текстовое описание при индексации или хранить как единый фрагмент.

Частые вопросы

Почему 512 токенов считается базовым размером?

512 токенов - это примерно 350-400 слов. Достаточно для одного смыслового фрагмента. Меньше 256 - фрагменты становятся слишком короткими и теряют контекст. Больше 1024 - точность поиска падает, потому что один фрагмент охватывает несколько тем и числовое представление «усредняет» их смысл. Уменьшайте размер до 256 при поиске точных фактов (конкретный пункт СП, конкретная цифра прайса). Увеличивайте до 1024 при вопросах, требующих развёрнутых объяснений.

Когда смысловая нарезка оправдана, несмотря на дополнительные затраты?

Оправдана при неоднородной структуре: технические нормативы, многостраничные договоры, длинные аналитические документы. Не оправдана для коротких FAQ или прайса, где каждая запись уже является отдельным фрагментом. Для базы из 500 документов стоимость смысловой нарезки - около $1. Если прирост в 9% к точности ответов важен для фирмы - используйте.

Как parent-child реализовать в LangChain без LlamaIndex?

Через ParentDocumentRetriever с InMemoryStore для теста или MongoDB/Redis для рабочей среды. Маленькие фрагменты (128 токенов) хранятся в векторной базе с parent_id в метаданных. Большие (512 токенов) хранятся в хранилище «ключ-значение» по parent_id. При поиске находим маленькие, подтягиваем их родителей.

Влияет ли перекрытие на стоимость хранения?

Да. Перекрытие 20% увеличивает число фрагментов и объём базы примерно на 15-20%. При 1 миллионе фрагментов это около 150 тысяч дополнительных записей. При Qdrant Cloud - примерно +$15-25 в месяц. На скорость поиска влияние минимальное. Перекрытие 10-15% обычно оптимально по соотношению качество/стоимость.

Что делать с очень длинными таблицами?

Таблицы нарезать нельзя - теряется контекст строк и столбцов. Три подхода: конвертировать таблицу в текстовое описание через модель при индексации; индексировать каждую строку отдельно с заголовком таблицы в метаданных; конвертировать в Markdown и хранить как единый фрагмент. Последний вариант работает, когда модель может обработать большой фрагмент - при контекстном окне 128K токенов это почти всегда.

Как запустить без программиста

Вся описанная логика нарезки реализована в готовых no-code инструментах. Не нужно писать код - нужно выбрать параметры через интерфейс.

Flowise - визуальный конструктор RAG-систем. Бесплатный план покрывает тест и малую нагрузку. Загружаете документы, выбираете тип нарезки и размер фрагмента через интерфейс, подключаете языковую модель. Итог - рабочий бот за 4-8 часов без программиста.

Dify - аналог с более полным интерфейсом. Бесплатный план включает до 200 документов и базовые сценарии. Поддерживает parent-child через настройку «индексации» в интерфейсе.

n8n + LangChain - для тех, кто готов разобраться с визуальной автоматизацией. Гибче Flowise, но требует понимания структуры узлов. Бесплатный self-hosted вариант без ограничений.

Начать лучше с Flowise: загрузить 20-30 документов стройфирмы, собрать тест из 20 реальных вопросов, проверить точность с размером фрагмента 512 токенов и перекрытием 10%. За вечер понятно, работает ли это на вашей базе.

Что дальше

Следующий шаг - улучшить сам поиск: гибридный поиск (классический поиск по ключевым словам плюс векторный поиск по смыслу) и пересортировка результатов дают ещё +15-30% к качеству поверх правильной нарезки. Читайте /uchebnik/hybrid-search-reranking.

Основа по RAG: /uchebnik/sverhpro-rag-dlya-nachinayuschih.

AI Компас (https://t.me/kosmoslab_ai) - канал для предпринимателей в РФ и СНГ о том, как применять AI в своём бизнесе без программиста. Разбираем конкретные инструменты, схемы и ошибки - то, что реально можно внедрить самому или поставить задачу менеджеру.

ai-uchebnik.ru - все гайды.