Эшелонированная защита от галлюцинаций
Языковая модель уверенно выдумает лекцию, цитату, ссылку. Удержать её в честных рамках — это не один хитрый промпт, а работа в три шага: достать нужные данные, отбросить лишние и только потом дать модели говорить.
Мы делаем чат по корпусу духовных лекций. Вся суть — в одном правиле: каждое утверждение должно вести к настоящему источнику, иначе ассистент отказывается отвечать. Сама по себе языковая модель нарушает это правило с лёгкостью — она галлюцинирует: выдумывает лекции, которых нет, сочиняет цитаты, придумывает ссылки, неотличимые от настоящих. Больше всего этим грешат модели подешевле и длинные разговоры. Разрыв между моделями можно измерить, это не просто впечатление, и это важно: держать передовую модель на каждой реплике нам не по карману.
Есть утешительный миф, будто всё лечится одним хорошим промптом. Нет. Честность здесь — свойство архитектуры, а у архитектуры три шага:
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, который могла бы подделать, а любую ссылку, которую она всё же выдаёт, сверяют с живой картой и удаляют, если она никуда не ведёт. Честность — это не поведение, на которое мы надеемся; её задают правила и код, сотрудничает модель или нет.
Поэтому система и продолжает улучшаться. Каждый слой на виду — порог, ворота, проверка, записанная в лог оценка, — так что каждый можно измерить, настроить или убрать по фактам. Один гигантский промпт — это чёрный ящик, на который остаётся только молиться; стопка маленьких, понятных правил — то, что действительно поддаётся инженерии. Точность здесь — не размер модели. Это архитектура вокруг неё.
Часть проекта
Слушай Садху