MiniSearch / eval /retrieval.integration.test.ts
system's picture
system HF Staff
Sync from felladrin/MiniSearch@6c9fb20
1a6d0d2 verified
Raw History Blame Contribute Delete
4.76 kB
// @vitest-environment node
/**
* Offline retrieval eval: runs the real reranker through rankSearchResults on
* the fixed golden set and scores the ranking with nDCG and recall against the
* human-labeled relevant results.
*
* It calls rankSearchResults with preserveTopResults=true, exactly the way the
* app does for text search (searchEndpointServerHook.ts), so the pinned-first
* result branch is exercised. The golden set therefore includes entries whose
* first candidate is irrelevant: that is the case where the pin costs the
* ranking, and without it the eval could not tell a good pin from a bad one.
*
* This is the regression signal for changes to the reranker or to
* rankSearchResults (the score filter, the top-result preservation, the sort).
* Golden-set structure is validated in goldenSet.test.ts (default suite).
*
* npx vitest run --config vitest.eval.config.ts retrieval
*
* Loads the real ONNX model, so it is excluded from the default suite and from
* CI (which has no model on disk); it is a local signal, matching the server
* integration-test house style.
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { rankSearchResults } from "../server/rankSearchResults.ts";
import {
startRerankerService,
stopRerankerService,
} from "../server/rerankerService.ts";
import { goldenQueries } from "./goldenSet.ts";
import { ndcgAtK, recallAtK } from "./metrics.ts";
import { K, MIN_MEAN_NDCG, MIN_MEAN_RECALL } from "./thresholds.ts";
// The floors (MIN_MEAN_NDCG / MIN_MEAN_RECALL) and the no-op ceilings that
// keep them falsifiable live in thresholds.ts; see that file for the band
// they're set in and why the headroom is intentional.
interface PerQueryScore {
id: string;
ndcg: number;
recall: number;
}
describe("retrieval eval (real reranker)", () => {
const scores: PerQueryScore[] = [];
beforeAll(async () => {
await startRerankerService();
}, 600_000);
afterAll(async () => {
await stopRerankerService();
});
for (const golden of goldenQueries) {
it(`scores ${golden.id}`, async () => {
const candidates: [title: string, content: string, url: string][] =
golden.results.map(({ title, snippet, url }) => [title, snippet, url]);
// preserveTopResults=true matches the app's text-search call.
const ranked = await rankSearchResults(
golden.query,
"text",
candidates,
true,
);
expect(ranked.length, `${golden.id}: empty ranking`).toBeGreaterThan(0);
// The pin is the point of the [1,3] entries: the first candidate must
// stay first regardless of the model's scores. A floor-only assert can't
// catch a broken pin (it would raise the mean), so pin it directly.
expect(
ranked[0][2],
`${golden.id}: preserveTopResults did not pin the first candidate`,
).toBe(golden.results[0].url);
const rankedUrls = ranked.map(([, , url]) => url);
const relevantUrls = golden.relevant.map((i) => golden.results[i].url);
const ndcg = ndcgAtK(rankedUrls, relevantUrls, K);
const recall = recallAtK(rankedUrls, relevantUrls, K);
// Bounds that also catch a metric regression (e.g. nDCG > 1 from a
// duplicate url).
expect(ndcg, `${golden.id}: nDCG out of range`).toBeGreaterThanOrEqual(0);
expect(ndcg, `${golden.id}: nDCG out of range`).toBeLessThanOrEqual(1);
expect(
recall,
`${golden.id}: recall out of range`,
).toBeGreaterThanOrEqual(0);
expect(recall, `${golden.id}: recall out of range`).toBeLessThanOrEqual(
1,
);
scores.push({ id: golden.id, ndcg, recall });
}, 120_000);
}
it("meets the aggregate regression thresholds", () => {
// Print the breakdown before asserting, so a per-query failure still shows
// which queries scored what (the diagnostic you need when a regression fires).
const meanNdcg =
scores.length > 0
? scores.reduce((sum, s) => sum + s.ndcg, 0) / scores.length
: 0;
const meanRecall =
scores.length > 0
? scores.reduce((sum, s) => sum + s.recall, 0) / scores.length
: 0;
console.table(
scores.map((s) => ({
id: s.id,
[`nDCG@${K}`]: s.ndcg.toFixed(3),
[`recall@${K}`]: s.recall.toFixed(3),
})),
);
console.log(
`mean nDCG@${K}=${meanNdcg.toFixed(3)} mean recall@${K}=${meanRecall.toFixed(3)}`,
);
expect(scores).toHaveLength(goldenQueries.length);
expect(meanNdcg, `mean nDCG@${K} regressed`).toBeGreaterThanOrEqual(
MIN_MEAN_NDCG,
);
expect(meanRecall, `mean recall@${K} regressed`).toBeGreaterThanOrEqual(
MIN_MEAN_RECALL,
);
});
});