← Журнал
Jiva Studio

Эшелонированная защита от галлюцинаций

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

Слушай СадхуSakhaEngineeringRAG

Мы делаем чат по корпусу духовных лекций. Вся суть — в одном правиле: каждое утверждение должно вести к настоящему источнику, иначе ассистент отказывается отвечать. Сама по себе языковая модель нарушает это правило с лёгкостью — она галлюцинирует: выдумывает лекции, которых нет, сочиняет цитаты, придумывает ссылки, неотличимые от настоящих. Больше всего этим грешат модели подешевле и длинные разговоры. Разрыв между моделями можно измерить, это не просто впечатление, и это важно: держать передовую модель на каждой реплике нам не по карману.

Есть утешительный миф, будто всё лечится одним хорошим промптом. Нет. Честность здесь — свойство архитектуры, а у архитектуры три шага:

flowchart TD
  Q["Вопрос"] --> S1["1 · Достать нужные данные"]
  S1 --> S2["2 · Отбросить лишние данные"]
  S2 --> S3["3 · Собрать обоснованный ответ"]
  S3 --> A["Ответ с воспроизводимыми ссылками"]

Достать нужный материал. Отбросить всё, что не дотягивает до планки. И только потом дать модели писать — проверяя каждую ссылку, которую она выдаёт. Это эшелонированная защита из инженерии безопасности, перенесённая на генерацию с дополненной выборкой (RAG): ни один слой не считается достаточным сам по себе, и большинство слоёв вообще не обращаются к LLM. Пройдёмся по всем трём.

Этап первый — достать нужные данные

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

flowchart TD
  Q["Вопрос плюс запланированные перефразировки"] --> D["Дорожки плотных векторов"]
  Q --> L["Лексическая дорожка"]
  Q --> ADR["Поиск по адресу, напр. BG 2.13"]
  Q --> MEM["Поиск по кураторской памяти"]
  D --> POOL["Пул кандидатов"]
  L -->|"проталкивает свои попадания"| POOL
  ADR -->|"проталкивается выше порога"| POOL
  POOL --> RR["Кросс-энкодер-реранкер"]
  RR -->|"топ-k плюс резервы по видам"| N["Обоснованные заметки"]
  MEM -->|"закреплённые ссылки"| N
  • Плотная выборка — дорожка, которая находит фрагменты по смыслу. Каждый фрагмент — это эмбеддинг на 1536 измерений в традиции би-энкодера / DPR, так что вопрос про «общественные сословия» всё равно найдёт лекцию, где сказано только varṇāśrama. Точно сравнивать запрос с сотнями тысяч векторов слишком медленно для живого ответа, поэтому мы ищем приближённо, по ближайшим соседям на графе HNSW (ANN) — почти полная выборка за миллисекунды. Это выручает читателя, который не знает, какими словами говорит корпус.
  • Лексический поиск нужен потому, что эмбеддинги всё смазывают. Спросите конкретное имя, транслитерированный термин или номер стиха — и разреженное совпадение из семейства BM25 (полнотекстовый поиск Postgres плюс триграммы) найдёт точную строку, которую размытый вектор задвинул бы ниже её околосинонимов. Поэтому эта дорожка не соревнуется по баллам — она проталкивает свои попадания в пул кандидатов, чтобы точный термин, который набрал пользователь, не потерялся незаметно.
  • Поиск по структурному адресу. «BG 2.13» — это адрес стиха, а не фраза. Детерминированный SQL-запрос находит его и проталкивает в пул выше порога шума, чтобы реранкер оценил его по тексту.

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

Настоящий выбор делает реранкер

Косинус би-энкодера — дешёвая догадка: он кодирует вопрос и фрагмент по отдельности и меряет угол между ними. Вместе он их ни разу не прочитал. Поэтому после выборки — это классический паттерн retrieve-and-rerank — собранные в пул кандидаты идут в кросс-энкодер (Voyage rerank-2), который оценивает каждую пару (вопрос, фрагмент) целиком. Именно этот балл, а не косинус, задаёт итоговый порядок. Выборка дешёвая и жадная; точность мы возвращаем реранкингом. А точность здесь — это и есть защита от галлюцинаций: чем меньше нерелевантных почти-попаданий дойдёт до модели, тем меньше правдоподобного, но неверного материала, на котором она построит уверенный ответ.

pool = by_cos[:RERANK_POOL_CAP]                 # top 60 by cosine
scored = await reranker.rerank(rerank_query, texts)
for idx, rs in scored:
    pool[idx].rerank_score = rs                 # cosine is left untouched
ranked = sorted(pool, key=lambda r: _rank_key(r, boost_kinds), reverse=True)
kept = ranked[:RERANK_TOP_K]                    # keep 16; reserves added next

Здесь важны два решения. Реранкер — главный отборщик: жёсткого порога по баллу реранкинга нет, потому что баллы кросс-энкодера не откалиброваны между разными вопросами. Отсекаем по рангу — топ-16 из 60. А поскольку кросс-энкодер обучен на прозе и втихую предпочитает многословные расшифровки лекций скупым стихам, после отсечения идут резервы по видам. Они гарантируют, что уцелеют хотя бы два стиха и два фрагмента из библиотеки — так ответ никогда не остаётся без писания.

Когда вопрос прямо просит определённый вид — «покажи мне стих» — этот вид получает лёгкий сдвиг в очерёдности и гарантированный минимум:

def _rank_key(r, boost_kinds):
    base = r.rerank_score if r.rerank_score is not None else -1.0
    if boost_kinds and r.kind in boost_kinds:
        base += KIND_BOOST_DELTA                # 0.15: an ordering nudge, not a score
    return (base, r.score)

Кураторская память: рука человека в поиске

Одного лишь подобия не всегда хватает. Для некоторых вопросов уже есть точный ответ, который заранее подготовил редактор — канонический набор стихов или подводка, которая их связывает. Это кураторская память, самый интересный источник в системе.

flowchart TD
  CUR["Куратор"] -->|"посевной скилл, инструменты MCP"| LIB["library.db"]
  LIB -->|"индексатор, ежечасно"| PG["Postgres: заметки плюс эмбеддинги"]
  QQ["Вопрос плюс перефразировки"] --> LK["Поиск атрибуции"]
  PG --> LK
  LK -->|"закреплённые: 0.85, или граница пересужена"| REF["Цитируемые кураторские ссылки"]
  LK -->|"заметка памяти: 0.60"| NB["Нецитируемый фон"]
  REF --> ANS["Ответ"]
  NB -->|"задаёт подачу, не цитируется"| ANS

Как она собирается. Куратор создаёт записи через посевной скилл, который управляет внутренним набором инструментов: закрепить точный вопрос за вручную отобранными стихами или написать фоновую заметку с несколькими триггерными фразами. Тексты автоматически переводятся на все локали и публикуются. Через несколько часов индексатор переносит их в Postgres и строит эмбеддинги — и, что важно, кодирует триггерные фразы и куски заметки вместе. Поэтому вопрос, который перекликается с телом заметки, а не только с триггером, всё равно её найдёт.

Как она находится. В момент запроса вопрос пользователя и каждую запланированную перефразировку кодируют и сверяют с кураторскими векторами. Пороги намеренно разные для разных видов, и в этой асимметрии — весь довод о безопасности:

  • Закреплённый источник цитируем и авторитетен, поэтому планка высокая: косинус ≥ 0.85 на том же языке. «Может быть» из пограничной зоны 0.70–0.85 на одном косинусе доверия не заслуживает — его заново взвешивает тот же кросс-энкодер, оценивая пару (вопрос × формулировка куратора), и принимает только при ≥ 0.50. Нет судьи — значит отказ. Кураторскую цитату нельзя выдумать из размытого совпадения.
  • Заметка памяти — лишь подсказка к подаче, поэтому её планка ниже (косинус ≥ 0.60) и судья ей не нужен: нестрогое совпадение безвредно, ведь заметку никогда не цитируют. Эти 0.60 откалиброваны, а не взяты с потолка: настоящие перефразировки триггера ложатся в районе 0.62–0.69, а ложное совпадение просто из той же книги держится около 0.49.

Сильная заметка — совпадение ≥ 0.70 и хотя бы три разрешимые ссылки — позволяет полностью пропустить дорогой обход корпуса:

def assess_sufficiency(question_matches, memory):
    if question_matches:                 # a curated pinned question
        return CORRECT                   # → trust the curated refs, skip the sweep
    if memory_is_sufficient(memory):     # strong note: >= 3 refs and score >= 0.70
        return CORRECT
    return INCORRECT                     # → fall through to the full corpus sweep

Как она используется. Вот изящная часть — и та, где я ошибся, когда писал это впервые: заметку и её ссылки обрабатывают по-разному. Вручную отобранные ссылки заметки — настоящие источники: они попадают в цитируемый пул и цитируются как обычно. Сама заметка добавляется как фон, который задаёт подачу, но не несёт метки цитаты и никогда не цитируется:

def _attach_memory(result, mem):
    result.memory_note = mem.note                  # non-citable framing
    if mem.envelopes:                              # the curator's chosen refs…
        result.authoritative_refs += mem.envelopes # …DO cite, at score 0.75
    return result

Так человек может направлять, какие настоящие источники всплывут и как они свяжутся, и эта подсказка ни разу не превратится в выдуманную ссылку.

Этап второй — отбросить лишние данные

Хорошая полнота — это лишь половина дела. Именно из пула, набитого почти-попаданиями, модель уговаривает себя на уверенный неверный ответ. Поэтому всё, что ниже планки, отбрасывают до того, как модель это увидит.

flowchart TD
  P["Реранжированные кандидаты"] --> F{"косинусный предпорог 0.18"}
  F -->|"ниже"| X["отбросить как шум"]
  F -->|"выше"| C{"ворота покрытия"}
  C -->|"макс >= 0.65, или макс >= 0.55 при 2+ кусках лекций"| G["обосновать"]
  C -->|"макс < 0.40"| B["уйти во внекорпусный запасной путь"]
  G --> PL{"планировщик нашёл рабочий тезис?"}
  PL -->|"все заметки слабы"| R["вернуть пусто → отказ"]
  PL -->|"да"| S["передать на синтез"]

Порог ниже, чем кажется, — и это намеренно. Заманчиво поставить высокий косинусный порог и счесть шумом всё, что ниже. Однажды мы так и сделали — на 0.45 — и это втихую убивало нужные стихи, которые спас бы кросс-энкодер. Поэтому на живом пути предпорог опускается до 0.18, ровно чтобы отсечь явный мусор, а остальное решает реранкер:

# 0.18 on the live rerank path; the old flat 0.45 killed real ~0.30 verses
floor = RERANK_NOISE_PREFLOOR if rerank_active else _RELEVANCE_FLOOR
for batch in per_query:
    for r in batch:
        if r.score < floor and not r.forced:
            continue                     # too far off to be worth reranking

Настоящий отказ — это ворота покрытия. Отбросить отдельные слабые попадания — не то же самое, что решить, что весь пул слишком скуден для ответа. И это решение — горстка простых булевых проверок, без всякой LLM:

def is_coverage_sufficient(result, min_max_score=0.55, min_lectures=2):
    if result.max_score < min_max_score:
        return False
    return len(result.by_kind.get("lecture", [])) >= min_lectures

def is_coverage_good_enough(result):
    if is_coverage_sufficient(result):
        return True
    return result.max_score >= 0.65      # one confident hit is enough on its own

def should_bail_out(result):
    return result.max_score < 0.40       # nothing close — stop digging

У простого RAG есть слепая зона: он считает, что поиск сработал. Когда корпус просто не покрывает вопрос, наивный RAG всё равно скармливает модели слабые фрагменты — прямое приглашение к сочинительству. Корректирующий RAG (CRAG) закрывает эту дыру: он оценивает пул, прежде чем ему довериться, — и наш вариант это CRAG в миниатюре. Сильный пул обосновывает ответ; одного уверенного попадания уже хватает; а пул, чьё лучшее совпадение ниже 0.40, запускает срыв (bail-out) — система прекращает поиск и передаёт вопрос внекорпусному запасному пути. Тот отвечает из общих знаний более сильной модели, снабжает ответ обязательной оговоркой и не имеет права прикреплять хоть какую-то ссылку на писание. Это, кстати, единственное место, где вызывают более сильную модель. И запускает его именно нехватка покрытия, а не догадка, будто вопрос «выглядит трудным».

Последние ворота перед генерацией — планировщик синтеза: дешёвая модель выстраивает уцелевшие заметки в набросок, и если ни одна из них на деле не подкрепляет тезис, она возвращает пустой набросок. Это вынуждает к прямому отказу — ещё до того, как запустится дорогая модель ответа.

Этап третий — собрать обоснованный ответ

Только теперь модель пишет, но и тут она зажата в рамки.

flowchart TD
  N["Заметки в виде целочисленных сносок"] --> O["Планировщик наброска"]
  O --> CO["Оставить только цитируемые заметки"]
  CO --> ST["Синтезатор стримит ответ"]
  ST --> E["Фильтр маркеров"]
  E -->|"разрешается"| W["воспроизводимая ссылка"]
  E -->|"выдумана"| DR["молча отброшена"]

Сузить поверхность. Модель ни разу не видит настоящий track ID, который могла бы скопировать. Каждую заметку ей показывают как голую целочисленную сноску; настоящий идентификатор держат на сервере и подставляют только на выходе:

header = f"[^{idx}]"   # 1, 2, 3 … minted per turn; the real track_id is
                       # held server-side and substituted only on output

Нигде в её контексте нет токена вида track_… или BG_…, который можно было бы скопировать. Полную версию этой истории мы рассказали в Ссылке, которую модель не может подделать.

Лестница моделей. Разные подзадачи работают на разных моделях; действующую модель для каждой задают в Langfuse, так что любую можно подменить для A/B-теста без деплоя.

ЗадачаМодель
Маршрутизация намеренияgemini-3.1-flash-lite
Планирование запроса и темыgemini-3.1-flash-lite
Планировщик атрибуции заметокgemini-2.5-flash
Заголовки, подсказкиgemini-2.5-flash-lite
Запасной путь при сбоеclaude-3-haiku
Внекорпусные знанияclaude-sonnet-4.6

В эту таблицу заложены два вывода. Flash-Lite мы попробовали на планировщике атрибуции заметок и отказались: на стенде он примерно в 5% случаев выдавал битые ссылки на заметки, поэтому этот шаг работает на полном Flash. А за дешёвыми моделями нужен глаз да глаз: когда в меню всего один инструмент, Gemini Flash-Lite игнорирует tool_choice="required" и вместо вызова отвечает по обучающим данным. Поэтому мы передаём точное имя инструмента, чтобы всё же добиться вызова.

Фильтр. Пока модель стримит, каждый похожий на цитату маркер проверяют и либо разворачивают в воспроизводимый виджет, либо отбрасывают. Пропущенная ссылка — безопасный сбой; уверенно неверная — нет:

if ref is None:                       # an alias the model invented
    log.info("chat_marker_alias_miss", ref=n, known_max=len(self._aliases))
    return ""                         # drop it; a missing cite is a safe failure

Тот же фильтр отбрасывает сноску с номером, который так и не отчеканили, карточку стиха или главы, которой нет в карте источников этого хода, ссылку на действие, которое не сработало, кривую скобку, повторную цитату и просочившийся внутренний токен. Всё, что не удаётся свести к настоящему источнику, он удаляет.

Прокурор

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

existing = set(await catalog_repo.filter_existing_track_ids(all_track_ids))
for tid in (*cite_track_ids, *card_track_ids, *outline_track_ids):
    if tid not in existing:
        broken += 1

Настоящий судья добросовестности работает офлайн: слепой кросс-модельный A/B-судья на claude-opus-4.8 при температуре 0. Стороны ему назначают детерминированно, по id вопроса, чтобы он не мог предпочесть одну из них, — попарные LLM-судьи страдают известной позиционной предвзятостью. Он оценивает добросовестность, полноту, структуру и сбалансированность. Медленному, дорогому, но надёжному судье самое место там, где он не способен ни замедлить живой ответ, ни исказить его.

Что не сработало

Для полноты — то, что мы пробовали и бросили. Вызов инструментов для цитат: дешёвые модели его игнорировали, сильная дублировала ссылку прямо в тексте. Ограниченное декодирование под схему — то, что вендоры продают как структурированный вывод. Мы надеялись, что так невалидную цитату станет буквально невозможно сгенерировать. На практике настоящая блокировка токенов работает лишь для нескольких моделей, а для остальных вырождается в подсказку, которую они игнорируют. Работу, которая им оказалась не по силам, в итоге сделал фильтр маркеров. Силовое промптирование с MANDATORY/FORBIDDEN: помогает немного, но само по себе никогда не спасает. И мы перестали втихую латать ошибки модели — теперь мы записываем в лог каждую, потому что нельзя улучшить число, которое не измеряешь.

В завершение

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

Вместо этого мы построили систему, которая структурно не способна выдать выдумку за факт. Нужные источники находят и ранжируют кросс-энкодером ещё до того, как написано хоть слово. Всё ниже планки отбрасывают, а скудный пул вынуждает к честному отказу. Модель ни разу не видит id, который могла бы подделать, а любую ссылку, которую она всё же выдаёт, сверяют с живой картой и удаляют, если она никуда не ведёт. Честность — это не поведение, на которое мы надеемся; её задают правила и код, сотрудничает модель или нет.

Поэтому система и продолжает улучшаться. Каждый слой на виду — порог, ворота, проверка, записанная в лог оценка, — так что каждый можно измерить, настроить или убрать по фактам. Один гигантский промпт — это чёрный ящик, на который остаётся только молиться; стопка маленьких, понятных правил — то, что действительно поддаётся инженерии. Точность здесь — не размер модели. Это архитектура вокруг неё.


Часть проекта

Слушай Садху

Открыть проект