mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-10-17 13:19:18 +02:00
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
365 lines
15 KiB
TypeScript
365 lines
15 KiB
TypeScript
import { BaseCommand, flags } from '@adonisjs/core/ace'
|
|
import type { CommandOptions } from '@adonisjs/core/types/ace'
|
|
import type { RetrievalAggregate } from '../../app/utils/eval/retrieval_metrics.js'
|
|
|
|
/**
|
|
* Score NOMAD's retrieval against the frozen golden set.
|
|
*
|
|
* No chat model is involved, so this is deterministic and hardware-independent:
|
|
* a movement in these numbers is a code change, not a slow machine or an unlucky
|
|
* sample. It is the fast inner loop for anything touching chunking, embedding,
|
|
* thresholds, or reranking.
|
|
*
|
|
* node ace eval:retrieval
|
|
* node ace eval:retrieval --ablate # is the reranker helping?
|
|
* node ace eval:retrieval --threshold=0.5 # sweep the Qdrant cutoff
|
|
* node ace eval:retrieval --min-final-score=0.6 # sweep the post-rerank floor
|
|
* node ace eval:retrieval --tag=multi-hop # one slice only
|
|
*/
|
|
export default class EvalRetrieval extends BaseCommand {
|
|
static commandName = 'eval:retrieval'
|
|
static description = 'Score RAG retrieval against the golden set (deterministic, no chat model)'
|
|
|
|
@flags.string({ description: 'Chunks to retrieve per query (default: the production value)' })
|
|
declare topK: string
|
|
|
|
@flags.string({ description: 'Minimum similarity score (default: the production value)' })
|
|
declare threshold: string
|
|
|
|
@flags.string({
|
|
description:
|
|
'Post-rerank relevance floor; chunks below it are dropped (default: the production value). ' +
|
|
'Reads RAG_MIN_FINAL_SCORE, never the rag.minRelevance setting — see EvalRetrievalService.',
|
|
})
|
|
declare minFinalScore: string
|
|
|
|
@flags.string({
|
|
description:
|
|
'Run the retrieval relevance check on this model after the floor (default: off, keeping this tier model-free)',
|
|
})
|
|
declare judgeModel: string
|
|
|
|
@flags.boolean({ description: 'Also score the raw dense, reranked, and diversified orderings' })
|
|
declare ablate: boolean
|
|
|
|
@flags.string({
|
|
description:
|
|
'Comma-separated golden suites to run, by filename without .jsonl (default: core). ' +
|
|
'Extra suites are opt-in so they cannot move the numbers a baseline was recorded against.',
|
|
})
|
|
declare suite: string
|
|
|
|
@flags.string({ description: 'Only run goldens carrying this tag' })
|
|
declare tag: string
|
|
|
|
@flags.boolean({ description: 'Print each failing question and what it retrieved' })
|
|
declare verbose: boolean
|
|
|
|
@flags.boolean({ description: 'Leave application debug logging on (very noisy)' })
|
|
declare debug: boolean
|
|
|
|
@flags.boolean({ description: 'Write a JSON + Markdown report to tests/eval/reports/' })
|
|
declare report: boolean
|
|
|
|
@flags.string({
|
|
description:
|
|
"Write every golden's full pre-floor candidate pool (boosted + cosine scores) to this JSON path, " +
|
|
'for simulating a relevance gate offline',
|
|
})
|
|
declare dump: string
|
|
|
|
static options: CommandOptions = {
|
|
startApp: true,
|
|
}
|
|
|
|
async run() {
|
|
const { EvalCorpusService } = await import('#services/eval_corpus_service')
|
|
const { EvalRetrievalService } = await import('#services/eval_retrieval_service')
|
|
const { EvalReportService } = await import('#services/eval_report_service')
|
|
const { quietLogging } = await import('../../app/utils/eval/quiet.js')
|
|
|
|
const corpusService = await this.app.container.make(EvalCorpusService)
|
|
const retrievalService = await this.app.container.make(EvalRetrievalService)
|
|
const reportService = await this.app.container.make(EvalReportService)
|
|
const restoreLogging = quietLogging(this.debug)
|
|
|
|
try {
|
|
const chunks = await retrievalService.assertCorpusReady()
|
|
const fingerprint = await corpusService.fingerprint()
|
|
let goldens = await corpusService.loadGoldens(this.suite ? this.suite.split(',').map((s) => s.trim()) : undefined)
|
|
|
|
if (this.tag) {
|
|
goldens = goldens.filter((g) => g.tags.includes(this.tag))
|
|
if (goldens.length === 0) {
|
|
this.logger.error(`No goldens carry the tag "${this.tag}"`)
|
|
this.exitCode = 1
|
|
return
|
|
}
|
|
}
|
|
|
|
this.logger.info(`Corpus ${fingerprint} · ${chunks} chunks · ${goldens.length} goldens`)
|
|
this.logger.info('')
|
|
|
|
const started = Date.now()
|
|
const result = await retrievalService.run(goldens, {
|
|
topK: this.topK ? Number.parseInt(this.topK, 10) : undefined,
|
|
scoreThreshold: this.threshold ? Number.parseFloat(this.threshold) : undefined,
|
|
minFinalScore: this.minFinalScore ? Number.parseFloat(this.minFinalScore) : undefined,
|
|
ablate: this.ablate,
|
|
dump: Boolean(this.dump),
|
|
judgeModel: this.judgeModel || undefined,
|
|
})
|
|
const elapsed = ((Date.now() - started) / 1000).toFixed(1)
|
|
|
|
this.logger.info(
|
|
`Params: topK=${result.params.topK} threshold=${result.params.scoreThreshold} ` +
|
|
`minFinalScore=${result.params.minFinalScore} (${elapsed}s)`
|
|
)
|
|
if (result.judge) {
|
|
const j = result.judge
|
|
this.logger.info(
|
|
`Relevance check: ${j.model} · ${j.calls} calls · rejected ${j.rejected} chunk(s) · ` +
|
|
`${j.failures} failed open · mean ${Math.round(j.meanMs)}ms max ${Math.round(j.maxMs)}ms`
|
|
)
|
|
}
|
|
this.logger.info('')
|
|
this.printAggregate('OVERALL', result.overall, result.params.kValues)
|
|
|
|
if (result.unresolvedChunks > 0) {
|
|
// Every retrieved chunk should belong to the eval corpus. Anything else
|
|
// means the collection filter leaked and the numbers above describe a
|
|
// corpus nobody chose.
|
|
this.logger.error(
|
|
`${result.unresolvedChunks} retrieved chunk(s) did not belong to the eval corpus — the collection filter leaked.`
|
|
)
|
|
this.exitCode = 1
|
|
}
|
|
|
|
this.printThresholdGuidance(result.overall)
|
|
|
|
if (result.ablation) {
|
|
this.logger.info('')
|
|
this.logger.info('=== Stage ablation (does each heuristic earn its place?) ===')
|
|
const k = result.params.kValues.includes(5) ? 5 : result.params.kValues[0]
|
|
const row = (name: string, agg: RetrievalAggregate) =>
|
|
this.logger.info(
|
|
` ${name.padEnd(14)} recall@${k}=${fmt(agg.recall[k])} ndcg@${k}=${fmt(agg.ndcg[k])} mrr=${fmt(agg.mrr)} prec@${k}=${fmt(agg.precision[k])}`
|
|
)
|
|
row('dense only', result.ablation.dense)
|
|
row('+ rerank', result.ablation.reranked)
|
|
row('+ diversity', result.ablation.diversified)
|
|
this.explainAblation(result.ablation, k)
|
|
}
|
|
|
|
this.logger.info('')
|
|
this.logger.info('=== By tag ===')
|
|
for (const [tag, agg] of Object.entries(result.byTag).sort()) {
|
|
const k = result.params.kValues.includes(5) ? 5 : result.params.kValues[0]
|
|
// A refusal-only tag has no recall to report; what it has is the leak rate,
|
|
// which is the number #1341 is about.
|
|
const leak =
|
|
agg.nonEmptyRateOnRefusal === null ? '' : ` leaked=${pct(agg.nonEmptyRateOnRefusal)}`
|
|
this.logger.info(
|
|
` ${tag.padEnd(20)} n=${String(agg.cases).padStart(3)} recall@${k}=${fmt(agg.recall[k])} ndcg@${k}=${fmt(agg.ndcg[k])}${leak}`
|
|
)
|
|
}
|
|
|
|
const k = result.params.kValues.includes(5) ? 5 : result.params.kValues[0]
|
|
const misses = result.cases.filter((c) => !c.expectRefusal && (c.recall[k] ?? 1) < 1)
|
|
this.logger.info('')
|
|
this.logger.info(`${misses.length} of ${result.overall.answerable} answerable questions missed at k=${k}`)
|
|
|
|
if (this.verbose && misses.length > 0) {
|
|
this.logger.info('')
|
|
this.logger.info('=== Misses ===')
|
|
for (const miss of misses) {
|
|
this.logger.info(` ${miss.id}`)
|
|
this.logger.info(` wanted: ${miss.relevantDocIds.join(', ') || '(none)'}`)
|
|
this.logger.info(` retrieved: ${miss.retrievedDocIds.join(', ') || '(nothing)'}`)
|
|
}
|
|
} else if (misses.length > 0) {
|
|
this.logger.info('Re-run with --verbose to see which questions and what they retrieved.')
|
|
}
|
|
|
|
// The other failure: a question the corpus cannot answer that retrieved
|
|
// something anyway. Each of these becomes a citation the user sees.
|
|
const leaks = result.cases.filter((c) => c.expectRefusal && !c.empty)
|
|
if (leaks.length > 0) {
|
|
this.logger.info('')
|
|
this.logger.info(`${leaks.length} out-of-corpus question(s) retrieved something anyway`)
|
|
if (this.verbose) {
|
|
this.logger.info('')
|
|
this.logger.info('=== Leaks ===')
|
|
for (const leak of leaks) {
|
|
this.logger.info(` ${leak.id}`)
|
|
const top = leak.top
|
|
? ` (top final=${leak.top.score.toFixed(3)} semantic=${leak.top.semanticScore?.toFixed(3) ?? 'n/a'})`
|
|
: ''
|
|
this.logger.info(` retrieved: ${leak.retrievedDocIds.join(', ')}${top}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.dump && result.pools) {
|
|
const { writeFile } = await import('node:fs/promises')
|
|
await writeFile(this.dump, JSON.stringify({ fingerprint, params: result.params, pools: result.pools }, null, 2))
|
|
this.logger.info('')
|
|
this.logger.success(`Candidate pools written: ${this.dump}`)
|
|
}
|
|
|
|
if (this.report) {
|
|
const meta = await reportService.buildMeta('retrieval', fingerprint, {
|
|
...result.params,
|
|
tag: this.tag ?? null,
|
|
})
|
|
const doc = reportService.fromRetrieval(meta, result)
|
|
const slug = `retrieval-${meta.createdAt.replace(/[:.]/g, '-')}`
|
|
const path = await reportService.write(doc, slug, renderRetrievalMarkdown(doc, result, misses))
|
|
this.logger.info('')
|
|
this.logger.success(`Report written: ${path}`)
|
|
}
|
|
} catch (error) {
|
|
this.logger.error(error instanceof Error ? error.message : String(error))
|
|
this.exitCode = 1
|
|
} finally {
|
|
restoreLogging()
|
|
}
|
|
}
|
|
|
|
private printAggregate(label: string, agg: RetrievalAggregate, kValues: number[]) {
|
|
this.logger.info(`=== ${label} (${agg.answerable} answerable of ${agg.cases}) ===`)
|
|
const row = (name: string, values: Record<number, number | null>) =>
|
|
this.logger.info(
|
|
` ${name.padEnd(10)} ${kValues.map((k) => `@${k}=${fmt(values[k])}`).join(' ')}`
|
|
)
|
|
row('recall', agg.recall)
|
|
row('hit rate', agg.hitRate)
|
|
row('precision', agg.precision)
|
|
row('ndcg', agg.ndcg)
|
|
this.logger.info(` mrr ${fmt(agg.mrr)}`)
|
|
}
|
|
|
|
/**
|
|
* Turn the score distributions into an actual recommendation. The raw
|
|
* percentiles are the evidence; this is the reading of them, which is what
|
|
* the threshold constants have never had.
|
|
*
|
|
* Printed per axis, because the two cutoffs filter different numbers:
|
|
* `--threshold` is applied by Qdrant to the raw cosine score, and
|
|
* `--min-final-score` is applied after reranking. Reading one off the other's
|
|
* percentiles is wrong by the reranker's boost factor.
|
|
*/
|
|
private printThresholdGuidance(agg: RetrievalAggregate) {
|
|
this.logger.info('')
|
|
this.logger.info('=== Score separation (how to calibrate the cutoffs) ===')
|
|
this.printAxis(
|
|
'semantic, pre-rerank — calibrates --threshold',
|
|
agg.relevantScores,
|
|
agg.irrelevantScores
|
|
)
|
|
this.printAxis(
|
|
'final, post-rerank — calibrates --min-final-score',
|
|
agg.relevantFinalScores,
|
|
agg.irrelevantFinalScores
|
|
)
|
|
if (agg.emptyRateOnAnswerable !== null && agg.emptyRateOnAnswerable > 0) {
|
|
this.logger.warning(
|
|
` ${pct(agg.emptyRateOnAnswerable)} of answerable questions retrieved nothing — threshold may be too high.`
|
|
)
|
|
}
|
|
if (agg.nonEmptyRateOnRefusal !== null && agg.nonEmptyRateOnRefusal > 0) {
|
|
this.logger.warning(
|
|
` ${pct(agg.nonEmptyRateOnRefusal)} of out-of-corpus questions retrieved something anyway — that context is what produces confident wrong answers.`
|
|
)
|
|
}
|
|
}
|
|
|
|
/** One axis of the separation table, plus the reading of it. */
|
|
private printAxis(
|
|
label: string,
|
|
rel: RetrievalAggregate['relevantScores'],
|
|
irr: RetrievalAggregate['irrelevantScores']
|
|
) {
|
|
if (!rel && !irr) return
|
|
this.logger.info(` ${label}`)
|
|
const row = (name: string, d: NonNullable<typeof rel>) =>
|
|
this.logger.info(
|
|
` ${name.padEnd(18)} n=${d.count} min=${d.min.toFixed(3)} p10=${d.p10.toFixed(3)} median=${d.median.toFixed(3)} p90=${d.p90.toFixed(3)}`
|
|
)
|
|
if (rel) row('relevant chunks', rel)
|
|
if (irr) row('irrelevant chunks', irr)
|
|
if (rel && irr) {
|
|
if (rel.p10 > irr.p90) {
|
|
this.logger.success(
|
|
` Clean separation: a cutoff between ${irr.p90.toFixed(3)} and ${rel.p10.toFixed(3)} splits them.`
|
|
)
|
|
} else {
|
|
this.logger.warning(
|
|
` Overlapping: relevant p10 (${rel.p10.toFixed(3)}) sits below irrelevant p90 (${irr.p90.toFixed(3)}); ` +
|
|
`a cutoff near ${rel.min.toFixed(3)} keeps every relevant chunk seen here.`
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private explainAblation(ablation: { dense: RetrievalAggregate; reranked: RetrievalAggregate; diversified: RetrievalAggregate }, k: number) {
|
|
const verdict = (name: string, before: number | null, after: number | null) => {
|
|
if (before === null || after === null) return
|
|
const delta = after - before
|
|
if (Math.abs(delta) < 1e-6) {
|
|
this.logger.warning(` ${name} changed nothing at k=${k} — it is complexity with no measured benefit.`)
|
|
} else if (delta < 0) {
|
|
this.logger.warning(` ${name} made ndcg@${k} worse by ${Math.abs(delta).toFixed(4)}.`)
|
|
} else {
|
|
this.logger.success(` ${name} improved ndcg@${k} by ${delta.toFixed(4)}.`)
|
|
}
|
|
}
|
|
verdict('Reranking', ablation.dense.ndcg[k], ablation.reranked.ndcg[k])
|
|
verdict('Source diversity', ablation.reranked.ndcg[k], ablation.diversified.ndcg[k])
|
|
}
|
|
}
|
|
|
|
const fmt = (v: number | null) => (v === null ? ' n/a' : v.toFixed(3))
|
|
const pct = (v: number) => `${(v * 100).toFixed(0)}%`
|
|
|
|
/**
|
|
* The human-readable half of a report. Leads with the misses, because when a
|
|
* number moves the next question is always "which questions?" and a table of
|
|
* aggregates cannot answer it.
|
|
*/
|
|
function renderRetrievalMarkdown(doc: any, result: any, misses: any[]): string {
|
|
const lines: string[] = ['# Retrieval eval', '']
|
|
lines.push(`- corpus: \`${doc.meta.corpusFingerprint}\``)
|
|
lines.push(`- commit: \`${doc.meta.gitSha ?? 'unknown'}\`${doc.meta.gitDirty ? ' (dirty tree)' : ''}`)
|
|
lines.push(`- when: ${doc.meta.createdAt}`)
|
|
lines.push(
|
|
`- params: topK=${result.params.topK} threshold=${result.params.scoreThreshold} minFinalScore=${result.params.minFinalScore}`
|
|
)
|
|
lines.push('')
|
|
lines.push('## Metrics', '')
|
|
lines.push('| metric | value |', '|---|---:|')
|
|
for (const [name, value] of Object.entries(doc.metrics)) {
|
|
lines.push(`| ${name} | ${value === null ? 'n/a' : (value as number).toFixed(4)} |`)
|
|
}
|
|
lines.push('')
|
|
lines.push(`## Misses (${misses.length})`, '')
|
|
if (misses.length === 0) {
|
|
lines.push('None.')
|
|
} else {
|
|
for (const m of misses) {
|
|
lines.push(`### \`${m.id}\``)
|
|
lines.push(`- wanted: ${m.relevantDocIds.join(', ') || '_none_'}`)
|
|
lines.push(`- retrieved: ${m.retrievedDocIds.join(', ') || '_nothing_'}`)
|
|
lines.push('')
|
|
}
|
|
}
|
|
lines.push('## By tag', '')
|
|
lines.push('| tag | recall@5 | ndcg@5 |', '|---|---:|---:|')
|
|
for (const [tag, metrics] of Object.entries(doc.byTag).sort()) {
|
|
const m = metrics as Record<string, number | null>
|
|
const cell = (v: number | null | undefined) => (v === null || v === undefined ? 'n/a' : v.toFixed(4))
|
|
lines.push(`| ${tag} | ${cell(m['recall@5'])} | ${cell(m['ndcg@5'])} |`)
|
|
}
|
|
return lines.join('\n') + '\n'
|
|
}
|