From acff580ed2f3fe4a6c7435c4de744b404d3ddcd1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 21 Sep 2026 11:58:39 -0700 Subject: [PATCH 1/2] fix(knowledge): treat a reach of nothing as a bounded set of nothing A member who reads no document in the bases, such as one with no source of their own yet, resolved as an unbounded reach: the vector leg then scanned the sliced sources for readable rows it could not find until its deadline, and the keyword leg ranked every match to the same end, so a search that could return nothing spent every budget it had. A counted reach of zero is now a bounded empty set, remembered like any reach, and both legs answer at once. An index the planner has no rows for keeps its old answer, since a bound of zero looks at nothing. --- apps/sim/lib/knowledge/search/queries.test.ts | 61 +++++++++++++++++ apps/sim/lib/knowledge/search/queries.ts | 66 +++++++++++++------ 2 files changed, 107 insertions(+), 20 deletions(-) diff --git a/apps/sim/lib/knowledge/search/queries.test.ts b/apps/sim/lib/knowledge/search/queries.test.ts index 8dc22757afb..a87db76de49 100644 --- a/apps/sim/lib/knowledge/search/queries.test.ts +++ b/apps/sim/lib/knowledge/search/queries.test.ts @@ -2051,6 +2051,67 @@ describe('permitted-document planner', () => { expect(reachCounts()).toHaveLength(2) }) + it('reports a caller who reaches nothing as a bounded set of nothing, and remembers it', async () => { + dbChainMockFns.execute.mockImplementation(async (query) => { + const statement = render(query).sql + if (statement.includes('EXPLAIN')) + return [{ 'QUERY PLAN': [{ Plan: { 'Plan Rows': 1_000_000 } }] }] + if (statement.includes(') reached')) return [{ n: 0 }] + return [] + }) + const reachCounts = () => statements().filter((query) => query.sql.includes(') reached')) + const plan = { + connectors: { workspace: [], admin: [], members: [], liveProofRequired: [] }, + observers: { confirmed: [], observed: [] }, + memberSources: [], + connectorTypes: new Map(), + uploads: true, + } + const budget = () => new SearchBudget('vector', performance.now() + 10_000) + await expect( + resolveReach(['org-index'], scope('reaches-nothing'), budget(), plan) + ).resolves.toEqual({ kind: 'bounded', documents: [] }) + expect(reachCounts()).toHaveLength(1) + /** Remembered like any reach: the next search neither counts nor probes. */ + await expect( + resolveReach(['org-index'], scope('reaches-nothing'), budget(), plan) + ).resolves.toEqual({ kind: 'bounded', documents: [] }) + await expect( + resolvePermittedDocuments({ + knowledgeBaseIds: ['org-index'], + access: scope('reaches-nothing'), + budget: budget(), + accessPlan: plan, + }) + ).resolves.toEqual({ kind: 'bounded', documents: [] }) + expect(reachCounts()).toHaveLength(1) + expect(probes()).toBe(0) + }) + + it('does not read an unanalyzed index as a reach of nothing', async () => { + /** The planner knows no rows yet, so the bound is zero and the count looked at nothing. */ + dbChainMockFns.execute.mockImplementation(async (query) => { + const statement = render(query).sql + if (statement.includes('EXPLAIN')) return [{ 'QUERY PLAN': [{ Plan: { 'Plan Rows': 0 } }] }] + if (statement.includes(') reached')) return [{ n: 0 }] + return [] + }) + await expect( + resolveReach( + ['org-index'], + scope('unanalyzed'), + new SearchBudget('vector', performance.now() + 10_000), + { + connectors: { workspace: [], admin: [], members: [], liveProofRequired: [] }, + observers: { confirmed: [], observed: [] }, + memberSources: [], + connectorTypes: new Map(), + uploads: true, + } + ) + ).resolves.toEqual({ kind: 'unbounded', broad: true }) + }) + it('reports a leg whose own deadline passed during the count as short, not failed', async () => { const budget = new SearchBudget('vector', performance.now() - 1) await expect( diff --git a/apps/sim/lib/knowledge/search/queries.ts b/apps/sim/lib/knowledge/search/queries.ts index 4ae24ac79ad..5eb009491fe 100644 --- a/apps/sim/lib/knowledge/search/queries.ts +++ b/apps/sim/lib/knowledge/search/queries.ts @@ -1184,8 +1184,16 @@ export const BROAD_REACH_SHARE = 0.25 */ const SATURATED_REACH_TTL_MS = 5 * 60 * 1000 -/** A saturated reach, and whether it is broad enough to walk the whole graph for. */ -const saturatedReach = new LRUCache({ +/** + * A counted reach: whether it is broad enough to walk the whole graph for, or empty, in which + * case the caller reads nothing in these bases and no leg has anything to rank. + */ +interface RememberedReach { + broad: boolean + empty: boolean +} + +const saturatedReach = new LRUCache({ max: 10_000, ttl: SATURATED_REACH_TTL_MS, }) @@ -1249,24 +1257,25 @@ async function estimateFilteredDocuments( } /** - * Whether a reach is broad: the caller reaches at least {@link BROAD_REACH_SHARE} of the bases' - * documents. Counted once against that bound and remembered, so the first search after the - * window pays for it and the rest do not. A caller whose probe already saturated is known to - * reach past the probe's limit, so a bound inside that limit is met without counting. + * How far a caller reaches: broad when they reach at least {@link BROAD_REACH_SHARE} of the + * bases' documents, empty when they reach none. Counted once against that bound and remembered, + * so the first search after the window pays for it and the rest do not. A caller whose probe + * already saturated is known to reach past the probe's limit, so a bound inside that limit is + * met without counting. * * The count reads as many index entries as the caller reaches, so on a large index it can cost * more than the leg it serves; it gets the probe's share of the deadline, never the whole leg's. * A count that runs out of that share answers `null`: the leg keeps its time and its deadline * intact, and the caller decides this search alone without remembering anything. */ -async function reachIsBroad( +async function countReach( knowledgeBaseIds: string[], access: KnowledgeAccessScope, budget: SearchBudget | undefined, plan: SearchAccessPlan | undefined, saturated: boolean -): Promise { - if (access.kind !== 'user') return true +): Promise { + if (access.kind !== 'user') return { broad: true, empty: false } const countBudget = budget?.capped(VECTOR_PROBE_BUDGET_MS) try { const total = @@ -1274,7 +1283,7 @@ async function reachIsBroad( context: countBudget, })) ?? 0 const bound = Math.ceil(total * BROAD_REACH_SHARE) - if (saturated && bound <= VECTOR_PROBE_DOCUMENT_LIMIT) return true + if (saturated && bound <= VECTOR_PROBE_DOCUMENT_LIMIT) return { broad: true, empty: false } const [row] = await runSearchQuery(countBudget, 'permitted_documents', (executor) => executor.execute<{ n: number }>(sql` SELECT count(*) AS n FROM ( @@ -1288,7 +1297,9 @@ async function reachIsBroad( LIMIT ${bound} ) reached`) ) - return Number(row?.n ?? 0) >= bound + const reached = Number(row?.n ?? 0) + /** A count that looked and found nothing: only a bound of zero looks at nothing. */ + return { broad: reached >= bound, empty: bound > 0 && reached === 0 } } catch (error) { if (!budget || !countBudget?.isTimeout(error)) throw error /** Only the count's share was spent; the leg's own deadline still governs. */ @@ -1343,13 +1354,13 @@ export async function resolveReach( ): Promise { const key = reachKey(knowledgeBaseIds, access, plan) const remembered = key ? saturatedReach.get(key) : undefined - if (remembered) return { kind: 'unbounded', broad: remembered.broad } + if (remembered) return permittedFromReach(remembered) try { - const broad = await reachIsBroad(knowledgeBaseIds, access, budget, plan, false) + const reach = await countReach(knowledgeBaseIds, access, budget, plan, false) /** A count that ran out of time decides this search only; the next one counts again. */ - if (broad === null) return { kind: 'unbounded', broad: true } - if (key) saturatedReach.set(key, { broad }) - return { kind: 'unbounded', broad } + if (reach === null) return { kind: 'unbounded', broad: true } + if (key) saturatedReach.set(key, reach) + return permittedFromReach(reach) } catch (error) { /** The leg's own deadline passed during the count: the leg is short, the search is not failed. */ if (!budget?.isTimeout(error)) throw error @@ -1357,6 +1368,17 @@ export async function resolveReach( } } +/** + * A reach of nothing is a bounded set of nothing: a caller who reads no document in these bases, + * such as a member with no source of their own yet, has nothing for any leg to rank, where an + * unbounded set would have each leg scan for rows it cannot find. + */ +function permittedFromReach(reach: RememberedReach): PermittedDocuments { + return reach.empty + ? { kind: 'bounded', documents: [] } + : { kind: 'unbounded', broad: reach.broad } +} + /** * Resolve the permitted set with the candidate predicate both legs apply, so restricting a leg * to it never admits a document the leg would otherwise refuse. Tag filters stay chunk-level in @@ -1384,6 +1406,10 @@ export async function resolvePermittedDocuments(params: { params.accessPlan && (dateFilterCondition(params.filters) || params.filters?.source) ) const remembered = key && !filteredDirectly ? saturatedReach.get(key) : undefined + if (remembered?.empty) { + annotateSearchDiagnostics({ permittedDocuments: 'bounded', permittedDocumentCount: 0 }) + return { kind: 'bounded', documents: [] } + } if (remembered) { probe = { kind: 'saturated' } broad = remembered.broad @@ -1408,7 +1434,7 @@ export async function resolvePermittedDocuments(params: { } if (probe.kind === 'saturated') { try { - const counted = await reachIsBroad( + const reach = await countReach( params.knowledgeBaseIds, params.access, params.budget, @@ -1416,9 +1442,9 @@ export async function resolvePermittedDocuments(params: { true ) /** A count that ran out of time decides this search only; the next one counts again. */ - if (counted !== null) { - broad = counted - if (key) saturatedReach.set(key, { broad }) + if (reach !== null) { + broad = reach.broad + if (key) saturatedReach.set(key, reach) } } catch (error) { /** The leg's own deadline passed during the count: the leg is short, the search is not failed. */ From 72e4068a5937eb55f7d0fabd293a4b5336a8ee6e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 21 Sep 2026 12:06:07 -0700 Subject: [PATCH 2/2] fix(knowledge): count emptiness on every search and honor it on the probe path Emptiness decides completeness, not strategy, so it is never remembered: a member who gains a readable document is answered on their next search, and the count of a reach of nothing costs almost nothing. A saturated probe whose count then finds nothing answers the same bounded empty set. --- apps/sim/lib/knowledge/search/queries.test.ts | 24 ++++++---- apps/sim/lib/knowledge/search/queries.ts | 48 ++++++++----------- 2 files changed, 37 insertions(+), 35 deletions(-) diff --git a/apps/sim/lib/knowledge/search/queries.test.ts b/apps/sim/lib/knowledge/search/queries.test.ts index a87db76de49..219f323d72e 100644 --- a/apps/sim/lib/knowledge/search/queries.test.ts +++ b/apps/sim/lib/knowledge/search/queries.test.ts @@ -2051,7 +2051,7 @@ describe('permitted-document planner', () => { expect(reachCounts()).toHaveLength(2) }) - it('reports a caller who reaches nothing as a bounded set of nothing, and remembers it', async () => { + it('reports a caller who reaches nothing as a bounded set of nothing, counted every time', async () => { dbChainMockFns.execute.mockImplementation(async (query) => { const statement = render(query).sql if (statement.includes('EXPLAIN')) @@ -2071,21 +2071,29 @@ describe('permitted-document planner', () => { await expect( resolveReach(['org-index'], scope('reaches-nothing'), budget(), plan) ).resolves.toEqual({ kind: 'bounded', documents: [] }) - expect(reachCounts()).toHaveLength(1) - /** Remembered like any reach: the next search neither counts nor probes. */ + /** Emptiness decides completeness, so it is never remembered: the next search counts again. */ await expect( resolveReach(['org-index'], scope('reaches-nothing'), budget(), plan) ).resolves.toEqual({ kind: 'bounded', documents: [] }) + expect(reachCounts()).toHaveLength(2) + }) + + it('reports a saturated probe whose count then finds nothing as a bounded set of nothing', async () => { + dbChainMockFns.execute.mockImplementation(async (query) => { + const statement = render(query).sql + if (isProbeStatement(statement)) return [{ id: null, connectorId: null, saturated: true }] + if (statement.includes('EXPLAIN')) + return [{ 'QUERY PLAN': [{ Plan: { 'Plan Rows': 1_000_000 } }] }] + if (statement.includes(') reached')) return [{ n: 0 }] + return [] + }) await expect( resolvePermittedDocuments({ knowledgeBaseIds: ['org-index'], - access: scope('reaches-nothing'), - budget: budget(), - accessPlan: plan, + access: scope('saturated-then-nothing'), + budget: new SearchBudget('vector', performance.now() + 10_000), }) ).resolves.toEqual({ kind: 'bounded', documents: [] }) - expect(reachCounts()).toHaveLength(1) - expect(probes()).toBe(0) }) it('does not read an unanalyzed index as a reach of nothing', async () => { diff --git a/apps/sim/lib/knowledge/search/queries.ts b/apps/sim/lib/knowledge/search/queries.ts index 5eb009491fe..f2ff5bf066c 100644 --- a/apps/sim/lib/knowledge/search/queries.ts +++ b/apps/sim/lib/knowledge/search/queries.ts @@ -1188,12 +1188,16 @@ const SATURATED_REACH_TTL_MS = 5 * 60 * 1000 * A counted reach: whether it is broad enough to walk the whole graph for, or empty, in which * case the caller reads nothing in these bases and no leg has anything to rank. */ -interface RememberedReach { +interface CountedReach { broad: boolean empty: boolean } -const saturatedReach = new LRUCache({ +/** + * Only breadth is remembered. Emptiness decides completeness, not strategy, so it is counted on + * every search: the count of a reach of nothing finds nothing and costs almost nothing. + */ +const saturatedReach = new LRUCache({ max: 10_000, ttl: SATURATED_REACH_TTL_MS, }) @@ -1258,10 +1262,13 @@ async function estimateFilteredDocuments( /** * How far a caller reaches: broad when they reach at least {@link BROAD_REACH_SHARE} of the - * bases' documents, empty when they reach none. Counted once against that bound and remembered, - * so the first search after the window pays for it and the rest do not. A caller whose probe - * already saturated is known to reach past the probe's limit, so a bound inside that limit is - * met without counting. + * bases' documents, empty when they reach none. A reach of nothing is a bounded set of nothing: a + * caller who reads no document in these bases, such as a member with no source of their own yet, + * has nothing for any leg to rank, where an unbounded set would have each leg scan to its + * deadline for rows it cannot find. Breadth is counted once against the bound and remembered, so + * the first search after the window pays for it and the rest do not. A caller whose probe already + * saturated is known to reach past the probe's limit, so a bound inside that limit is met without + * counting. * * The count reads as many index entries as the caller reaches, so on a large index it can cost * more than the leg it serves; it gets the probe's share of the deadline, never the whole leg's. @@ -1274,7 +1281,7 @@ async function countReach( budget: SearchBudget | undefined, plan: SearchAccessPlan | undefined, saturated: boolean -): Promise { +): Promise { if (access.kind !== 'user') return { broad: true, empty: false } const countBudget = budget?.capped(VECTOR_PROBE_BUDGET_MS) try { @@ -1354,13 +1361,14 @@ export async function resolveReach( ): Promise { const key = reachKey(knowledgeBaseIds, access, plan) const remembered = key ? saturatedReach.get(key) : undefined - if (remembered) return permittedFromReach(remembered) + if (remembered) return { kind: 'unbounded', broad: remembered.broad } try { const reach = await countReach(knowledgeBaseIds, access, budget, plan, false) /** A count that ran out of time decides this search only; the next one counts again. */ if (reach === null) return { kind: 'unbounded', broad: true } - if (key) saturatedReach.set(key, reach) - return permittedFromReach(reach) + if (reach.empty) return { kind: 'bounded', documents: [] } + if (key) saturatedReach.set(key, { broad: reach.broad }) + return { kind: 'unbounded', broad: reach.broad } } catch (error) { /** The leg's own deadline passed during the count: the leg is short, the search is not failed. */ if (!budget?.isTimeout(error)) throw error @@ -1368,17 +1376,6 @@ export async function resolveReach( } } -/** - * A reach of nothing is a bounded set of nothing: a caller who reads no document in these bases, - * such as a member with no source of their own yet, has nothing for any leg to rank, where an - * unbounded set would have each leg scan for rows it cannot find. - */ -function permittedFromReach(reach: RememberedReach): PermittedDocuments { - return reach.empty - ? { kind: 'bounded', documents: [] } - : { kind: 'unbounded', broad: reach.broad } -} - /** * Resolve the permitted set with the candidate predicate both legs apply, so restricting a leg * to it never admits a document the leg would otherwise refuse. Tag filters stay chunk-level in @@ -1406,10 +1403,6 @@ export async function resolvePermittedDocuments(params: { params.accessPlan && (dateFilterCondition(params.filters) || params.filters?.source) ) const remembered = key && !filteredDirectly ? saturatedReach.get(key) : undefined - if (remembered?.empty) { - annotateSearchDiagnostics({ permittedDocuments: 'bounded', permittedDocumentCount: 0 }) - return { kind: 'bounded', documents: [] } - } if (remembered) { probe = { kind: 'saturated' } broad = remembered.broad @@ -1442,9 +1435,10 @@ export async function resolvePermittedDocuments(params: { true ) /** A count that ran out of time decides this search only; the next one counts again. */ - if (reach !== null) { + if (reach?.empty) probe = { kind: 'documents', documents: [] } + else if (reach !== null) { broad = reach.broad - if (key) saturatedReach.set(key, reach) + if (key) saturatedReach.set(key, { broad }) } } catch (error) { /** The leg's own deadline passed during the count: the leg is short, the search is not failed. */