Skip to content

Commit 8b1a096

Browse files
committed
improvement(knowledge): remember a caller's saturated search reach
A caller whose tokens reach more documents than the permitted-set limit paid the reach count on every search only to learn again that the set is unbounded. That answer is now remembered per bases and token set for five minutes. The probe now reports saturation apart from a timeout, and only saturation is remembered; an unbounded set only means the legs apply the full access predicate per candidate, so a stale answer costs speed, never access.
1 parent f76aca1 commit 8b1a096

2 files changed

Lines changed: 113 additions & 31 deletions

File tree

‎apps/sim/lib/knowledge/search/queries.test.ts‎

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1529,13 +1529,57 @@ describe('permitted-document planner', () => {
15291529
probeRows = [...rows]
15301530
const permitted = await resolvePermittedDocuments({
15311531
knowledgeBaseIds: ['org-index'],
1532-
access: reader,
1532+
access: { ...reader, tokens: [`u:resolves-${kind}@example.com`] },
15331533
})
15341534
expect(permitted.kind).toBe(kind)
15351535
if (permitted.kind === 'bounded')
15361536
expect(permitted.documents).toEqual([{ id: 'doc-a', connectorId: null }])
15371537
})
15381538

1539+
describe('saturated reach', () => {
1540+
const scope = (name: string): UserAccessScope => ({
1541+
...reader,
1542+
tokens: [`u:${name}@example.com`],
1543+
})
1544+
const resolve = (access: UserAccessScope, knowledgeBaseIds = ['org-index']) =>
1545+
resolvePermittedDocuments({ knowledgeBaseIds, access })
1546+
const probes = () => statements().filter((query) => isProbeStatement(query.sql)).length
1547+
1548+
it('is remembered, so a broad caller skips the probe on the next search', async () => {
1549+
probeRows = [{ id: null, connectorId: null, saturated: true }]
1550+
const broad = scope('broad')
1551+
expect((await resolve(broad)).kind).toBe('unbounded')
1552+
expect((await resolve({ ...broad, tokens: [...broad.tokens].reverse() })).kind).toBe(
1553+
'unbounded'
1554+
)
1555+
expect(probes()).toBe(1)
1556+
})
1557+
1558+
it('is remembered per set of bases and tokens', async () => {
1559+
probeRows = [{ id: null, connectorId: null, saturated: true }]
1560+
await resolve(scope('per-key'))
1561+
probeRows = [{ id: 'doc-a', connectorId: null, saturated: false }]
1562+
expect((await resolve(scope('per-key'), ['other-index'])).kind).toBe('bounded')
1563+
expect((await resolve(scope('per-key-other'))).kind).toBe('bounded')
1564+
expect(probes()).toBe(3)
1565+
})
1566+
1567+
it('is not inferred from a bounded set or a probe that ran out of time', async () => {
1568+
probeRows = [{ id: 'doc-a', connectorId: null, saturated: false }]
1569+
await resolve(scope('bounded'))
1570+
await resolve(scope('bounded'))
1571+
expect(probes()).toBe(2)
1572+
const budget = new SearchBudget('vector', performance.now() - 1)
1573+
await resolvePermittedDocuments({
1574+
knowledgeBaseIds: ['org-index'],
1575+
access: scope('timed-out'),
1576+
budget,
1577+
})
1578+
probeRows = [{ id: 'doc-a', connectorId: null, saturated: false }]
1579+
expect((await resolve(scope('timed-out'))).kind).toBe('bounded')
1580+
})
1581+
})
1582+
15391583
it('reports an exhausted vector budget as unbounded instead of failing both legs', async () => {
15401584
const budget = new SearchBudget('vector', performance.now() - 1)
15411585
const permitted = await resolvePermittedDocuments({

‎apps/sim/lib/knowledge/search/queries.ts‎

Lines changed: 68 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import {
77
knowledgeConnector,
88
} from '@sim/db/schema'
99
import { createLogger } from '@sim/logger'
10+
import { sha256Hex } from '@sim/security/hash'
1011
import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors'
1112
import { and, eq, inArray, isNull, type SQL, sql } from 'drizzle-orm'
13+
import { LRUCache } from 'lru-cache'
1214
import {
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
*/
846854
async 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+
960991
export 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

Comments
 (0)