Skip to content

Commit fd418a3

Browse files
committed
fix(knowledge): give the reach count the probe's share of the deadline, not the leg's
Counting a caller's reach reads as many index entries as they reach, so on a large index it took the whole vector budget and left the leg no time to walk; the search then reported the leg timed out with the count's stage measured twice. The count now runs under the probe's share of the deadline, a count that runs out answers nothing for this search without marking the leg, and the reach memo holds for an hour: reach moves slowly and a stale answer costs speed, never access.
1 parent d2a4e47 commit fd418a3

2 files changed

Lines changed: 58 additions & 47 deletions

File tree

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2075,9 +2075,10 @@ describe('permitted-document planner', () => {
20752075
)
20762076
expect(reach).toEqual({ kind: 'unbounded', broad: false })
20772077
expect(reachCounts()).toHaveLength(1)
2078-
/** The count is the search's own read: it runs inside the leg's deadline statement. */
2078+
/** The count is the search's own read, under the probe's share of the deadline, not the leg's. */
20792079
const countAt = statements().findIndex((query) => query.sql.includes(') reached'))
20802080
expect(statements()[countAt - 1].sql).toContain('statement_timeout')
2081+
expect(Number(statements()[countAt - 1].params[0])).toBeLessThanOrEqual(600)
20812082
})
20822083

20832084
it('does not remember a reach whose count ran out of time', async () => {
@@ -2102,6 +2103,8 @@ describe('permitted-document planner', () => {
21022103
})
21032104
expect(plan).toEqual({ kind: 'unbounded', broad: true })
21042105
expect(reachCounts()).toHaveLength(1)
2106+
/** Only the count's share of the deadline was spent; the leg is not the one that timed out. */
2107+
expect(budget.timedOut).toBe(false)
21052108
/** The next search counts again rather than trusting an answer that never came. */
21062109
await resolveReach(
21072110
['org-index'],

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

Lines changed: 54 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1180,9 +1180,11 @@ export const BROAD_REACH_SHARE = 0.25
11801180
/**
11811181
* How long a caller's saturated reach is remembered. Reach counts the documents a caller's tokens
11821182
* touch in the bases, which moves slowly, and an unbounded set only means the legs search the
1183-
* index with the full access predicate, so a stale answer costs speed, never access.
1183+
* index with the full access predicate, so a stale answer costs speed, never access. Counting it
1184+
* is the one read of a search that scales with the caller's reach rather than the query, so it is
1185+
* remembered for long.
11841186
*/
1185-
const SATURATED_REACH_TTL_MS = 5 * 60 * 1000
1187+
const SATURATED_REACH_TTL_MS = 60 * 60 * 1000
11861188

11871189
/** A saturated reach, and whether it is broad enough to walk the whole graph for. */
11881190
const saturatedReach = new LRUCache<string, { broad: boolean }>({
@@ -1253,35 +1255,48 @@ async function estimateFilteredDocuments(
12531255
* documents. Counted once against that bound and remembered, so the first search after the
12541256
* window pays for it and the rest do not. A caller whose probe already saturated is known to
12551257
* reach past the probe's limit, so a bound inside that limit is met without counting.
1258+
*
1259+
* The count reads as many index entries as the caller reaches, so on a large index it can cost
1260+
* more than the leg it serves; it gets the probe's share of the deadline, never the whole leg's.
1261+
* A count that runs out of that share answers `null`: the leg keeps its time and its deadline
1262+
* intact, and the caller decides this search alone without remembering anything.
12561263
*/
12571264
async function reachIsBroad(
12581265
knowledgeBaseIds: string[],
12591266
access: KnowledgeAccessScope,
12601267
budget: SearchBudget | undefined,
12611268
plan: SearchAccessPlan | undefined,
12621269
saturated: boolean
1263-
): Promise<boolean> {
1270+
): Promise<boolean | null> {
12641271
if (access.kind !== 'user') return true
1265-
const total =
1266-
(await indexDocumentCounts.fetch([...knowledgeBaseIds].sort().join(','), {
1267-
context: budget,
1268-
})) ?? 0
1269-
const bound = Math.ceil(total * BROAD_REACH_SHARE)
1270-
if (saturated && bound <= VECTOR_PROBE_DOCUMENT_LIMIT) return true
1271-
const [row] = await runSearchQuery(budget, 'permitted_documents', (executor) =>
1272-
executor.execute<{ n: number }>(sql`
1273-
SELECT count(*) AS n FROM (
1274-
SELECT 1 FROM ${document}
1275-
WHERE ${and(
1276-
isNull(document.deletedAt),
1277-
knowledgeAclOverlapCondition(access),
1278-
inArray(document.knowledgeBaseId, knowledgeBaseIds),
1279-
planSourceCondition(plan)
1280-
)}
1281-
LIMIT ${bound}
1282-
) reached`)
1283-
)
1284-
return Number(row?.n ?? 0) >= bound
1272+
const countBudget = budget?.capped(VECTOR_PROBE_BUDGET_MS)
1273+
try {
1274+
const total =
1275+
(await indexDocumentCounts.fetch([...knowledgeBaseIds].sort().join(','), {
1276+
context: countBudget,
1277+
})) ?? 0
1278+
const bound = Math.ceil(total * BROAD_REACH_SHARE)
1279+
if (saturated && bound <= VECTOR_PROBE_DOCUMENT_LIMIT) return true
1280+
const [row] = await runSearchQuery(countBudget, 'permitted_documents', (executor) =>
1281+
executor.execute<{ n: number }>(sql`
1282+
SELECT count(*) AS n FROM (
1283+
SELECT 1 FROM ${document}
1284+
WHERE ${and(
1285+
isNull(document.deletedAt),
1286+
knowledgeAclOverlapCondition(access),
1287+
inArray(document.knowledgeBaseId, knowledgeBaseIds),
1288+
planSourceCondition(plan)
1289+
)}
1290+
LIMIT ${bound}
1291+
) reached`)
1292+
)
1293+
return Number(row?.n ?? 0) >= bound
1294+
} catch (error) {
1295+
if (!budget || !countBudget?.isTimeout(error)) throw error
1296+
/** Only the count's share was spent; the leg's own deadline still governs. */
1297+
budget.remaining()
1298+
return null
1299+
}
12851300
}
12861301

12871302
/**
@@ -1331,15 +1346,11 @@ export async function resolveReach(
13311346
const key = reachKey(knowledgeBaseIds, access, plan)
13321347
const remembered = key ? saturatedReach.get(key) : undefined
13331348
if (remembered) return { kind: 'unbounded', broad: remembered.broad }
1334-
try {
1335-
const broad = await reachIsBroad(knowledgeBaseIds, access, budget, plan, false)
1336-
if (key) saturatedReach.set(key, { broad })
1337-
return { kind: 'unbounded', broad }
1338-
} catch (error) {
1339-
if (!budget?.isTimeout(error)) throw error
1340-
/** A count that ran out of time decides this search only; the next one counts again. */
1341-
return { kind: 'unbounded', broad: true }
1342-
}
1349+
const broad = await reachIsBroad(knowledgeBaseIds, access, budget, plan, false)
1350+
/** A count that ran out of time decides this search only; the next one counts again. */
1351+
if (broad === null) return { kind: 'unbounded', broad: true }
1352+
if (key) saturatedReach.set(key, { broad })
1353+
return { kind: 'unbounded', broad }
13431354
}
13441355

13451356
/**
@@ -1392,18 +1403,17 @@ export async function resolvePermittedDocuments(params: {
13921403
probe = { kind: 'timed_out' }
13931404
}
13941405
if (probe.kind === 'saturated') {
1395-
try {
1396-
broad = await reachIsBroad(
1397-
params.knowledgeBaseIds,
1398-
params.access,
1399-
params.budget,
1400-
params.accessPlan,
1401-
true
1402-
)
1406+
const counted = await reachIsBroad(
1407+
params.knowledgeBaseIds,
1408+
params.access,
1409+
params.budget,
1410+
params.accessPlan,
1411+
true
1412+
)
1413+
/** A count that ran out of time decides this search only; the next one counts again. */
1414+
if (counted !== null) {
1415+
broad = counted
14031416
if (key) saturatedReach.set(key, { broad })
1404-
} catch (error) {
1405-
if (!params.budget?.isTimeout(error)) throw error
1406-
/** A count that ran out of time decides this search only; the next one counts again. */
14071417
}
14081418
}
14091419
}
@@ -2658,9 +2668,7 @@ export async function retrieveKnowledgeSearch(
26582668
* readable documents enumerated ahead of ranking: its reach alone chooses between one
26592669
* walk over the whole graph and a search of each source.
26602670
*/
2661-
await measureSearchStage('permitted_documents', () =>
2662-
resolveReach(knowledgeBaseIds, access, budgets.vector, accessPlan)
2663-
)
2671+
await resolveReach(knowledgeBaseIds, access, budgets.vector, accessPlan)
26642672
: await resolvePermittedDocuments({
26652673
knowledgeBaseIds,
26662674
access,

0 commit comments

Comments
 (0)