@@ -7,8 +7,10 @@ import {
77 knowledgeConnector ,
88} from '@sim/db/schema'
99import { createLogger } from '@sim/logger'
10+ import { sha256Hex } from '@sim/security/hash'
1011import { getErrorMessage , getPostgresErrorCode } from '@sim/utils/errors'
1112import { and , eq , inArray , isNull , type SQL , sql } from 'drizzle-orm'
13+ import { LRUCache } from 'lru-cache'
1214import {
1315 knowledgeAccessCondition ,
1416 knowledgeAclOverlapCondition ,
@@ -833,23 +835,29 @@ export async function handleVectorOnlySearch(params: SearchParams): Promise<Sear
833835 return selectVectorResults ( params )
834836}
835837
838+ type ProbeOutcome =
839+ | { kind : 'documents' ; documents : PermittedDocument [ ] }
840+ /** The caller reads more documents than an exact ranking can afford. */
841+ | { kind : 'saturated' }
842+ /** The probe spent its own deadline before finding out. */
843+ | { kind : 'timed_out' }
844+
836845/**
837846 * Enumerate the documents the caller may read, stopping once there are more of them than an exact
838847 * ranking can afford. The bound is documents examined, not chunks accumulated: the access
839848 * predicate is evaluated once per document, and a search index holds only a few chunks per
840849 * document, so a chunk-bounded enumeration walks many times more documents than its limit says.
841850 *
842- * Returns the documents with their sources, or `null` when the permitted set exceeded that bound
843- * or the probe spent its own deadline finding out — neither is a failure of the leg, which keeps
844- * the candidates it already has.
851+ * Neither saturation nor a timeout is a failure of the leg, which keeps the candidates it
852+ * already has.
845853 */
846854async function probeVisibleDocuments (
847855 knowledgeBaseIds : string [ ] ,
848856 conditions : ( SQL | undefined ) [ ] ,
849857 access : KnowledgeAccessScope ,
850858 budget : SearchBudget | undefined ,
851859 stage : 'vector.probe' | 'permitted_documents'
852- ) : Promise < PermittedDocument [ ] | null > {
860+ ) : Promise < ProbeOutcome > {
853861 const probeBudget = budget ?. capped ( VECTOR_PROBE_BUDGET_MS )
854862 try {
855863 const probed = await runSearchQuery ( probeBudget , stage , ( executor ) =>
@@ -858,13 +866,18 @@ async function probeVisibleDocuments(
858866 )
859867 )
860868 /** The saturation sentinel is only ever emitted alone. */
861- if ( probed . length > VECTOR_PROBE_DOCUMENT_LIMIT || probed [ 0 ] ?. saturated ) return null
862- return probed . map ( ( { id, connectorId } ) => ( { id, connectorId } ) )
869+ if ( probed . length > VECTOR_PROBE_DOCUMENT_LIMIT || probed [ 0 ] ?. saturated ) {
870+ return { kind : 'saturated' }
871+ }
872+ return {
873+ kind : 'documents' ,
874+ documents : probed . map ( ( { id, connectorId } ) => ( { id, connectorId } ) ) ,
875+ }
863876 } catch ( error ) {
864877 if ( ! budget || ! probeBudget ?. isTimeout ( error ) ) throw error
865878 /** Only the probe's share was spent; the leg's own deadline still governs. */
866879 budget . remaining ( )
867- return null
880+ return { kind : 'timed_out' }
868881 }
869882}
870883
@@ -957,36 +970,61 @@ export type PermittedDocuments =
957970 * It runs ahead of both legs on the vector leg's budget, so exhausting that budget here reports
958971 * `unbounded` and marks the vector leg timed out rather than failing the keyword leg with it.
959972 */
973+ /**
974+ * How long a caller's saturated reach is remembered. Reach counts the documents a caller's tokens
975+ * touch in the bases, which moves slowly, and an unbounded set only means the legs search the
976+ * index with the full access predicate, so a stale answer costs speed, never access.
977+ */
978+ const SATURATED_REACH_TTL_MS = 5 * 60 * 1000
979+
980+ const saturatedReach = new LRUCache < string , true > ( { max : 10_000 , ttl : SATURATED_REACH_TTL_MS } )
981+
982+ /** Reach depends only on the bases and the caller's tokens; filters narrow the set, not the reach. */
983+ function reachKey (
984+ knowledgeBaseIds : readonly string [ ] ,
985+ access : KnowledgeAccessScope
986+ ) : string | null {
987+ if ( access . kind !== 'user' ) return null
988+ return `${ [ ...knowledgeBaseIds ] . sort ( ) . join ( ',' ) } :${ sha256Hex ( [ ...access . tokens ] . sort ( ) . join ( '\n' ) ) } `
989+ }
990+
960991export async function resolvePermittedDocuments ( params : {
961992 knowledgeBaseIds : string [ ]
962993 access : KnowledgeAccessScope
963994 filters ?: WorkspaceSearchFilters
964995 budget ?: SearchBudget
965996} ) : Promise < PermittedDocuments > {
966- let documents : PermittedDocument [ ] | null
967- try {
968- documents = await probeVisibleDocuments (
969- params . knowledgeBaseIds ,
970- candidateDocumentConditions (
997+ const key = reachKey ( params . knowledgeBaseIds , params . access )
998+ let probe : ProbeOutcome
999+ if ( key && saturatedReach . get ( key ) ) {
1000+ probe = { kind : 'saturated' }
1001+ } else {
1002+ try {
1003+ probe = await probeVisibleDocuments (
9711004 params . knowledgeBaseIds ,
1005+ candidateDocumentConditions (
1006+ params . knowledgeBaseIds ,
1007+ params . access ,
1008+ params . filters ,
1009+ knowledgeMetadataCandidateAccessCondition ( params . access )
1010+ ) ,
9721011 params . access ,
973- params . filters ,
974- knowledgeMetadataCandidateAccessCondition ( params . access )
975- ) ,
976- params . access ,
977- params . budget ,
978- 'permitted_documents'
979- )
980- } catch ( error ) {
981- if ( ! params . budget ?. isTimeout ( error ) ) throw error
982- documents = null
1012+ params . budget ,
1013+ 'permitted_documents'
1014+ )
1015+ } catch ( error ) {
1016+ if ( ! params . budget ?. isTimeout ( error ) ) throw error
1017+ probe = { kind : 'timed_out' }
1018+ }
1019+ if ( key && probe . kind === 'saturated' ) saturatedReach . set ( key , true )
9831020 }
984- const permitted : PermittedDocuments = documents
985- ? { kind : 'bounded' , documents }
986- : { kind : 'unbounded' }
1021+ const permitted : PermittedDocuments =
1022+ probe . kind === 'documents'
1023+ ? { kind : 'bounded' , documents : probe . documents }
1024+ : { kind : 'unbounded' }
9871025 annotateSearchDiagnostics ( {
9881026 permittedDocuments : permitted . kind ,
989- ...( documents ? { permittedDocumentCount : documents . length } : { } ) ,
1027+ ...( probe . kind === ' documents' ? { permittedDocumentCount : probe . documents . length } : { } ) ,
9901028 } )
9911029 return permitted
9921030}
@@ -1180,16 +1218,16 @@ async function selectVectorResults(params: SearchParams): Promise<SearchResult[]
11801218 * afford. An `unbounded` permitted set already proved it is not, so the probe is skipped.
11811219 */
11821220 if ( selected . length < candidateLimit && params . permitted ?. kind !== 'unbounded' ) {
1183- const visibleDocuments = await probeVisibleDocuments (
1221+ const probe = await probeVisibleDocuments (
11841222 params . knowledgeBaseIds ,
11851223 [ ...candidateDocumentVisibility , documentTagCondition ] ,
11861224 params . access ,
11871225 params . budget ,
11881226 'vector.probe'
11891227 )
1190- if ( visibleDocuments ) {
1191- annotateSearchDiagnostics ( { vectorProbeDocumentCount : visibleDocuments . length } )
1192- selected = await rankPermittedExactly ( visibleDocuments . map ( ( { id } ) => id ) )
1228+ if ( probe . kind === 'documents' ) {
1229+ annotateSearchDiagnostics ( { vectorProbeDocumentCount : probe . documents . length } )
1230+ selected = await rankPermittedExactly ( probe . documents . map ( ( { id } ) => id ) )
11931231 }
11941232 }
11951233 }
0 commit comments