Skip to content

Commit f1a8e9d

Browse files
committed
perf(knowledge): stop the keyword leg detoasting every match's vector
Selecting the cosine distance in the ranking query made Postgres detoast the 1536-dimension embedding and compute a distance for every full-text match before the LIMIT applied, so cost tracked how common the query term was rather than topK. On a 20k-chunk base with a term matching every row that was 61,055 buffer hits against 1,030 for the same query without the projection. Rank on ids and ts_rank_cd alone, then hydrate only the rows that survive the limit. Same results, and the worst case drops to ~27ms end to end.
1 parent 03eda34 commit f1a8e9d

2 files changed

Lines changed: 74 additions & 42 deletions

File tree

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

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,31 @@ describe('Knowledge Search Utils', () => {
394394
expect(dbChainMockFns.select).toHaveBeenCalledTimes(knowledgeBaseIds.length)
395395
})
396396

397+
it('ranks without selecting the embedding column, then hydrates the survivors', async () => {
398+
queueTableRows(schemaMock.embedding, [{ id: 'kw-1', keywordRank: 0.9 }])
399+
queueTableRows(schemaMock.embedding, [makeResult('kw-1')])
400+
401+
const results = await executeKeywordSearch({
402+
knowledgeBaseIds: ['kb-1'],
403+
topK: 10,
404+
query: 'PROJ-1234',
405+
queryVector: JSON.stringify([0.1, 0.2, 0.3]),
406+
})
407+
408+
expect(results.map((r) => r.id)).toEqual(['kw-1'])
409+
expect(dbChainMockFns.select).toHaveBeenCalledTimes(2)
410+
411+
/**
412+
* Projecting the distance in the ranking pass makes Postgres detoast the
413+
* 1536-dimension vector for every full-text match before the LIMIT, so
414+
* cost tracks how common the term is rather than topK. The ranking pass
415+
* must select ids and relevance only.
416+
*/
417+
const rankingSelect = dbChainMockFns.select.mock.calls[0][0]
418+
expect(Object.keys(rankingSelect)).toEqual(['id', 'keywordRank'])
419+
expect(Object.keys(dbChainMockFns.select.mock.calls[1][0])).toContain('distance')
420+
})
421+
397422
it('uses a single query when the parallel threshold is not crossed', async () => {
398423
const knowledgeBaseIds = ['kb-1', 'kb-2']
399424
expect(getQueryStrategy(knowledgeBaseIds.length, 10).useParallel).toBe(false)
@@ -451,7 +476,9 @@ describe('Knowledge Search Utils', () => {
451476
})
452477

453478
it('runs both legs and fuses them in hybrid mode', async () => {
479+
// Vector leg, then the keyword leg's ranking pass, then its hydration pass.
454480
queueTableRows(schemaMock.embedding, [makeResult('vector-hit')])
481+
queueTableRows(schemaMock.embedding, [{ id: 'keyword-hit', keywordRank: 0.9 }])
455482
queueTableRows(schemaMock.embedding, [makeResult('keyword-hit')])
456483

457484
const results = await executeKnowledgeSearch({
@@ -463,7 +490,7 @@ describe('Knowledge Search Utils', () => {
463490
})
464491

465492
expect(results.map((r) => r.id).sort()).toEqual(['keyword-hit', 'vector-hit'])
466-
expect(dbChainMockFns.select).toHaveBeenCalledTimes(2)
493+
expect(dbChainMockFns.select).toHaveBeenCalledTimes(3)
467494
})
468495

469496
it('falls back to vector results when the keyword leg fails', async () => {

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

Lines changed: 46 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { db } from '@sim/db'
22
import { document, embedding } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { getErrorMessage } from '@sim/utils/errors'
5-
import { and, eq, inArray, isNull, sql } from 'drizzle-orm'
5+
import { and, eq, inArray, isNull, type SQL, sql } from 'drizzle-orm'
66
import type { StructuredFilter } from '@/lib/knowledge/types'
77

88
const logger = createLogger('KnowledgeSearch')
@@ -570,6 +570,14 @@ export interface KeywordSearchParams {
570570
* lexically consume every slot, so an exact-token hit in a smaller base would
571571
* never reach fusion. Both legs must draw candidates the same way, or rank
572572
* fusion is combining rankings taken over differently-shaped pools.
573+
*
574+
* Ranking and hydration are two steps on purpose. Projecting the cosine
575+
* distance in the ranking query makes Postgres detoast the 1536-dimension
576+
* vector and compute a distance for *every* full-text match before the `LIMIT`
577+
* applies — work that scales with how common the query term is rather than
578+
* with `topK` (measured at ~59x the buffer reads on a 20k-chunk base for a term
579+
* matching every row). Ranking therefore touches no vectors, and only the rows
580+
* that survive the limit are hydrated.
573581
*/
574582
export async function executeKeywordSearch(params: KeywordSearchParams): Promise<SearchResult[]> {
575583
const { knowledgeBaseIds, topK, query, queryVector, structuredFilters } = params
@@ -584,58 +592,55 @@ export async function executeKeywordSearch(params: KeywordSearchParams): Promise
584592
? getStructuredTagFilters(structuredFilters, embedding)
585593
: []
586594

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-
}
595+
const rankConditions = (kbScope: SQL | undefined) =>
596+
and(
597+
kbScope,
598+
...getVisibilityConditions(),
599+
sql`${embedding.contentTsv} @@ ${tsQuery}`,
600+
...tagFilterConditions
601+
)
602+
603+
/** Ranking pass: ids and relevance only, so no vector is read. */
604+
const rankRows = (kbScope: SQL | undefined, limit: number) =>
605+
db
606+
.select({ id: embedding.id, keywordRank: rankExpr.as('keyword_rank') })
607+
.from(embedding)
608+
.innerJoin(document, eq(embedding.documentId, document.id))
609+
.where(rankConditions(kbScope))
610+
.orderBy(sql`${rankExpr} DESC`)
611+
.limit(limit)
594612

595613
const strategy = getQueryStrategy(knowledgeBaseIds.length, topK)
596614

615+
let ranked: { id: string; keywordRank: number }[]
597616
if (strategy.useParallel) {
598617
const parallelLimit = Math.ceil(topK / knowledgeBaseIds.length) + 5
599-
600618
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)
616-
)
619+
knowledgeBaseIds.map((kbId) => rankRows(eq(embedding.knowledgeBaseId, kbId), parallelLimit))
617620
)
621+
ranked = perBase.flat().sort((a, b) => b.keywordRank - a.keywordRank)
622+
} else {
623+
ranked = await rankRows(inArray(embedding.knowledgeBaseId, knowledgeBaseIds), topK)
624+
}
618625

619-
return perBase
620-
.flat()
621-
.sort((a, b) => b.keywordRank - a.keywordRank)
622-
.slice(0, topK)
626+
const topIds = ranked.slice(0, topK).map((row) => row.id)
627+
if (topIds.length === 0) {
628+
return []
623629
}
624630

625-
return await db
626-
.select(selectFields)
627-
.from(embedding)
628-
.innerJoin(document, eq(embedding.documentId, document.id))
629-
.where(
630-
and(
631-
inArray(embedding.knowledgeBaseId, knowledgeBaseIds),
632-
...getVisibilityConditions(),
633-
sql`${embedding.contentTsv} @@ ${tsQuery}`,
634-
...tagFilterConditions
631+
/** Hydration pass: full rows plus the cosine distance, bounded to the survivors. */
632+
const hydrated = await db
633+
.select(
634+
getSearchResultFields(
635+
sql<number>`${embedding.embedding} <=> ${queryVector}::vector`.as('distance')
635636
)
636637
)
637-
.orderBy(sql`${rankExpr} DESC`)
638-
.limit(topK)
638+
.from(embedding)
639+
.innerJoin(document, eq(embedding.documentId, document.id))
640+
.where(and(inArray(embedding.id, topIds), ...getVisibilityConditions()))
641+
642+
const rowById = new Map(hydrated.map((row) => [row.id, row]))
643+
return topIds.map((id) => rowById.get(id)).filter((row): row is SearchResult => row !== undefined)
639644
}
640645

641646
/**

0 commit comments

Comments
 (0)