@@ -608,33 +608,32 @@ export async function executeKeywordSearch(params: KeywordSearchParams): Promise
608608 * Equal scores are common and must not be broken by list order: rank *n* in one
609609 * leg always ties rank *n* in every other leg, so sorting alone would let the
610610 * first list monopolize the head of the output and starve the others entirely
611- * at small `topK`. Selection therefore drains the legs round-robin among tied
612- * candidates, and a candidate from a leg that has contributed fewer rows so far
613- * wins the tie. A total tie goes to the earliest list, so callers put the leg
614- * whose hits the other leg cannot produce first.
611+ * at small `topK`. Selection therefore drains each tie group round-robin,
612+ * preferring the candidate whose least-served leg has been served least.
613+ *
614+ * A row is credited to *every* leg that returned it, not to one chosen leg: it
615+ * satisfied all of them, and charging a shared hit to a single leg would leave
616+ * the round-robin owing the other one a slot it has already been served —
617+ * which at small `topK` evicts a row only the shared hit's leg could produce.
618+ * A total tie goes to the earliest list, so callers put the leg whose hits the
619+ * other leg cannot produce first.
615620 */
616621export function fuseByReciprocalRank ( rankedLists : SearchResult [ ] [ ] , topK : number ) : SearchResult [ ] {
617622 const scores = new Map < string , number > ( )
618623 const rowById = new Map < string , SearchResult > ( )
624+ const legsOfRow = new Map < string , number [ ] > ( )
619625
620- for ( const list of rankedLists ) {
626+ rankedLists . forEach ( ( list , leg ) => {
621627 list . forEach ( ( row , index ) => {
622628 scores . set ( row . id , ( scores . get ( row . id ) ?? 0 ) + 1 / ( RRF_K + index + 1 ) )
623629 if ( ! rowById . has ( row . id ) ) {
624630 rowById . set ( row . id , row )
625631 }
626- } )
627- }
628-
629- /** Leg each row is attributed to for interleaving: where it ranked best, earliest leg wins. */
630- const legOfRow = new Map < string , number > ( )
631- const bestRankOfRow = new Map < string , number > ( )
632- rankedLists . forEach ( ( list , leg ) => {
633- list . forEach ( ( row , index ) => {
634- const currentBest = bestRankOfRow . get ( row . id )
635- if ( currentBest === undefined || index < currentBest ) {
636- bestRankOfRow . set ( row . id , index )
637- legOfRow . set ( row . id , leg )
632+ const legs = legsOfRow . get ( row . id )
633+ if ( legs ) {
634+ if ( ! legs . includes ( leg ) ) legs . push ( leg )
635+ } else {
636+ legsOfRow . set ( row . id , [ leg ] )
638637 }
639638 } )
640639 } )
@@ -645,6 +644,10 @@ export function fuseByReciprocalRank(rankedLists: SearchResult[][], topK: number
645644 )
646645
647646 const contributed = rankedLists . map ( ( ) => 0 )
647+ /** How starved a candidate's most-neglected leg is; lower wins the tie. */
648+ const starvation = ( id : string ) =>
649+ Math . min ( ...( legsOfRow . get ( id ) ?? [ 0 ] ) . map ( ( leg ) => contributed [ leg ] ) )
650+
648651 const fused : SearchResult [ ] = [ ]
649652 let groupStart = 0
650653
@@ -655,21 +658,19 @@ export function fuseByReciprocalRank(rankedLists: SearchResult[][], topK: number
655658 groupEnd ++
656659 }
657660
658- // Drain this tie group round-robin, always taking from the leg that has contributed least.
659661 const group = ordered . slice ( groupStart , groupEnd )
660662 while ( group . length > 0 && fused . length < topK ) {
661663 let pick = 0
662664 for ( let i = 1 ; i < group . length ; i ++ ) {
663- if (
664- contributed [ legOfRow . get ( group [ i ] . id ) ?? 0 ] <
665- contributed [ legOfRow . get ( group [ pick ] . id ) ?? 0 ]
666- ) {
665+ if ( starvation ( group [ i ] . id ) < starvation ( group [ pick ] . id ) ) {
667666 pick = i
668667 }
669668 }
670669 const [ row ] = group . splice ( pick , 1 )
671670 fused . push ( row )
672- contributed [ legOfRow . get ( row . id ) ?? 0 ] ++
671+ for ( const leg of legsOfRow . get ( row . id ) ?? [ ] ) {
672+ contributed [ leg ] ++
673+ }
673674 }
674675
675676 groupStart = groupEnd
0 commit comments