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 — следующий
этап, и они зависят от того, чтобы этот сначала накопил данные.