Как я научил RAG говорить «не знаю» — и это дало больше, чем смена модели

Как я научил RAG говорить «не знаю» — и это дало больше, чем смена модели

Два месяца назад клиент прислал мне скриншот и матом не ругался только потому, что был в деловой переписке. Наш RAG-бот, который должен был отвечать по договорам и техспецификациям, выдал: «Согласно договору №247 от 15 марта, оплата производится в течение 14 рабочих дней после подписания акта». Проблема была в том, что договора №247 в базе не существовало вообще. Модель его просто придумала — с правильной структурой, реалистичными датами и уверенным тоном.

Я тогда закрыл ноутбук, пошёл на кухню и полчаса смотрел в окно. Потому что понимал: это не баг, это фича. LLM так устроены — они не умеют говорить «я не знаю». Они умеют генерировать правдоподобный текст. А правдоподобие и правда — это две очень разные вещи, особенно когда речь про деньги клиента.

Спойлер: через три недели этот же бот отвечал «в материалах этого нет, передаю оператору с контекстом запроса» — и клиент впервые за квартал поставил нам плюс. Что изменилось — ниже.

Почему стандартные методы не работали

Первой мыслью было классическое: проблема в retrieval. Значит, надо сделать retrieval лучше.

Я перебрал всё по списку, который сейчас знает любой, кто открывал туториал по RAG:

  • увеличил top-k с 3 до 10 — модель стала чаще галлюцинировать, потому что в контекст попадали совсем нерелевантные чанки;
  • поменял эмбеддинги с text-embedding-3-small на large — стало чуть лучше, но не принципиально;
  • добавил гибридный поиск (вектор + BM25) — выиграл 2–3% по точности на тестовом наборе;
  • прикрутил реранкер — ещё плюс несколько процентов, но латентность выросла вдвое.

Через неделю возни клиент написал: «Стало лучше. Но вчера он выдал, что ООО „Ромашка“ — наш партнёр с 2019 года. А у нас в базе вообще нет „Ромашки“».

И вот тут меня торкнуло. Проблема была не в том, что модель плохо ищет. Проблема была в том, что когда она не находит — она всё равно отвечает. У системы не было третьего состояния. Только «нашёл и ответил» и «придумал и ответил». Состояния «не нашёл и промолчал» не существовало в принципе.

Откуда взялись негатив-тесты

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

В контексте RAG это выглядит так: вы берёте вопрос, на который в базе заведомо нет ответа, и проверяете, что модель не начнёт его выдумывать. Звучит очевидно, но на практике почти никто этого не делает. Все пишут «golden set» — набор вопросов с известными правильными ответами — и меряют точность. А вот набор вопросов с правильным ответом «нет в базе» — почти никто.

Я сначала подумал: ну соберу десяток таких вопросов, проверю, всё будет хорошо. Собрал. Проверил. Из десяти вопросов модель придумала ответ на семь. Семь из десяти. При том, что на обычном golden set у меня было ~82% правильных ответов.

То есть у меня был бот, который на типовых вопросах работал хорошо, а на вопросах «вне базы» работал как уверенный лжец. И это, оказывается, типовая картина — потом я общался с тремя другими LLMOps-инженерами, у всех примерно те же цифры.

Trap-чанки: ловушки внутри базы

Одно дело — проверить модель на вопросах без ответа. Другое — сделать так, чтобы retrieval сам по себе не вытаскивал мусор. Тут появились trap-чанки. Trap-чанк — это специально сгенерированный кусок текста, который похож на реальный документ, но заведомо ложный. Добавляется он в индекс не для того, чтобы модель его использовала, а чтобы модель его избегала.

Звучит дико, но работает через реранкинг и скоринг. Логика такая:

  • Пользователь задаёт вопрос.
  • Retrieval тянет top-k чанков, среди которых могут быть trap-чанки.
  • Если trap-чанк попал в топ-выдачу с высоким скором — это сигнал, что retrieval ошибся, потому что настоящий релевантный документ должен был быть выше.
  • Включаем тревожный флаг и уходим в эскалацию.

Пример trap-чанка, который я сгенерил для теста:

Договор №000-TEST-2024. Контрагент: ООО «Тестовый контрагент». Предмет: поставка несуществующего оборудования. Срок: 30 рабочих дней. Ответственный: Иван Иванов.

На первый взгляд — нормальный документ. Но по метаданным помечен как is_trap=true, и если retrieval его вытаскивает с высокой схожестью, значит, эмбеддинги работают некорректно. Ловушка поймала мне три кейса за первый же день: на вопросах про «договор с компанией X», которой в базе нет, retrieval тянул этот trap-чанк с косинусом 0.87. Без trap-чанка модель бы просто сгенерила красивый ответ, а с ним — я увидел проблему в логах.

Как их генерить:

  • Вручную для критичных сущностей (конкретные компании, имена, номера договоров) — 20–30 штук, полчаса работы.
  • Через LLM с промптом вроде «сгенерируй реалистичный договор с вымышленными реквизитами, сохрани структуру и стиль оригиналов из базы». Я генерил Llama 3.1 70B, получилось около 200 штук за вечер.
  • Через шаблоны для типовых документов: подставляете случайные номера, даты, имена — получаете пачку trap-документов.

Главное правило: trap-чанк должен быть похож на реальные документы, а не отличаться от них. Иначе реранкер будет легко его отсеивать, и ловушка перестанет ловить.

Golden set с тремя вердиктами

Обычный golden set — это пары «вопрос → правильный ответ». Мой — это тройки: «вопрос → вердикт → обоснование».

  • ANSWER — ответ есть в базе, модель должна его найти и процитировать;
  • REFUSE — ответа нет в базе, модель должна честно сказать «не знаю»;
  • PARTIAL — частичный ответ возможен, модель должна явно указать, что именно она знает, а чего не хватает.

Пример из моего набора:

Вопрос: «Какие условия оплаты в договоре №247?» Вердикт: REFUSE Обоснование: Договора №247 нет в базе Вопрос: «Кто отвечает за проект "Альфа"?» Вердикт: PARTIAL Обоснование: В базе есть упоминание проекта, но имя ответственного не указано Вопрос: «Какой срок поставки по договору №189?» Вердикт: ANSWER Обоснование: В договоре №189 пункт 4.2: 30 рабочих дней

Собрал 200 таких троек: 100 ANSWER, 70 REFUSE, 30 PARTIAL. Заняло это три дня с двумя перерывами на кофе и один раз на то, чтобы просто посидеть в тишине. Самый мерзкий этап во всей истории — но без него дальше ничего не работало.

Автоматизировать генерацию REFUSE-кейсов нельзя. Их должен писать человек, который знает, чего в базе нет. Это и есть самое ценное в таком тесте: он кодирует знание о границах базы знаний, а не только о её содержимом.

Один if, который изменил всё

Теперь главное — как это всё встроить в pipeline. У меня это выглядит примерно так:

def retrieve_and_check(query, index, trap_index): results = index.search(query, top_k=5) trap_hits = trap_index.search(query, top_k=3) # Считаем уверенность retrieval max_score = results[0].score if results else 0 trap_max_score = trap_hits[0].score if trap_hits else 0 # Вердикт 1: retrieval не уверен if max_score < CONFIDENCE_THRESHOLD: return Verdict.REFUSE, results, "низкая схожесть" # Вердикт 2: trap-чанк слишком близок if trap_max_score > max_score * 0.9: return Verdict.REFUSE, results, "trap-чанк в топе" # Вердикт 3: всё ок, пускаем в генерацию return Verdict.ANSWER, results, None

Потом, в зависимости от вердикта:

  • ANSWER → обычный RAG с цитированием источников;
  • REFUSE → никакой генерации, честный ответ «в материалах этого нет» + тикет оператору с сохранённым запросом.

Всё. Буквально один if (ну, два) на пути, а эффект — как от смены модели. Потому что до этого модель пыталась ответить всегда. И всегда что-то придумывала, когда не находила. Теперь она молчит там, где должна молчать, — и это звучит для пользователя умнее, чем красивая галлюцинация.

Цитирование как второй рубеж обороны

Даже когда вердикт ANSWER, модель может врать — просто уже не про «чего нет», а про «что именно написано». Тут спасает жёсткое цитирование.

Я делаю так: модель генерирует ответ, но каждое утверждение должно быть подкреплено конкретным чанком из retrieval. Если она не может указать источник — утверждение удаляется из ответа. Реализация — через post-processing: после генерации прогоняю ответ через ещё один LLM-вызов, который для каждого предложения ищет источник в retrieval и либо оставляет с цитатой, либо вырезает.

Да, это два LLM-вызова на один пользовательский запрос. Да, это дороже. Но с локальным стеком на Ollama (который я описывал в первой статье) стоимость вызова — ноль, только время. А по времени это добавляет 300–500 мс на запрос, что для внутренних инструментов незаметно.

Результат: в ответах всегда есть ссылки на конкретные документы. Если ссылки нет — значит, утверждения не было. Клиент видит «по договору №189, пункт 4.2: срок 30 дней» и может открыть документ и проверить. Никаких «согласно нашим данным» из воздуха.

Эскалация с контекстом — то, за что клиент сказал «спасибо»

Самый недооценённый элемент системы, и именно он закрыл сделку.

Когда RAG говорит «не знаю» — это ещё не конец разговора. Это момент, когда пользователь либо уходит злым, либо получает адекватную эскалацию. Разница — в том, что передаётся оператору.

Плохая эскалация: «Ваш вопрос передан в поддержку. Ожидайте ответа.» Пользователь рассказывает вопрос заново, оператор тратит 5 минут на контекст, клиент чувствует себя идиотом.

Нормальная эскалация (то, что сделал я):

  • сохраняется оригинальный запрос;
  • сохраняются топ-3 чанка, которые retrieval пытался использовать;
  • сохраняется вердикт и причина (низкая схожесть / trap-чанк);
  • сохраняется история диалога, если вопрос не первый.

Оператор видит карточку: «Пользователь спросил про договор №247. Бот не нашёл, схожесть 0.34, trap-срабатываний нет. Похоже, договора реально нет в базе — уточните у клиента или добавьте документ.» И у оператора уже есть контекст. Он не тратит время на «а что именно вы хотели узнать» — он идёт разбираться с сутью.

Клиент получает уведомление: «Ваш вопрос принят, специалист ответит в течение часа. Для справки: бот передал ему ваш запрос и контекст, пересказывать не нужно.» И это та самая фраза, после которой клиент впервые за квартал написал «ребята, спасибо».

Цифры, которые я получил

На тестовом наборе из 200 вопросов (100 ANSWER, 70 REFUSE, 30 PARTIAL) — до внедрения негатив-тестов и trap-чанков:

  • точность на ANSWER: 82%
  • точность на REFUSE: 23% (то есть в 77% случаев модель придумывала ответ)
  • доля «тихих уходов» в проде: ~18% (пользователь получил ответ, но не воспользовался им и не написал в поддержку)

После внедрения:

  • точность на ANSWER: 79% (чуть упала, потому что часть пограничных кейсов ушла в REFUSE)
  • точность на REFUSE: 94%
  • доля «тихих уходов»: ~4%
  • количество эскалаций на оператора выросло с 8% до 22%, но качество эскалаций выросло кратно — операторы сами это отметили

То есть формальная точность на типовых вопросах чуть просела, а реальная полезность системы — выросла. Это и есть та самая метрика, которую не меряют в туториалах: сколько раз пользователь доверяет ответу.

Когда это не работает

Честный блок, без него статья — реклама.

  • База знаний кривая. Если документы в базе сами по себе противоречат друг другу или устарели, негатив-тесты это не починят. Сначала порядок в базе, потом ловушки. В обратном порядке не работает — и это ровно то, о чём писал Карим из NODA в статье про смету ИИ-поддержки: самая дорогая строка — это выгребание знаний из голов, а не код.
  • Поток меньше 50 запросов в день. Настраивать trap-чанки, собирать golden set на 200 троек, держать реранкер — это имеет смысл, когда есть статистика. На 20 запросах в день ручной разбор каждого кейса даст больше, чем вся эта machinery.
  • Домен, где «не знаю» недопустимо. Есть сценарии, где модель обязана выдать хоть что-то: внутренние подсказки для сотрудников, черновики, креатив. Там честное «не знаю» будет бесить сильнее, чем аккуратная галлюцинация с пометкой «возможно». Негатив-тесты — для случаев, где цена ошибки выше цены отказа.
  • Публичные чат-боты с хайповыми ожиданиями. Пользователи, которые пришли «поговорить с ИИ», часто интерпретируют «я не знаю» как «бот тупой». Для них лучше мягкий уход («давайте я уточню, перефразируйте вопрос») вместо жёсткого отказа. Но внутри корпоративных систем — наоборот.

Выводы, которые я сделал для себя

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

Второй инсайт: один if в правильном месте даёт больше, чем смена модели. Я правда пробовал и Llama 3.1 8B, и 70B, и Qwen 2.5 — разница была в единицах процентов. А порог уверенности + trap-чанки дали скачок в 70 пунктов по REFUSE-точности. Это не значит, что модели не важны — значит, что архитектура важнее модели в большинстве реальных сценариев.

Третий инсайт, самый неожиданный: клиенты ценят «не знаю» больше, чем «возможно». Когда бот признаёт границы своего знания, он начинает выглядеть умнее, а не тупее. Парадокс, который ломает интуицию, но подтверждается каждым кейсом.

Весь код из этой статьи — пороги, trap-генератор, golden set — лежит в обновлённом репозитории . Если будете внедрять, начните с 20 REFUSE-кейсов вручную и одного trap-чанка. Это займёт час и даст понимание, нужно ли вам вообще это всё.

Предыдущие статьи из серии:

Больше разборов про RAG, векторные БД и продакшен-ошибки — в канале @llmops_engineering. Там же, по запросу «+» в @llmops_engineering1, отдаю чек-лист «10 проверок RAG перед выкаткой в прод» — он собран как раз из тех граблей, которые я собирал последние два года. Негатив-тесты там под пунктом №7.

P.S. Отдельное спасибо Кариму из NODA за формулировку «подписка превращается в зарплату» из нашей дискуссии — она попала во вторую статью, а её логика про базу знаний как самую дорогую строку сметы напрямую повлияла на раздел «когда это не работает» здесь. Вот так комментарии на VC превращаются в главы статей. Приносите свои кейсы в комментарии — лучшие из них попадут в следующие разборы.

11