Skip to content

Commit 3c5bc90

Browse files
committed
feat(kpi): significance testing, a holdout half, and nDCG@10
The harness was being asked to resolve differences it could not. Decisions have rested on natural-macro moves of 0.1–0.5 points, and its own README records a change that moved the mean by +0.0 while 260 queries churned. "19 better / 7 worse" is a better signal than the mean and still not an answer. lib/stats.js reports the same per-query deltas three ways: a seeded paired bootstrap 95% CI, a Wilcoxon signed-rank p-value (non-parametric because the per-query metric is discrete, bounded and mostly exactly zero — where a t-test misbehaves), and the same test over TOPIC MEANS, because the headline is a macro average and a claim about a macro has to be tested over topics. Paired throughout: both rankers see the same queries, so the variance that matters is the variance of the differences. It also calibrates the evidence already in use. 19 better / 7 worse of 2,281 at equal magnitudes is p = 0.019 — significant, so the churn count was worth more than it looked. 12 better / 10 worse is p = 0.68 and means nothing. lib/split.js cuts each set in two and both halves print by default, because every constant in the ranker was chosen by sweeping it against these sets and a training score that reads as a measurement is the failure this prevents. The cut is BY TOPIC, never by query: the natural harvest expands each seed a–z, so `imqueue rpc` and `imqueue rpc example` are near-twins on the same page, and splitting by query would put twins on both sides and make the holdout agree with the fit by construction. Artificial is cut by TARGET PAGE — its `bucket` is a query SHAPE, and cutting on shape produced halves made of different populations and read the difference as a 5.6-point fitting gap on a ranker nobody had tuned against that set at all. The result is good news that was not guaranteed: natural fit 87.8% holdout 90.3% (-2.5, holdout BETTER) artificial fit 98.5% holdout 98.5% (+0.0) question fit 62.4% holdout 60.6% (+1.8, inside the noise of 56 queries) No detectable overfitting after twenty rounds of hand-tuning. nDCG@10 is reported beside the linear metric rather than replacing it. The -10 per position rule says something true — nobody scrolls to row eleven — but it overstates #5 against #7 and understates #1 against #2; the two together say more than either. One relevant document per query, because `expect` lists ALTERNATIVES, and textbook DCG summing over them would reward returning three spellings of the same answer. `grades` is honoured where a query carries one, so a graded label set needs no further harness change. compare.js gains the question set. It measured two of the three populations, and the missing one is the only set that has ever caught a regression the other two could not see. check-search-ranking.js gains three cases for the ranker's new relaxation pass, including the gate itself — a query that returns results must never be rewritten. The safety argument for spelling correction is worth exactly as much as that assertion. Pins search-ranker 6fc2ca8.
1 parent 539edb6 commit 3c5bc90

9 files changed

Lines changed: 626 additions & 20 deletions

File tree

scripts/check-search-ranking.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -741,6 +741,64 @@ if (!df || !ranker.state.t2.docs) {
741741
}
742742
}
743743

744+
// ---- relaxation fires ONLY on an empty result set ------------------------------
745+
// The whole safety argument for spelling correction is the gate: a query that returns
746+
// something is scored by identical code, so the feature cannot move the KPI. That argument is
747+
// worth exactly as much as this assertion — remove the gate and the ranker starts silently
748+
// answering questions nobody asked, with no test objecting.
749+
//
750+
// The ordering case is here too. `nestjs.microservices cqrs` is answered by splitting the
751+
// compound alone, and a single combined relaxation pass also "corrected" `cqrs` to `cars`
752+
// (one substitution, and `cars` is in five sections) — announcing a query about CQRS as a
753+
// query about cars. Confident rewrites have to be tried before guesses.
754+
{
755+
const RELAXED = [
756+
{
757+
query: 'nestjs.microservices cqrs',
758+
corrected: 'nestjs microservices cqrs',
759+
protects: 'a dotted compound is split, and the unknown word beside it is left alone',
760+
regression: 'returned zero results; then returned results "for nestjs microservices cars"',
761+
},
762+
{
763+
query: 'imqeueue',
764+
corrected: 'imqueue',
765+
protects: 'one transposed key is corrected against the corpus vocabulary',
766+
regression: '29% of one-transposed-key queries returned an empty result set',
767+
},
768+
{
769+
query: 'watcherChekcDelay',
770+
corrected: null,
771+
protects: 'a query that already returns something is never rewritten',
772+
regression: 'n/a — this is the gate the safety argument rests on',
773+
},
774+
];
775+
776+
for (const testCase of RELAXED) {
777+
const q = ranker.parseQuery(testCase.query);
778+
const hits = ranker.search(q);
779+
780+
if (!hits.length) {
781+
fail(`"${testCase.query}" returns nothing — ${testCase.protects}`);
782+
continue;
783+
}
784+
if (testCase.corrected === null) {
785+
if (q.corrected) {
786+
fail(`"${testCase.query}" was rewritten to "${q.corrected}" although it had results — `
787+
+ 'relaxation must fire only on an empty result set');
788+
} else {
789+
pass(`"${testCase.query}" is not rewritten — ${testCase.protects}`);
790+
}
791+
continue;
792+
}
793+
if (q.corrected !== testCase.corrected) {
794+
fail(`"${testCase.query}" was rewritten to "${q.corrected}", expected `
795+
+ `"${testCase.corrected}" — ${testCase.protects}`);
796+
} else {
797+
pass(`"${testCase.query}" → "${q.corrected}" — ${testCase.protects}`);
798+
}
799+
}
800+
}
801+
744802
if (failures) {
745803
console.error(`\n${failures} search ranking check(s) failed.`);
746804
process.exit(1);

scripts/search-kpi/README.md

Lines changed: 107 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ A number that says whether a change to the ranker made search better or worse, m
44
before we have any query logs of our own.
55

66
```bash
7-
npm run kpi:search # the numbers
7+
npm run kpi:search # the numbers — all three sets
88
npm run kpi:search:worst # plus the 40 worst misses, with what was expected
9+
npm run kpi:compare # this working tree's ranker vs the pinned one, with significance
910
```
1011

1112
Both query sets are committed, so the measurement is reproducible and a change in the number
@@ -21,6 +22,16 @@ npm run kpi:search:gen # regenerate the 10,000 artificial queries from curr
2122
Position 1 scores 100%, and every position below it costs 10 points, so position 11 and
2223
"not returned at all" both score 0. Nobody scrolls to the eleventh row of a site search.
2324

25+
**nDCG@10 is reported next to it**, and is the standard one. The linear −10 metric above is this
26+
project's own and it overstates the difference between #5 and #7 while understating #1 against #2;
27+
nDCG's log discount is closer to how clicks actually fall off. Both are printed because they
28+
disagree in a useful direction: a change that lifts a query from #4 to #2 is +20 of 100 in accuracy
29+
terms and a large nDCG move.
30+
31+
One relevant document per query, deliberately — `expect` lists *alternatives*, so the ideal ranking
32+
puts one of them first, not all of them. Summing gains over the alternatives (textbook DCG) would
33+
reward a ranker for returning three spellings of the same answer.
34+
2435
Position is read from the flat merged list — what `/search/` renders as "Everything". The
2536
dialog also splits results into Answers/Docs/API groups, so a hit at flat position 4 can be
2637
the first row of its own group there. Flat position is the pessimistic reading and the one
@@ -88,11 +99,58 @@ edition, which is the right shape for that risk rather than an average.
8899

89100
| | natural | artificial | question |
90101
|---|---|---|---|
91-
| micro | **94.0%** | 89.9% | 64.1% |
92-
| macro | **88.9%** | 94.5% | **61.1%** |
102+
| micro | **94.3%** | 90.8% | 64.5% |
103+
| macro | **89.1%** | 95.3% | **61.5%** |
104+
| nDCG@10 | 90.7% | 86.6% | 60.1% |
93105
| recall@6 ||| 66.1% (micro) / 62.9% (macro) |
94-
| never found ||| 19.1% |
95-
| typos (reported apart) || 36.4% ||
106+
| never found ||| 18.3% |
107+
| typos (reported apart) || **55.2%** ||
108+
109+
## Is that delta real?
110+
111+
A change of 0.1–0.5 macro points has been enough to keep or drop a ranker change here, and the
112+
section below records one that moved the mean by **+0.0** while 260 queries churned. So every
113+
comparison now reports the same per-query deltas three ways (`lib/stats.js`):
114+
115+
- a **paired bootstrap 95% CI** on the mean delta, seeded so it is reproducible — if it straddles
116+
zero the change is *unmeasured*, whatever the point estimate says;
117+
- a **Wilcoxon signed-rank** p-value, non-parametric because the per-query metric is discrete,
118+
bounded and mostly exactly zero, which is where a t-test misbehaves;
119+
- the same test **over topic means**, because the headline is a macro average and a claim about a
120+
macro has to be tested over topics rather than over queries.
121+
122+
Paired is the load-bearing word: both rankers see the same queries, so the variance that matters is
123+
the variance of the *differences*. Comparing two independent CIs on the means would call almost
124+
everything a tie.
125+
126+
Calibration worth keeping in mind: **19 better / 7 worse out of 2,281, at equal magnitudes, is
127+
p = 0.019** — significant. The churn count was a better signal than it looked. **12 better / 10
128+
worse is p = 0.68** and means nothing.
129+
130+
## Fit and holdout
131+
132+
Every constant in the ranker was chosen by sweeping it against these sets, which makes every number
133+
above a **training** score. Each set is therefore cut in two and both halves are printed by default
134+
(`lib/split.js`).
135+
136+
The cut is **by topic, never by query**: the natural harvest expands each seed a–z, so
137+
`imqueue rpc` and `imqueue rpc example` are near-twins answered by the same page, and splitting by
138+
query would put twins on both sides and make the holdout agree with the fit by construction. The
139+
artificial set is cut by **target page** for the same reason — its `bucket` field is a query *shape*
140+
(title-salient, body-salient), and cutting on shape produced two halves made of different
141+
populations and read their difference as a 5.6-point fitting gap on a ranker never tuned against it.
142+
143+
Measured, and it is good news that was not guaranteed:
144+
145+
| set | fit | holdout | gap |
146+
|---|---|---|---|
147+
| natural (55 topics) | 87.8% | **90.3%** | −2.5 |
148+
| artificial (1,237 pages) | 98.5% | 98.5% | +0.0 |
149+
| question (18 topics) | 62.4% | 60.6% | +1.8 |
150+
151+
Natural's holdout is *better* than its fit, and artificial's halves are identical. **There is no
152+
detectable overfitting** in twenty rounds of hand-tuning — the weights generalise across topics they
153+
were not fitted on. The question set's +1.8 is inside the noise of 56 queries.
96154

97155
The question set's weakest topics, and they point the same way the diagnosis above does — every
98156
one of them is answered by an API symbol page, which has no question-shaped text to compete with:
@@ -195,6 +253,47 @@ additionally a family of ion-channel genes.
195253
- **Google autocomplete is not our traffic.** It is web-search intent, scored here as if it
196254
were site-search intent. Real logs will disagree.
197255
- **The natural set is skewed** toward whatever Google has many completions for.
198-
- **Typos are reported separately** and never folded into the headline, because the ranker
199-
has no fuzzy matching at all — mixing them in would move the KPI for a reason unrelated to
200-
relevance weighting.
256+
- **Typos are reported separately** and never folded into the headline. They are now partly
257+
answered — see below — but a spelling correction moves the number for a reason unrelated to
258+
relevance weighting, so mixing them in would make the headline mean two things.
259+
260+
## The relaxation pass, and why it could not regress anything
261+
262+
A query that returns **nothing** gets a second attempt: dotted compounds the corpus does not
263+
contain are split into parts it does (`nestjs.microservices``nestjs microservices`), and unknown
264+
words are corrected against the corpus vocabulary by restricted Damerau-Levenshtein distance —
265+
restricted, because the measured typo class is one transposed key, which plain Levenshtein scores as
266+
two edits.
267+
268+
**The gate is the whole safety argument.** It runs only when the ranked list is empty, so any query
269+
that returns at least one result today is scored by byte-identical code and cannot move. Measured
270+
against that prediction:
271+
272+
| | before | after |
273+
|---|---|---|
274+
| typo accuracy | 36.2% | **55.2%** |
275+
| typo empty result set | 29.1% | **5.8%** |
276+
| typo never found | 54.3% | 34.3% |
277+
| natural | 94.2% | 94.3% (2 queries improved, 0 worse) |
278+
| artificial | 90.8% | 90.8% (**0 queries changed**) |
279+
| question | 64.5% | 64.5% (0 changed) |
280+
281+
The two natural queries that moved were returning nothing before. `check-search-ranking.js` asserts
282+
the gate, because the safety argument is worth exactly as much as that assertion.
283+
284+
Two design notes that cost a measurement each:
285+
286+
1. **Confident rewrites are tried before guesses.** `nestjs.microservices cqrs` is answered by
287+
splitting alone. A single combined pass also "corrected" `cqrs` to `cars` — one substitution, and
288+
`cars` is in five sections — and announced a query about CQRS as a query about cars.
289+
2. **A df floor does not separate good corrections from bad ones.** `cars` has df 5, and 67% of the
290+
prose vocabulary has df ≤ 5, so any threshold that rejects `cars` rejects most legitimate
291+
corrections too. The ordering above is what fixes it.
292+
293+
Corrections are **announced** (`3 results for “nestjs microservices cqrs”` in the status line, which
294+
is already an aria-live region) and the highlighter marks the corrected term, not the misspelling.
295+
A search that quietly answers a different question is worse than one that finds nothing.
296+
297+
What this deliberately does **not** fix: a typo whose query still returned something irrelevant.
298+
That is the larger half of the typo gap (34.3% still never found) and it needs a change that can
299+
regress, so it needs to be measured rather than argued.

scripts/search-kpi/compare.js

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ const os = require('node:os');
2626
const path = require('node:path');
2727
const { execFileSync } = require('node:child_process');
2828

29-
const { load, page, accuracyFor } = require('./lib/harness');
29+
const { load, page, accuracyFor, ndcgFor } = require('./lib/harness');
30+
const { verdict } = require('./lib/stats.js');
3031

3132
const ROOT = path.join(__dirname, '..', '..');
3233
const { RANKER_DIR } = require('../lib/ranker.js');
@@ -104,31 +105,78 @@ function main() {
104105
const before = load(DIR, baselineFile);
105106
const after = load(DIR);
106107

107-
const readJson = (file) => JSON.parse(fs.readFileSync(path.join(__dirname, 'data', file), 'utf8'));
108+
const readJson = (file) => {
109+
const at = path.join(__dirname, 'data', file);
110+
111+
return fs.existsSync(at) ? JSON.parse(fs.readFileSync(at, 'utf8')) : null;
112+
};
113+
const setOf = (file, key) => {
114+
const data = readJson(file);
115+
116+
return data ? data[key] : null;
117+
};
108118
const sets = {
109-
natural: readJson('natural-judged.json').judged,
110-
artificial: readJson('artificial-queries.json').main,
119+
natural: setOf('natural-judged.json', 'judged'),
120+
artificial: setOf('artificial-queries.json', 'main'),
121+
// The chat-shaped set was measured only by questions.js --ref, so a comparison run reported
122+
// two of the three populations. It is the one that caught a real regression the other two
123+
// could not see, which makes leaving it out of the default comparison the wrong default.
124+
question: setOf('question-queries.json', 'queries'),
111125
};
112126

113127
let dirty = false;
114128

115129
for (const [name, cases] of Object.entries(sets)) {
130+
if (!cases) {
131+
console.log(`\n=== ${name}: SET NOT PRESENT, not measured ===`);
132+
continue;
133+
}
134+
116135
const better = [];
117136
const worse = [];
137+
// Per-query deltas, INCLUDING the zeros — see lib/stats.js. `byTopic` carries the same deltas
138+
// grouped, because the headline number is a macro average and a claim about a macro has to be
139+
// tested over topics rather than over queries.
140+
const deltas = [];
141+
const ndcgDeltas = [];
142+
const byTopic = new Map();
118143

119144
for (const testCase of cases) {
120145
const expect = (Array.isArray(testCase.expect) ? testCase.expect : [testCase.expect])
121146
.map(page);
122147
const b = rankOf(before, testCase.query, expect);
123148
const a = rankOf(after, testCase.query, expect);
149+
const delta = accuracyFor(a) - accuracyFor(b);
150+
const topic = testCase.label || testCase.bucket || '(none)';
124151

125-
if (accuracyFor(a) > accuracyFor(b)) better.push({ ...testCase, b, a });
126-
if (accuracyFor(a) < accuracyFor(b)) worse.push({ ...testCase, b, a });
152+
deltas.push(delta);
153+
ndcgDeltas.push((ndcgFor(a, 1, 1) - ndcgFor(b, 1, 1)) * 100);
154+
155+
if (!byTopic.has(topic)) byTopic.set(topic, []);
156+
byTopic.get(topic).push(delta);
157+
158+
if (delta > 0) better.push({ ...testCase, b, a });
159+
if (delta < 0) worse.push({ ...testCase, b, a });
127160
}
128161

129-
console.log(`\n=== ${name} (n = ${cases.length}) vs ${REF} ===`);
130-
console.log(`better: ${better.length} worse: ${worse.length} `
131-
+ `unchanged: ${cases.length - better.length - worse.length}`);
162+
// One number per topic, so each topic weighs the same as every other — the macro definition.
163+
const topicDeltas = [...byTopic.values()]
164+
.map((list) => list.reduce((x, y) => x + y, 0) / list.length);
165+
166+
const micro = verdict(deltas);
167+
const macro = verdict(topicDeltas);
168+
169+
console.log(`\n=== ${name} (n = ${cases.length}, ${byTopic.size} topics) vs ${REF} ===`);
170+
console.log(` accuracy micro ${micro.line}`);
171+
console.log(` accuracy macro ${macro.line}`);
172+
console.log(` nDCG@10 micro ${verdict(ndcgDeltas).line}`);
173+
174+
// The honest reading, spelled out because "unmeasured" is the result most likely to be
175+
// misread as "safe". It means this set cannot tell, not that nothing happened.
176+
if (!micro.significant && !macro.significant && (better.length || worse.length)) {
177+
console.log(` → ${better.length + worse.length} queries moved and neither average clears `
178+
+ 'zero: this change is UNMEASURED on this set, not neutral.');
179+
}
132180

133181
if (!worse.length) {
134182
continue;

scripts/search-kpi/lib/harness.js

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,35 @@ function accuracyFor(position) {
7474
return position >= 1 && position <= 10 ? 100 - 10 * (position - 1) : 0;
7575
}
7676

77+
// nDCG@10, reported ALONGSIDE the accuracy above rather than instead of it.
78+
//
79+
// The linear −10-per-position metric is this project's own invention and it says something true —
80+
// "nobody scrolls to the eleventh row" — but it discounts positions 5 to 10 far more harshly than
81+
// any reader behaves, and it cannot express a partially-right answer. nDCG is the field's standard
82+
// and its log discount is the shape click distributions actually follow, so the two together say
83+
// more than either: a change that moves a query from #4 to #2 barely registers in accuracy terms
84+
// (+20 of 100) and is a large nDCG move, while #1 to #2 is the reverse.
85+
//
86+
// SINGLE RELEVANT DOCUMENT per query, deliberately. `expect` lists ALTERNATIVES — "the topic index
87+
// or the article it lists, either is right" — so the ideal ranking has one relevant document at
88+
// position 1, not three. Summing gains over the alternatives, as textbook DCG would, would credit a
89+
// ranker for returning three spellings of the same answer and would make a query with more
90+
// acceptable answers score higher for the same reader experience.
91+
//
92+
// `grades` is honoured when a query carries one ({url: gain}), so a graded label set can be
93+
// introduced later without touching this. Absent, every listed URL is worth the same.
94+
const NDCG_K = 10;
95+
96+
function ndcgFor(position, gain, maxGain) {
97+
if (!position || position > NDCG_K) {
98+
return 0;
99+
}
100+
101+
const ideal = maxGain || 1;
102+
103+
return ((gain || 1) / ideal) / Math.log2(position + 1);
104+
}
105+
77106
// One case = one query plus the URL(s) that would be a correct top result. `expect` may be
78107
// a string or an array: several pages can be equally right (a topic index and the article
79108
// it lists), and pretending otherwise would score a good answer as a miss.
@@ -91,25 +120,32 @@ function evaluate(ranker, cases, options) {
91120
try {
92121
hits = ranker.search(ranker.parseQuery(testCase.query)).slice(0, limit);
93122
} catch (error) {
94-
results.push({ ...testCase, position: 0, accuracy: 0, error: String(error.message) });
123+
results.push({ ...testCase, position: 0, accuracy: 0, ndcg: 0, error: String(error.message) });
95124
continue;
96125
}
97126

98127
let position = 0;
128+
let gain = 0;
99129

100130
for (let i = 0; i < hits.length; i++) {
101131
const url = strict ? hits[i].record.u : page(hits[i].record.u);
102132

103133
if (!hits[i].external && expected.includes(url)) {
104134
position = i + 1;
135+
gain = testCase.grades ? Number(testCase.grades[url]) || 1 : 1;
105136
break;
106137
}
107138
}
108139

140+
const maxGain = testCase.grades
141+
? Math.max(...Object.values(testCase.grades).map(Number))
142+
: 1;
143+
109144
results.push({
110145
...testCase,
111146
position,
112147
accuracy: accuracyFor(position),
148+
ndcg: ndcgFor(position, gain, maxGain) * 100,
113149
returned: hits.length,
114150
top: hits.length ? hits[0].record.u : null,
115151
topScore: hits.length ? Math.round(hits[0].score) : 0,
@@ -143,6 +179,7 @@ function summarise(results) {
143179
return {
144180
total,
145181
accuracy: sum((r) => r.accuracy) / total,
182+
ndcg: sum((r) => r.ndcg || 0) / total,
146183
top1: (count((r) => r.position === 1) / total) * 100,
147184
top3: (count((r) => r.position >= 1 && r.position <= 3) / total) * 100,
148185
top5: (count((r) => r.position >= 1 && r.position <= 5) / total) * 100,
@@ -174,6 +211,7 @@ function table(label, summary) {
174211
const lines = [
175212
`${label} (n = ${summary.total})`,
176213
` accuracy (KPI) ${pct(summary.accuracy)}`,
214+
` nDCG@10 ${pct(summary.ndcg)}`,
177215
` #1 exactly ${pct(summary.top1)}`,
178216
` in top 3 ${pct(summary.top3)}`,
179217
` in top 5 ${pct(summary.top5)}`,
@@ -188,4 +226,4 @@ function table(label, summary) {
188226
return lines.join('\n');
189227
}
190228

191-
module.exports = { load, evaluate, summarise, table, accuracyFor, page, median };
229+
module.exports = { load, evaluate, summarise, table, accuracyFor, ndcgFor, page, median };

0 commit comments

Comments
 (0)