Skip to content

Commit ff29701

Browse files
committed
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.
1 parent 5249902 commit ff29701

5 files changed

Lines changed: 381 additions & 227 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/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)