Skip to content

feat(eval): add an offline answer-quality and retrieval eval harness - #2382

Merged
felladrin merged 7 commits into
mainfrom
feat/offline-eval-harness
Aug 16, 2026
Merged

feat(eval): add an offline answer-quality and retrieval eval harness#2382
felladrin merged 7 commits into
mainfrom
feat/offline-eval-harness

Conversation

@felladrin

@felladrin felladrin commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Description

Closes #2192.

There was no eval harness, so a change to the prompt, the reranker, or the model shipped blind: nothing measured whether the ranking or the answer got better or worse.

This PR adds a small offline eval in eval/, driven by a fixed golden set of 26 queries. It is a regression baseline, not a benchmark.

Retrieval

Runs the real ONNX reranker through rankSearchResults (with preserveTopResults=true, the same call the app makes for text search) and scores the ranking with nDCG@3 and recall@3 against the labeled relevant results. It fails if the mean drops below a floor set in the middle of the band between a working reranker and a no-op (identity) reranker, with headroom on both sides: the golden set is curated so a no-op reranker scores 0.587 / 0.500 while a working one scores 0.950 / 0.981, and the floors (0.75 / 0.70) sit between them. A "stays falsifiable" test pins the no-op baseline below the floors, so a future edit to the set that breaks the premise (both relevant results in the input top-3) fails loudly. The headroom is intentional: the reranker is a quantized model whose scores drift across execution providers.

Answer

Builds the exact prompt the app sends (the real getFormattedSearchResults + getDefaultChatMessages, with pubSub mocked to the golden query), asks a chosen LLM backend for an answer, and grades it with a separate LLM judge against a rubric. It also checks a deterministic citation rate and a snippet-only-facts aggregate. It is gated on EVAL_LLM_API_KEY and skips cleanly without one.

What changed

Area Change
eval/goldenSet.ts 26 hand-curated queries: labeled relevant results, reference answers, rubrics. Includes fictional snippet-only facts (the model cannot know them) and entries whose first candidate is irrelevant, so the pinned-first branch can cost the ranking.
eval/metrics.ts Pure nDCG@k / recall@k (binary relevance).
eval/retrieval.integration.test.ts Real reranker on the golden set; fails below the floor.
eval/answer.integration.test.ts App prompt + LLM judge, plus citation-rate and snippet-only checks. Key-gated.
eval/goldenSet.test.ts, eval/promptConstruction.test.ts, eval/metrics.test.ts Default suite: golden-set structure, prompt construction, metrics.
eval/goldenPrompt.ts Shared prompt builder used by both prompt tests, so they cannot drift.
shared/defaultSystemPrompt.ts The default system prompt, extracted so the client and the eval share one source (a stale copy would let prompt regressions ship green).
client/modules/settings.ts Uses the shared prompt constant.
tsconfig.eval.json, vitest.eval.config.ts Type-check and run the model/network tests separately (node env, no jsdom setup).
package.json eval, eval:retrieval, eval:answer scripts; lint now type-checks the eval.
docs/development-commands.md, eval/README.md Docs.

How to test

  1. npm run lint (clean, including the new tsc -p tsconfig.eval.json).
  2. npm test (the golden-set, prompt-construction, and metrics tests run here, so they are covered in CI).
  3. npm run eval:retrieval (needs the model in server/models/; local only, CI has no model on disk). Baseline on my machine: mean nDCG@3 0.950, mean recall@3 0.981 (floors 0.75 / 0.70).
  4. npm run eval:answer (needs EVAL_LLM_API_KEY; I could not run it here, no key).

Known limitations

  • The answer eval's LLM-judged path is not exercised here (no API key). On a first keyed run it is worth confirming that deleting the citation paragraph from DEFAULT_SYSTEM_PROMPT drops the printed citation rate under 0.8 (the results block shows markdown links in-context, so the model may still cite).
  • The prompt is built for the no-page-content case (pageContents = {}), so allocatePageExcerpts / formatExcerpt never appear in a graded prompt even though enablePageContentFetch defaults to true. "The exact prompt the app sends" holds for the no-page-content case.
  • The answer eval hardcodes temperature: 0 (deliberate, for determinism) and max_tokens: 4096 rather than the app's getDefaultChatCompletionCreateParamsStreaming() (temperature: 0.35), so a change to the app's sampling params is invisible to it. max_tokens / temperature are also rejected by o-series / GPT-5-style endpoints (max_completion_tokens, fixed temperature); the documented default is gpt-4o-mini.
  • Roughly a third of the rubric points are negative ("does not give a wrong year"), which LLM judges pass by default; that is a judge-variance source, so the citation rate and the snippet-only aggregate do the real work. The snippet-only floor (0.8) spans 3 entries x 2 rubric points = 6 binary decisions, so it is effectively "at least 5 of 6".
  • No retry on transient LLM errors (a single 429 / 5xx fails the run); the answer eval is local-only and key-gated, so this is a false-red source rather than a correctness one.

Note: the golden-set validator rejects $& / $` / $' / $$ in result fields, which fences the eval off from the pre-existing String.replace bug in systemPrompt.ts. That bug stays separate and out of scope here.

@felladrin
felladrin force-pushed the feat/offline-eval-harness branch from 72fcedb to f9ada4f Compare August 16, 2026 00:15
@felladrin
felladrin marked this pull request as ready for review August 16, 2026 00:16
Add an offline eval (eval/) that gives a regression signal for changes to the
reranker, the system prompt, the search-results formatting, or the model
(#2192). Every prompt/reranker/model change previously shipped blind.

- Retrieval eval: runs the real ONNX reranker through rankSearchResults with
  preserveTopResults=true (matching the app's text search) on a fixed 26-query
  golden set and scores nDCG@3/recall@3 against labeled relevant results,
  failing if the mean drops below a floor set with explicit headroom.
- Answer eval: builds the exact app prompt (real getFormattedSearchResults +
  getDefaultChatMessages, pubSub mocked to the golden query) and grades the
  answer with an LLM judge against a rubric, plus a deterministic citation
  rate and a snippet-only-facts aggregate. Gated on an API key; skips cleanly
  without one.
- Golden set: hand-curated queries with labeled relevant results, reference
  answers, and rubrics, including fictional snippet-only facts that force the
  search-results path and entries whose first candidate is irrelevant so the
  pinned-first branch can cost.
- Extracts the default system prompt to shared/defaultSystemPrompt.ts so the
  client and the eval share one source (a stale copy would let prompt
  regressions ship green).
- nDCG/recall metrics, golden-set validation, and prompt-construction tests
  run in the default suite; the model/network tests run under
  vitest.eval.config.ts (excluded from the main suite and CI, which has no
  model on disk).

npm run eval / eval:retrieval / eval:answer. See eval/README.md.
…dator

The retrieval eval could not distinguish a working reranker from a no-op:
22 of 26 golden entries labeled both relevant results in the top-3 of the
input order, so a reranker that just preserved the input order scored mean
nDCG@3 0.846 / recall@3 0.923 - above the recall floor (0.9) and within
0.034 of the nDCG floor (0.88). The recall floor was unfalsifiable and the
nDCG floor caught a dead reranker by less than one query's worth of mean
movement.

Scatter the second relevant result past rank 3 in those entries (golden-set
data only, no code change), so a no-op reranker now scores 0.587 / 0.500
while a working reranker still scores 0.950 / 0.981. Re-tune the floors to
the middle of that band (0.75 nDCG, 0.70 recall) with headroom on both
sides. Verified: a no-op reranker now fails the eval, a working one passes.

Also: print the per-query breakdown before the aggregate asserts (so a
per-query failure still shows the diagnostic), tighten the golden-set
validator (duplicate/non-integer relevant indices, a 6-result prompt-budget
cap, empty-field checks), pin the client's default system prompt to the
shared source, and drop the unused jest-dom types from the node-env eval
tsconfig.
…t pin

The dynamic-range fix was data ordering only, and the property it rests on
(at least one relevant result outside the input top-3) lived in a comment in
a different file. A contributor adding entries with both relevant results in
the input top-3 would silently make the floors unfalsifiable and the suite
would go green. Add a 'stays falsifiable' test that scores the golden set as
a no-op reranker and asserts the mean stays below the eval's floors (0.65 /
0.60 ceilings vs the 0.75 / 0.70 floors); it fails on the pre-fix set
(0.846) and passes on the fixed set (0.587). State the constraint in the
goldenSet.ts header and the README.

Also pin the top-result pin directly: the [1,3] entries are meant to let the
eval tell a good pin from a bad one, but a floor-only assert can't (a broken
pin raises the mean). Assert ranked[0] is the first candidate, which holds
deterministically and independent of the model.
The 'stays falsifiable' test asserted on the mean, but its message named a
per-entry cause: a single entry with both relevant results in the input top-3
(0.602 nDCG, 0.519 recall) still passed the mean ceilings (0.65 / 0.60), so
the exact regression it was added to catch landed silently. Assert the
per-entry invariant directly in the validator (at least one relevant result
outside the input top-K), and keep the aggregate ceiling as a second layer
for erosion the per-entry rule can't see. Verified: one [0,1] entry now fails
the validator.

Extract K and the floor/ceiling pairs into eval/thresholds.ts, imported by
both the integration test and goldenSet.test.ts, and assert the ceilings stay
below the floors - the coupling was previously comment-only, so lowering a
floor to its ceiling would invert the premise with nothing failing.

Also make the CI-run pin test in rankSearchResults.test.ts genuine: it gave
index 0 the highest score, so a plain descending sort satisfied it and the
test passed even with the pin deleted. Flip the scores so only the pin keeps
the first candidate first.
…pointers

The per-entry invariant used an index test (some(i) => i >= K), which is only
equivalent to 'a no-op reranker scores this entry perfectly' while
relevant.length <= K. An entry with more than K labels (e.g. relevant [0,1,2,3])
saturates the input top-K and is scored perfectly by a no-op reranker, yet the
index test passed it. Use a count comparison instead (relevantInTopK <
min(K, relevant.length)), which is a strict tightening: it implies no-op recall
is below the achievable max and nDCG < 1. Verified: a saturated 4-label entry
now fails the validator.

Point the goldenSet.ts header at thresholds.ts (the floors moved there in the
last commit) and mention the per-entry validator. Cut the floating comment in
retrieval.integration.test.ts down to a pointer (it duplicated the numbers now
in thresholds.ts), and update the README to describe both falsifiability
checks.
The README said rankSearchResults 'keeps only the top 9 reranked results' with
preserveTopResults=true, but it drops nothing: it returns [firstResult,
...nextTopResults, ...remainingResults]. The real effect of the 9-slice is that
the head and the tail are sorted as two independent blocks, so the output is not
globally sorted past position 9. Correct the description.

Also drop the 'both relevant results' phrasing in the falsifiability test
comment (the per-entry check no longer assumes two labels) and give
metrics.test.ts the explicit .ts import extension the rest of eval/ uses.
The README's 'Nothing is dropped.' was false as written: filterResultsByScore
does drop results (the eval exists partly to catch that). The claim is true of
the 9-way split (slice(0,9) + slice(9) covers everything), which is what needed
correcting, so scope it: the split drops nothing; anything missing was removed
earlier by the score filter.

Also interpolate K in the two falsifiability assert messages (they hardcoded
'top-3' and would go stale if K changed; K is imported and in scope).
@felladrin
felladrin force-pushed the feat/offline-eval-harness branch from f9ada4f to 24d3cac Compare August 16, 2026 00:24
@felladrin
felladrin merged commit 31e9aa5 into main Aug 16, 2026
6 of 7 checks passed
@felladrin
felladrin deleted the feat/offline-eval-harness branch August 16, 2026 00:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add an offline answer-quality / retrieval eval harness

1 participant