33 document ,
44 embedding ,
55 embeddingKeywordSearch ,
6+ embeddingKeywordTin ,
67 embeddingSearch ,
78 knowledgeConnector ,
89} from '@sim/db/schema'
@@ -33,6 +34,7 @@ import {
3334import { workspaceSearchFilterConditions } from '@/lib/knowledge/search/filter-conditions'
3435import type { WorkspaceSearchFilters } from '@/lib/knowledge/search/filters'
3536import { applyRecencyBoost , RRF_K } from '@/lib/knowledge/search/recency'
37+ import { resolveTinKeywordQuery } from '@/lib/knowledge/search/tin-keyword'
3638import {
3739 coerceTagFilterValue ,
3840 escapeLikePattern ,
@@ -489,6 +491,13 @@ export function getStructuredTagFilters(filters: StructuredFilter[], embeddingTa
489491 */
490492const FTS_CONFIG = 'english'
491493
494+ /**
495+ * Chunks Tin ranks before access is checked, widening while too few are readable to fill a page.
496+ * A caller past the permitted-set limit reads a large share of the index, so the first window
497+ * almost always fills; the widest bounds the work before the GIN ranking takes over.
498+ */
499+ const TIN_KEYWORD_WINDOWS = [ 2000 , 10_000 , 50_000 ] as const
500+
492501/**
493502 * Row visibility predicates shared by every search leg: a chunk is only
494503 * retrievable when both it and its document are enabled, the document finished
@@ -1310,6 +1319,96 @@ export async function executeKeywordSearch(params: KeywordSearchParams): Promise
13101319 ...tagFilterConditions ,
13111320 ]
13121321 const candidateRank = sql < number > `ts_rank_cd(${ embeddingKeywordSearch . contentTsv } , ${ tsQuery } )`
1322+ /**
1323+ * A caller reaching past the permitted-set limit reads much of the index, so ranking every
1324+ * match before checking access is the leg's whole cost for a common term. Where the Tin
1325+ * projection is complete, BM25 ranks inside the bases first and access is checked only on the
1326+ * top of that ranking.
1327+ */
1328+ const tinQuery =
1329+ params . permitted ?. kind === 'unbounded' && tagFilterConditions . length === 0
1330+ ? await resolveTinKeywordQuery ( knowledgeBaseIds , query , FTS_CONFIG )
1331+ : null
1332+ annotateSearchDiagnostics ( {
1333+ ...( params . permitted ?. kind === 'unbounded'
1334+ ? { keywordRanking : tinQuery ? 'tin' : 'gin' }
1335+ : { } ) ,
1336+ } )
1337+ const documentConditions = ( excludedSources : readonly string [ ] ) =>
1338+ and (
1339+ ...candidateDocumentConditions (
1340+ knowledgeBaseIds ,
1341+ access ,
1342+ params . filters ,
1343+ knowledgeMetadataCandidateAccessCondition ( access )
1344+ ) ,
1345+ excludeSearchSources ( excludedSources )
1346+ )
1347+ /**
1348+ * One page from the top of Tin's ranking. The window of ranked chunks widens while too few of
1349+ * them are readable to fill the page; if the widest window still cannot, the page is left to
1350+ * the GIN ranking, which covers every match.
1351+ */
1352+ const selectTinPage = async (
1353+ scopedQuery : SQL ,
1354+ limit : number ,
1355+ offset : number ,
1356+ excludedSources : readonly string [ ]
1357+ ) : Promise < SearchReadCandidatePage | null > => {
1358+ for ( const window of TIN_KEYWORD_WINDOWS ) {
1359+ if ( window < offset + limit ) continue
1360+ const [ page ] = await runSearchQuery ( params . budget , 'keyword.tin' , ( executor ) =>
1361+ executor . execute < { ranked : number ; candidates : SearchReadCandidate [ ] } > ( sql `
1362+ WITH ranked_tin_chunks AS MATERIALIZED (
1363+ SELECT ${ embeddingKeywordTin . id } AS id, ${ embeddingKeywordTin . documentId } AS document_id,
1364+ ${ embeddingKeywordTin . enabled } AS enabled,
1365+ tin.full_score(${ embeddingKeywordTin } .ctid) AS keyword_rank
1366+ FROM ${ embeddingKeywordTin }
1367+ WHERE ${ embeddingKeywordTin . content } ==> (${ scopedQuery } )
1368+ ORDER BY keyword_rank DESC
1369+ LIMIT ${ window }
1370+ ), visible_keyword_documents AS MATERIALIZED (
1371+ SELECT ${ document . id } AS id FROM ${ document }
1372+ WHERE ${ and (
1373+ sql `${ document . id } = ANY (ARRAY(SELECT document_id FROM ranked_tin_chunks))` ,
1374+ documentConditions ( excludedSources )
1375+ ) }
1376+ ), page AS (
1377+ SELECT ranked_tin_chunks.id, ${ document . id } AS "documentId",
1378+ ${ document . connectorId } AS "connectorId",
1379+ ${ SEARCH_READ_CANDIDATE_FIELDS . liveAuthorizationSource } AS "liveAuthorizationSource",
1380+ ranked_tin_chunks.keyword_rank
1381+ FROM ranked_tin_chunks INNER JOIN ${ document }
1382+ ON ${ document . id } = ranked_tin_chunks.document_id
1383+ WHERE ranked_tin_chunks.enabled
1384+ AND ranked_tin_chunks.document_id IN (SELECT id FROM visible_keyword_documents)
1385+ ORDER BY ranked_tin_chunks.keyword_rank DESC, ranked_tin_chunks.id
1386+ LIMIT ${ limit } OFFSET ${ offset }
1387+ )
1388+ SELECT (SELECT count(*)::int FROM ranked_tin_chunks) AS ranked,
1389+ coalesce((
1390+ SELECT json_agg(json_build_object(
1391+ 'id', page.id, 'documentId', page."documentId", 'connectorId', page."connectorId",
1392+ 'liveAuthorizationSource', page."liveAuthorizationSource"
1393+ ) ORDER BY page.keyword_rank DESC, page.id)
1394+ FROM page
1395+ ), '[]'::json) AS candidates
1396+ ` )
1397+ )
1398+ annotateSearchDiagnostics ( { keywordTinWindow : window } )
1399+ if ( page . candidates . length === limit || page . ranked < window ) {
1400+ return { candidates : page . candidates , nextOffset : offset + page . candidates . length }
1401+ }
1402+ }
1403+ return null
1404+ }
1405+ /** Parenthesized where used: `==>` binds tighter than `||`. */
1406+ const tinScope = tinQuery
1407+ ? sql `'(' || ${ sql . join (
1408+ knowledgeBaseIds . map ( ( id ) => sql `knowledge_tin_base_token(${ id } ) || '^0'` ) ,
1409+ sql ` || ' OR ' || `
1410+ ) } || ') AND (' || ${ tinQuery } || ')'`
1411+ : undefined
13131412 /** Keep readable identities and rank scalars separate so sorts never carry full text-search vectors. */
13141413 return selectAuthorizedSearchResults ( {
13151414 leg : 'keyword' ,
@@ -1330,6 +1429,11 @@ export async function executeKeywordSearch(params: KeywordSearchParams): Promise
13301429 ? permittedDocumentIds ( params . permitted . documents , excludedSources )
13311430 : undefined
13321431 if ( permittedIds ?. length === 0 ) return { candidates : [ ] , nextOffset : offset }
1432+ if ( tinScope && ! permittedIds ) {
1433+ const tinPage = await selectTinPage ( tinScope , limit , offset , excludedSources )
1434+ if ( tinPage ) return tinPage
1435+ annotateSearchDiagnostics ( { keywordRanking : 'gin' } )
1436+ }
13331437 const baseScope = and (
13341438 inArray ( embeddingKeywordSearch . knowledgeBaseId , knowledgeBaseIds ) ,
13351439 eq ( embeddingKeywordSearch . enabled , true )
@@ -1373,13 +1477,7 @@ export async function executeKeywordSearch(params: KeywordSearchParams): Promise
13731477 SELECT ${ document . id } AS id FROM ${ document }
13741478 WHERE ${ and (
13751479 sql `${ document . id } = ANY (ARRAY(SELECT document_id FROM matched_keyword_chunks))` ,
1376- ...candidateDocumentConditions (
1377- knowledgeBaseIds ,
1378- access ,
1379- params . filters ,
1380- knowledgeMetadataCandidateAccessCondition ( access )
1381- ) ,
1382- excludeSearchSources ( excludedSources )
1480+ documentConditions ( excludedSources )
13831481 ) }
13841482 ), ranked_keyword_candidates AS MATERIALIZED (
13851483 SELECT matched_keyword_chunks.id, matched_keyword_chunks.document_id,
0 commit comments