← Журнал
Jiva Studio

Как работает research-агент

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

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

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

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

Чтобы было нагляднее, проведём через всю статью один вопрос — что такое реинкарнация? На каждом шаге будут настоящие числа: что сколько набрало, что проходит, а что выкидывается. (Значения репрезентативные — это форма настоящего трейса, а не данные конкретного читателя.)

Агент, а не петля

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

Поэтому мы забрали у неё автономию. Anthropic в статье Building Effective Agents объясняет: самостоятельный агент — тот, что сам выбирает следующий шаг, — оправдан только на открытых задачах, которые нельзя расписать заранее. А когда шаги уже известны, надёжнее и намного дешевле фиксированный workflow — вызовы модели, связанные вашим кодом. Поэтому обработка research-запроса — это детерминированный граф (на LangGraph), где модель зовут только на шаги, где правда нужно суждение: спланировать поиск, разложить ответ на тезисы, написать текст. Всё между ними делает код.

flowchart TD
  START(["реплика пользователя"]) --> CC{{"детерминированные классификаторы<br/>до LLM"}}
  CC -->|"простой прямой запрос"| SV["ответить сразу"]
  CC -->|"нужен research"| R{{"LLM-роутер<br/>определяет интент"}}
  R -->|"research, unknown"| RW["research-воркер"]
  R -->|"locate, catalog…"| OTHER["остальные воркеры"]
  RW --> SP["планировщик синтеза"]
  SP -->|"обоснованный план"| SY["синтезатор стримит ответ"]
  SP -->|"в корпусе пусто"| CF["fallback вне корпуса"]
  CF --> SY
  OTHER --> SY

  classDef grnd fill:#89b4fa,stroke:#6c7086,color:#1e1e2e;
  class RW,SP,CF grnd;

Здесь уже видно два небольших решения. Перед LLM-роутером стоит слой дешёвых детерминированных классификаторов — для простых прямых запросов он вообще исключает модель: голый БГ 2.13 просто показывается как есть. Наш вопрос не из таких: что такое реинкарнация? классифицируется как research и идёт верхним путём по графу. А интент unknown никогда не отвечает пустотой: он проходит через лёгкий research-проход, чтобы одна ошибка классификации не обернулась уверенным «не нашёл».

Насколько глубоко искать?

Не каждому вопросу нужен полный проход по корпусу. Поэтому агент сначала дёшево спрашивает себя: а нет ли у нас уже готового ответа, который вручную отобрал редактор? Здесь два подхода: Corrective-RAG — сначала проверь, что уже есть, а потом ищи ещё — и Self-RAG, где модель оценивает, достаточно ли материала. Делаем это вообще без лишнего вызова модели, по уже посчитанным сигналам:

# research/sufficiency.py — decide the effort level from evidence in hand
def assess_sufficiency(question_matches, memory) -> str:
    if question_matches:                 # an editor pinned THIS answer to THIS question
        return CORRECT
    if memory_is_sufficient(memory):     # a strong curated note with ≥3 resolved refs
        return CORRECT
    return INCORRECT                      # nothing curated → do the wide sweep

Результат — это просто уровень усилий. Lean берёт выбранные куратором источники плюс небольшой дополнительный поиск. Wide запускает полный поиск по корпусу, до двух раундов. У что такое реинкарнация? нет ни закреплённого ответа, ни сильного совпадения с памятью — поэтому assess_sufficiency возвращает INCORRECT, и вопрос идёт путём wide. Важно принять это решение до поиска: вопрос, на который куратор уже ответил, пропускает полный проход — ~194 источника и ~20 секунд — и обходится ~56 источниками. Подробнее про скорость — в отдельной статье: куда уходили тридцать секунд.

Два способа искать и короткий список

Если дело доходит до поиска, агент ищет сразу двумя способами. Векторный поиск работает по смыслу: каждый фрагмент превращается в эмбеддинг и сравнивается с эмбеддингом вопроса через индекс pgvector. Он отлично ловит смыслы, но слеп к точным строкам — номеру стиха вроде БГ 2.13 (числа не эмбеддятся), санскритскому слову yoga-kṣema, короткому стиху с низкой оценкой. Поэтому рядом идёт второй, лексический поиск — обычное полнотекстовое совпадение, — и его находки попадают в пул независимо от векторной оценки. Задача лексического поиска одна: дотащить нужный фрагмент до отбора; а подходит ли он — решают дальше.

Для что такое реинкарнация? планировщик раскрывает несколько углов — определение, душа меняет тела, что происходит при смерти, — и оба поиска возвращают пёстрый пул: пару клипов лекций, стихи БГ 2.13 и БГ 2.22, комментарий, короткий стих, втащенный лексическим поиском по слову punar-janma, и немного шума.

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

q: «что такое реинкарнация?»   intent=research   path=wide

косинус  тип          фрагмент                                реранк  оставили
 0.63    лекция       «душа меняет тела, как одежду»           0.94    да
 0.71    стих         BG 2.13  dehino 'smin yathā dehe…        0.88    да
 0.66    комментарий  комментарий на BG 2.13                   0.81    да
 0.34    стих         BG 2.20  na jāyate mriyate vā… (лексика)  0.71    да
 0.68    стих         BG 2.22  vāsāṁsi jīrṇāni…                 0.60    да
 0.47    лекция       «как управлять храмом»                   0.21    нет
 0.12    —            «квантовое сознание…»       ниже порога   —      нет

В этом блоке видно два момента. Обычная лекция оказывается выше сильного стиха БГ 2.13, потому что, прочитанная вместе с вопросом, она отвечает прямее — порядок поменялся. И реранкер перебивает косинус в обе стороны: спасает БГ 2.20, точный стих, который векторный поиск почти закопал, и выбрасывает «как управлять храмом», который векторному поиску показался нормальным, но не по теме. А посторонний кусок до реранкера вообще не доходит — его раньше срезает мягкий порог шума.

Но от одного перекоса стоит подстраховаться. Кросс-энкодер обучен на прозе, поэтому болтливую расшифровку лекции он склонен ставить выше сухого стиха — это БГ 2.20 в блоке: точно в тему, но с косинусом настолько низким, что легко могла вылететь снизу. Чтобы писание не выдавливало, при отсечке держим резервы по типам: хотя бы два стиха и два фрагмента из книг всегда проходят — но каждый выше порога релевантности, чтобы не протащить ничего постороннего. Какие типы попадут в отбор и в каком порядке они встанут — две разные задачи:

flowchart LR
  RANK["реранкер: лучшие по релевантности<br/>чаще всего лекции"] --> RES{"есть стихи и<br/>фрагменты из книг?"}
  RES -->|"да"| DONE["итоговый набор"]
  RES -->|"нет, но что-то выше порога"| PULL["добираем их"]
  PULL --> DONE

Полная история «достать и отсеять», с числами, — в статье ответы, которые ссылаются на источники.

Две оценки — две задачи

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

  • Косинус — векторная близость по стабильной шкале [0,1]. Это оценка для отбора: любое решение «да/нет», достаточно ли фрагмент хорош, читает косинус, поэтому одно и то же число везде значит одно и то же.
  • Оценка реранкера — релевантность от кросс-энкодера. Это оценка для порядка: она решает, что идёт первым внутри одного ответа, и больше ничего.

Возьмите БГ 2.20 из блока: низкий косинус, высокая оценка реранкера. Оценка реранкера даёт ему место в порядке. Но гейт покрытия — проверка «а нашли ли мы вообще достаточно, чтобы ответить?» — читает только косинус, где это низкое число почти ничего не весит; он смотрит на верх набора, видит уверенное попадание и две лекции и останавливается после первого раунда. Тот же фрагмент, два числа, две задачи.

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

# research/pipeline.py — merge without ever comparing the two scales
def _tier_key(e):
    rs = e.get("rerank_score")
    if rs is None:
        return (1, e.get("score") or 0.0)   # tier 1: curated refs, by cosine
    return (0, rs)                           # tier 0: reranked chunks, by rerank score

Что уже знает редактор

Самый сильный сигнал агента — вовсе не поиск, а редактор, который уже решил, каким должен быть ответ. Эти решения живут в атрибуциях, трёх видов: pinned — готовый ответ, который редактор закрепил за конкретным вопросом; boost — тема, поднимающая связанный материал; memory — фоновая заметка: дополнительный контекст, который помогает ответу, но который не цитируют. У нашего вопроса про реинкарнацию закреплённого ответа не было — поэтому он и пошёл путём wide; а у вопроса вроде что такое душа? он часто есть, и тогда почти весь поиск пропускают.

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

Ещё одно — про языки. Наш эмбеддер text-embedding-3-small даёт около 62% на английском поиске и лишь ~44% на многоязычном бенчмарке MIRACL. Из-за этого разрыва тот же вопрос по-русски — что такое реинкарнация? — приходит с косинусной форой: его стихи возвращаются на пару пунктов ниже, чем в английском прогоне. Поэтому для переведённого запроса пороги приёма делают ниже, чтобы это компенсировать: ответу pinned нужен косинус 0.85 на английском и 0.80 на переводе. А как один и тот же чат отвечает читателям на языках, на которых корпус вообще не писали, — отдельная история: один чат для всех языков.

От заметок к ответу

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

тезис 1  душа вечна и отлична от тела          BG 2.13, лекция   сильный
тезис 2  при смерти переходит в новое тело     BG 2.22           сильный
тезис 3  поступки определяют новое рождение    лучшая 0.41       слабый

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

flowchart TD
  BO["планировщик: вопрос → тезисы и черновые заметки"] --> RK["переранжировать заметки каждого тезиса<br/>якорь — предложение-тезис"]
  RK --> THIN{"слабый тезис?<br/>шаткая лучшая заметка, мало сильных"}
  THIN -->|"нет"| DONE["план → синтезатор"]
  THIN -->|"да"| AUG["один свежий поиск под этот тезис<br/>переранжировать и снова"]
  AUG --> DONE

На выходе это читается как обычный абзац, только каждое утверждение привязано к источнику, который можно открыть в один тап:

Душа не умирает вместе с телом [^БГ 2.20]; она просто переходит дальше —
«как человек надевает новые одежды, сняв старые» [^БГ 2.22].

Когда в корпусе ничего нет

Иногда в корпусе и правда ничего нет. Раньше в ответ шёл сухой отказ — и часто это был неверный ответ: ответ вполне может существовать, просто не в нашем корпусе. Поэтому, когда планировщик отвергает все найденные заметки, агент один раз зовёт модель посильнее, просит её ответить из общих знаний с обязательной оговоркой и — в духе HyDE — превращает этот ответ в новые поисковые зацепки, чтобы ещё раз пройтись по корпусу и оставить только сильные попадания. Оговорку добавляет сервер, а не модель; никакого судьи «а это правда?» на лету нет (модель, оценивающая собственные факты, лишь себе подыгрывает), поэтому достоверность проверяют офлайн, на других моделях. Полностью это описано в статье ответы, которые ссылаются на источники.

Во что обходится ответ

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

ШагМодель~Токены (вход / выход)~Цена
РоутерGemini Flash-Lite1500 / 50$0.0002
Планировщик запросовGemini Flash-Lite1000 / 150$0.0002
Извлечение темGemini Flash-Lite800 / 50$0.0001
Эмбеддинг (вопрос и заметки)text-embedding-3-small~3000$0.0001
Реранжирование (поиск и по тезисам)Voyage rerank-2~25 000$0.0013
Планировщик синтезаGemini Flash4500 / 400$0.0024
Вступление и заключениеGemini Flash-Lite2000 / 300$0.0003
Синтезатор (сам ответ)Gemini Flash-Lite6000 / 1200$0.0011
Итого≈ $0.006

(Всё, кроме поиска, работает на Gemini. Ставки за миллион токенов: Gemini Flash-Lite 0.10/0.10 / 0.40 и Flash 0.30/0.30 / 2.50, Voyage rerank-2 0.05,[textembedding3small](https://openai.com/api/pricing/)0.05, [text-embedding-3-small](https://openai.com/api/pricing/) 0.02. Число токенов зависит от сложности вопроса; здесь — средний запрос.)

Самый дорогой шаг — не написание ответа, а планировщик синтеза и реранкер: там мы платим за суждение и релевантность. Синтезатор, который и складывает текст, работает на Gemini Flash-Lite по 0.10/0.10 / 0.40 за миллион токенов, поэтому сам ответ на 500 слов стоит около десятой доли цента — меньше, чем ранжирование, которое отобрало для него источники.

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

ПодходМодель~Ценак конвейеру
Наш полный конвейерGemini + Voyage≈ $0.006
один прямой вызовGemini 3.1 Pro≈ $0.026~4×
один прямой вызовGPT-5.4≈ $0.033~5×
один прямой вызовClaude Sonnet≈ $0.036~6×
один прямой вызовClaude Opus 4.8≈ $0.060~10×
один прямой вызовGPT-5.5≈ $0.066~11×

И даже это сравнение — в пользу одиночного вызова: у него один заход, без своего поиска и без обоснования. Настоящая агентная петля на любой из этих моделей делает пять-семь таких вызовов на растущем с каждым ходом контексте — умножаем ещё раз, и петля на Opus выходит под $0.30–0.40, в полсотни раз дороже нашего конвейера. Дешевизна тут — не модель поменьше, которая работает хуже, а архитектурное решение. Тот же урок, что и в цитате, которую модель не подделает: качество может идти от устройства системы, а не от размера модели.

Единственное место, где всё-таки работает мощная модель, — это fallback вне корпуса (раздел «Когда в корпусе ничего нет»): один вызов Claude Sonnet примерно за $0.01, который почти удваивает стоимость запроса, — и срабатывает он только на настоящем промахе, где заплатить за аккуратный ответ как раз и стоит. А любой обычный вопрос остаётся авторитетным, ничего не выдумывает, отвечает за секунды и стоит меньше цента.

Коротко

Если снять с агента всё лишнее, большую часть породили несколько принципов:

  • Детерминизм вместо суждения там, где это дешевле и не хуже. Веерный поиск заменил ReAct-петлю; детерминированные классификаторы для простых запросов вообще убрали LLM; отдельные генераторы вступления и заключения заменили споры с промптом. «Чини в коде, а не воюй с моделью» — дословная строчка из нашей истории.
  • Две оценки — две задачи. Косинус — для отбора, кросс-энкодер — для порядка. Смешаешь их — и каждый порог теряет смысл.
  • Сперва состав, потом порядок. Резервы следят, чтобы в ответе были все нужные типы источников; порядок между ними задаёт реранкер. Оставишь только второе — и ассистент по стихам начнёт цитировать одни лекции.
  • Каждый порог — это замеренное число, а не догадка: каждый стоит между двумя наблюдёнными группами оценок — настоящие совпадения по одну сторону, ложные по другую.
  • Деградируй мягко — с единственным исключением. Любой шаг может выйти по таймауту и пройти дальше с частичным результатом; единственная ошибка, которую агент не проглатывает, — по-настоящему недоступный провайдер: тогда он честно говорит «сервис на секунду недоступен» вместо уверенного ответа без опоры.

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


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

Слушай Садху

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