feat(search): add generic field-spec record ranking with exact-mode bypass

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5 committed 2026-09-17 10:55:37 +02:00
1 parent df9f093b5d
commit 3d3b8ff512
2 files changed
+284

No files matched your search

+170
View File
@@ -0,0 +1,170 @@
/**
* Generic relevance ranking over record collections.
*
* Services declare WHAT to search via FieldSpec declarations; this module
* decides HOW WELL each record matches.
*/
import { normalizeText, tokenizeQuery } from "./tokenize.ts";
import { scoreField, type FieldScore } from "./score.ts";
import { extractSnippet } from "./snippet.ts";
export type MatchMode = "fuzzy" | "exact";
export interface FieldSpec<T> {
/** Field name reported back in FieldMatch.field. */
name: string;
/** Relative importance; scores are normalized by the highest declared weight. */
weight: number;
/** Pulls the searchable text out of a record. */
extract: (record: T) => string | string[] | undefined;
}
export interface FieldMatch {
field: string;
score: number;
snippet?: string;
}
export interface Ranked<T> {
record: T;
/** 0..1, the best weighted field score. */
score: number;
/** Only fields that scored above zero, highest first. */
matches: FieldMatch[];
}
export interface RankOptions<T = unknown> {
minScore?: number;
limit?: number;
matchMode?: MatchMode;
/** Used for deterministic tie-breaking. Defaults to reading `record.id`. */
idOf?: (record: T) => number;
}
export const DEFAULT_MIN_SCORE = 0.35;
/** Below this query length, edit distance is meaningless (spec section 5). */
const MIN_FUZZY_QUERY_LENGTH = 3;
function defaultIdOf(record: unknown): number {
return (record as { id?: number })?.id ?? 0;
}
function fieldTexts<T>(record: T, spec: FieldSpec<T>): string[] {
const raw = spec.extract(record);
if (raw === undefined || raw === null) {
return [];
}
if (Array.isArray(raw)) {
return raw.filter((value): value is string => typeof value === "string" && value.length > 0);
}
return raw.length > 0 ? [raw] : [];
}
/**
* Case-insensitive substring containment, reproducing the pre-fuzzy behaviour
* byte for byte.
*/
function scoreExact(query: string, text: string): FieldScore {
const haystack = normalizeText(text);
const needle = normalizeText(query);
if (needle.length === 0) {
return { score: 0, matchedOffsets: [] };
}
const index = haystack.indexOf(needle);
return index === -1 ? { score: 0, matchedOffsets: [] } : { score: 1, matchedOffsets: [index] };
}
/**
* Ranks records against a query using the supplied field declarations.
*
* Record score is the best weighted field score:
* recordScore = max over fields of (fieldScore * weight) / maxDeclaredWeight
*
* Normalizing by the maximum DECLARED weight (not the best matching weight)
* means a hit on a low-weight field yields a proportionally lower record
* score rather than being rescaled up to 1.
*
* The minScore gate is applied to the RAW field score, never to this
* weighted value.
*/
export function rankRecords<T>(
records: T[],
query: string,
fields: FieldSpec<T>[],
opts?: RankOptions<T>
): Ranked<T>[] {
const matchMode = opts?.matchMode ?? "fuzzy";
const minScore = opts?.minScore ?? DEFAULT_MIN_SCORE;
const idOf = opts?.idOf ?? defaultIdOf;
const trimmedQuery = query.trim();
if (trimmedQuery.length === 0) {
return records.map((record) => ({ record, score: 0, matches: [] }));
}
const queryTokens = tokenizeQuery(trimmedQuery);
const useExact =
matchMode === "exact" ||
trimmedQuery.length < MIN_FUZZY_QUERY_LENGTH ||
queryTokens.length === 0;
// Exact mode must not apply minScore: a containment hit on a low-weight
// field legitimately scores below it, and dropping those would break
// backward compatibility.
const threshold = useExact ? Number.EPSILON : minScore;
const maxWeight = fields.reduce((highest, field) => Math.max(highest, field.weight), 0) || 1;
const ranked: Ranked<T>[] = [];
for (const record of records) {
const matches: FieldMatch[] = [];
let bestWeighted = 0;
let bestRaw = 0;
for (const spec of fields) {
let bestScore: FieldScore = { score: 0, matchedOffsets: [] };
let bestText = "";
for (const text of fieldTexts(record, spec)) {
const result = useExact ? scoreExact(trimmedQuery, text) : scoreField(queryTokens, text);
if (result.score > bestScore.score) {
bestScore = result;
bestText = text;
}
}
if (bestScore.score > 0) {
matches.push({
field: spec.name,
score: Number(bestScore.score.toFixed(2)),
snippet: extractSnippet(bestText, bestScore.matchedOffsets) || undefined,
});
if (bestScore.score > bestRaw) {
bestRaw = bestScore.score;
}
const weighted = (bestScore.score * spec.weight) / maxWeight;
if (weighted > bestWeighted) {
bestWeighted = weighted;
}
}
}
// Gate on the RAW score (is this a real match?), order by the weighted
// score (how important is where it matched?). Gating on the weighted
// score would make every low-weight field unreachable — see the note in
// the plan for this task.
if (bestRaw >= threshold) {
matches.sort((a, b) => b.score - a.score);
ranked.push({ record, score: Number(bestWeighted.toFixed(2)), matches });
}
}
ranked.sort((a, b) => b.score - a.score || idOf(a.record) - idOf(b.record));
return opts?.limit !== undefined && opts.limit > 0 ? ranked.slice(0, opts.limit) : ranked;
}