project-nomad/admin/tests/eval/README.md
jakeaturner a915cdcfc9 feat(AI): add opt-in relevance check for off-topic retrieval
The post-rerank relevance floor rejects noise but passes coherent
off-topic questions, and it gets worse as the library grows. Adding
Gutenberg prose, the Python tutorial and MDN pages to the eval corpus
took the off-topic leak rate from 22% to 67% at the 0.62 floor.

Score cutoff does not fix it. The weakest correct top hit scores 0.666
while "write me a haiku about autumn leaves" pulls Walden at 0.705.
A cosine floor and a top-vs-median contrast test both cost recall
faster than they cut leaks.

- Add rag.relevanceCheck (default off). After the floor, the tasks
  model, or the chat model when none is set, gives one grammar-constrained
  on_topic verdict for the whole retrieved set. Off topic means no
  context and no citations. Any failure keeps the chunks unjudged.
- Add a Settings > Models toggle recommending an 8B+ tasks model.
  On llama3:8b the check cuts off-topic leaks from 67% to 6% for about
  5 points of core recall@5 and ~0.6s per turn. On 3B models it rejects
  most valid questions, which is why it is opt-in.
- Add 6 distractor documents and an off_topic suite (18 unanswerable
  questions, 7 distractor controls) to the eval corpus.
- Report per-tag leak rates and leaked cases with top scores in
  eval:retrieval. Add --judge-model to measure the check and --dump to
  export pre-floor candidate pools for offline gate simulation.
- Pin the check off in eval:generation so a local setting cannot move
  scores.
- Record the distractor sweep on RAG_MIN_FINAL_SCORE. The 0.62 default
  stays: 0.65 halves leaks at no measured recall cost but leaves only
  0.016 of margin.

The corpus fingerprint changes to b4ebe5dce8699f5d. Retrieval reports
from before this commit are not comparable, and a new baseline is
committed.

Refs #1341
2026-09-29 15:54:41 -07:00

452 lines
22 KiB
Markdown

# NOMAD AI Quality Harness
A reproducible way to measure whether NOMAD's RAG pipeline is getting better or
worse, and to tell a **code regression** apart from **a small model on modest
hardware being asked too much**.
This is a developer tool. There is no UI, nothing is user-facing, and none of it
runs in production.
> **Throughput is out of scope.** tokens/sec, time-to-first-token, and the NOMAD
> Score belong to `node ace benchmark:run`. Nothing here is comparable across
> machines and nothing here should ever be, because every developer and every
> user has different hardware. This measures *quality* only.
---
## The one-minute version
```bash
node ace eval:corpus --ingest # once, and after any corpus edit
node ace eval:retrieval --ablate # seconds, no chat model, deterministic
node ace eval:generation --model=<model> --all-modes # minutes; answers "code or model?"
node ace eval:generation --model=<model> --suite=long_context # context budgeting
```
---
## Golden suites
Each `.jsonl` under `tests/eval/goldens/` is a **suite**, named after the file.
Only `core` runs by default; anything else is opt-in via `--suite`.
That split exists for comparability. Aggregate metrics are means over whatever
ran, so quietly adding goldens to the default set would move `recall@k` and
`correctness` for reasons unrelated to the change under test — and silently
invalidate every committed baseline.
| Suite | What it is for |
|---|---|
| `core` | The default 99 questions. Retrieval and generation quality. |
| `long_context` | Conversations long enough to exercise the budget planner. |
| `off_topic` | Coherent questions the corpus cannot answer, plus distractor controls (#1341). |
`--suite a,b` runs several. `eval:corpus` always validates every suite, since a
malformed fixture should fail validation whether or not it is in the default run.
### The `off_topic` suite
Eighteen questions no document answers (general trivia, chit-chat, requests to
write something, personal asks, and domains the corpus lacks, such as gardening)
and seven controls that the `distractor-*` documents *do* answer.
Every refusal case must retrieve **nothing**. `eval:retrieval` prints a `leaked=`
rate per tag and, with `--verbose`, each leaked question with its top chunk's
boosted and cosine scores. The controls keep a fix honest: a gate that stops the
leaks by also rejecting "how do list comprehensions work in Python?" has traded
one bug for another.
The suite exists because the relevance floor looked fine on the original 28
documents (22% of these leaked) and fails on a well-stocked library (67% leaked
once Gutenberg prose and programming docs were added). A small corpus
understates this failure, so keep the distractors in.
Retrieval-only for now. The generation tier scores `expectRefusal` as "the model
declined", and "Paris" is the right answer to the capital question as long as
nothing is cited for it.
### The `long_context` suite
Four conversations testing the two opposite ways context budgeting can fail:
- **`history-retention`** — a fact stated early, then enough filler to look
worth trimming, but still inside the window. A failure means good context was
thrown away that would have fit.
- **`eviction`** — far more history than the window holds, with the needed fact
in the *last* turn before the question. A failure means the model lost the
thread of what was just said, which is the more serious of the two.
Both grade on a specific figure or arithmetic rather than prose, so a confident
non-answer cannot pass.
---
## Why the three modes matter
`eval:generation --all-modes` runs every question three ways. That is what turns
"the AI gave a bad answer" into something you can act on:
| Mode | Context the model gets | What a low score means |
|---|---|---|
| `oracle` | The golden's own documents, injected verbatim | **The model.** Retrieval was perfect by construction, so this is the model's ceiling with this prompt. |
| `e2e` | Whatever real retrieval found | The actual product experience. |
| `noretrieval` | Nothing | The model's parametric baseline — what it knows without NOMAD. |
Read the decomposition it prints:
- **`oracle` is low** → the model cannot use good context. No amount of retrieval
work will fix it. The honest answer to the user is "run a larger model."
- **`oracle - e2e` is large** → the model *can* use good context but is not being
given it. This is a retrieval bug, and it is ours.
- **`e2e - noretrieval` is near zero** → RAG is not contributing. Check that
retrieval is actually reaching the prompt.
There is a fourth reference line, `--model=mock`, which needs no Ollama at all.
It answers by echoing the injected context, so it is the **extractive ceiling**:
the best a perfect model could do given the current retrieval. A real model
below the mock line is the bottleneck; a mock line that is itself low means
retrieval is.
---
## Commands
### `eval:corpus`
```bash
node ace eval:corpus --check # validate corpus + goldens, no services needed
node ace eval:corpus --status # fingerprint and current chunk count
node ace eval:corpus --ingest # wipe and rebuild (always a full rebuild)
node ace eval:corpus --reset # remove every eval chunk
```
Ingest goes through NOMAD's **real** `RagService.embedAndStoreText`, so chunk
size, the token-estimate ratio, the `search_document:` prefix, and the embedding
model are all inside the measurement. Change any of them and the score moves —
which is the point.
### Isolation: how the eval corpus stays out of your knowledge base
**The eval corpus is not a separate Qdrant collection.** It lives in the same
`nomad_knowledge_base` collection as your real documents, tagged with the
reserved payload value `collection: __nomad_eval__`. That is not a shortcut —
NOMAD "collections" *are* payload tags rather than separate Qdrant collections,
so using the tag means the harness exercises the same filter path production
chat uses, with no production code changed to accommodate it.
Three separate mechanisms keep the two apart:
**1. Writes are scoped.** Ingest only ever adds points carrying the eval tag.
`--reset` deletes by that filter and nothing else. Your documents are never
written, re-tagged, or deleted. (On the machine this was built on: 306 points
before ingest, 335 after — exactly the 29 eval chunks, nothing else moved.)
**2. Reads are filtered server-side.** Every eval query passes
`must: [{ key: 'collection', match: { value: '__nomad_eval__' } }]`, and Qdrant
applies it during search against a keyword payload index, so your documents
never compete for a result slot. Verified directly against a knowledge base
containing unrelated content — the same query returns a real user document at
similarity **0.83** unfiltered, and only eval documents at ~0.50 filtered:
```
unfiltered: 0.831 evolution_of_steam_locomotive.txt
filtered: 0.522 water-river-song.md [eval]
```
**3. A leak would fail the run, loudly.** Every retrieved chunk's `source` is
resolved back to a corpus document by path. Anything outside
`tests/eval/corpus/` is counted as an unresolved chunk, and a non-zero count
prints an error and exits 1 rather than quietly reporting a score. The check is
on the resolved path, not the file extension — NOMAD embeds its own
`admin/docs/*.md` into the knowledge base on first run, so an extension check
would have accepted a leaked `faq.md` as the plausible document id "faq".
See `tests/unit/eval_source_guard.spec.ts`.
**The one thing the tag does not isolate** is the *physical* collection: your
documents and the eval fixtures share an HNSW index. That has no effect on
correctness (the filter is applied during search) and results were verified
byte-identical across runs, but if you want true physical separation the change
is to thread a collection name through `_ensureCollection`,
`embedAndStoreText`, and `searchSimilarDocuments`. That was deliberately not
done, because it means touching three production methods for a test-only
benefit that the payload filter already delivers.
### `eval:retrieval`
```bash
node ace eval:retrieval
node ace eval:retrieval --ablate # is the reranker earning its complexity?
node ace eval:retrieval --threshold=0.5 # sweep the Qdrant cutoff
node ace eval:retrieval --min-final-score=0.7 # sweep the post-rerank relevance floor
node ace eval:retrieval --tag=multi-hop
node ace eval:retrieval --verbose # show every miss and what it retrieved
node ace eval:retrieval --report # write JSON + Markdown to reports/
node ace eval:retrieval --suite=off_topic --judge-model=llama3:8b # measure the relevance check
node ace eval:retrieval --suite=core,off_topic --dump=pools.json # candidate pools for offline gate sims
```
`--judge-model` runs the opt-in relevance check (`rag.relevanceCheck`) after the
floor and prints its latency and how often it failed open. It makes the run
model-dependent, so it is never part of a baseline. `--dump` writes every
golden's full pre-floor candidate pool with both scores, which lets you simulate
a new gate in a few lines of script instead of a sweep per setting.
**There are two cutoffs, on two different scores, and they do not interchange.**
`--threshold` is what Qdrant applies to the raw cosine score, before reranking;
`--min-final-score` (`RAG_MIN_FINAL_SCORE`) is the relevance floor applied to
the reranked score, and it is the one that decides whether a chunk is injected
at all. Reranking's boosts are score-scaled, so the same number means different
things on the two axes — the score-separation table prints both, labelled by
which flag each one calibrates. Read the right row.
Both tiers pin the floor to `RAG_MIN_FINAL_SCORE` and deliberately ignore the
user's `rag.minRelevance` setting, for the same reason the harness never sets
`skipRetrieval`: a slider position on one machine must not move the numbers a
baseline was recorded against.
Embedding is the only model call, and its output is stable, so **this tier is
deterministic and hardware-independent**. Two runs produce byte-identical
numbers. A movement here is a code change, full stop — which makes it the only
tier worth gating CI on.
Multi-turn goldens are scored on their raw final message, because resolving the
coreference needs the chat model and would make the tier non-deterministic. That
bucket therefore reports the honest floor; the rewrite's contribution shows up in
the generation tier instead.
### `eval:generation`
```bash
node ace eval:generation --model=mock # no Ollama required
node ace eval:generation --model=llama3.2:latest --all-modes
node ace eval:generation --model=llama3:8b --repeats=5 --verbose
node ace eval:generation --model=... --tag=out-of-corpus
```
Runs at `temperature: 0` with a fixed seed. That reduces variance but does not
eliminate it — llama.cpp batching and GPU scheduling still move outputs — so
`--repeats` defaults to 3 and any question whose pass/fail flips across repeats
is reported as **unstable** and excluded from gating. Do not read an unstable
question as a regression.
Before scoring, the harness evicts other resident models and burns one throwaway
generation, borrowed from `BenchmarkService` for the same reason it added them:
a cold first run behaves differently.
### `eval:matrix`
```bash
node ace eval:matrix --models=qwen2.5:0.5b,llama3.2:latest,llama3:8b --limit=25
node ace eval:matrix --models=... --promote
```
Produces the **capability table** — the artifact that answers a support ticket.
When a user reports a bad answer, look up their model:
- scoring at or near its row → the model is at its ceiling, not a bug
- scoring well below its row → their config or our code, worth investigating
### `eval:compare`
```bash
node ace eval:retrieval --report
node ace eval:compare <report.json> --promote=retrieval # set the baseline
node ace eval:compare tests/eval/baselines/<fp>/retrieval.json <new-report.json>
```
Exits non-zero when any metric regresses beyond the tolerance (default `0.02`).
It boots no services, so CI can run it against a committed baseline without
standing up MySQL, Redis, Qdrant, or Ollama.
**It refuses to compare reports with different corpus fingerprints.** That is
correct behaviour, not a limitation: if the corpus, chunk size, or embedding
model changed, the two runs measured different things and diffing them would
manufacture a regression. Re-baseline instead.
---
## The corpus and the goldens
- `corpus/*.md` — 28 short documents across NOMAD's real domains, plus six
`distractor-*` documents (Project Gutenberg excerpts, the Python tutorial, MDN
pages). The distractors stand in for a library with general prose in it, where
something is always semantically near the question. Sources and licences are
in each file's header: Gutenberg texts are public domain, the Python tutorial
is PSF License v2, MDN is CC-BY-SA 2.5.
- `goldens/*.jsonl` — 99 questions, one JSON object per line.
The corpus is built with deliberate traps, not just easy questions:
| Tag | What it tests |
|---|---|
| `distractor` | `water-river-song.md` is a poem that shares vocabulary with real water questions and answers none of them. This is exactly the "poetic, tangential passage" failure `SYSTEM_PROMPTS.rag_context` rule 1 defends against. |
| `near-miss` | Water-bath vs pressure canning: two documents that look alike and give opposite advice. |
| `out-of-corpus` | Questions the corpus genuinely cannot answer, including adversarial ones about topics the corpus *partly* covers (the TR-88's warranty). The right answer is to decline. |
| `fictional` | The Thornfield protocol and the TR-88 pump do not exist. No model can know them, so a correct answer proves retrieval worked rather than that the model memorised the internet. |
| `acronym` / `acronym-control` | The same question asked with an acronym and spelled out, to measure what `preprocessQuery`'s 28-entry glossary expansion actually buys. |
| `chunk-boundary` | Facts buried late in the one long document, which is the only one that splits into multiple chunks. |
| `multi-hop` | Answers requiring two documents (elevation table + boiling times). |
| `multi-turn` | A pronoun in the second turn, the only thing that exercises `rewriteQueryWithContext`. |
### Golden format
```jsonc
{
"id": "water-boil-altitude-01",
"query": "How long do I need to boil water at high altitude?",
"turns": [], // prior messages for multi-turn cases
"relevantDocIds": ["water-boiling"], // corpus filenames without .md
"mustInclude": ["\\b(3|three) minutes?"], // case-insensitive REGEX
"mustNotInclude": ["distill"],
"expectRefusal": false, // true for out-of-corpus
"tags": ["single-hop", "numeric"]
}
```
`mustInclude` and `mustNotInclude` entries are **regular expressions**, so one
entry can accept "3 minutes" or "three minutes" without inflating the list.
Every pattern is compiled at load, so a bad regex fails immediately rather than
on the one run where it finally matters.
Validation is strict on purpose. A golden that lists a document not in the
corpus, or that expects a refusal while also naming relevant documents, is
rejected at load — those mistakes are otherwise invisible and just quietly lower
the score forever.
### Editing the corpus
Any edit changes the fingerprint, which invalidates every existing baseline.
That is deliberate. After editing:
```bash
node ace eval:corpus --check # validate first
node ace eval:corpus --ingest # rebuild
node ace eval:retrieval --report
node ace eval:compare <report.json> --promote=retrieval
```
Note the safety facts in the corpus are real. If you add documents, keep any
health, water, or food-safety content accurate — invent only clearly-fictional
non-safety things (place names, equipment model numbers) when you need something
unguessable.
---
## What the metrics mean
**Retrieval** — measured at two levels, because they answer different questions.
Document level (recall, hit rate, MRR, nDCG) collapses chunks to their source
document: *did the answer's document reach the context?* Chunk level (precision)
does not dedupe: *how much of what we injected is noise?* — five chunks from one
irrelevant document cost a small model five slots.
`nDCG` normalizes against the *known* number of relevant documents, not against
whatever was retrieved. It is the metric that catches "right documents, wrong
order", a reranking regression that leaves recall untouched while pushing the
answer to position five where a 1B model's 2-result budget will never see it.
The implementation is cross-checked against `pytrec_eval` (TREC's reference
implementation) — see `tests/unit/eval_retrieval_metrics.spec.ts`.
**Generation** — all deterministic, no judge model required:
| Metric | What it catches |
|---|---|
| `correctness` | The `mustInclude` / `mustNotInclude` assertions. |
| `refusalCorrectness` | Declining out-of-corpus questions *and* not hedging on answerable ones. This is the "Sorry, I wasn't able to find specific context" symptom, measured. |
| `leakageRate` | Narrating retrieval ("according to Context 1", "the knowledge base"), which `rag_context` rule 4 forbids. Pure regex, zero ambiguity, catches a bad prompt edit on the first run. |
| `groundedness` | Fraction of the answer's numeric claims that appear in the injected context. |
| `thinkTagLeakRate` | Reasoning tags reaching the user. Should always be 0. |
| `historyElidedRate` | Share of cases where history had to be trimmed to fit the window. |
| `chunksDroppedRate` | Share of cases where a retrieved chunk did not fit the context budget. |
| `promptTokenError` | Mean error of the token estimator against the backend's real `prompt_eval_count`. |
**Read the budgeting metrics before concluding the model got worse.** A drop in
`correctness` alongside a rise in `historyElidedRate` is not the model answering
badly — it is the model never being shown the material. Those are different
bugs with different fixes, and before these metrics existed they were
indistinguishable.
`promptTokenError` gates the estimator that every one of those trims is decided
against. NOMAD cannot tokenize exactly (no vocabulary offline, and Ollama exposes
no tokenize endpoint), so it estimates and calibrates per model from the real
counts the backend reports. If this drifts, the budget is being enforced against
a number that is no longer true. Measured at ~4.6% on llama3:8b.
**Groundedness only sees numbers, and only numbers above 10.** An answer that
fabricates a procedure or a proper noun scores a perfect 1.0. It is a
fabrication *detector*, not a faithfulness guarantee. Numbers are the right
first target for this domain — a wrong bleach dose or canner pressure is a wrong
answer with consequences — but do not read a high score as "the answer is
faithful". Small integers are excluded because "3 layers" and "step 2" appear in
any prose and would swamp the signal.
---
## Known limitations
Read these before trusting a number.
1. **The corpus is small, so retrieval recall has little headroom.** 28
documents produce 29 chunks; retrieving the top 5 means retrieving 17% of the
entire corpus on every query. Real NOMAD knowledge bases hold millions of
chunks. `recall@5` therefore sits near 0.99 and cannot detect a modest
retrieval regression. The metrics that *do* have headroom on this corpus are
`recall@1`, `precision@k`, `nonEmptyRateOnRefusal`, and the score
distributions. To make recall discriminating, add substantially more
distractor documents, or ingest `install/wikipedia_en_100_mini_*.zim` under
the eval tag as a harder tier.
2. **`oracle` is not guaranteed to beat `e2e`.** Oracle injects the golden's
whole documents; e2e injects up to `maxResults` retrieved chunks, which can
include a genuinely helpful extra document. Treat small inversions as noise
unless they survive `--repeats=5`.
3. **Temperature 0 is not determinism.** The generation tier still moves between
runs. Always report `--repeats` ≥ 3 before concluding anything, and ignore
questions the harness flags as unstable.
4. **No LLM judge yet.** Faithfulness beyond numeric grounding, completeness,
and answer relevance are not measured. `autoevals` (MIT, TypeScript, talks to
Ollama through the same `/v1` endpoint `OllamaService` already uses) is the
intended addition, reported in a separate section and never mixed into the
deterministic scores — a weak local judge grading a weak local model is not
something to gate on.
---
## Layout
```
tests/eval/
corpus/ 34 markdown fixtures (28 domain + 6 distractor) — the frozen knowledge base
goldens/ one JSONL file per suite (core: 99 questions)
baselines/ <corpus-fingerprint>/*.json — COMMITTED; the gate compares against these
reports/ run artifacts — gitignored
```
Baselines are filed under their corpus fingerprint so it is structurally
impossible to overwrite one corpus's baseline with a run against another.
Implementation:
- `app/utils/eval/*` — pure functions (metrics, golden parsing, report diffing).
No I/O, no models, no services. 153 tests, run with:
```bash
npm run test:eval # this harness only — should always be green
npm run test:unit # every tests/unit spec
```
`test:unit` currently reports **7 pre-existing failures** unrelated to this
harness: `drug_interactions`, `drug_ingest_status`, `drug_labels`, and
`rag_retrieval_toggle` are written against `@japa/runner` rather than
`node:test`, and `app_auto_update`, `content_auto_update`, and
`content_auto_update_backoff` import services that need a booted application.
Both groups belong in the Japa suite (`node ace test`, which needs MySQL and
Redis). They fail identically before and after any change here — use
`test:eval` when you want a signal you can trust.
- `app/services/eval_*_service.ts` — orchestration; these need Qdrant and Ollama.
- `commands/eval/*` — the CLI.
- `app/services/rag_pipeline_service.ts` — the prompt pipeline, shared with the
chat endpoint. The harness measures production code, not a copy of it.