1+ import type { RerankCandidate , RerankResult , RerankOptions } from './types' ;
2+ import { tokenizeQuery , countOccurrences , countTermMatches , isWordBoundary } from './helpers' ;
3+
4+ const DEFAULTS = {
5+ phraseWeight : 3.0 ,
6+ phraseRepeatBonus : 0.5 ,
7+ termWeight : 1.0 ,
8+ termRepeatBonus : 0.2 ,
9+ substringWeight : 0.3 ,
10+ headingTermBonus : 2.0 ,
11+ headingPhraseBonus : 2.5 ,
12+ originalScoreCarry : 0.1 ,
13+ } as const ;
14+
15+ /**
16+ * Rerank candidates by keyword / phrase overlap with the query.
17+ * Scores are normalised to [0, 1] via min-max scaling.
18+ */
19+ export async function rerank (
20+ query : string ,
21+ candidates : RerankCandidate [ ] ,
22+ options ?: RerankOptions ,
23+ ) : Promise < RerankResult [ ] > {
24+ if ( candidates . length === 0 ) return [ ] ;
25+
26+ const w = {
27+ phraseWeight : options ?. phraseWeight ?? DEFAULTS . phraseWeight ,
28+ phraseRepeatBonus : options ?. phraseRepeatBonus ?? DEFAULTS . phraseRepeatBonus ,
29+ termWeight : options ?. termWeight ?? DEFAULTS . termWeight ,
30+ termRepeatBonus : options ?. termRepeatBonus ?? DEFAULTS . termRepeatBonus ,
31+ substringWeight : options ?. substringWeight ?? DEFAULTS . substringWeight ,
32+ headingTermBonus : options ?. headingTermBonus ?? DEFAULTS . headingTermBonus ,
33+ headingPhraseBonus : options ?. headingPhraseBonus ?? DEFAULTS . headingPhraseBonus ,
34+ originalScoreCarry : options ?. originalScoreCarry ?? DEFAULTS . originalScoreCarry ,
35+ } ;
36+
37+ const lowerQuery = query . toLowerCase ( ) ;
38+ const queryTerms = tokenizeQuery ( lowerQuery ) ;
39+ const queryPhrase = lowerQuery . trim ( ) ;
40+
41+ const scored = candidates . map ( ( c ) => {
42+ const content = c . content . toLowerCase ( ) ;
43+ let score = 0 ;
44+
45+ // 1. Exact phrase match
46+ if ( content . includes ( queryPhrase ) ) {
47+ score += w . phraseWeight + ( countOccurrences ( content , queryPhrase ) - 1 ) * w . phraseRepeatBonus ;
48+ }
49+
50+ // 2. Per-term matching
51+ for ( const term of queryTerms ) {
52+ if ( content . includes ( term ) ) {
53+ score += isWordBoundary ( content , term ) ? w . termWeight + ( countTermMatches ( content , term ) - 1 ) * w . termRepeatBonus : w . substringWeight ;
54+ }
55+ }
56+
57+ // 3. Heading path bonus
58+ if ( c . headingPath ) {
59+ const heading = c . headingPath . toLowerCase ( ) ;
60+ for ( const term of queryTerms ) {
61+ if ( heading . includes ( term ) ) score += w . headingTermBonus ;
62+ }
63+ if ( queryPhrase . length > 2 && heading . includes ( queryPhrase ) ) {
64+ score += w . headingPhraseBonus ;
65+ }
66+ }
67+
68+ // 4. Carry over original score
69+ score += c . score * w . originalScoreCarry ;
70+
71+ return { id : c . id , score } ;
72+ } ) ;
73+
74+ // Min-max normalise to [0, 1]
75+ const scores = scored . map ( ( s ) => s . score ) ;
76+ const min = Math . min ( ...scores ) ;
77+ const max = Math . max ( ...scores ) ;
78+ const range = max - min || 1 ;
79+
80+ return scored . map ( ( s ) => ( { id : s . id , score : ( s . score - min ) / range } ) ) ;
81+ }
0 commit comments