@@ -2,7 +2,7 @@ import { db } from '@sim/db'
22import { document , embedding } from '@sim/db/schema'
33import { createLogger } from '@sim/logger'
44import { 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'
66import type { StructuredFilter } from '@/lib/knowledge/types'
77
88const 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 */
574582export 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