Skip to content

Commit acff580

Browse files
committed
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.
1 parent d03d33e commit acff580

2 files changed

Lines changed: 107 additions & 20 deletions

File tree

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2051,6 +2051,67 @@ 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, and remembers it', 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+
expect(reachCounts()).toHaveLength(1)
2075+
/** Remembered like any reach: the next search neither counts nor probes. */
2076+
await expect(
2077+
resolveReach(['org-index'], scope('reaches-nothing'), budget(), plan)
2078+
).resolves.toEqual({ kind: 'bounded', documents: [] })
2079+
await expect(
2080+
resolvePermittedDocuments({
2081+
knowledgeBaseIds: ['org-index'],
2082+
access: scope('reaches-nothing'),
2083+
budget: budget(),
2084+
accessPlan: plan,
2085+
})
2086+
).resolves.toEqual({ kind: 'bounded', documents: [] })
2087+
expect(reachCounts()).toHaveLength(1)
2088+
expect(probes()).toBe(0)
2089+
})
2090+
2091+
it('does not read an unanalyzed index as a reach of nothing', async () => {
2092+
/** The planner knows no rows yet, so the bound is zero and the count looked at nothing. */
2093+
dbChainMockFns.execute.mockImplementation(async (query) => {
2094+
const statement = render(query).sql
2095+
if (statement.includes('EXPLAIN')) return [{ 'QUERY PLAN': [{ Plan: { 'Plan Rows': 0 } }] }]
2096+
if (statement.includes(') reached')) return [{ n: 0 }]
2097+
return []
2098+
})
2099+
await expect(
2100+
resolveReach(
2101+
['org-index'],
2102+
scope('unanalyzed'),
2103+
new SearchBudget('vector', performance.now() + 10_000),
2104+
{
2105+
connectors: { workspace: [], admin: [], members: [], liveProofRequired: [] },
2106+
observers: { confirmed: [], observed: [] },
2107+
memberSources: [],
2108+
connectorTypes: new Map(),
2109+
uploads: true,
2110+
}
2111+
)
2112+
).resolves.toEqual({ kind: 'unbounded', broad: true })
2113+
})
2114+
20542115
it('reports a leg whose own deadline passed during the count as short, not failed', async () => {
20552116
const budget = new SearchBudget('vector', performance.now() - 1)
20562117
await expect(

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

Lines changed: 46 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1184,8 +1184,16 @@ 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. */
1188-
const saturatedReach = new LRUCache<string, { broad: boolean }>({
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 RememberedReach {
1192+
broad: boolean
1193+
empty: boolean
1194+
}
1195+
1196+
const saturatedReach = new LRUCache<string, RememberedReach>({
11891197
max: 10_000,
11901198
ttl: SATURATED_REACH_TTL_MS,
11911199
})
@@ -1249,32 +1257,33 @@ async function estimateFilteredDocuments(
12491257
}
12501258

12511259
/**
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.
1260+
* How far a caller reaches: broad when they reach at least {@link BROAD_REACH_SHARE} of the
1261+
* bases' documents, empty when they reach none. Counted once against that bound and remembered,
1262+
* so the first search after the window pays for it and the rest do not. A caller whose probe
1263+
* already saturated is known to reach past the probe's limit, so a bound inside that limit is
1264+
* met without counting.
12561265
*
12571266
* The count reads as many index entries as the caller reaches, so on a large index it can cost
12581267
* more than the leg it serves; it gets the probe's share of the deadline, never the whole leg's.
12591268
* A count that runs out of that share answers `null`: the leg keeps its time and its deadline
12601269
* intact, and the caller decides this search alone without remembering anything.
12611270
*/
1262-
async function reachIsBroad(
1271+
async function countReach(
12631272
knowledgeBaseIds: string[],
12641273
access: KnowledgeAccessScope,
12651274
budget: SearchBudget | undefined,
12661275
plan: SearchAccessPlan | undefined,
12671276
saturated: boolean
1268-
): Promise<boolean | null> {
1269-
if (access.kind !== 'user') return true
1277+
): Promise<RememberedReach | null> {
1278+
if (access.kind !== 'user') return { broad: true, empty: false }
12701279
const countBudget = budget?.capped(VECTOR_PROBE_BUDGET_MS)
12711280
try {
12721281
const total =
12731282
(await indexDocumentCounts.fetch([...knowledgeBaseIds].sort().join(','), {
12741283
context: countBudget,
12751284
})) ?? 0
12761285
const bound = Math.ceil(total * BROAD_REACH_SHARE)
1277-
if (saturated && bound <= VECTOR_PROBE_DOCUMENT_LIMIT) return true
1286+
if (saturated && bound <= VECTOR_PROBE_DOCUMENT_LIMIT) return { broad: true, empty: false }
12781287
const [row] = await runSearchQuery(countBudget, 'permitted_documents', (executor) =>
12791288
executor.execute<{ n: number }>(sql`
12801289
SELECT count(*) AS n FROM (
@@ -1288,7 +1297,9 @@ async function reachIsBroad(
12881297
LIMIT ${bound}
12891298
) reached`)
12901299
)
1291-
return Number(row?.n ?? 0) >= bound
1300+
const reached = Number(row?.n ?? 0)
1301+
/** A count that looked and found nothing: only a bound of zero looks at nothing. */
1302+
return { broad: reached >= bound, empty: bound > 0 && reached === 0 }
12921303
} catch (error) {
12931304
if (!budget || !countBudget?.isTimeout(error)) throw error
12941305
/** Only the count's share was spent; the leg's own deadline still governs. */
@@ -1343,20 +1354,31 @@ export async function resolveReach(
13431354
): Promise<PermittedDocuments> {
13441355
const key = reachKey(knowledgeBaseIds, access, plan)
13451356
const remembered = key ? saturatedReach.get(key) : undefined
1346-
if (remembered) return { kind: 'unbounded', broad: remembered.broad }
1357+
if (remembered) return permittedFromReach(remembered)
13471358
try {
1348-
const broad = await reachIsBroad(knowledgeBaseIds, access, budget, plan, false)
1359+
const reach = await countReach(knowledgeBaseIds, access, budget, plan, false)
13491360
/** 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 }
1361+
if (reach === null) return { kind: 'unbounded', broad: true }
1362+
if (key) saturatedReach.set(key, reach)
1363+
return permittedFromReach(reach)
13531364
} catch (error) {
13541365
/** The leg's own deadline passed during the count: the leg is short, the search is not failed. */
13551366
if (!budget?.isTimeout(error)) throw error
13561367
return { kind: 'unbounded', broad: true }
13571368
}
13581369
}
13591370

1371+
/**
1372+
* A reach of nothing is a bounded set of nothing: a caller who reads no document in these bases,
1373+
* such as a member with no source of their own yet, has nothing for any leg to rank, where an
1374+
* unbounded set would have each leg scan for rows it cannot find.
1375+
*/
1376+
function permittedFromReach(reach: RememberedReach): PermittedDocuments {
1377+
return reach.empty
1378+
? { kind: 'bounded', documents: [] }
1379+
: { kind: 'unbounded', broad: reach.broad }
1380+
}
1381+
13601382
/**
13611383
* Resolve the permitted set with the candidate predicate both legs apply, so restricting a leg
13621384
* 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: {
13841406
params.accessPlan && (dateFilterCondition(params.filters) || params.filters?.source)
13851407
)
13861408
const remembered = key && !filteredDirectly ? saturatedReach.get(key) : undefined
1409+
if (remembered?.empty) {
1410+
annotateSearchDiagnostics({ permittedDocuments: 'bounded', permittedDocumentCount: 0 })
1411+
return { kind: 'bounded', documents: [] }
1412+
}
13871413
if (remembered) {
13881414
probe = { kind: 'saturated' }
13891415
broad = remembered.broad
@@ -1408,17 +1434,17 @@ export async function resolvePermittedDocuments(params: {
14081434
}
14091435
if (probe.kind === 'saturated') {
14101436
try {
1411-
const counted = await reachIsBroad(
1437+
const reach = await countReach(
14121438
params.knowledgeBaseIds,
14131439
params.access,
14141440
params.budget,
14151441
params.accessPlan,
14161442
true
14171443
)
14181444
/** A count that ran out of time decides this search only; the next one counts again. */
1419-
if (counted !== null) {
1420-
broad = counted
1421-
if (key) saturatedReach.set(key, { broad })
1445+
if (reach !== null) {
1446+
broad = reach.broad
1447+
if (key) saturatedReach.set(key, reach)
14221448
}
14231449
} catch (error) {
14241450
/** The leg's own deadline passed during the count: the leg is short, the search is not failed. */

0 commit comments

Comments
 (0)