Skip to content

Commit 75156bc

Browse files
authored
fix(knowledge): recover vector candidates an HNSW post-filter discards (#7971)
* fix(knowledge): recover vector candidates an HNSW post-filter discards pgvector's HNSW index post-filters by construction, so a visibility predicate can only discard neighbours the graph walk already committed to. When the documents a caller may read are a small share of the index, the traversal returns a handful of candidates instead of its limit. Rank the permitted set exactly when the traversal comes back underfilled and a bounded probe says that set is small enough to afford. A traversal that fills its limit is returned untouched, so a scope the graph serves well pays nothing. Bound the probe by documents examined rather than chunks accumulated, and give it its own sub-budget so deciding against a rescue can never cost the leg its results. Retune the scan settings so the tuple budget sits an order of magnitude above the beam, which is what lets the iterative scan iterate at all, and so the narrower beam lowers the leg's uninterruptible floor. * test(knowledge): cover the capped step budget * fix(knowledge): require an enabled chunk when a tag filter decides a document A document whose only tagged chunk is disabled could be admitted by the probe and then discarded by ranking, spending the probe's document bound on a document that can contribute no candidate. Update the KB block fan-out integration test to the strategy it now exercises: a scope too small to fill the traversal probes once and rescues once.
1 parent 5249902 commit 75156bc

7 files changed

Lines changed: 441 additions & 231 deletions

File tree

‎apps/sim/app/api/knowledge/search/utils.test.ts‎

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -212,11 +212,14 @@ describe('Knowledge Search Utils', () => {
212212
describe('handleTagAndVectorSearch', () => {
213213
it('returns only bounded ranked rows without first materializing every matching tag ID', async () => {
214214
resetDbChainMock()
215-
queueTableRows(
216-
schemaMock.embedding,
217-
Array.from({ length: 201 }, (_, index) => ({ id: `candidate-${index}` }))
218-
)
219-
queueTableRows(schemaMock.embedding, [makeResult('second', 0.2), makeResult('first', 0.1)])
215+
dbChainMockFns.execute.mockImplementation(async (query) => {
216+
const statement = (query as { toSQL: () => { sql: string } }).toSQL().sql
217+
if (statement.includes('AS visible')) return []
218+
if (statement.includes(') + 0 LIMIT')) return [{ id: 'first' }, { id: 'second' }]
219+
if (statement.includes('WITH scored_search_candidates'))
220+
return [makeResult('second', 0.2), makeResult('first', 0.1)]
221+
return [{ id: 'doc-first' }, { id: 'doc-second' }]
222+
})
220223
queueTableRows(schemaMock.embedding, [makeResult('second', 0.2), makeResult('first', 0.1)])
221224

222225
const results = await handleTagAndVectorSearch({
@@ -231,11 +234,13 @@ describe('Knowledge Search Utils', () => {
231234
})
232235

233236
expect(results.map((row) => row.id)).toEqual(['first', 'second'])
234-
expect(dbChainMockFns.select).toHaveBeenCalledTimes(3)
235-
expect(Object.keys(dbChainMockFns.select.mock.calls[0][0])).toEqual(['id'])
236-
expect(dbChainMockFns.limit).toHaveBeenNthCalledWith(1, 400)
237-
expect(dbChainMockFns.select.mock.calls[1][0]).toHaveProperty('distance')
238-
expect(dbChainMockFns.limit).toHaveBeenCalledWith(20)
237+
/** Only hydration reads through the query builder; ranking never materializes tag IDs. */
238+
expect(dbChainMockFns.select).toHaveBeenCalledTimes(1)
239+
expect(dbChainMockFns.select.mock.calls[0][0]).toHaveProperty('distance')
240+
const exact = dbChainMockFns.execute.mock.calls
241+
.map(([query]) => (query as { toSQL: () => { sql: string; params: unknown[] } }).toSQL())
242+
.find((statement) => statement.sql.includes(') + 0 LIMIT'))!
243+
expect(exact.params).toContain(400)
239244
})
240245

241246
it('should throw error when no filters provided', async () => {
@@ -552,13 +557,13 @@ describe('Knowledge Search Utils', () => {
552557
})
553558

554559
expect(results.map((r) => r.id)).toEqual(['vector-hit'])
555-
expect(dbChainMockFns.select).toHaveBeenCalledTimes(2)
560+
expect(dbChainMockFns.select).toHaveBeenCalledTimes(1)
556561
})
557562

558563
it('runs both legs and fuses them in hybrid mode', async () => {
559564
/**
560-
* The raw vector probe does not consume a table chain. Keyword ranking and
561-
* hydration complete before vector exact ranking and content hydration.
565+
* Vector ranking is raw SQL throughout and consumes no table chain. Keyword ranking and
566+
* hydration complete before vector content hydration.
562567
*/
563568
dbChainMockFns.execute.mockResolvedValue([{ id: 'vector-hit' }])
564569
queueTableRows(schemaMock.embedding, [{ id: 'keyword-hit', keywordRank: 0.9 }])
@@ -576,7 +581,7 @@ describe('Knowledge Search Utils', () => {
576581
})
577582

578583
expect(results.map((r) => r.id).sort()).toEqual(['keyword-hit', 'vector-hit'])
579-
expect(dbChainMockFns.select).toHaveBeenCalledTimes(4)
584+
expect(dbChainMockFns.select).toHaveBeenCalledTimes(3)
580585
})
581586

582587
it('propagates unexpected keyword errors after the vector leg finishes', async () => {
@@ -601,7 +606,7 @@ describe('Knowledge Search Utils', () => {
601606
queryVector: { vector: JSON.stringify(TEST_EMBEDDING), dimensions: 1536 },
602607
})
603608
).rejects.toBe(failure)
604-
expect(dbChainMockFns.select).toHaveBeenCalledTimes(3)
609+
expect(dbChainMockFns.select).toHaveBeenCalledTimes(2)
605610
})
606611

607612
it('skips both query legs when only tag filters are provided', async () => {

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

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ describe('API-key KB block fan-out', () => {
9191
const previousDebug = db.$client.options.debug
9292
const statements: string[] = []
9393
db.$client.options.debug = (_connection, query) => {
94-
if (statements.length < 250) statements.push(query)
94+
if (statements.length < 1000) statements.push(query)
9595
}
9696
try {
9797
const results = await Promise.all(
@@ -124,9 +124,26 @@ describe('API-key KB block fan-out', () => {
124124
expect(result.rows[0].knowledgeBaseId).toBe(bases[index].id)
125125
expect(result.rows[0].distance).toBeCloseTo(0)
126126
}
127-
expect(statements.filter((query) => query.includes('statement_timeout'))).toHaveLength(54)
128-
expect(statements.filter((query) => query.includes('+ 0'))).toHaveLength(18)
129-
expect(statements.some((query) => query.includes('hnsw.iterative_scan'))).toBe(false)
127+
const matching = (fragment: string) =>
128+
statements.filter((query) => query.includes(fragment))
129+
/**
130+
* Every statement runs under the leg's deadline: the candidate search reinstates it after
131+
* tuning the scan, and the probe, the exact ranking, the rerank and hydration each open
132+
* with one of their own.
133+
*/
134+
expect(matching('statement_timeout')).toHaveLength(bases.length * 6)
135+
/**
136+
* A scope this small leaves the bounded traversal short of its candidate limit, so every
137+
* search probes once and rescues once — never a widening retry loop.
138+
*/
139+
expect(matching('hnsw.iterative_scan')).toHaveLength(bases.length)
140+
expect(matching('AS visible')).toHaveLength(bases.length)
141+
expect(matching(') + 0 LIMIT')).toHaveLength(bases.length)
142+
expect(matching('scored_search_candidates')).toHaveLength(bases.length)
143+
/** The probe enumerates visible documents; it never ranks them. */
144+
expect(
145+
statements.filter((query) => query.includes('AS id FROM') && !query.includes('ORDER BY'))
146+
).toHaveLength(bases.length)
130147
} finally {
131148
db.$client.options.debug = previousDebug
132149
}

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,23 @@ describe('search SQL deadline', () => {
4444
}
4545
})
4646

47+
it('caps a step without letting it shorten or outlive the leg it came from', () => {
48+
vi.spyOn(performance, 'now').mockReturnValue(0)
49+
const controller = new AbortController()
50+
const leg = new SearchBudget('vector', 1000, controller.signal)
51+
const step = leg.capped(600)
52+
expect(step.deadline).toBe(600)
53+
expect(step.leg).toBe('vector')
54+
expect(step.signal).toBe(controller.signal)
55+
/** Spending the step is the step's own business; the leg keeps its deadline and its verdict. */
56+
step.isTimeout(new SearchDeadlineError())
57+
expect(step.timedOut).toBe(true)
58+
expect(leg.timedOut).toBe(false)
59+
expect(leg.remaining()).toBe(1000)
60+
/** A step longer than what the leg has left cannot extend it. */
61+
expect(new SearchBudget('vector', 200).capped(600).deadline).toBe(200)
62+
})
63+
4764
it('preserves cancellation and unexpected errors instead of labeling them incomplete evidence', async () => {
4865
const controller = new AbortController()
4966
const budget = new SearchBudget('keyword', performance.now() - 1, controller.signal)

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,18 @@ export class SearchBudget {
3939
return remaining
4040
}
4141

42+
/**
43+
* A shorter deadline for one step of the leg, carrying the same leg and cancellation signal.
44+
* Spending it is the step's own business: the leg's deadline is untouched and still governs.
45+
*/
46+
capped(milliseconds: number): SearchBudget {
47+
return new SearchBudget(
48+
this.leg,
49+
Math.min(this.deadline, performance.now() + milliseconds),
50+
this.signal
51+
)
52+
}
53+
4254
isTimeout(error: unknown): boolean {
4355
this.signal?.throwIfAborted()
4456
if (error instanceof SearchDeadlineError || getPostgresErrorCode(error) === '57014') {

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export type SearchStage =
4747
| 'vector.settings'
4848
| 'vector.probe'
4949
| 'vector.rerank'
50+
| 'vector.exact_candidates'
5051
| 'vector.exact'
5152
| 'vector.candidate_search'
5253
| 'source_overview'
@@ -76,7 +77,7 @@ export interface SearchDiagnosticMetadata {
7677
searchMode?: 'hybrid' | 'vector'
7778
boostRecency?: boolean
7879
embeddingDimensions?: number
79-
vectorRanking?: 'exact' | 'candidate-rerank'
80+
vectorRanking?: 'exact' | 'exact-candidates' | 'candidate-rerank'
8081
vectorCandidateStorage?: 'stored-halfvec'
8182
/**
8283
* Whether the bounded traversal filled its candidate limit. `underfilled` means visibility
@@ -86,6 +87,8 @@ export interface SearchDiagnosticMetadata {
8687
vectorCandidateScan?: 'planned' | 'underfilled'
8788
vectorBudgetMs?: number
8889
vectorCandidateLimit?: number
90+
/** Visible documents the tractability probe enumerated, capped at its own document limit. */
91+
vectorProbeDocumentCount?: number
8992
vectorCandidateCount?: number
9093
vectorCandidateDimensions?: number
9194
resultCount?: number

0 commit comments

Comments
 (0)