Skip to content

Commit 96090e0

Browse files
authored
fix(knowledge): treat a reach of nothing as a bounded set of nothing (#8113)
1 parent d03d33e commit 96090e0

2 files changed

Lines changed: 106 additions & 17 deletions

File tree

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

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2051,6 +2051,75 @@ describe('permitted-document planner', () => {
20512051
expect(reachCounts()).toHaveLength(2)
20522052
})
20532053

2054+
it('reports a caller who reaches nothing as a bounded set of nothing, counted every time', async () => {
2055+
dbChainMockFns.execute.mockImplementation(async (query) => {
2056+
const statement = render(query).sql
2057+
if (statement.includes('EXPLAIN'))
2058+
return [{ 'QUERY PLAN': [{ Plan: { 'Plan Rows': 1_000_000 } }] }]
2059+
if (statement.includes(') reached')) return [{ n: 0 }]
2060+
return []
2061+
})
2062+
const reachCounts = () => statements().filter((query) => query.sql.includes(') reached'))
2063+
const plan = {
2064+
connectors: { workspace: [], admin: [], members: [], liveProofRequired: [] },
2065+
observers: { confirmed: [], observed: [] },
2066+
memberSources: [],
2067+
connectorTypes: new Map(),
2068+
uploads: true,
2069+
}
2070+
const budget = () => new SearchBudget('vector', performance.now() + 10_000)
2071+
await expect(
2072+
resolveReach(['org-index'], scope('reaches-nothing'), budget(), plan)
2073+
).resolves.toEqual({ kind: 'bounded', documents: [] })
2074+
/** Emptiness decides completeness, so it is never remembered: the next search counts again. */
2075+
await expect(
2076+
resolveReach(['org-index'], scope('reaches-nothing'), budget(), plan)
2077+
).resolves.toEqual({ kind: 'bounded', documents: [] })
2078+
expect(reachCounts()).toHaveLength(2)
2079+
})
2080+
2081+
it('reports a saturated probe whose count then finds nothing as a bounded set of nothing', async () => {
2082+
dbChainMockFns.execute.mockImplementation(async (query) => {
2083+
const statement = render(query).sql
2084+
if (isProbeStatement(statement)) return [{ id: null, connectorId: null, saturated: true }]
2085+
if (statement.includes('EXPLAIN'))
2086+
return [{ 'QUERY PLAN': [{ Plan: { 'Plan Rows': 1_000_000 } }] }]
2087+
if (statement.includes(') reached')) return [{ n: 0 }]
2088+
return []
2089+
})
2090+
await expect(
2091+
resolvePermittedDocuments({
2092+
knowledgeBaseIds: ['org-index'],
2093+
access: scope('saturated-then-nothing'),
2094+
budget: new SearchBudget('vector', performance.now() + 10_000),
2095+
})
2096+
).resolves.toEqual({ kind: 'bounded', documents: [] })
2097+
})
2098+
2099+
it('does not read an unanalyzed index as a reach of nothing', async () => {
2100+
/** The planner knows no rows yet, so the bound is zero and the count looked at nothing. */
2101+
dbChainMockFns.execute.mockImplementation(async (query) => {
2102+
const statement = render(query).sql
2103+
if (statement.includes('EXPLAIN')) return [{ 'QUERY PLAN': [{ Plan: { 'Plan Rows': 0 } }] }]
2104+
if (statement.includes(') reached')) return [{ n: 0 }]
2105+
return []
2106+
})
2107+
await expect(
2108+
resolveReach(
2109+
['org-index'],
2110+
scope('unanalyzed'),
2111+
new SearchBudget('vector', performance.now() + 10_000),
2112+
{
2113+
connectors: { workspace: [], admin: [], members: [], liveProofRequired: [] },
2114+
observers: { confirmed: [], observed: [] },
2115+
memberSources: [],
2116+
connectorTypes: new Map(),
2117+
uploads: true,
2118+
}
2119+
)
2120+
).resolves.toEqual({ kind: 'unbounded', broad: true })
2121+
})
2122+
20542123
it('reports a leg whose own deadline passed during the count as short, not failed', async () => {
20552124
const budget = new SearchBudget('vector', performance.now() - 1)
20562125
await expect(

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

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1184,7 +1184,19 @@ export const BROAD_REACH_SHARE = 0.25
11841184
*/
11851185
const SATURATED_REACH_TTL_MS = 5 * 60 * 1000
11861186

1187-
/** A saturated reach, and whether it is broad enough to walk the whole graph for. */
1187+
/**
1188+
* A counted reach: whether it is broad enough to walk the whole graph for, or empty, in which
1189+
* case the caller reads nothing in these bases and no leg has anything to rank.
1190+
*/
1191+
interface CountedReach {
1192+
broad: boolean
1193+
empty: boolean
1194+
}
1195+
1196+
/**
1197+
* Only breadth is remembered. Emptiness decides completeness, not strategy, so it is counted on
1198+
* every search: the count of a reach of nothing finds nothing and costs almost nothing.
1199+
*/
11881200
const saturatedReach = new LRUCache<string, { broad: boolean }>({
11891201
max: 10_000,
11901202
ttl: SATURATED_REACH_TTL_MS,
@@ -1249,32 +1261,36 @@ async function estimateFilteredDocuments(
12491261
}
12501262

12511263
/**
1252-
* Whether a reach is broad: the caller reaches at least {@link BROAD_REACH_SHARE} of the bases'
1253-
* documents. Counted once against that bound and remembered, so the first search after the
1254-
* window pays for it and the rest do not. A caller whose probe already saturated is known to
1255-
* reach past the probe's limit, so a bound inside that limit is met without counting.
1264+
* How far a caller reaches: broad when they reach at least {@link BROAD_REACH_SHARE} of the
1265+
* bases' documents, empty when they reach none. A reach of nothing is a bounded set of nothing: a
1266+
* caller who reads no document in these bases, such as a member with no source of their own yet,
1267+
* has nothing for any leg to rank, where an unbounded set would have each leg scan to its
1268+
* deadline for rows it cannot find. Breadth is counted once against the bound and remembered, so
1269+
* the first search after the window pays for it and the rest do not. A caller whose probe already
1270+
* saturated is known to reach past the probe's limit, so a bound inside that limit is met without
1271+
* counting.
12561272
*
12571273
* The count reads as many index entries as the caller reaches, so on a large index it can cost
12581274
* more than the leg it serves; it gets the probe's share of the deadline, never the whole leg's.
12591275
* A count that runs out of that share answers `null`: the leg keeps its time and its deadline
12601276
* intact, and the caller decides this search alone without remembering anything.
12611277
*/
1262-
async function reachIsBroad(
1278+
async function countReach(
12631279
knowledgeBaseIds: string[],
12641280
access: KnowledgeAccessScope,
12651281
budget: SearchBudget | undefined,
12661282
plan: SearchAccessPlan | undefined,
12671283
saturated: boolean
1268-
): Promise<boolean | null> {
1269-
if (access.kind !== 'user') return true
1284+
): Promise<CountedReach | null> {
1285+
if (access.kind !== 'user') return { broad: true, empty: false }
12701286
const countBudget = budget?.capped(VECTOR_PROBE_BUDGET_MS)
12711287
try {
12721288
const total =
12731289
(await indexDocumentCounts.fetch([...knowledgeBaseIds].sort().join(','), {
12741290
context: countBudget,
12751291
})) ?? 0
12761292
const bound = Math.ceil(total * BROAD_REACH_SHARE)
1277-
if (saturated && bound <= VECTOR_PROBE_DOCUMENT_LIMIT) return true
1293+
if (saturated && bound <= VECTOR_PROBE_DOCUMENT_LIMIT) return { broad: true, empty: false }
12781294
const [row] = await runSearchQuery(countBudget, 'permitted_documents', (executor) =>
12791295
executor.execute<{ n: number }>(sql`
12801296
SELECT count(*) AS n FROM (
@@ -1288,7 +1304,9 @@ async function reachIsBroad(
12881304
LIMIT ${bound}
12891305
) reached`)
12901306
)
1291-
return Number(row?.n ?? 0) >= bound
1307+
const reached = Number(row?.n ?? 0)
1308+
/** A count that looked and found nothing: only a bound of zero looks at nothing. */
1309+
return { broad: reached >= bound, empty: bound > 0 && reached === 0 }
12921310
} catch (error) {
12931311
if (!budget || !countBudget?.isTimeout(error)) throw error
12941312
/** Only the count's share was spent; the leg's own deadline still governs. */
@@ -1345,11 +1363,12 @@ export async function resolveReach(
13451363
const remembered = key ? saturatedReach.get(key) : undefined
13461364
if (remembered) return { kind: 'unbounded', broad: remembered.broad }
13471365
try {
1348-
const broad = await reachIsBroad(knowledgeBaseIds, access, budget, plan, false)
1366+
const reach = await countReach(knowledgeBaseIds, access, budget, plan, false)
13491367
/** A count that ran out of time decides this search only; the next one counts again. */
1350-
if (broad === null) return { kind: 'unbounded', broad: true }
1351-
if (key) saturatedReach.set(key, { broad })
1352-
return { kind: 'unbounded', broad }
1368+
if (reach === null) return { kind: 'unbounded', broad: true }
1369+
if (reach.empty) return { kind: 'bounded', documents: [] }
1370+
if (key) saturatedReach.set(key, { broad: reach.broad })
1371+
return { kind: 'unbounded', broad: reach.broad }
13531372
} catch (error) {
13541373
/** The leg's own deadline passed during the count: the leg is short, the search is not failed. */
13551374
if (!budget?.isTimeout(error)) throw error
@@ -1408,16 +1427,17 @@ export async function resolvePermittedDocuments(params: {
14081427
}
14091428
if (probe.kind === 'saturated') {
14101429
try {
1411-
const counted = await reachIsBroad(
1430+
const reach = await countReach(
14121431
params.knowledgeBaseIds,
14131432
params.access,
14141433
params.budget,
14151434
params.accessPlan,
14161435
true
14171436
)
14181437
/** A count that ran out of time decides this search only; the next one counts again. */
1419-
if (counted !== null) {
1420-
broad = counted
1438+
if (reach?.empty) probe = { kind: 'documents', documents: [] }
1439+
else if (reach !== null) {
1440+
broad = reach.broad
14211441
if (key) saturatedReach.set(key, { broad })
14221442
}
14231443
} catch (error) {

0 commit comments

Comments
 (0)