@@ -604,6 +604,14 @@ export async function executeKeywordSearch(params: KeywordSearchParams): Promise
604604 * Rank fusion is used rather than score normalization because cosine distance
605605 * and `ts_rank_cd` are on incomparable scales with no corpus-independent
606606 * mapping between them. Rows are deduped by chunk id, first occurrence wins.
607+ *
608+ * Equal scores are common and must not be broken by list order: rank *n* in one
609+ * leg always ties rank *n* in every other leg, so sorting alone would let the
610+ * 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.
607615 */
608616export function fuseByReciprocalRank ( rankedLists : SearchResult [ ] [ ] , topK : number ) : SearchResult [ ] {
609617 const scores = new Map < string , number > ( )
@@ -618,9 +626,56 @@ export function fuseByReciprocalRank(rankedLists: SearchResult[][], topK: number
618626 } )
619627 }
620628
621- return [ ...rowById . values ( ) ]
622- . sort ( ( a , b ) => ( scores . get ( b . id ) ?? 0 ) - ( scores . get ( a . id ) ?? 0 ) )
623- . slice ( 0 , topK )
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 )
638+ }
639+ } )
640+ } )
641+
642+ // Stable sort keeps rowById insertion order (earliest leg first) inside each tie group.
643+ const ordered = [ ...rowById . values ( ) ] . sort (
644+ ( a , b ) => ( scores . get ( b . id ) ?? 0 ) - ( scores . get ( a . id ) ?? 0 )
645+ )
646+
647+ const contributed = rankedLists . map ( ( ) => 0 )
648+ const fused : SearchResult [ ] = [ ]
649+ let groupStart = 0
650+
651+ while ( groupStart < ordered . length && fused . length < topK ) {
652+ const groupScore = scores . get ( ordered [ groupStart ] . id ) ?? 0
653+ let groupEnd = groupStart
654+ while ( groupEnd < ordered . length && ( scores . get ( ordered [ groupEnd ] . id ) ?? 0 ) === groupScore ) {
655+ groupEnd ++
656+ }
657+
658+ // Drain this tie group round-robin, always taking from the leg that has contributed least.
659+ const group = ordered . slice ( groupStart , groupEnd )
660+ while ( group . length > 0 && fused . length < topK ) {
661+ let pick = 0
662+ 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+ ) {
667+ pick = i
668+ }
669+ }
670+ const [ row ] = group . splice ( pick , 1 )
671+ fused . push ( row )
672+ contributed [ legOfRow . get ( row . id ) ?? 0 ] ++
673+ }
674+
675+ groupStart = groupEnd
676+ }
677+
678+ return fused
624679}
625680
626681export async function handleTagAndVectorSearch ( params : SearchParams ) : Promise < SearchResult [ ] > {
@@ -725,5 +780,11 @@ export async function executeKnowledgeSearch(
725780
726781 const [ vectorResults , keywordResults ] = await Promise . all ( [ vectorSearch , keywordSearch ] )
727782
728- return fuseByReciprocalRank ( [ vectorResults , keywordResults ] , topK )
783+ /**
784+ * Lexical leg first: on a total tie it wins, which is the behavior this mode
785+ * exists for — an exact-token chunk the vector leg ranked below its distance
786+ * threshold is precisely what a caller opted into hybrid to recover, and at
787+ * `topK: 1` something has to win.
788+ */
789+ return fuseByReciprocalRank ( [ keywordResults , vectorResults ] , topK )
729790}
0 commit comments