Skip to content

Commit 454f44c

Browse files
committed
fix(knowledge): validate bounded compact scan plans
1 parent 7828bde commit 454f44c

2 files changed

Lines changed: 40 additions & 16 deletions

File tree

apps/sim/lib/knowledge/__integration__/search-latency.integration.ts

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ const unrelatedChunkCount = Number(
7575
const evictSharedBuffers = process.env.KNOWLEDGE_SEARCH_PERFORMANCE_EVICT_BUFFERS === 'true'
7676
const dimensions = 1536
7777
const candidateDimensions = 512
78-
const hybridCandidateLimit = 1600
78+
const HYBRID_CANDIDATE_LIMIT = 1600
7979
const chunksPerDocument = 4
8080
const logger = createLogger('SearchLatencyIntegration')
8181
const fixtureSchema = z.object({
@@ -106,7 +106,7 @@ const reused = reuseFile ? readFixtureReport(reuseFile) : undefined
106106
const ids = reused?.fixture ?? createKnowledgeAclFixtureIds()
107107
const unrelated = reused?.unrelatedFixture ?? createKnowledgeAclFixtureIds()
108108
const fullWidthFixture = reused?.fullWidthFixture ?? createKnowledgeAclFixtureIds()
109-
const fullWidthChunkCount = 5000
109+
const FULL_WIDTH_CHUNK_COUNT = 5000
110110
const organizationChatId = generateId()
111111
function topicVector(topic = 0) {
112112
const vector = Array.from({ length: dimensions }, (_, index) =>
@@ -129,7 +129,7 @@ const report: Record<string, unknown> = {
129129
fixtureVersion: 2,
130130
chunkCount,
131131
unrelatedChunkCount,
132-
fullWidthChunkCount,
132+
fullWidthChunkCount: FULL_WIDTH_CHUNK_COUNT,
133133
dimensions,
134134
candidateDimensions,
135135
chunksPerDocument,
@@ -162,6 +162,7 @@ interface ExplainNode {
162162
'Node Type': string
163163
'Actual Rows': number
164164
'Actual Loops': number
165+
'Rows Removed by Filter'?: number
165166
'Plan Rows'?: number
166167
'Shared Hit Blocks'?: number
167168
'Shared Read Blocks'?: number
@@ -179,6 +180,7 @@ const explainNodeSchema: z.ZodType<ExplainNode> = z.lazy(() =>
179180
'Node Type': z.string(),
180181
'Actual Rows': z.number(),
181182
'Actual Loops': z.number(),
183+
'Rows Removed by Filter': z.number().optional(),
182184
'Plan Rows': z.number().optional(),
183185
'Shared Hit Blocks': z.number().optional(),
184186
'Shared Read Blocks': z.number().optional(),
@@ -430,7 +432,7 @@ function expectCompleteVectorSearch(diagnostics: z.infer<typeof diagnosticSchema
430432
async function sample(
431433
label: string,
432434
run: () => ReturnType<typeof search>,
433-
options: { explain?: boolean } = {}
435+
options: { explain?: boolean; candidateScanRowLimit?: number } = {}
434436
) {
435437
captured.length = 0
436438
diagnosticLog?.mockClear()
@@ -530,7 +532,24 @@ async function sample(
530532
`"embedding_search"."${width === 1536 ? 'vector' : `vector_${width}`}"`
531533
)
532534
expect(diagnostics.vectorCandidateLimit).toBeGreaterThan(0)
533-
assertIndexedCandidates(parsedPlan[0].Plan, diagnostics.vectorCandidateLimit!, width)
535+
if (options.candidateScanRowLimit !== undefined) {
536+
/** A small model-specific projection can be cheaper to rank through its KB index. */
537+
assertCompactCandidates(parsedPlan[0].Plan)
538+
const scans = explainNodes(parsedPlan[0].Plan).filter(
539+
(node) => node['Relation Name'] === 'embedding_search' && node['Actual Loops'] > 0
540+
)
541+
expect(scans.length).toBeGreaterThan(0)
542+
for (const node of scans) {
543+
const visited =
544+
(node['Actual Rows'] + (node['Rows Removed by Filter'] ?? 0)) * node['Actual Loops']
545+
/** EXPLAIN rounds per-worker row averages to integers. */
546+
expect(visited).toBeLessThanOrEqual(
547+
options.candidateScanRowLimit + node['Actual Loops'] - 1
548+
)
549+
}
550+
} else {
551+
assertIndexedCandidates(parsedPlan[0].Plan, diagnostics.vectorCandidateLimit!, width)
552+
}
534553
}
535554
if (query.query.includes('WITH visible_keyword_documents')) {
536555
assertScalarKeywordSorts(parsedPlan[0].Plan)
@@ -727,7 +746,7 @@ describe.skipIf(!enabled)('Knowledge search latency on a realistic indexed corpu
727746
await db.execute(sql`
728747
WITH source AS MATERIALIZED (
729748
SELECT id, content, embedding FROM embedding
730-
WHERE knowledge_base_id = ${ids.knowledgeBaseId} ORDER BY id LIMIT ${fullWidthChunkCount}
749+
WHERE knowledge_base_id = ${ids.knowledgeBaseId} ORDER BY id LIMIT ${FULL_WIDTH_CHUNK_COUNT}
731750
), documents AS (
732751
INSERT INTO document
733752
(id, knowledge_base_id, connector_id, external_id, filename, file_url, file_size,
@@ -748,7 +767,7 @@ describe.skipIf(!enabled)('Knowledge search latency on a realistic indexed corpu
748767
const [fullWidthSize] = await db.execute<{ count: number }>(
749768
sql`SELECT count(*)::int AS count FROM embedding WHERE knowledge_base_id = ${fullWidthFixture.knowledgeBaseId}`
750769
)
751-
expect(fullWidthSize.count).toBe(fullWidthChunkCount)
770+
expect(fullWidthSize.count).toBe(FULL_WIDTH_CHUNK_COUNT)
752771
await db.execute(sql`UPDATE embedding SET tag1 = 'selected' WHERE knowledge_base_id = ${ids.knowledgeBaseId}
753772
AND tag1 IS DISTINCT FROM 'selected'
754773
AND document_id IN (SELECT id FROM document WHERE knowledge_base_id = ${ids.knowledgeBaseId} AND external_id::int < 600)`)
@@ -1149,14 +1168,14 @@ describe.skipIf(!enabled)('Knowledge search latency on a realistic indexed corpu
11491168
true
11501169
)
11511170
const probe = plans.find((plan) => plan.kind === 'probe')!
1152-
expect(probe.plan[0].Plan['Actual Rows']).toBe(Math.min(count, hybridCandidateLimit))
1171+
expect(probe.plan[0].Plan['Actual Rows']).toBe(Math.min(count, HYBRID_CANDIDATE_LIMIT))
11531172
expect(assertIndexedChunkProbe(probe.plan[0].Plan)).toBe(
1154-
Math.min(documentCount, hybridCandidateLimit / chunksPerDocument)
1173+
Math.min(documentCount, HYBRID_CANDIDATE_LIMIT / chunksPerDocument)
11551174
)
11561175
expect(plans.filter((plan) => plan.kind === 'vector')).toHaveLength(
1157-
count < hybridCandidateLimit ? 0 : 1
1176+
count < HYBRID_CANDIDATE_LIMIT ? 0 : 1
11581177
)
1159-
if (count > hybridCandidateLimit) {
1178+
if (count > HYBRID_CANDIDATE_LIMIT) {
11601179
const rerank = plans.find((plan) => plan.kind === 'rerank')!
11611180
const actual = await db.$client.unsafe(rerank.query, rerank.parameters).values()
11621181
const expected = await db.execute<{ id: string }>(sql`SELECT id FROM embedding
@@ -1224,9 +1243,9 @@ describe.skipIf(!enabled)('Knowledge search latency on a realistic indexed corpu
12241243
expectCompleteVectorSearch(broad.diagnostics)
12251244
expect(broad.result.data.results).toHaveLength(15)
12261245
const broadProbe = broad.plans.find((plan) => plan.kind === 'probe')!
1227-
expect(broadProbe.plan[0].Plan['Actual Rows']).toBe(hybridCandidateLimit)
1246+
expect(broadProbe.plan[0].Plan['Actual Rows']).toBe(HYBRID_CANDIDATE_LIMIT)
12281247
expect(assertIndexedChunkProbe(broadProbe.plan[0].Plan)).toBe(
1229-
hybridCandidateLimit / chunksPerDocument
1248+
HYBRID_CANDIDATE_LIMIT / chunksPerDocument
12301249
)
12311250
const { result, plans, diagnostics } = await sample(`member-scope.${surface}`, () =>
12321251
surface === 'copilot' ? search(ids.bobId) : searchDashboard('Orion deployment', ids.bobId)
@@ -1356,8 +1375,10 @@ describe.skipIf(!enabled)('Knowledge search latency on a realistic indexed corpu
13561375
report[`${label}.recall`] = { neighbors: expected.length, recall }
13571376
saveReport()
13581377
}
1359-
const fullWidth = await sample('workspace-kb.full-width', () =>
1360-
searchWorkspaceKb('Orion deployment', { fixture: fullWidthFixture })
1378+
const fullWidth = await sample(
1379+
'workspace-kb.full-width',
1380+
() => searchWorkspaceKb('Orion deployment', { fixture: fullWidthFixture }),
1381+
{ candidateScanRowLimit: FULL_WIDTH_CHUNK_COUNT }
13611382
)
13621383
expectCompleteVectorSearch(fullWidth.diagnostics)
13631384
expect(fullWidth.diagnostics.vectorCandidateDimensions).toBe(dimensions)

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,10 @@ const logger = createLogger('KnowledgeSearchQueries')
4949
const UNDEFINED_OBJECT_SQLSTATE = '42704'
5050
/** Bound candidate pages retained while live permissions are checked. */
5151
const MAX_AUTHORIZED_SEARCH_CANDIDATES = 20_000
52-
/** Stop a permission-starved graph walk early enough to scan the filtered projection instead. */
52+
/**
53+
* Stop a permission-starved graph walk early enough to scan the filtered projection instead.
54+
* This approximate iterative-visit threshold excludes pgvector's initial scan; it is not a row limit.
55+
*/
5356
const CANDIDATE_HNSW_MAX_SCAN_TUPLES = '1000'
5457
const CANDIDATE_HNSW_EF_SEARCH = '1000'
5558
const CANDIDATE_HNSW_SCAN_MEM_MULTIPLIER = '2'

0 commit comments

Comments
 (0)