Когда RAG-помощник по базе 1С отвечает мимо, первым делом грешат на модель или на поиск. Чаще виноват text splitter — то, как документ порезан на rag чанки перед векторизацией. Если выгрузка номенклатуры 1С разорвана посреди строки, а из регламента вырезан абзац без заголовка раздела, никакой ретривер уже не спасёт: в индексе просто нет фрагмента, который отвечает на вопрос целиком. Ниже — сплиттеры LangChain на реальных 1С-документах и приём с метаданными, который у меня вытянул качество ответов сильнее, чем смена модели эмбеддингов.

Зачем вообще резать документ
Соблазн скормить модели весь документ целиком упирается в три вещи.
Окно контекста. Описание конфигурации или свод регламентов — сотни тысяч символов. В промпт они не влезут, а если влезут, то ценой всего остального диалога.
Стоимость. В промпт уходят найденные фрагменты, и чем крупнее чанк, тем больше лишнего вы оплачиваете на каждом вопросе. Три чанка по 500 символов против трёх по 5000 — десятикратная разница в цене при той же пользе.
Точность попадания. Это главное. Эмбеддинг — один вектор на весь чанк, он усредняет смысл. Если в чанк попали и условия доставки, и порядок возврата, и реквизиты, вектор получится «обо всём сразу» и не совпадёт близко ни с одним конкретным вопросом. Мелкий чанк про одну мысль ищется точно, большой размывается.
Как нарезка встраивается в общий конвейер — индексация, векторное хранилище, сборка цепочки — разобрано в статье про RAG на LangChain для 1С. Здесь я копаю только шаг нарезки.
Сплиттеры LangChain: какой под какой документ
Все они живут в пакете langchain_text_splitters и имеют одинаковый интерфейс: split_text(text) возвращает список строк или Document.
CharacterTextSplitter — по одному разделителю
Самый прямолинейный: режет по указанному символу — точке, переносу строки, точке с запятой.
from langchain_text_splitters import CharacterTextSplitter
splitter = CharacterTextSplitter(
separator=".", # разделитель
chunk_size=50, # размер чанка
chunk_overlap=10, # перекрытие между чанками
)
chunks = splitter.split_text(text)
Нюанс, на который натыкаются все: длина чанка может превысить chunk_size. Сплиттер не режет кусок между двумя разделителями. Если между точками умещается абзац на 900 символов, а вы просили 50 — получите чанк на 900. А на выгрузке номенклатуры, где точек может не быть вовсе, separator="." просто вернёт весь текст одним куском.
RecursiveCharacterTextSplitter — рабочая лошадка
Рекурсивный сплиттер получает список разделителей по убыванию «крупности» и идёт сверху вниз: сначала режет по пустым строкам, если кусок всё ещё велик — по переносам, потом по пробелам.
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=50, # размер чанка
chunk_overlap=0, # перекрытие между чанками
separators=["\n\n", "\n", "<>", " "], # символы для разбиения
)
chunks = splitter.split_text(text)
Ключевое отличие: длина чанка всегда ограничена chunk_size. Поэтому в большинстве случаев берут именно его. Список separators — точка настройки под формат: в примере выше в него добавлен нестандартный <>, потому что им были размечены смысловые границы исходника. Так же можно вписать разделитель полей из выгрузки 1С — ;, | или строку-шапку таблицы, — и сплиттер начнёт уважать границы записей, а не рвать номенклатуру посреди артикула.
TokenTextSplitter — когда важен бюджет модели
Модель считает не символы, а токены, и лимиты у неё тоже в токенах.
from langchain_text_splitters import TokenTextSplitter
splitter = TokenTextSplitter(
chunk_size=20, # количество токенов в чанке
chunk_overlap=2, # перекрытие между чанками
)
chunks = splitter.split_text(text)
Для русского текста разница ощутима: кириллица токенизируется дороже латиницы, и «1000 символов» оказываются заметно больше токенов, чем вы прикидывали по английским примерам из документации.
MarkdownHeaderTextSplitter — для регламентов и документации
Если документ структурирован заголовками (внутренний регламент, описание конфигурации, выгруженная статья с ИТС), резать его по символам — расточительство: автор уже разметил смысловые границы.
from langchain_text_splitters import MarkdownHeaderTextSplitter
with open("rag/programs.md", encoding="utf-8") as f:
programs_text = f.read()
headers_to_split_on = [("#", "category"), ("##", "type")]
splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
chunks = splitter.split_text(programs_text)
На выходе — список Document, у каждого в metadata лежат заголовки, под которыми он находился. Это критично, и почему — в разделе про вклейку метаданных ниже.
RecursiveJsonSplitter — для выгрузок из 1С
Выгрузка справочника или ответ HTTP-сервиса 1С — чаще всего JSON. Резать его по символам нельзя: развалится структура.
import json
from langchain_text_splitters import RecursiveJsonSplitter
with open("rag/products.json", encoding="utf-8") as f:
products_dict = json.loads(f.read())
splitter = RecursiveJsonSplitter(max_chunk_size=500)
chunks = splitter.split_json(json_data=products_dict, convert_lists=True)
Сплиттер спускается по вложенности и собирает чанки так, чтобы каждый оставался валидным JSON. Параметр convert_lists=True превращает списки в словари с индексами-ключами — иначе длинный массив номенклатуры разрезать не получится и он уедет одним куском.
Код и HTML
Для исходников есть языко-зависимые сплиттеры, знающие про границы функций и классов, а для веб-страниц — HTMLSectionSplitter и HTMLHeaderTextSplitter по тегам заголовков.
from langchain_text_splitters import PythonCodeTextSplitter, Language
splitter = PythonCodeTextSplitter(chunk_size=500)
chunks = splitter.split_text(python_text)
# то же самое через рекурсивный сплиттер с набором разделителей языка
python_splitter = RecursiveCharacterTextSplitter.from_language(
language=Language.PYTHON, chunk_size=500
)
Если страницу надо ещё и скачать, загрузчик умеет резать на лету — так собирается база знаний из статей Инфостарта или выгруженных страниц ИТС, без промежуточных файлов:
from langchain_community.document_loaders import WebBaseLoader
splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=50)
loader = WebBaseLoader("https://infostart.ru/1c/articles/1234532/")
documents = loader.load_and_split(text_splitter=splitter)
chunk_overlap: за что платим перекрытием
chunk_overlap заставляет соседние чанки перекрываться на N символов или токенов. Смысл простой: граница реза почти всегда проходит не там, где надо. Условие «скидка действует, если сумма документа превышает» осталось в конце одного чанка, а «100 000 рублей» уехало в начало следующего — и по отдельности они на вопрос не отвечают. Перекрытие даёт второму чанку хвост первого, и мысль сохраняется хотя бы в одном.
Платим дважды. Объёмом: перекрытие в 20% — это на 20% больше векторов, места и времени индексации. И дублями в выдаче: ретривер с k=3 может вернуть три пересекающихся чанка с одним и тем же абзацем, и вместо трёх фактов модель получит один трижды. Рабочий ориентир — 10–15% от chunk_size; при 1000 символов это 50–150, как в примере с веб-страницей выше.
Главный приём: вклеить метаданные в текст чанка
Это раздел, ради которого стоило писать статью. Разбили регламент по заголовкам, получили аккуратные чанки, и один из них выглядит так:
Проходной балл бюджет: 300
Минимальный проходной бал по ЕГЭ:
- Русский язык 55/70
Заголовки # Государство и ## Эффективное государственное управление лежат в metadata документа — то есть вне текста, который векторизуется. Эмбеддинг считается только по page_content. И на вопрос «какой проходной балл на госуправление» этот чанк не найдётся: нужных слов в его тексте попросту нет.
Лечится это в три строки — заголовки вклеиваются прямо в текст чанка:
def generate_documents() -> List[Document]:
# Открываем структурированный документ и считываем его содержимое
with open("rag/programs.md", encoding="utf-8") as f:
programs_text = f.read()
# Определяем заголовки, по которым будем разделять текст
headers_to_split_on = [("#", "department"), ("##", "direction")]
splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
chunks = splitter.split_text(programs_text)
# Заголовки уходят ВНУТРЬ текста чанка, а не только в metadata
documents_list = []
for chunk in chunks:
doc = (
f"Факультет: {chunk.metadata['department']}\n"
f"Направление: {chunk.metadata['direction']}\n"
f"Информация:{chunk.page_content}"
)
documents_list.append(
Document(page_content=doc.lower(), metadata=chunk.metadata)
)
return documents_list
Теперь чанк начинается со строки «факультет: … / направление: …» и совпадает с вопросом пользователя по всем ключевым словам сразу. Metadata при этом остаётся на месте — она нужна для фильтрации и для показа источника в ответе.
Второй момент в том же коде — .lower(). Текст приводится к нижнему регистру перед векторизацией: пользователь пишет «эффективное государственное управление», в документе стоит «Эффективное Государственное Управление», и для многих моделей эмбеддингов это не одно и то же. Регистр надо приводить симметрично — и при индексации, и при поиске.
На 1С-документах приём работает так же. Чанк «Срок оплаты: 10 банковских дней» вне контекста бесполезен: непонятно, по какому договору и контрагенту. Вклейте в текст шапку («Договор поставки № … / Контрагент: … / Раздел: Порядок расчётов») — и фрагмент начнёт находиться по нормальному человеческому вопросу. Как этот текст превращается в вектор и какой модели его отдавать — в статье про семантический поиск по данным 1С на эмбеддингах и FAISS.
Ранний и совсем простой пример того же подхода — разбиение по markdown-заголовкам с укладкой в векторное хранилище — есть в старом разборе AI-менеджера на GigaChat.
Как подобрать размер чанка под 1С-документ
Универсального числа нет, но есть правило: чанк должен содержать один законченный ответ. Отсюда прикидка по типу источника.
- Короткие карточки — 200–400 символов, без перекрытия. Номенклатура, контрагенты, справочник ошибок. Каждая запись самодостаточна: резать её нельзя, склеивать соседние — тоже, два разных товара в одном чанке дадут смазанный вектор. Нарезка идёт не по символам, а по границам записей.
- Регламенты и документация — 800–1500 символов, перекрытие 100–150. Мысль здесь живёт в пределах абзаца-двух, и рекурсивный сплиттер по
["\n\n", "\n", " "]попадает почти всегда. Если документ размечен заголовками — сначалаMarkdownHeaderTextSplitter, и только слишком крупные секции добивать рекурсивным. - Таблицы — отдельная история. Нарезка «в лоб» по 1000 символов на выгрузке номенклатуры рвёт таблицу посреди строки: в один чанк уезжает артикул без цены, в другой — цена без наименования, оба бесполезны. Лечится не подбором
chunk_size, а сменой формата: разложить таблицу построчно и каждую строку превратить в текст с явными подписями полей («Номенклатура: … Артикул: … Цена: …») — тот же приём вклейки, применённый к колонкам.
Проверять результат надо не глазами по коду, а по выдаче: задайте десяток реальных вопросов пользователей и посмотрите, что вернул ретривер. Если в найденных чанках нет ответа — дело в нарезке, менять модель бесполезно.
Когда чанкинг не нужен
Если источник уже структурирован, резать нечего. Классика — база вопросов и ответов: единица смысла задана самой таблицей, и никакой сплиттер её не улучшит.
for qa in all_qa:
documents.append(
Document(
page_content=qa.question.lower(), # векторизуем ТОЛЬКО вопрос
metadata={
"id": qa.id,
"answer": qa.answer, # ответ отдаём как есть, поиском не трогаем
"links": qa.links,
},
)
)
ids.append(qa.id)
В вектор уходит только вопрос — пользователь пишет вопрос, а не ответ, и сравнивать надо похожее с похожим. Ответ лежит в metadata и достаётся целиком, без риска, что его порежут по границе чанка. Так же устроены справочник типовых ошибок 1С, база обращений техподдержки, FAQ по конфигурации — везде, где у контента есть естественная единица. Прежде чем настраивать сплиттеры, посмотрите на источник: возможно, его достаточно выгрузить из 1С в правильном виде, и шаг нарезки исчезнет вместе со всеми проблемами.
Куда дальше
Нарезка — один шаг конвейера. Дальше идут выбор модели эмбеддингов, векторное хранилище, ретривер и сборка цепочки: всё это в общей статье про RAG на LangChain для 1С, а векторный поиск подробно — в материале про эмбеддинги и FAISS.
Если хотите собрать такой помощник по своей базе от начала до конца — с загрузкой данных из 1С, векторным хранилищем и рабочей цепочкой вопрос-ответ — этому посвящён курс ChatGPT и нейросети в 1С: там всё то же самое делается руками на реальных данных, а не на учебных примерах.