feat(eval): add an offline answer-quality and retrieval eval harness - #2382
Merged
Conversation
felladrin
force-pushed
the
feat/offline-eval-harness
branch
from
August 16, 2026 00:15
72fcedb to
f9ada4f
Compare
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
force-pushed
the
feat/offline-eval-harness
branch
from
August 16, 2026 00:24
f9ada4f to
24d3cac
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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(withpreserveTopResults=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 onEVAL_LLM_API_KEYand skips cleanly without one.What changed
eval/goldenSet.tseval/metrics.tseval/retrieval.integration.test.tseval/answer.integration.test.tseval/goldenSet.test.ts,eval/promptConstruction.test.ts,eval/metrics.test.tseval/goldenPrompt.tsshared/defaultSystemPrompt.tsclient/modules/settings.tstsconfig.eval.json,vitest.eval.config.tspackage.jsoneval,eval:retrieval,eval:answerscripts;lintnow type-checks the eval.docs/development-commands.md,eval/README.mdHow to test
npm run lint(clean, including the newtsc -p tsconfig.eval.json).npm test(the golden-set, prompt-construction, and metrics tests run here, so they are covered in CI).npm run eval:retrieval(needs the model inserver/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).npm run eval:answer(needsEVAL_LLM_API_KEY; I could not run it here, no key).Known limitations
DEFAULT_SYSTEM_PROMPTdrops the printed citation rate under 0.8 (the results block shows markdown links in-context, so the model may still cite).pageContents = {}), soallocatePageExcerpts/formatExcerptnever appear in a graded prompt even thoughenablePageContentFetchdefaults to true. "The exact prompt the app sends" holds for the no-page-content case.temperature: 0(deliberate, for determinism) andmax_tokens: 4096rather than the app'sgetDefaultChatCompletionCreateParamsStreaming()(temperature: 0.35), so a change to the app's sampling params is invisible to it.max_tokens/temperatureare also rejected by o-series / GPT-5-style endpoints (max_completion_tokens, fixed temperature); the documented default isgpt-4o-mini.Note: the golden-set validator rejects
$&/$`/$'/$$in result fields, which fences the eval off from the pre-existingString.replacebug insystemPrompt.ts. That bug stays separate and out of scope here.