33 document ,
44 embedding ,
55 embeddingKeywordSearch ,
6+ embeddingKeywordTin ,
67 embeddingSearch ,
78 knowledgeConnector ,
89} from '@sim/db/schema'
@@ -35,6 +36,7 @@ import {
3536import { workspaceSearchFilterConditions } from '@/lib/knowledge/search/filter-conditions'
3637import type { WorkspaceSearchFilters } from '@/lib/knowledge/search/filters'
3738import { applyRecencyBoost , RRF_K } from '@/lib/knowledge/search/recency'
39+ import { resolveTinKeywordQuery } from '@/lib/knowledge/search/tin-keyword'
3840import {
3941 coerceTagFilterValue ,
4042 escapeLikePattern ,
@@ -491,6 +493,13 @@ export function getStructuredTagFilters(filters: StructuredFilter[], embeddingTa
491493 */
492494const FTS_CONFIG = 'english'
493495
496+ /**
497+ * Chunks Tin ranks before access is checked, widening while too few are readable to fill a page.
498+ * A caller past the permitted-set limit reads a large share of the index, so the first window
499+ * almost always fills; the widest bounds the work before the GIN ranking takes over.
500+ */
501+ const TIN_KEYWORD_WINDOWS = [ 2000 , 10_000 , 50_000 ] as const
502+
494503/**
495504 * Row visibility predicates shared by every search leg: a chunk is only
496505 * retrievable when both it and its document are enabled, the document finished
@@ -1348,6 +1357,96 @@ export async function executeKeywordSearch(params: KeywordSearchParams): Promise
13481357 ...tagFilterConditions ,
13491358 ]
13501359 const candidateRank = sql < number > `ts_rank_cd(${ embeddingKeywordSearch . contentTsv } , ${ tsQuery } )`
1360+ /**
1361+ * A caller reaching past the permitted-set limit reads much of the index, so ranking every
1362+ * match before checking access is the leg's whole cost for a common term. Where the Tin
1363+ * projection is complete, BM25 ranks inside the bases first and access is checked only on the
1364+ * top of that ranking.
1365+ */
1366+ const tinQuery =
1367+ params . permitted ?. kind === 'unbounded' && tagFilterConditions . length === 0
1368+ ? await resolveTinKeywordQuery ( knowledgeBaseIds , query , FTS_CONFIG , params . budget )
1369+ : null
1370+ annotateSearchDiagnostics ( {
1371+ ...( params . permitted ?. kind === 'unbounded'
1372+ ? { keywordRanking : tinQuery ? 'tin' : 'gin' }
1373+ : { } ) ,
1374+ } )
1375+ const documentConditions = ( excludedSources : readonly string [ ] ) =>
1376+ and (
1377+ ...candidateDocumentConditions (
1378+ knowledgeBaseIds ,
1379+ access ,
1380+ params . filters ,
1381+ knowledgeMetadataCandidateAccessCondition ( access )
1382+ ) ,
1383+ excludeSearchSources ( excludedSources )
1384+ )
1385+ /**
1386+ * One page from the top of Tin's ranking. The window of ranked chunks widens while too few of
1387+ * them are readable to fill the page; if the widest window still cannot, the page is left to
1388+ * the GIN ranking, which covers every match.
1389+ */
1390+ const selectTinPage = async (
1391+ scopedQuery : SQL ,
1392+ limit : number ,
1393+ offset : number ,
1394+ excludedSources : readonly string [ ]
1395+ ) : Promise < SearchReadCandidatePage | null > => {
1396+ for ( const window of TIN_KEYWORD_WINDOWS ) {
1397+ if ( window < offset + limit ) continue
1398+ const [ page ] = await runSearchQuery ( params . budget , 'keyword.tin' , ( executor ) =>
1399+ executor . execute < { ranked : number ; candidates : SearchReadCandidate [ ] } > ( sql `
1400+ WITH ranked_tin_chunks AS MATERIALIZED (
1401+ SELECT ${ embeddingKeywordTin . id } AS id, ${ embeddingKeywordTin . documentId } AS document_id,
1402+ ${ embeddingKeywordTin . enabled } AS enabled,
1403+ tin.full_score(${ embeddingKeywordTin } .ctid) AS keyword_rank
1404+ FROM ${ embeddingKeywordTin }
1405+ WHERE ${ embeddingKeywordTin . content } ==> (${ scopedQuery } )
1406+ ORDER BY keyword_rank DESC
1407+ LIMIT ${ window }
1408+ ), visible_keyword_documents AS MATERIALIZED (
1409+ SELECT ${ document . id } AS id FROM ${ document }
1410+ WHERE ${ and (
1411+ sql `${ document . id } = ANY (ARRAY(SELECT document_id FROM ranked_tin_chunks))` ,
1412+ documentConditions ( excludedSources )
1413+ ) }
1414+ ), page AS (
1415+ SELECT ranked_tin_chunks.id, ${ document . id } AS "documentId",
1416+ ${ document . connectorId } AS "connectorId",
1417+ ${ SEARCH_READ_CANDIDATE_FIELDS . liveAuthorizationSource } AS "liveAuthorizationSource",
1418+ ranked_tin_chunks.keyword_rank
1419+ FROM ranked_tin_chunks INNER JOIN ${ document }
1420+ ON ${ document . id } = ranked_tin_chunks.document_id
1421+ WHERE ranked_tin_chunks.enabled
1422+ AND ranked_tin_chunks.document_id IN (SELECT id FROM visible_keyword_documents)
1423+ ORDER BY ranked_tin_chunks.keyword_rank DESC, ranked_tin_chunks.id
1424+ LIMIT ${ limit } OFFSET ${ offset }
1425+ )
1426+ SELECT (SELECT count(*)::int FROM ranked_tin_chunks) AS ranked,
1427+ coalesce((
1428+ SELECT json_agg(json_build_object(
1429+ 'id', page.id, 'documentId', page."documentId", 'connectorId', page."connectorId",
1430+ 'liveAuthorizationSource', page."liveAuthorizationSource"
1431+ ) ORDER BY page.keyword_rank DESC, page.id)
1432+ FROM page
1433+ ), '[]'::json) AS candidates
1434+ ` )
1435+ )
1436+ annotateSearchDiagnostics ( { keywordTinWindow : window } )
1437+ if ( page . candidates . length === limit || page . ranked < window ) {
1438+ return { candidates : page . candidates , nextOffset : offset + page . candidates . length }
1439+ }
1440+ }
1441+ return null
1442+ }
1443+ /** Parenthesized where used: `==>` binds tighter than `||`. */
1444+ const tinScope = tinQuery
1445+ ? sql `'(' || ${ sql . join (
1446+ knowledgeBaseIds . map ( ( id ) => sql `knowledge_tin_base_token(${ id } ) || '^0'` ) ,
1447+ sql ` || ' OR ' || `
1448+ ) } || ') AND (' || ${ tinQuery } || ')'`
1449+ : undefined
13511450 /** Keep readable identities and rank scalars separate so sorts never carry full text-search vectors. */
13521451 return selectAuthorizedSearchResults ( {
13531452 leg : 'keyword' ,
@@ -1368,6 +1467,11 @@ export async function executeKeywordSearch(params: KeywordSearchParams): Promise
13681467 ? permittedDocumentIds ( params . permitted . documents , excludedSources )
13691468 : undefined
13701469 if ( permittedIds ?. length === 0 ) return { candidates : [ ] , nextOffset : offset }
1470+ if ( tinScope && ! permittedIds ) {
1471+ const tinPage = await selectTinPage ( tinScope , limit , offset , excludedSources )
1472+ if ( tinPage ) return tinPage
1473+ annotateSearchDiagnostics ( { keywordRanking : 'gin' } )
1474+ }
13711475 const baseScope = and (
13721476 inArray ( embeddingKeywordSearch . knowledgeBaseId , knowledgeBaseIds ) ,
13731477 eq ( embeddingKeywordSearch . enabled , true )
@@ -1411,13 +1515,7 @@ export async function executeKeywordSearch(params: KeywordSearchParams): Promise
14111515 SELECT ${ document . id } AS id FROM ${ document }
14121516 WHERE ${ and (
14131517 sql `${ document . id } = ANY (ARRAY(SELECT document_id FROM matched_keyword_chunks))` ,
1414- ...candidateDocumentConditions (
1415- knowledgeBaseIds ,
1416- access ,
1417- params . filters ,
1418- knowledgeMetadataCandidateAccessCondition ( access )
1419- ) ,
1420- excludeSearchSources ( excludedSources )
1518+ documentConditions ( excludedSources )
14211519 ) }
14221520 ), ranked_keyword_candidates AS MATERIALIZED (
14231521 SELECT matched_keyword_chunks.id, matched_keyword_chunks.document_id,
0 commit comments