Skip to content

Commit 03eda34

Browse files
committed
fix(knowledge): fan the keyword leg out per knowledge base
The vector leg caps candidates per base once getQueryStrategy sets useParallel, but the keyword leg always ran one global query with a single LIMIT. Searching several bases at once let whichever one ranks strongest lexically consume every slot, so an exact-token hit in a smaller base never reached fusion — the case hybrid exists to serve. The keyword leg now uses the same strategy: per-base queries under the same parallel limit, re-ranked globally on a selected ts_rank_cd. Both legs draw candidates the same way, so fusion combines rankings over the same pool.
1 parent 07d80d0 commit 03eda34

2 files changed

Lines changed: 99 additions & 5 deletions

File tree

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,11 @@ afterEach(() => {
4646
})
4747

4848
import {
49+
executeKeywordSearch,
4950
executeKnowledgeSearch,
5051
fuseByReciprocalRank,
5152
generateSearchEmbedding,
53+
getQueryStrategy,
5254
handleTagAndVectorSearch,
5355
handleTagOnlySearch,
5456
handleVectorOnlySearch,
@@ -356,6 +358,57 @@ describe('Knowledge Search Utils', () => {
356358
})
357359
})
358360

361+
describe('executeKeywordSearch', () => {
362+
beforeEach(() => {
363+
resetDbChainMock()
364+
})
365+
366+
it('returns nothing for a whitespace-only query without touching the database', async () => {
367+
const results = await executeKeywordSearch({
368+
knowledgeBaseIds: ['kb-123'],
369+
topK: 10,
370+
query: ' ',
371+
queryVector: JSON.stringify([0.1, 0.2, 0.3]),
372+
})
373+
374+
expect(results).toEqual([])
375+
expect(dbChainMockFns.select).not.toHaveBeenCalled()
376+
})
377+
378+
it('issues one query per knowledge base once the parallel threshold is crossed', async () => {
379+
const knowledgeBaseIds = ['kb-1', 'kb-2', 'kb-3', 'kb-4', 'kb-5']
380+
expect(getQueryStrategy(knowledgeBaseIds.length, 10).useParallel).toBe(true)
381+
382+
await executeKeywordSearch({
383+
knowledgeBaseIds,
384+
topK: 10,
385+
query: 'PROJ-1234',
386+
queryVector: JSON.stringify([0.1, 0.2, 0.3]),
387+
})
388+
389+
/**
390+
* A single global LIMIT would let the lexically strongest base consume
391+
* every slot, so an exact-token hit in a smaller base never reaches
392+
* fusion. The vector leg already fans out here; both legs must match.
393+
*/
394+
expect(dbChainMockFns.select).toHaveBeenCalledTimes(knowledgeBaseIds.length)
395+
})
396+
397+
it('uses a single query when the parallel threshold is not crossed', async () => {
398+
const knowledgeBaseIds = ['kb-1', 'kb-2']
399+
expect(getQueryStrategy(knowledgeBaseIds.length, 10).useParallel).toBe(false)
400+
401+
await executeKeywordSearch({
402+
knowledgeBaseIds,
403+
topK: 10,
404+
query: 'PROJ-1234',
405+
queryVector: JSON.stringify([0.1, 0.2, 0.3]),
406+
})
407+
408+
expect(dbChainMockFns.select).toHaveBeenCalledTimes(1)
409+
})
410+
})
411+
359412
describe('executeKnowledgeSearch', () => {
360413
beforeEach(() => {
361414
resetDbChainMock()

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

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,12 @@ export interface KeywordSearchParams {
564564
* can report `similarity` for rows only the lexical leg found. Unlike the vector
565565
* leg there is no distance threshold — surfacing exact-token matches that are
566566
* semantically distant is the entire point of this leg.
567+
*
568+
* Candidate gathering mirrors the vector leg's `getQueryStrategy`: across many
569+
* knowledge bases a single global `LIMIT` lets whichever base ranks strongest
570+
* lexically consume every slot, so an exact-token hit in a smaller base would
571+
* never reach fusion. Both legs must draw candidates the same way, or rank
572+
* fusion is combining rankings taken over differently-shaped pools.
567573
*/
568574
export async function executeKeywordSearch(params: KeywordSearchParams): Promise<SearchResult[]> {
569575
const { knowledgeBaseIds, topK, query, queryVector, structuredFilters } = params
@@ -573,16 +579,51 @@ export async function executeKeywordSearch(params: KeywordSearchParams): Promise
573579
}
574580

575581
const tsQuery = sql`websearch_to_tsquery(${FTS_CONFIG}, ${query})`
582+
const rankExpr = sql<number>`ts_rank_cd(${embedding.contentTsv}, ${tsQuery})`
576583
const tagFilterConditions = structuredFilters?.length
577584
? getStructuredTagFilters(structuredFilters, embedding)
578585
: []
579586

580-
return await db
581-
.select(
582-
getSearchResultFields(
583-
sql<number>`${embedding.embedding} <=> ${queryVector}::vector`.as('distance')
587+
/** Selected alongside the row so per-base batches can be re-ranked globally. */
588+
const selectFields = {
589+
...getSearchResultFields(
590+
sql<number>`${embedding.embedding} <=> ${queryVector}::vector`.as('distance')
591+
),
592+
keywordRank: rankExpr.as('keyword_rank'),
593+
}
594+
595+
const strategy = getQueryStrategy(knowledgeBaseIds.length, topK)
596+
597+
if (strategy.useParallel) {
598+
const parallelLimit = Math.ceil(topK / knowledgeBaseIds.length) + 5
599+
600+
const perBase = await Promise.all(
601+
knowledgeBaseIds.map((kbId) =>
602+
db
603+
.select(selectFields)
604+
.from(embedding)
605+
.innerJoin(document, eq(embedding.documentId, document.id))
606+
.where(
607+
and(
608+
eq(embedding.knowledgeBaseId, kbId),
609+
...getVisibilityConditions(),
610+
sql`${embedding.contentTsv} @@ ${tsQuery}`,
611+
...tagFilterConditions
612+
)
613+
)
614+
.orderBy(sql`${rankExpr} DESC`)
615+
.limit(parallelLimit)
584616
)
585617
)
618+
619+
return perBase
620+
.flat()
621+
.sort((a, b) => b.keywordRank - a.keywordRank)
622+
.slice(0, topK)
623+
}
624+
625+
return await db
626+
.select(selectFields)
586627
.from(embedding)
587628
.innerJoin(document, eq(embedding.documentId, document.id))
588629
.where(
@@ -593,7 +634,7 @@ export async function executeKeywordSearch(params: KeywordSearchParams): Promise
593634
...tagFilterConditions
594635
)
595636
)
596-
.orderBy(sql`ts_rank_cd(${embedding.contentTsv}, ${tsQuery}) DESC`)
637+
.orderBy(sql`${rankExpr} DESC`)
597638
.limit(topK)
598639
}
599640

0 commit comments

Comments
 (0)