Skip to content

Commit 930a1d4

Browse files
committed
fix(knowledge): stop rank fusion from starving the lexical leg
Rank n in one leg always ties rank n in the other, so ordering the fused list by score alone let whichever leg was scored first take every tied slot. At topK=1 that meant a hybrid search returned exactly the vector-only result and discarded the exact keyword match the mode exists to recover. Selection now orders by score and drains each tie group round-robin, taking from whichever leg has contributed fewest rows so far. The lexical leg is passed first so it wins a total tie, since a chunk the vector leg ranked below its distance threshold is the case hybrid was opted into for.
1 parent 3364960 commit 930a1d4

2 files changed

Lines changed: 111 additions & 5 deletions

File tree

apps/sim/app/api/knowledge/search/utils.test.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,10 @@ describe('Knowledge Search Utils', () => {
237237
10
238238
)
239239

240-
expect(fused.map((r) => r.id)).toEqual(['shared', 'vector-only', 'keyword-only'])
240+
expect(fused[0].id).toBe('shared')
241+
// The two single-leg rows tie; `shared` was already credited to leg 0, so
242+
// the round-robin owes leg 1 the next slot.
243+
expect(fused.map((r) => r.id)).toEqual(['shared', 'keyword-only', 'vector-only'])
241244
})
242245

243246
it('dedupes by chunk id, keeping the first occurrence', () => {
@@ -280,6 +283,48 @@ describe('Knowledge Search Utils', () => {
280283
expect(fused[0].id).toBe('deep-shared')
281284
})
282285

286+
it('does not let the first leg starve the second at small topK', () => {
287+
const lexicalOnly = makeResult('lexical-only')
288+
const vectorOnly = makeResult('vector-only')
289+
290+
/**
291+
* Rank 1 in each leg scores identically. Ordering by score alone would
292+
* always emit the first list's row, so a `topK: 1` hybrid search would
293+
* return exactly what vector-only search already returned.
294+
*/
295+
expect(fuseByReciprocalRank([[lexicalOnly], [vectorOnly]], 1).map((r) => r.id)).toEqual([
296+
'lexical-only',
297+
])
298+
expect(fuseByReciprocalRank([[lexicalOnly], [vectorOnly]], 2).map((r) => r.id)).toEqual([
299+
'lexical-only',
300+
'vector-only',
301+
])
302+
})
303+
304+
it('interleaves tied ranks so neither leg monopolizes the head', () => {
305+
const legA = [makeResult('a1'), makeResult('a2'), makeResult('a3')]
306+
const legB = [makeResult('b1'), makeResult('b2'), makeResult('b3')]
307+
308+
expect(fuseByReciprocalRank([legA, legB], 6).map((r) => r.id)).toEqual([
309+
'a1',
310+
'b1',
311+
'a2',
312+
'b2',
313+
'a3',
314+
'b3',
315+
])
316+
})
317+
318+
it('still floats a row found by both legs above every single-leg row', () => {
319+
const shared = makeResult('shared')
320+
const legA = [makeResult('a1'), shared]
321+
const legB = [makeResult('b1'), shared]
322+
323+
// shared is rank 2 in both legs (2/62) and outscores either rank-1 row (1/61);
324+
// it is credited to leg A, so tied `a1`/`b1` resolve in leg B's favor.
325+
expect(fuseByReciprocalRank([legA, legB], 3).map((r) => r.id)).toEqual(['shared', 'b1', 'a1'])
326+
})
327+
283328
it('trims the fused list to topK', () => {
284329
const rows = Array.from({ length: 8 }, (_, i) => makeResult(`chunk-${i}`))
285330

apps/sim/app/api/knowledge/search/utils.ts

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
608616
export 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

626681
export 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

Comments
 (0)