RAG выдавал чушь: нашли проблему в чанкинге
Собрали RAG по внутренним документам — и через неделю эксплуатации система начала уверенно нести чушь. Не «не знаю», а именно неверные ответы на вопросы, где нужный абзац лежал в базе прямым текстом. Первая реакция была предсказуемой: «модель слабая, надо менять на другую». Оказалось — дело не в модели, а в том, как документы порезали на куски перед индексацией. Разберём инцидент по шагам: с чего началось, куда смотрели зря, что нашли на самом деле и как починили.
Содержание
Что случилось: правильные документы, неправильные ответы
Ситуация типовая для любого RAG (Retrieval-Augmented Generation) на своих документах: пользователь задаёт вопрос, система ищет релевантные куски текста в векторной базе, подставляет их в промпт вместе с вопросом и просит LLM ответить на основе найденного. Инцидент проявился так:
- на прямые вопросы вида «какой лимит указан в договоре для клиентов категории B» модель называла цифру из другого раздела документа;
- на вопросы про процедуру («что делать, если...») ответ обрывался на середине или смешивал два разных сценария в один;
- при этом на простые вопросы, где вся нужная информация укладывалась в одно короткое предложение, RAG отвечал нормально.
Важная деталь, на которую сначала не обратили внимания: нужный текст точно был загружен в базу. Мы это проверяли — открывали исходный документ и находили нужный абзац глазами. Значит, проблема не в том, что данных нет, а в том, что происходит с ними между загрузкой и ответом.
Первая версия: во всём виновата модель
Естественный первый вывод — модель галлюцинирует. Логика на первый взгляд разумная: LLM известны тем, что уверенно придумывают факты, когда не знают ответа, и это отдельно разобранный механизм, не имеющий прямого отношения к RAG. Поэтому первым делом попробовали:
- сменить модель на более крупную — ответы стали чуть более «уверенными» по стилю, но так же неточными по фактам;
- уменьшить температуру генерации до нуля — часть случайного шума ушла, но неверные цифры остались теми же неверными цифрами, просто стабильно воспроизводились;
- переписать системный промпт с явным требованием «отвечай только на основе предоставленного контекста, если ответа нет — скажи, что не знаешь» — это немного снизило количество откровенных выдумок, но не решило основную проблему: модель по-прежнему путала данные, которые формально были у неё перед глазами.
Час-полтора на эти эксперименты ушли впустую, потому что все они меняли то, как модель обрабатывает контекст, а не то, что именно оказывается в этом контексте. Это и есть типичная ошибка диагностики RAG: смотреть на выход модели, а не на её вход.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Развернуть ИИ на сервереРеальная диагностика: что на самом деле нашёл поиск
Переломный момент — перестать читать ответ модели и начать читать то, что реально было найдено и передано в промпт. Практически любой RAG-стек это позволяет:
- в готовых интерфейсах вроде AnythingLLM или Open WebUI есть кнопка/раздел «источники» (sources), который показывает конкретные куски текста, использованные для ответа;
- в самодельном пайплайне на LangChain или LlamaIndex достаточно вывести в лог результат вызова ретривера до того, как он уйдёт в промпт LLM.
Пример на LangChain — просто печатаем то, что вернул векторный поиск, до генерации ответа:
docs = vectorstore.similarity_search(query, k=4)
for i, d in enumerate(docs):
print(f"--- chunk {i} ---")
print("source:", d.metadata.get("source"))
print(d.page_content)
print()
Именно этот вывод и оказался ключом к разгадке. Половина найденных кусков была не то чтобы совсем не по теме — они были *обрывочными*. Нужное предложение было разорвано между двумя соседними чанками: в одном оставалось начало мысли без вывода, в другом — вывод без контекста, откуда он взялся. Модель честно получала на вход половину информации и честно на основе этой половины домысливала вторую половину — вот откуда бралась «уверенная чушь».
Отдельно стоит сверить, что происходит на этапе между запросом и ответом целиком — если хотите разобраться в механике поиска подробнее, у нас есть отдельный разбор что происходит внутри RAG между вопросом и ответом.
Почему чанкинг ломает поиск: три типичные причины
Когда стало ясно, что дело не в модели, а в найденных кусках, разбор сузился до одного вопроса: как документ был порезан на чанки перед тем, как превратиться в эмбеддинги. Нашли сразу три проблемы, которые встречаются в большинстве самодельных RAG почти в любой комбинации.
Слишком маленькие чанки теряют контекст. Если резать документ по фиксированному числу символов без учёта смысла (условно — «каждые 300 символов — новый чанк»), важное предложение почти гарантированно оказывается разорвано пополам между двумя соседними кусками. Каждый такой кусок по отдельности превращается в эмбеддинг, который отражает смысл только своей половины — и при поиске может не совпасть с вопросом, где спрашивается про мысль целиком.
Слишком большие чанки размывают релевантность. Обратная крайность — резать документ на большие блоки «про запас», чтобы уж точно ничего не потерять. Проблема в том, что эмбеддинг-модель кодирует смысл всего чанка одним вектором. Если в чанк попадает несколько разнородных тем (например, три разных пункта регламента подряд), вектор получается «усреднённым» — он не похож ни на один из вопросов конкретно про один из этих пунктов, и релевантный чанк может проиграть в ранжировании менее релевантному, но более «сфокусированному» куску.
Разбивка «в лоб» игнорирует структуру документа. Наш конкретный документ — это регламент с таблицами, нумерованными списками и вложенными пунктами. Нарезка по символьному лимиту без учёта разметки резала прямо посреди таблицы: заголовки колонок попадали в один чанк, а строки со значениями — в следующий. При поиске система находила либо шапку таблицы без данных, либо данные без подписи, что для табличных вопросов («какой лимит для категории B») было фатально — именно этот кейс и запустил весь инцидент.
Если вы только собираете свой RAG с нуля и хотите заранее избежать этих трёх ошибок, полезно посмотреть пошаговый пример сборки RAG-пайплайна — от нарезки документа и от выбора модели эмбеддингов зависит одинаково много.
Как чинили: overlap и разбивка по смысловым границам
Фикс делали в два захода, потому что первое же изменение помогло, но не полностью.
Шаг 1 — добавили перекрытие (overlap) между соседними чанками. Вместо жёсткой нарезки «встык» соседние куски стали частично дублировать конец предыдущего чанка в начале следующего. Это резко снизило число случаев, когда важное предложение разрывалось ровно посередине — если мысль попадала на границу, она теперь целиком помещалась хотя бы в один из двух соседних чанков.
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=800,
chunk_overlap=150,
separators=["\n\n", "\n", ". ", " "],
)
chunks = splitter.split_text(document_text)
Здесь важна честная оговорка: конкретные цифры chunk_size и chunk_overlap выше — не универсальный рецепт, а стартовая точка для одного конкретного регламента. Оптимальный размер чанка и величина перекрытия зависят от типа документов (короткие FAQ, длинные регламенты с таблицами, юридические тексты со сложной вложенностью пунктов, техническая документация с кодом), от модели эмбеддингов и от того, какие вопросы реально задают пользователи. Единственный рабочий способ найти свои цифры — экспериментировать: менять размер и перекрытие, каждый раз заново смотреть на реально найденные чанки (тем же способом, что и в диагностике выше), а не только на итоговый ответ модели.
Шаг 2 — заменили нарезку по символам на нарезку по структуре документа. Для регламентов с таблицами и заголовками простого chunk_size оказалось недостаточно — overlap снижал число разрывов, но не убирал их полностью на границах таблиц. Помогла нарезка по смысловым границам: разбивка идёт в первую очередь по заголовкам разделов и абзацам, а таблицы и списки по возможности не разрезаются вообще, попадая в чанк целиком (или уходят отдельным чанком со своим заголовком-контекстом).
from langchain.text_splitter import MarkdownHeaderTextSplitter
headers_to_split_on = [("#", "h1"), ("##", "h2"), ("###", "h3")]
md_splitter = MarkdownHeaderTextSplitter(headers_to_split_on)
sections = md_splitter.split_text(markdown_text)
# каждую секцию можно доразбить RecursiveCharacterTextSplitter,
# если она всё ещё слишком длинная для одного эмбеддинга
После этих двух изменений переиндексировали базу заново (векторная база хранит эмбеддинги старых чанков, простое изменение кода сплиттера ничего не даёт, пока не пересчитать всё заново) и прогнали тот же набор вопросов, на которых система раньше ошибалась. Разрывы посреди таблиц исчезли, а количество «уверенно неверных» ответов упало заметно — точную цифру улучшения в процентах называть не будем, потому что она специфична для нашего набора документов и вопросов и не переносится на чужой случай напрямую.
Отдельный нюанс, который стоит держать в голове: overlap и умная разбивка увеличивают количество чанков и, соответственно, размер векторной базы и время индексации. Для больших корпусов документов это может быть заметно, и стоит заранее закладывать этот рост в ресурсы сервера, где крутится векторная база.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Развернуть ИИ на сервереНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Как понять, что проблема именно в чанкинге, а не в модели или в эмбеддингах?
Смотрите не на ответ модели, а на сырые чанки, которые вернул поиск (через интерфейс источников или через прямой вывод ретривера). Если чанки в принципе релевантны, но обрывочны или содержат смешанные темы — дело в нарезке. Если чанки вообще не про то, о чём вопрос, — стоит сначала проверить модель эмбеддингов и способ ранжирования, а не чанкинг.
Overlap нужно ставить всегда, «на всякий случай»?
Нет. Overlap помогает против разрывов на границах, но увеличивает число чанков и раздувает базу без пользы, если документы и так хорошо режутся по структуре (короткие независимые пункты, FAQ). Проверяйте на своих документах — универсального «всегда ставь overlap 150» не существует.
Можно ли обойтись без переразбивки документа, а просто увеличить количество найденных чанков (top-k)?
Иногда да, это самый дешёвый первый эксперимент — увеличить k в поиске и посмотреть, помогает ли. Но это лечит симптом, а не причину: если сама нарезка рвёт нужную мысль пополам, увеличение k просто добавит в контекст больше обрывков, а не соберёт разорванное предложение целиком. Если top-k не помогает за пару итераций — переходите к разбору чанкинга.
Реранкер (reranker) решает эту проблему вместо переразбивки?
Реранкер помогает точнее отсортировать уже найденные чанки по релевантности, но если нужной информации целиком нет ни в одном из чанков-кандидатов (она разорвана на два), реранкер не может её «собрать» — он выбирает лучшее из того, что есть. Разбор того, зачем реранкер нужен в RAG и что он умеет, а что нет, стоит прочитать отдельно.
Нужно ли переиндексировать всю базу после изменения параметров сплиттера?
Да, обязательно. Смена chunk_size, chunk_overlap или логики разбивки не трогает уже посчитанные эмбеддинги старых чанков — их нужно пересчитать заново с нуля, иначе в базе останется смесь старой и новой нарезки, и диагностировать проблемы станет сложнее, а не проще.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →