Skip to content

Commit 318b236

Browse files
committed
fix(knowledge): credit a shared hit to every leg that returned it
Attributing a row found by both legs to a single leg left the round-robin owing the other leg a slot it had already been served. With a shared rank-1 hit and topK 2, that evicted the lexical-only row — the exact match hybrid was enabled to recover — in favor of the vector-only one. A shared row satisfied every leg that returned it, so every one of them is now charged for it. Tie-breaking prefers the candidate whose least-served leg has been served least, which also removes the arbitrary best-rank attribution.
1 parent 930a1d4 commit 318b236

2 files changed

Lines changed: 50 additions & 29 deletions

File tree

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

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -238,9 +238,9 @@ describe('Knowledge Search Utils', () => {
238238
)
239239

240240
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'])
241+
// `shared` is credited to both legs, so the following tie is even and
242+
// resolves to the earliest list.
243+
expect(fused.map((r) => r.id)).toEqual(['shared', 'vector-only', 'keyword-only'])
244244
})
245245

246246
it('dedupes by chunk id, keeping the first occurrence', () => {
@@ -320,9 +320,29 @@ describe('Knowledge Search Utils', () => {
320320
const legA = [makeResult('a1'), shared]
321321
const legB = [makeResult('b1'), shared]
322322

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'])
323+
// shared is rank 2 in both legs (2/62) and outscores either rank-1 row (1/61).
324+
expect(fuseByReciprocalRank([legA, legB], 3).map((r) => r.id)).toEqual(['shared', 'a1', 'b1'])
325+
})
326+
327+
it('does not let a shared top hit evict the lexical-only row at topK 2', () => {
328+
const shared = makeResult('shared')
329+
const lexicalOnly = makeResult('lexical-only')
330+
const vectorOnly = makeResult('vector-only')
331+
332+
/**
333+
* `shared` is rank 1 in both legs. Crediting it to only one leg would
334+
* leave the round-robin owing the other leg the remaining slot, evicting
335+
* the row that only the shared hit's leg could produce.
336+
*/
337+
const fused = fuseByReciprocalRank(
338+
[
339+
[shared, lexicalOnly],
340+
[shared, vectorOnly],
341+
],
342+
2
343+
)
344+
345+
expect(fused.map((r) => r.id)).toEqual(['shared', 'lexical-only'])
326346
})
327347

328348
it('trims the fused list to topK', () => {

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

Lines changed: 24 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
616621
export 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

Comments
 (0)