← Журнал
Jiva Studio

Ответы со ссылками на источники

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

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

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

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

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

Что за задача

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

Для пользователя этот сбой незаметен. Ответ выглядит таким же гладким, как обоснованный. Но гладкость — не истина. И вся задача проектирования сводится к одному: как измерить разрыв между «на это у нас есть материал» и «мы вот-вот что-то выдумаем» — дёшево и до того, как запустится дорогая генерация?

Как мы храним источники

Нельзя сослаться на то, что плохо сохранил. Всё, что можно искать, — отрезок лекции, стих, абзац комментария — это один фрагмент, и у каждого фрагмента есть метка kind. Эти виды — язык, на котором говорит вся остальная система: track_transcript, verse, commentary, prose_chapter, letter. Почти каждый следующий шаг — переранжирование, резервы по видам, фильтр покрытия — опирается на эту метку.

Что хранит фрагмент, зависит от того, что это такое: цитата должна указывать на нужную вещь.

  • Фрагмент-транскрипт относится к треку — отдельной записи лекции — и хранит (track_id, start_ms, end_ms): какая запись и точный отрезок внутри неё до миллисекунды. Именно эту тройку проигрывает плашка-цитата, когда вы по ней нажимаете. Ради этой точности аудио так и хранится.
  • Фрагмент-библиотека (стих, комментарий, письмо) хранит вместо этого структурный адрес — книгу, токен места, человеческую метку, — и тогда «BG 2.13» ведёт к настоящему стиху, а не к случайному совпадению слов.

У обоих внутри лежит одно и то же: текст, язык и векторный эмбеддинг для поиска по смыслу.

Два разных нарезчика, и это намеренно. Лекции и писания нарезаются по-разному — вот мы их и нарезаем по-разному. Транскрипты режутся на окна примерно по 45 секунд, с небольшим перехлёстом и пределом в 8000 символов — достаточно длинные, чтобы вместить законченную мысль, и достаточно короткие, чтобы указывать точно. Текст библиотеки складывается по абзацам и предложениям в куски примерно по 900 символов. Стих и комментарий к нему уже структурированы, так что мы идём за этой структурой, а не воюем с ней.

Эмбеддинги. Каждый фрагмент прогоняется через text-embedding-3-small и индексируется по косинусному сходству через HNSW в pgvector. Это полоса «по смыслу».

У лексической полосы свои индексы, потому что сходство по смыслу не видит точных совпадений строк. GIN-индекс pg_trgm над addr_label || source_id || tokens ловит канонические адреса. Два GIN-индекса tsvector покрывают содержимое: один russian (стемминг Snowball) и один simple (без стемминга, чтобы санскритская транслитерация уцелела дословно).

Кураторская память хранится отдельно — в таблице attributions, где kind бывает pinned (одобренный редактором ответ на конкретный вопрос, который можно цитировать), boost (тема, которая подталкивает оценки) или memory (фоновая заметка: она влияет на подачу, но её никогда не цитируют). Любопытная деталь: когда индексатор переносит заметку в Postgres, он эмбеддит триггерные фразы и куски тела заметки вместе. Поэтому вопрос находит заметку, даже если перекликается с её сутью, а не только с триггером. Полная механика кураторской памяти — в статье Эшелонированная защита от галлюцинаций.

Что мы отсеиваем, прежде чем модель что-то увидит

Сначала полнота, потом точность. Дешёвая модель раскладывает вопрос на 1–4 типизированных подвопроса (у каждого — до двух перефразировок), и по каждому проходят все полосы. Дальше начинается отсев.

flowchart TD
  Q["Вопрос и подвопросы"] --> POOL["Пул кандидатов<br/>плотный × 3 вида, лексика, адрес"]
  POOL --> F{"косинусный преднижний порог<br/>0.18 на пути переранжирования"}
  F -->|ниже| X["отсеять как чистый шум"]
  F -->|выше или принудительно| CAP["обрезать до 60 по косинусу"]
  CAP --> RR["Voyage rerank-2<br/>кросс-энкодер"]
  RR --> K["оставить топ-16 по оценке ранжирования"]
  K --> RES["резервы по видам<br/>≥2 стихов, ≥2 из библиотеки"]
  RES --> DD["дедуп и сжатие<br/>только цитируемые заметки"]
  DD --> N["Обоснованные заметки"]

Несколько чисел здесь важны, и каждое далось нам трудно:

  • Преднижний порог шума — 0.18, а не 0.45. Раньше мы отбрасывали всё ниже ровного косинуса 0.45 — и это тихо убивало нужные стихи в районе 0.30, которые кросс-энкодер спас бы. Поэтому на боевом пути порог опускается до RERANK_NOISE_PREFLOOR = 0.18 (ровно чтобы отсечь мусор), а остальное решает переранжировщик. Лексические и адресные совпадения проходят порог принудительно, так что редкое имя не потеряется из-за робкого эмбеддинга.
  • Настоящий выбор делает переранжировщик. Пул обрезается до топ-60 по косинусу, каждую пару (вопрос, отрывок) Voyage rerank-2 оценивает вместе, и топ-16 по этой оценке проходят дальше. Отсекаем по рангу, а не по абсолютному порогу: оценки кросс-энкодера несопоставимы между разными вопросами, так что фиксированной границы тут не задашь.
  • Резервы по видам не дают ответу оголодать. Кросс-энкодер обучен на прозе и втихую предпочитает разговорчивые транскрипты сжатым стихам, поэтому после отсечки идут гарантированные резервы: хотя бы 2 стиха и 2 отрывка из библиотеки, каждый выше своего косинусного порога 0.40.
  • Потом дедуп и сжатие. Одинаковые клипы схлопываются, и прямо перед генерацией пул урезается до только тех заметок, на которые план и правда ссылается: контекст из 30–90 заметок сжимается до опорных заметок плана и нумеруется заново. Раздутый контекст только провоцирует дрейф «потерянного в середине» и ссылки мимо плана.

Полная история про переранжирование — и почему модель видит голое целое число вместо подделываемого ID трека — в статье Цитата, которую модель не подделает.

Проверка покрытия: одна дешёвая метрика, без LLM

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

COVERAGE_MIN_MAX_SCORE = 0.55
COVERAGE_MIN_LECTURES  = 2
_EARLY_EXIT_MAX_SCORE  = 0.65
_BAILOUT_MAX_SCORE     = 0.40

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

def is_coverage_good_enough(result):
    if is_coverage_sufficient(result):
        return True
    return result.max_score >= _EARLY_EXIT_MAX_SCORE   # one confident hit: 0.65

def should_bail_out(result):
    return result.max_score < _BAILOUT_MAX_SCORE       # nothing close: 0.40

Метрика — косинус, и это намеренно. max_score — это наибольшее косинусное сходство в ранжированном наборе, и никогда не оценка переранжирования. Это несущая деталь: оценки кросс-энкодера хороши, чтобы упорядочить внутри одного вопроса, но несопоставимы между вопросами, так что на них не построить абсолютный порог «знаем ли мы это?». А на косинусе — можно. Внутри каждый кандидат хранит свой настоящий косинус в score, а результат кросс-энкодера — в отдельном поле. Ровно затем, чтобы фильтры оставались откалиброванными под то значение, по которому их настраивали.

Тогда вердикты читаются ясно:

  • max_score ≥ 0.55 и ≥ 2 куска лекций → пул крепкий; обосновываем ответ.
  • max_score ≥ 0.65 → одного уверенного попадания уже хватает; заканчиваем поиск раньше и экономим раунд.
  • max_score < 0.40 → ничего близкого нет; выходим, а не платим за ещё один раунд поиска по той же пустоте.

Между этими границами система делает второй раунд развёртки (не больше двух) и проверяет заново. И есть последний фильтр даже после того, как покрытие прошло: дешёвый планировщик (gemini-2.5-flash) выстраивает уцелевшие заметки в план, и если ни одна из них не подкрепляет тезис, он возвращает пустой планOutline(theses=[]) — и это взводит флаг corpus_insufficient. Именно это, а не нулевой результат поиска, здесь и значит «ничего не нашли»: планировщик прочитал кандидатов и счёл их все не по теме.

Вне корпуса → честный ответ, а не отказ

Когда corpus_insufficient взведён, раньше мы выдавали сухое «не нашёл в корпусе.» Теперь есть путь поумнее. В дело вступает одна модель посильнее — и это единственное место в конвейере, где она появляется:

llm_fallback_knowledge = "openrouter/anthropic/claude-sonnet-4.6"

Резервная модель отвечает из общих знаний, на языке пользователя, и возвращает структуру MemoryAnswer { answer, search_queries, disclaimer, in_scope }. Из своего же ответа она выводит 3–5 зондов по корпусу, и мы ищем заново — но пускаем только куски выше жёсткого _FALLBACK_MIN_SCORE = 0.5, чтобы не впустить обратно хлам, который исходный запрос уже отверг. Уцелевшие куски становятся необязательными, ситуативными цитатами: их приводят лишь там, где какой-то и правда подкрепляет мысль, никогда не выдумывают, и ответ не отказывает.

flowchart TD
  CI["corpus_insufficient"] --> CL["Claude Sonnet 4.6<br/>ответ из общих знаний"]
  CL --> SC{"в теме?"}
  SC -->|"нет: готовка, спорт, код"| OOS["вежливый отказ"]
  SC -->|да| RS["новый поиск по 3–5 зондам<br/>оставить только оценку ≥ 0.5"]
  RS --> D["оговорку рисует сервер<br/>достоверный ответ, реальные заметки"]

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

Как держать задержку низкой при двух проходах

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

flowchart LR
  QP["план запроса<br/>flash-lite"] --> FN["развёртка: эмбеддинг и переранжирование<br/>без генерирующей LLM"]
  FN --> CG["фильтр покрытия<br/>только булевы, без LLM"]
  CG --> SP["планировщик плана<br/>flash"]
  SP --> SY["синтезатор<br/>deepseek-chat, стримит"]
  CG -.->|только при промахе| CF["Claude Sonnet 4.6<br/>дорогой проход"]
  • Предпроверка достаточности выбирает уровень усилий вообще без LLM. Если кураторская память уже отвечает на вопрос, ход идёт по экономной политике — без лишних раундов развёртки, с коротким списком — вместо двух раундов широкой политики примерно по 20 кандидатам. Прежняя развилка «коротко/длинно», которую это заменило, гоняла полный проход примерно по 100 источникам, около 20 секунд, даже там, где это было не нужно.
  • Фильтр покрытия бесплатен. Это несколько сравнений уже готовых чисел, так что он замыкает цепь до всякой генерации. Ранний выход на 0.65 пропускает второй раунд; выход на 0.40 пропускает вызов LLM для перегенерации запроса (около 7 с) и ещё одну развёртку (около 2 с) — примерно 9 секунд экономии на вопросах, на которые корпус всё равно не ответил бы.
  • Лестница моделей идёт от дешёвых к дорогим. Маршрутизация и планирование запроса — gemini-3.1-flash-lite; планировщик атрибуции заметок — полный gemini-2.5-flash (Flash-Lite примерно в 5% случаев на стенде галлюцинировал битые ссылки на заметки, так что на этой задаче от него отказались); синтезатор стримит на deepseek-chat. Claude Sonnet 4.6 вызывают только при промахе покрытия — никогда по догадке, что вопрос «выглядит трудным». Вступление и вовсе пишется параллельно с обоснованием, так что по реальному времени оно почти ничего не стоит.

Почему «я не знаю» — это фича

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

Но посмотрите, что такое «я не знаю» здесь на самом деле: не настроение и не тон, который принимает модель, а число, перешедшее черту — max_score < 0.40 или планировщик, вернувший пустой план. Это пишется в лог, это измеряется, а раз измеряется — это можно настраивать. Судья достоверности работает офлайн на claude-opus-4.8 при температуре 0, вслепую и с детерминированно назначенными сторонами, чтобы не отдать предпочтение какой-то позиции; он оценивает каждый ответ по достоверности, полноте, структуре и сбалансированности. Честность здесь — спроектированная величина, а не надежда.

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

Куда это движется

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


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

Слушай Садху

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