Skip to main content

ADR 0129 — RAG search telemetry is a TABLE, with the weights frozen in the row

Context​

apps/api/src/domain/rag/rag-search-limits.ts declares, in its own comment, that the four numbers of the hybrid search — the two weights (0.6/0.4), the threshold (0.2) and the number of candidates per signal — come from no calibration at all:

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 chose those values with the reasoning recorded (the scales of ts_rank and cosine are not comparable, and the larger weight on the vector side compensates for the shorter ruler of the lexical one) and said they would be revisited with data. The data never came, and the reason was not lack of use: the search left no trace at all. Zero table rows, zero rag.* events in the whole product. Without a trace, "calibrating" would be swapping one guess for another — and worse, it would be irreversible in practice, because there would be nothing to compare before and after.

This is the first of the five stages of the measurable RAG program, and it is deliberately the only one that does not change search behavior: it gives the eyes, and stops.

The concrete obstacle​

The obvious way out would be a session event — the event log is already the measuring instrument for the rest of the product, and the CLAUDE.md rule says execution metrics are extracted from it by script, never by hand. But session_events.session_id is NOT NULL (apps/api/src/db/schema/sessions.ts), and a search coming from the RAG tab is a PROJECT one: it has no session at all. Recording the telemetry only as an event would lose exactly the searches in which a human looked at the scores and judged — which are the ones that carry the only real signal the measurement can have.

It is the same class of problem that forced the cut of embedding metering in ADR 0075: token_usage.session_id is also NOT NULL, and indexing a repository does not happen inside a session. There the cut was declared and the spend was left out. Here the cut would be worse: half the searches would remain, and the ones that remain are the agents' — the ones nobody judges.

Decision​

1. Two tables in apps/api/src/db/schema/rag.ts, not events.

rag_searches (one row per search: hits with the 1-based rank of each chunk, degraded/vector_available, latency_ms, actor, and a NULLABLE session_id) and rag_feedback (the util/irrelevante vote on a chunk of a search). The aggregate's file already existed (chunks), so there is no change in the db/schema.ts barrel; the rag_verdict enum lives in the same file as the table that calls it, by the convention of ADR 0121, and actor_kind is reused from sessions.ts instead of redeclared.

session_id NULL is information, not absence of data: it means the search came from the tab.

2. The weights go FROZEN in the row.

pesosVigentes() (domain/rag/rag-telemetry.ts) copies {vector, lexical, threshold} from the moment of the search into the record. It is the same discipline as the price frozen in metering (ADR 0042) and the image_version of project_containers (ADR 0081).

Without the copy, the first calibration that touched RAG_SEARCH_WEIGHT_VECTOR would make all previous measurement start meaning something else, silently — and "did it improve after the change?", the only question the telemetry exists to answer, would become impossible to ask with the data at hand. It is a function and not an exported constant on purpose: a shared constant would be the same object in every JSONB row in memory.

3. The event exists, and it is NARRATION.

rag.search and rag.feedback enter the event log only when there is a session — the agent's path —, so the timeline can narrate what it did. The asymmetry is declared instead of hidden: the table is the source of the measurement, the event is narration, and medir:rag never reads the event log.

Both types are written OUT IN FULL at the emission points, not by constant: the inventory of docs/reference/events.md is generated by grepping type: '<x>.<y>' over the api's code, and a type behind a constant is invisible to it — the doc would pass green over an incomplete list. It is the same form execution.activated already uses. 'rag' enters PREFIXOS_DE_EVENTO (scripts/docs/generate.mjs) in the same commit.

4. Recording telemetry never brings down the search, and never fails silently.

The INSERT runs in a try/catch inside HybridSearchUseCase: whoever asks should not lose the answer because the measuring instrument fell. But the failure becomes a log with the origin classified (infra, ADR 0020) and searchId: null in the response — which is not the same as "found nothing": it is "there is nothing to attach a vote to", and the UI needs the two separated so as not to offer a control the api would refuse.

5. The vote requires the reference the search returned, and it is :direct.

An unknown searchId, or a chunkId that was not among that search's hits, is 400. It is not decorative rigor: the rank of the voted chunk is what separates "the index is poor" from "the WEIGHTS are wrong" — a poor index does not return the right chunk at any position, a wrong weight returns the right chunk at rank 7 —, and a vote without a rank produces a meaningless number.

The engine tool (rag_feedback) is category: :direct and does not become a proposed_action: rating a chunk has no external effect, and turning it into an action to approve would fill the queue with noise until nobody read the real ones anymore (the same rule that keeps rag_search/read_file out of the pipeline). The refusal comes back as an error tool-result, with the api's message, for the model to correct (RN-061/RN-163), never as a crash.

6. medir:rag fails for one thing only.

vector_available: false over the whole window: then what was measured is not the hybrid search, it is its lexical half, and calibrating a vector weight against that would be calibrating against a system that is not the one running. Everything else — precision@k, degradation rate, searches without a hit above the threshold, p50/p95 latency, rank distribution of what was voted useful — is report: it comes out in the table, it does not fail. Same mold as medir-execucao.ts.

Consequences​

Calibration becomes possible, and is still not done. This stage does not change chunking, weights or threshold, on purpose: touching the numbers before accumulating measurement would destroy the baseline it exists to create. The item remains declared open in CLAUDE.md, now with the instrument next to it.

degraded and vector_available are two columns, and today they are redundant. degraded = !vector_available at this moment. The redundancy is declared in the schema instead of hidden, and the reason not to derive one from the other on read is that they do not answer the same question: vector_available is a fact about the embedding PROVIDER at the instant of the search — it is what makes medir:rag fail —, and degraded is the word of the CONTRACT with the engine (POST /internal/rag/search), whose definition can grow to other degradations without the provider having fallen. Deriving would tie the two forever.

The vote does not survive "Reindex now". rag_feedback.chunk_id references chunks with CASCADE. Reindexing deletes and recreates the chunks, so the judgment history goes with them. Keeping an orphan vote pointing to a chunk that no longer exists would measure nothing — but the price is real and is written in the schema: whoever reindexes loses the comparison base, and the measurement restarts.

precision@k measures what was JUDGED, not what was returned. The denominator is the number of voted hits with rank ≤ k, never k. A hit without a vote is unknown, not irrelevant, and counting it as irrelevant would make precision plummet on its own every time someone voted on fewer chunks — measuring the willingness to vote, not the quality of the search. The accepted consequence is that the metric only exists where there are votes: with none, it is null and the table says "not measured", never 0.

The internal contract with the engine grew in both directions. POST /internal/rag/search now accepts OPTIONAL sessionId/agent in the body (the api has no way to deduce either; a session it did not receive is a session that did not exist) and returns searchId plus a chunkId per hit. The chunkId is the only exception to the deliberate simplification of that DTO — without the searchId/chunkId pair the agent would have nothing to point to. When searchId comes null, the tool omits the ids and the invitation to vote, instead of offering the model a reference that would be refused.

One more tool in the prompt of six agents. rag_feedback enters the six registries that already had rag_search (dev/tools.ex, gates/qa_tools.ex, qa_estrategia_agent.ex, qa_performance_seguranca_agent.ex, appsec_agent.ex, harness/tools.ex). Real cost: one more spec block in the context of each turn. The alternative — only human feedback — would leave out the path most searches go through.

A viewer route that WRITES. POST /projects/:projectId/rag/feedback has the same role as search, and not maintainer: voting neither spends nor configures anything, it is observation, and restricting it would empty the only source of truth of the measurement. It is classified in docs/security-surface.md with this reasoning; the containment is the use case's reference validation, not the role.

No new agent, and no gate. The golden set and the rag-acertivo gate are the next stage, and depend on this one having accumulated data before they exist.