Skip to content

Commit b0a68f2

Browse files
authored
fix(knowledge): keep vector candidate selection bounded (#7960)
* fix(knowledge): keep vector candidate selection bounded An underfilled ANN traversal rescored the whole compact projection and joined the visible document set to it. That fallback is O(corpus): on a 132k-chunk index it took 1.9s, and on a corpus an order of magnitude larger it exceeds the retrieval budget, so the vector leg returned nothing at all rather than fewer rows. Widening the search has no affordable form here. Measured at 10% visibility on the same index, scanning 6.5k tuples instead of 1.5k took 5.1s and 9.7s on consecutive identical runs, and joining visibility before scoring took 8.4s because it turns a sequential scan into a random lookup per document. The bounded traversal itself costs ~115ms whether or not it fills. The traversal is now the whole candidate set. An underfilled one yields fewer candidates and is reported as such, which strictly beats a leg that times out. * fix(knowledge): match the latency plan assertions to the bounded traversal The plan assertion still required the removed CTEs, and the predicates selecting which captured query to assert against matched the old CTE name with a lowercase fallback the rendered statement never produces. The candidate assertions would have stopped running rather than failing, so they now key on the visibility lateral through one shared predicate. The plan check drops the fallback-specific expectations and gains the one that guards this change: the traversal must never reach the projection by document lookup or sequential scan, at any candidate count.
1 parent 8396028 commit b0a68f2

4 files changed

Lines changed: 79 additions & 104 deletions

File tree

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

Lines changed: 31 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,15 @@ function explainNodes(node: ExplainNode): ExplainNode[] {
208208
}
209209

210210
/** Broad ranking must stop the ordered ANN scan instead of sorting every accessible chunk. */
211+
/**
212+
* The bounded ANN traversal, identified by the visibility lateral it alone carries. The probe
213+
* aliases its own lateral `scoped_chunk`, so this cannot match it, and matching on the rendered
214+
* clause casing would silently stop these assertions from running at all.
215+
*/
216+
function isVectorCandidateQuery(statement: string) {
217+
return statement.toLowerCase().includes(') as visible')
218+
}
219+
211220
function assertIndexedCandidates(
212221
plan: ExplainNode,
213222
candidateLimit: number,
@@ -218,31 +227,25 @@ function assertIndexedCandidates(
218227
? 'embedding_search_cosine_hnsw_idx'
219228
: `embedding_search_${width}_cosine_hnsw_idx`
220229
const nodes = explainNodes(plan)
221-
const initial = nodes.find((node) => node['Subplan Name'] === 'CTE initial_candidates')
222-
expect(initial).toBeDefined()
223-
const candidateNodes = explainNodes(initial!)
224-
expect(
225-
candidateNodes.some((node) => node['Index Name'] === indexName && node['Actual Loops'] > 0)
226-
).toBe(true)
227-
expect(candidateNodes.some((node) => node['Node Type'] === 'Sort')).toBe(false)
230+
expect(nodes.some((node) => node['Index Name'] === indexName && node['Actual Loops'] > 0)).toBe(
231+
true
232+
)
233+
/** The graph walk supplies the order, so a Sort here means the index ordering was discarded. */
234+
expect(nodes.some((node) => node['Node Type'] === 'Sort')).toBe(false)
235+
/**
236+
* The traversal is the whole candidate set. Reaching the projection by document lookup or by
237+
* sequential scan is the corpus-wide rescan this query exists to avoid, at any candidate count.
238+
*/
239+
expect(nodes.some((node) => node['Index Name'] === 'embedding_search_document_lookup_idx')).toBe(
240+
false
241+
)
228242
expect(
229-
candidateNodes.some((node) => node['Index Name'] === 'embedding_search_document_lookup_idx')
243+
nodes.some(
244+
(node) => node['Relation Name'] === 'embedding_search' && node['Node Type'] === 'Seq Scan'
245+
)
230246
).toBe(false)
231-
const filtered = nodes.find((node) => node['Subplan Name'] === 'CTE filtered_scores')
232-
expect(filtered).toBeDefined()
233-
expect(filtered!.Output).toHaveLength(3)
234-
expect(filtered!.Output![2]).toContain('<=>')
235-
if (initial!['Actual Rows'] >= candidateLimit) {
236-
for (const node of nodes.filter(
237-
(item) =>
238-
item['Subplan Name'] === 'CTE visible_search_documents' ||
239-
item['CTE Name'] === 'visible_search_documents' ||
240-
item['Subplan Name'] === 'CTE filtered_scores' ||
241-
item['CTE Name'] === 'filtered_scores'
242-
)) {
243-
expect(node['Actual Loops']).toBe(0)
244-
}
245-
}
247+
const traversed = nodes.find((node) => node['Index Name'] === indexName)!
248+
expect(traversed['Actual Rows']).toBeLessThanOrEqual(candidateLimit)
246249
}
247250

248251
/** Small scopes must seek chunk metadata by document without reading the full vector projection. */
@@ -294,7 +297,7 @@ const diagnosticSchema = z
294297
vectorBudgetMs: z.number().positive(),
295298
vectorCandidateDimensions: z.number().optional(),
296299
vectorCandidateLimit: z.number().optional(),
297-
vectorCandidateScan: z.enum(['planned', 'filtered']).optional(),
300+
vectorCandidateScan: z.enum(['planned', 'underfilled']).optional(),
298301
retrievalStatus: z.enum(['complete', 'partial']),
299302
timedOutLegs: z.array(z.enum(['vector', 'keyword', 'tags'])),
300303
toolResultBytes: z.number().int().nonnegative().optional(),
@@ -472,7 +475,7 @@ async function sample(
472475
(item.query.includes('order by') ||
473476
item.query.includes('limit') ||
474477
item.query.includes('CROSS JOIN LATERAL') ||
475-
item.query.includes('WITH visible_search_documents') ||
478+
isVectorCandidateQuery(item.query) ||
476479
item.query.includes('WITH scored_search_candidates') ||
477480
item.query.includes('WITH visible_keyword_documents'))
478481
)
@@ -497,10 +500,7 @@ async function sample(
497500
await tx.unsafe('SET LOCAL jit = off')
498501
await tx.unsafe("SET LOCAL hnsw.iterative_scan = 'relaxed_order'")
499502
await tx.unsafe('SET LOCAL hnsw.max_scan_tuples = 20000')
500-
if (
501-
query.query.includes('WITH visible_search_documents') ||
502-
(query.query.includes('from "embedding_search"') && query.query.includes('order by'))
503-
) {
503+
if (isVectorCandidateQuery(query.query)) {
504504
await tx.unsafe('SET LOCAL hnsw.max_scan_tuples = 1000')
505505
await tx.unsafe('SET LOCAL hnsw.ef_search = 1000')
506506
await tx.unsafe('SET LOCAL hnsw.scan_mem_multiplier = 2')
@@ -514,8 +514,7 @@ async function sample(
514514
plans.push({
515515
kind: query.query.includes('keyword_rank')
516516
? 'keyword'
517-
: query.query.includes('WITH visible_search_documents') ||
518-
(query.query.includes('from "embedding_search"') && query.query.includes('order by'))
517+
: isVectorCandidateQuery(query.query)
519518
? 'vector'
520519
: query.query.includes('order by') ||
521520
query.query.includes('WITH scored_search_candidates')
@@ -526,7 +525,7 @@ async function sample(
526525
plan: parsedPlan,
527526
})
528527
saveReport()
529-
if (query.query.includes('WITH visible_search_documents')) {
528+
if (isVectorCandidateQuery(query.query)) {
530529
const width = diagnostics.vectorCandidateDimensions!
531530
expect(query.query).toContain(
532531
`"embedding_search"."${width === 1536 ? 'vector' : `vector_${width}`}"`

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,13 +78,16 @@ export interface SearchDiagnosticMetadata {
7878
embeddingDimensions?: number
7979
vectorRanking?: 'exact' | 'candidate-rerank'
8080
vectorCandidateStorage?: 'stored-halfvec'
81-
/** Requested strategy, not an assertion about the physical index selected by PostgreSQL. */
82-
vectorCandidateScan?: 'planned' | 'filtered'
81+
/**
82+
* Whether the bounded traversal filled its candidate limit. `underfilled` means visibility
83+
* removed enough neighbours that the rerank pool is smaller than requested, which lowers recall
84+
* without widening the scan. Not an assertion about the physical index PostgreSQL selected.
85+
*/
86+
vectorCandidateScan?: 'planned' | 'underfilled'
8387
vectorBudgetMs?: number
8488
vectorCandidateLimit?: number
8589
vectorCandidateCount?: number
8690
vectorCandidateDimensions?: number
87-
vectorInitialCandidateCount?: number
8891
resultCount?: number
8992
/** Tool output before the executor's final egress projection; counts only, never content. */
9093
toolResultBytes?: number

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

Lines changed: 14 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,7 @@ describe('workspace-scoped vector retrieval', () => {
370370
if (failSettings) throw failSettings
371371
return []
372372
}
373-
if (statement.includes('WITH visible_search_documents')) {
373+
if (statement.includes('AS visible')) {
374374
if (failCandidates) throw failCandidates
375375
return candidates
376376
}
@@ -434,9 +434,7 @@ describe('workspace-scoped vector retrieval', () => {
434434
it('uses compact candidates for a large KB and applies full workspace access before its limit', async () => {
435435
queueTableRows(schemaMock.embedding, [...ranked].reverse())
436436
expect((await handleVectorOnlySearch(params)).map((row) => row.id)).toEqual(['near', 'far'])
437-
const candidate = statements().find((query) =>
438-
query.sql.includes('WITH visible_search_documents')
439-
)!
437+
const candidate = statements().find((query) => query.sql.includes('AS visible'))!
440438
expect(candidate.sql).toContain('CROSS JOIN LATERAL')
441439
expect(candidate.sql).toContain('LIMIT 1')
442440
const serialized = JSON.stringify(candidate)
@@ -501,9 +499,7 @@ describe('workspace-scoped vector retrieval', () => {
501499
const rows = await handleVectorOnlySearch({ ...params, topK: 1 })
502500

503501
expect(rows.map((row) => row.id)).toEqual(['far'])
504-
expect(
505-
statements().filter((query) => query.sql.includes('WITH visible_search_documents'))
506-
).toHaveLength(1)
502+
expect(statements().filter((query) => query.sql.includes('AS visible'))).toHaveLength(1)
507503
expect(getForConnectors).not.toHaveBeenCalled()
508504
})
509505

@@ -516,9 +512,7 @@ describe('workspace-scoped vector retrieval', () => {
516512
})
517513
expect(rows.map((row) => row.id)).toEqual(['near', 'far'])
518514
expect(Object.keys(dbChainMockFns.select.mock.calls[0][0])).toEqual(['id'])
519-
const candidate = statements().find((query) =>
520-
query.sql.includes('WITH visible_search_documents')
521-
)!
515+
const candidate = statements().find((query) => query.sql.includes('AS visible'))!
522516
expect(JSON.stringify(candidate)).toContain('common')
523517
expect(JSON.stringify(candidate)).toContain(String(schemaMock.embedding.tag1))
524518
expect(JSON.stringify(dbChainMockFns.where.mock.calls.at(-1)![0])).toContain('common')
@@ -534,9 +528,7 @@ describe('workspace-scoped vector retrieval', () => {
534528
const rows = await handleVectorOnlySearch({ ...params, knowledgeBaseIds })
535529
expect(rows.map((row) => row.id)).toEqual(['near', 'far'])
536530
expect(rows.every((row) => row.knowledgeBaseId === 'kb-1')).toBe(true)
537-
const candidateQueries = statements().filter((query) =>
538-
query.sql.includes('WITH visible_search_documents')
539-
)
531+
const candidateQueries = statements().filter((query) => query.sql.includes('AS visible'))
540532
expect(candidateQueries).toHaveLength(1)
541533
for (const id of knowledgeBaseIds) expect(JSON.stringify(candidateQueries[0])).toContain(id)
542534
expect(dbChainMockFns.transaction).toHaveBeenCalledOnce()
@@ -616,9 +608,7 @@ describe('workspace-scoped vector retrieval', () => {
616608
expect(statements().filter((query) => query.sql.includes('hnsw.iterative_scan'))).toHaveLength(
617609
1
618610
)
619-
const queries = statements().filter((query) =>
620-
query.sql.includes('WITH visible_search_documents')
621-
)
611+
const queries = statements().filter((query) => query.sql.includes('AS visible'))
622612
expect(queries).toHaveLength(2)
623613
expect(JSON.stringify(queries[0])).toBe(JSON.stringify(queries[1]))
624614
await vi.advanceTimersByTimeAsync(10 * 60 * 1000 + 1)
@@ -807,7 +797,7 @@ describe('live repository authorization follows ranked candidates', () => {
807797
dbChainMockFns.execute.mockImplementation(async (query) =>
808798
render(query).sql.includes('SELECT scoped_chunk.id')
809799
? (probePages.shift() ?? [])
810-
: render(query).sql.includes('WITH visible_search_documents')
800+
: render(query).sql.includes('AS visible')
811801
? (candidatePages.shift() ?? [])
812802
: render(query).sql.includes('WITH scored_search_candidates')
813803
? (rerankPages.shift() ?? [])
@@ -841,9 +831,8 @@ describe('live repository authorization follows ranked candidates', () => {
841831
})
842832
expect(rows.map((row) => row.id)).toEqual(['near'])
843833
const candidateQuery = dbChainMockFns.execute.mock.calls.find(([query]) =>
844-
render(query).sql.includes('WITH visible_search_documents')
834+
render(query).sql.includes('AS visible')
845835
)![0]
846-
expect(render(candidateQuery).sql).toContain('MATERIALIZED')
847836
expect(render(candidateQuery).sql).toContain('CROSS JOIN LATERAL')
848837
expect(render(candidateQuery).sql).toContain('LIMIT 1')
849838
expect(JSON.stringify(candidateQuery)).toContain('required_clause')
@@ -922,7 +911,7 @@ describe('live repository authorization follows ranked candidates', () => {
922911
}
923912
)
924913

925-
it('scans the filtered projection when ANN cannot fill its limit', async () => {
914+
it('keeps an underfilled ANN result instead of rescoring the whole projection', async () => {
926915
probePages.push(Array.from({ length: 400 }, (_, index) => ({ id: `probe-${index}` })))
927916
queueCandidates([{ id: 'selected' }], 1)
928917
queueRerank([candidate('selected', 'allowed-source')])
@@ -933,12 +922,12 @@ describe('live repository authorization follows ranked candidates', () => {
933922
{ id: 'selected', content: 'Verified fallback', distance: 0.1 },
934923
])
935924
const candidateQuery = dbChainMockFns.execute.mock.calls.find(([query]) =>
936-
render(query).sql.includes('WITH visible_search_documents')
925+
render(query).sql.includes('AS visible')
937926
)![0]
938-
expect(render(candidateQuery).sql).toContain('UNION ALL')
939-
expect(render(candidateQuery).sql).toContain('+ 0')
940-
expect(render(candidateQuery).sql).toContain('filtered_scores AS MATERIALIZED')
941-
expect(render(candidateQuery).sql).toContain('ORDER BY filtered_scores.distance + 0')
927+
/** Widening the scan on underfill is what made this leg exceed its budget on a large corpus. */
928+
expect(render(candidateQuery).sql).not.toContain('UNION ALL')
929+
expect(render(candidateQuery).sql).not.toContain('filtered_scores')
930+
expect(render(candidateQuery).sql).toContain('CROSS JOIN LATERAL')
942931
expect(JSON.stringify(dbChainMockFns.where.mock.calls.at(-1)![0])).toContain(
943932
'github_read_grant'
944933
)

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

Lines changed: 28 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,12 @@ const UNDEFINED_OBJECT_SQLSTATE = '42704'
5050
/** Bound candidate pages retained while live permissions are checked. */
5151
const MAX_AUTHORIZED_SEARCH_CANDIDATES = 20_000
5252
/**
53-
* Stop a permission-starved graph walk early enough to scan the filtered projection instead.
53+
* Bounds a permission-starved graph walk, which returns fewer candidates rather than widening.
5454
* This approximate iterative-visit threshold excludes pgvector's initial scan; it is not a row limit.
55+
*
56+
* Raising it trades recall for latency far more steeply than its size suggests: on a 132k-chunk
57+
* index at 10% visibility, visiting 6.5k tuples instead of 1.5k took 5.1s and 9.7s on consecutive
58+
* identical runs, against ~115ms for the bounded walk. Re-measure before changing it.
5559
*/
5660
const CANDIDATE_HNSW_MAX_SCAN_TUPLES = '1000'
5761
const CANDIDATE_HNSW_EF_SEARCH = '1000'
@@ -902,57 +906,37 @@ async function selectVectorResults(params: SearchParams): Promise<SearchResult[]
902906
),
903907
})
904908
/**
905-
* LIMIT keeps document authorization downstream of vector traversal, with a primary-key
906-
* lookup per candidate. Only an underfilled ANN scan materializes the visible document set.
907-
* Its exact fallback scores the compact projection once, then joins scalar distances to
908-
* visible identities; it cannot turn into a random vector lookup for every document.
909+
* The bounded ANN traversal is the whole candidate set. LIMIT keeps document authorization
910+
* downstream of the traversal, with a primary-key lookup per candidate.
911+
*
912+
* An underfilled traversal yields fewer candidates rather than widening the search. Widening
913+
* it has no affordable form here: rescoring the projection exhaustively is O(corpus) and a
914+
* deeper `hnsw.max_scan_tuples` is worse still — measured on a 132k-chunk index at 10%
915+
* visibility, the exhaustive rescan took 1.9s while scanning 6.5k tuples instead of 1.5k took
916+
* 5.1s and 9.7s on consecutive identical runs. Both exceed the retrieval budget on a corpus
917+
* an order of magnitude larger, and a leg that exceeds its budget returns nothing at all, so
918+
* fewer candidates strictly beats every widening strategy available.
909919
*/
910920
const identities = await withVectorScanSettings(
911921
(executor) =>
912-
executor.execute<{ id: string; initial_count: number }>(sql`
913-
WITH visible_search_documents AS MATERIALIZED (
914-
SELECT ${document.id} AS id FROM ${document}
915-
WHERE ${and(...candidateDocumentVisibility)}
916-
), initial_candidates AS MATERIALIZED (
917-
SELECT ${embeddingSearch.id} AS id FROM ${embeddingSearch}
918-
CROSS JOIN LATERAL (
919-
SELECT 1 FROM ${document}
920-
WHERE ${and(eq(document.id, embeddingSearch.documentId), ...candidateDocumentVisibility, candidateTagCondition)}
921-
LIMIT 1
922-
) AS visible
923-
WHERE ${and(
924-
inArray(embeddingSearch.knowledgeBaseId, params.knowledgeBaseIds),
925-
eq(embeddingSearch.enabled, true)
926-
)}
927-
ORDER BY ${candidateDistance} LIMIT ${candidateLimit}
928-
), filtered_scores AS MATERIALIZED (
929-
SELECT ${embeddingSearch.id} AS id, ${embeddingSearch.documentId} AS document_id,
930-
${candidateDistance} AS distance FROM ${embeddingSearch}
931-
WHERE ${and(
932-
inArray(embeddingSearch.knowledgeBaseId, params.knowledgeBaseIds),
933-
eq(embeddingSearch.enabled, true),
934-
candidateTagCondition
935-
)}
936-
AND (SELECT count(*) FROM initial_candidates) < ${candidateLimit}
937-
), candidates AS (
938-
SELECT id FROM initial_candidates
939-
WHERE (SELECT count(*) FROM initial_candidates) >= ${candidateLimit}
940-
UNION ALL (
941-
SELECT filtered_scores.id FROM filtered_scores
942-
INNER JOIN visible_search_documents ON visible_search_documents.id = filtered_scores.document_id
943-
WHERE (SELECT count(*) FROM initial_candidates) < ${candidateLimit}
944-
ORDER BY filtered_scores.distance + 0, filtered_scores.id
945-
LIMIT ${candidateLimit}
946-
)
947-
) SELECT id, (SELECT count(*)::int FROM initial_candidates) AS initial_count FROM candidates
922+
executor.execute<{ id: string }>(sql`
923+
SELECT ${embeddingSearch.id} AS id FROM ${embeddingSearch}
924+
CROSS JOIN LATERAL (
925+
SELECT 1 FROM ${document}
926+
WHERE ${and(eq(document.id, embeddingSearch.documentId), ...candidateDocumentVisibility, candidateTagCondition)}
927+
LIMIT 1
928+
) AS visible
929+
WHERE ${and(
930+
inArray(embeddingSearch.knowledgeBaseId, params.knowledgeBaseIds),
931+
eq(embeddingSearch.enabled, true)
932+
)}
933+
ORDER BY ${candidateDistance} LIMIT ${candidateLimit}
948934
`),
949935
params.budget
950936
)
951-
const initialCount = identities[0]?.initial_count ?? 0
952937
annotateSearchDiagnostics({
953938
vectorCandidateCount: identities.length,
954-
vectorInitialCandidateCount: initialCount,
955-
vectorCandidateScan: initialCount < candidateLimit ? 'filtered' : 'planned',
939+
vectorCandidateScan: identities.length < candidateLimit ? 'underfilled' : 'planned',
956940
})
957941
if (!identities.length) return { candidates: [], nextOffset: offset }
958942
/** Score each bounded candidate once; sorting the materialized scalar cannot invoke HNSW again. */

0 commit comments

Comments
 (0)