Перейти к основному содержимому

ADR 0129 — Телеметрия поиска RAG — это ТАБЛИЦА, с весами, замороженными в строке

Контекст​

apps/api/src/domain/rag/rag-search-limits.ts объявляет в собственном комментарии, что четыре числа гибридного поиска — два веса (0.6/0.4), порог (0.2) и число кандидатов на сигнал — не взяты ни из какой калибровки:

NENHUM dos quatro números abaixo vem de calibração com dado real: não há, ainda, um corpo de perguntas reais rodado contra este índice.

ADR 0080 выбрал эти значения с зафиксированным рассуждением (шкалы ts_rank и косинуса несравнимы, а больший вес векторной стороны компенсирует более короткую линейку лексики) и сказал, что их пересмотрят с данными. Данные так и не пришли, и причина была не в недостатке использования: поиск не оставлял никакого следа. Ноль строк таблицы, ноль событий rag.* во всём продукте. Без следа «калибровать» значило бы менять одну догадку на другую — и хуже, на практике необратимо, потому что не было бы с чем сравнить до и после.

Это первый из пяти этапов программы измеримого RAG, и он намеренно единственный, который не меняет поведения поиска: даёт глаза и останавливается.

Конкретное препятствие​

Очевидным выходом было бы событие сессии — event log уже является измерительным инструментом остального продукта, а правило CLAUDE.md говорит, что метрика исполнения извлекается из него скриптом, никогда вручную. Но session_events.session_id — NOT NULL (apps/api/src/db/schema/sessions.ts), а поиск из вкладки RAG принадлежит ПРОЕКТУ: у него нет никакой сессии. Регистрация телеметрии только как события потеряла бы ровно те поиски, в которых человек посмотрел на оценки и вынес суждение, — а только они несут единственный сигнал правды, который может иметь измерение.

Это тот же класс проблемы, что вынудил срез метеринга embedding в ADR 0075: token_usage.session_id тоже NOT NULL, а индексация репозитория не происходит внутри сессии. Там срез был объявлен, и расход остался вне. Здесь срез был бы хуже: осталась бы половина поисков, а оставшиеся — поиски агентов, которых никто не судит.

Решение​

1. Две таблицы в apps/api/src/db/schema/rag.ts, а не события.

rag_searches (одна строка на поиск: hits с 1-based рангом каждого куска, degraded/vector_available, latency_ms, актор и session_id NULLABLE) и rag_feedback (голос util/irrelevante за кусок поиска). Файл агрегата уже существовал (chunks), поэтому изменений в barrel db/schema.ts нет; enum rag_verdict живёт в том же файле, что и вызывающая его таблица, по соглашению ADR 0121, а actor_kind переиспользуется из sessions.ts, а не объявляется заново.

session_id NULL — это информация, а не отсутствие данных: значит, поиск пришёл из вкладки.

2. Веса идут ЗАМОРОЖЕННЫМИ в строке.

pesosVigentes() (domain/rag/rag-telemetry.ts) копирует {vector, lexical, threshold} момента поиска внутрь записи. Это та же дисциплина, что замороженная цена в метеринге (ADR 0042) и image_version у project_containers (ADR 0081).

Без копии первая калибровка, тронувшая RAG_SEARCH_WEIGHT_VECTOR, заставила бы всё предыдущее измерение начать означать другое, молча — и «стало ли лучше после изменения?», единственный вопрос, ради ответа на который существует телеметрия, стало бы невозможно задать с имеющимися данными. Это функция, а не экспортируемая константа, намеренно: общая константа была бы одним и тем же объектом в каждой строке JSONB в памяти.

3. Событие существует, и это ПОВЕСТВОВАНИЕ.

rag.search и rag.feedback входят в event log только когда есть сессия — путь агента, — чтобы timeline рассказывала, что он сделал. Асимметрия объявлена, а не спрятана: таблица — источник измерения, событие — повествование, и medir:rag никогда не читает event log.

Оба типа записаны ЦЕЛИКОМ в точках выпуска, а не константой: перечень docs/reference/events.md генерируется grep type: '<x>.<y>' по коду api, и тип за константой становится для него невидимым — документация прошла бы зелёной над неполным списком. Это та же форма, которую уже использует execution.activated. 'rag' входит в PREFIXOS_DE_EVENTO (scripts/docs/generate.mjs) в том же коммите.

4. Запись телеметрии никогда не роняет поиск и никогда не падает молча.

INSERT выполняется в try/catch внутри HybridSearchUseCase: спрашивающий не должен терять ответ из-за того, что упал измерительный инструмент. Но отказ становится логом с классифицированным источником (infra, ADR 0020) и searchId: null в ответе — что не то же самое, что «ничего не нашёл»: это «не к чему прикрепить голос», и UI нужно различать их, чтобы не предлагать элемент управления, который api отклонила бы.

5. Голос требует ссылки, которую вернул поиск, и является :direct.

Неизвестный searchId или chunkId, которого не было среди hits этого поиска, — 400. Это не декоративная строгость: ранг проголосованного куска — то, что отличает «индекс беден» от «ВЕСА неверны», — бедный индекс не возвращает правильный кусок ни на какой позиции, неверный вес возвращает правильный кусок на ранге 7, — а голос без ранга даёт число без смысла.

Инструмент engine (rag_feedback) — category: :direct и не становится proposed_action: оценка куска не имеет внешнего эффекта, а превращение её в действие на одобрение заполнило бы очередь шумом, пока никто не стал бы читать настоящие (та же мерка, что держит rag_search/read_file вне конвейера). Отказ возвращается как tool-result ошибки, с сообщением api, чтобы модель исправилась (RN-061/RN-163), никогда как crash.

6. medir:rag проваливает по одной-единственной причине.

vector_available: false во всём окне: тогда измерено не гибридный поиск, а его лексическая половина, и калибровать вес вектора против этого значило бы калибровать против системы, которая не та, что работает. Всё остальное — precision@k, доля деградаций, поиски без hit выше порога, задержка p50/p95, распределение рангов того, за что голосовали «полезно», — это отчёт: выводится в таблицу, не проваливает. Тот же шаблон, что medir-execucao.ts.

Последствия​

Калибровка становится возможной и остаётся не сделанной. Этот этап не меняет chunking, веса или порог, намеренно: трогать числа до накопления измерения уничтожило бы базовую линию, которую он существует создать. Пункт остаётся объявленным открытым в CLAUDE.md, теперь с инструментом рядом.

degraded и vector_available — две колонки, и сегодня они избыточны. Сейчас degraded = !vector_available. Избыточность объявлена в схеме, а не спрятана, и причина не выводить одну из другой при чтении — они отвечают не на один вопрос: vector_available — факт о PROVIDER embedding в момент поиска — именно он заставляет medir:rag проваливать, — а degraded — слово КОНТРАКТА с engine (POST /internal/rag/search), чьё определение может вырасти до других деградаций без падения provider. Выведение навсегда связало бы их.

Голос не переживает «Reindexar agora». rag_feedback.chunk_id ссылается на chunks с КАСКАДОМ. Переиндексация стирает и пересоздаёт chunks, так что история суждений уходит вместе с ними. Хранить осиротевший голос, указывающий на кусок, которого больше нет, ничего не измеряло бы — но цена реальна и записана в схеме: тот, кто переиндексирует, теряет базу сравнения, и измерение начинается заново.

precision@k измеряет СУЖДЁННОЕ, а не возвращённое. Знаменатель — число проголосованных hits ранга ≤ k, никогда k. Hit без голоса — неизвестен, а не нерелевантен, и счёт его нерелевантным заставил бы точность падать сама по себе каждый раз, когда кто-то голосовал бы за меньшее число кусков, — измеряя готовность голосовать, а не качество поиска. Принятое последствие — метрика существует только там, где есть голос: без единого она null, и таблица говорит «не измерено», никогда 0.

Внутренний контракт с engine вырос в обе стороны. POST /internal/rag/search начал принимать НЕОБЯЗАТЕЛЬНЫЕ sessionId/agent в теле (api не может вывести ни то, ни другое; сессия, которую она не получила, — сессия, которой не было) и возвращать searchId плюс chunkId на каждый hit. chunkId — единственное исключение из намеренного упрощения этого DTO — без пары searchId/chunkId агенту не на что было бы указать. Когда searchId приходит null, инструмент опускает id и приглашение голосовать, вместо того чтобы предлагать модели ссылку, которая была бы отклонена.

Ещё один инструмент в промпте шести агентов. rag_feedback входит в шесть реестров, где уже был rag_search (dev/tools.ex, gates/qa_tools.ex, qa_estrategia_agent.ex, qa_performance_seguranca_agent.ex, appsec_agent.ex, harness/tools.ex). Реальная цена: ещё один блок spec в контексте каждого хода. Альтернатива — только человеческая обратная связь — оставила бы вне путь, через который проходит большая часть поисков.

Маршрут viewer, который ПИШЕТ. POST /projects/:projectId/rag/feedback — та же роль, что search, а не maintainer: голосование ничего не тратит и не настраивает, это наблюдение, и его ограничение опустошило бы единственный источник правды измерения. Он классифицирован в docs/security-surface.md с этим рассуждением; сдерживание — проверка ссылки use case, а не роль.

Ни нового агента, ни gate. Golden set и gate rag-acertivo — следующий этап, и они зависят от того, чтобы этот сначала накопил данные.