Skip to content

Commit 5aa021e

Browse files
committed
fix(search): shorten direct retrieval and simplify result states
1 parent 09239ca commit 5aa021e

11 files changed

Lines changed: 132 additions & 35 deletions

File tree

apps/sim/app/api/knowledge/search/route.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ describe('workspace search route', () => {
4646
expect(call.input.filters).toEqual({ source: 'slack', documentIds: ['doc-1'] })
4747
expect(call.input.signal).toBe(request.signal)
4848
expect(call.input.allowPartialResults).toBe(true)
49+
expect(call.input.vectorBudgetMs).toBe(3000)
4950
controller.abort()
5051
expect(call.input.signal.aborted).toBe(true)
5152
await expect(response.json()).resolves.toEqual({

apps/sim/app/api/knowledge/search/route.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { knowledgeOperations } from '@/lib/knowledge/application/operations'
99
import { searchScopedKnowledge } from '@/lib/knowledge/application/workspace-search'
1010
import { sourceAuthor } from '@/lib/knowledge/search/author'
1111

12+
const DIRECT_SEARCH_VECTOR_BUDGET_MS = 3000
13+
1214
export const POST = defineInternalJsonRoute({
1315
contract: searchWorkspaceKnowledgeContract,
1416
auth: internalSessionAuth,
@@ -25,6 +27,7 @@ export const POST = defineInternalJsonRoute({
2527
query: body.query,
2628
topK: body.topK,
2729
allowPartialResults: true,
30+
vectorBudgetMs: DIRECT_SEARCH_VECTOR_BUDGET_MS,
2831
surface: 'dashboard' as const,
2932
signal: request.signal,
3033
}),

apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.test.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,13 @@ describe('source indexing context in search results', () => {
6868
mocks.overview.mockReturnValue({ data: undefined })
6969
await render()
7070
expect(container.textContent).not.toContain('Still indexing')
71-
expect(container.textContent).toContain('No documents you can read match')
71+
expect(container.textContent).toContain('Search found no results.')
7272
})
7373
})
7474

7575
describe('incomplete search coverage', () => {
7676
it.each([false, true])(
77-
'keeps matches and offers a retry without claiming absence (hasResults=%s)',
77+
'shows matches without timeout copy or retry controls (hasResults=%s)',
7878
async (hasResults) => {
7979
mocks.search.mockReturnValue({
8080
data: {
@@ -106,17 +106,17 @@ describe('incomplete search coverage', () => {
106106
await render()
107107
expect(container.textContent).not.toContain('Search couldn’t run')
108108
expect(container.textContent).not.toContain('No documents')
109-
expect(container.textContent).not.toContain('0 documents')
109+
expect(container.textContent).not.toContain('Some results may be missing.')
110+
expect(container.textContent).not.toContain('Search is incomplete.')
110111
expect(container.textContent).toContain(
111-
hasResults ? 'Some results may be missing.' : 'Search is incomplete.'
112+
hasResults ? '1 document' : 'Search found no results.'
112113
)
113114
if (hasResults) expect(container.textContent).toContain('Release plan')
114115
const retry = [...container.querySelectorAll('button')].find(
115116
(button) => button.textContent === 'Try again'
116117
)
117-
expect(retry).toBeDefined()
118-
await act(async () => retry!.click())
119-
expect(mocks.retry).toHaveBeenCalledOnce()
118+
expect(retry).toBeUndefined()
119+
expect(mocks.retry).not.toHaveBeenCalled()
120120
}
121121
)
122122
})

apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,6 @@ export function KnowledgeSearchResults({
144144
.filter((provider) => provider.isSyncing)
145145
.map((provider) => connectorDisplayName(provider.connectorType))
146146
const documents = useMemo(() => groupResultsByDocument(search?.results ?? []), [search?.results])
147-
const incomplete = search?.retrieval.status === 'partial'
148147
const sourceTypes = [
149148
...new Set([
150149
...(filters.source ? [filters.source] : []),
@@ -205,24 +204,18 @@ export function KnowledgeSearchResults({
205204
<div className='flex flex-col'>
206205
<div className='flex items-center gap-2 px-2 py-2'>
207206
<span className='min-w-0 flex-1 text-[var(--text-muted)] text-caption'>
208-
{incomplete && documents.length === 0 ? (
209-
'Search is incomplete.'
207+
{documents.length === 0 ? (
208+
'Search found no results.'
210209
) : (
211210
<>
212211
<span className='tabular-nums'>
213212
{documents.length === 1 ? '1 document' : `${documents.length} documents`}
214213
</span>
215214
{' · searched as you'}
216-
{incomplete && <span className='block'>Some results may be missing.</span>}
217215
</>
218216
)}
219217
{indexingNote && <span className='block'>{indexingNote}</span>}
220218
</span>
221-
{incomplete && (
222-
<Chip variant='border' disabled={isFetching} onClick={() => void refetchSearch()}>
223-
{isFetching ? 'Retrying…' : 'Try again'}
224-
</Chip>
225-
)}
226219
</div>
227220
{showFilters && (
228221
<div className='flex flex-wrap items-center gap-1.5 px-2 pb-2'>
@@ -256,15 +249,7 @@ export function KnowledgeSearchResults({
256249
))}
257250
</div>
258251
)}
259-
{documents.length === 0 ? (
260-
!incomplete && (
261-
<p className='px-2 py-2 text-[var(--text-muted)] text-caption'>
262-
{filtersActive
263-
? 'No documents match these filters.'
264-
: `No documents you can read match “${query}”.`}
265-
</p>
266-
)
267-
) : (
252+
{documents.length > 0 && (
268253
<div className='flex flex-col' onKeyDown={handleResultsKeyDown}>
269254
{documents.map((result) => {
270255
const source = toSource(result, query, scope)

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

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -480,17 +480,25 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu
480480
expect(run).not.toHaveBeenCalled()
481481
})
482482

483-
it.each(['keyword', 'both'] as const)(
484-
'handles %s SQL branches exceeding the deadline explicitly',
483+
it.each(['vector', 'keyword', 'both'] as const)(
484+
'keeps the Assistant budget when %s SQL branches are delayed',
485485
async (delayedLegs) => {
486+
diagnosticLog?.mockClear()
487+
let vectorDelayed = false
486488
const query = SearchBudget.prototype.query
487489
const delayed = vi.spyOn(SearchBudget.prototype, 'query').mockImplementation(function <T>(
488490
this: SearchBudget,
489491
stage: SearchStage,
490492
run: (executor: SearchExecutor) => PromiseLike<T>
491493
): Promise<T> {
492494
return query.call(this, stage, async (tx) => {
493-
if (delayedLegs === 'both' || this.leg === 'keyword')
495+
if (delayedLegs === 'vector' && this.leg === 'vector' && !vectorDelayed) {
496+
vectorDelayed = true
497+
await tx.execute(sql`SELECT pg_sleep(4)`)
498+
} else if (
499+
delayedLegs === 'both' ||
500+
(delayedLegs === 'keyword' && this.leg === 'keyword')
501+
)
494502
await tx.execute(sql`SELECT pg_sleep(9)`)
495503
return run(tx)
496504
}) as Promise<T>
@@ -509,6 +517,10 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu
509517
}),
510518
}
511519
)
520+
const completed = diagnosticLog?.mock.calls.find(
521+
([message]) => message === 'Knowledge search completed'
522+
)
523+
expect(diagnosticSchema.parse(completed?.[1]).vectorBudgetMs).toBe(8000)
512524
if (delayedLegs === 'both') {
513525
expect(result).toMatchObject({
514526
success: true,
@@ -521,14 +533,19 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu
521533
}
522534
expect(result).toMatchObject({
523535
success: true,
524-
data: { retrieval: { status: 'partial', timedOutLegs: ['keyword'] } },
536+
data: {
537+
retrieval:
538+
delayedLegs === 'vector'
539+
? { status: 'complete', timedOutLegs: [] }
540+
: { status: 'partial', timedOutLegs: ['keyword'] },
541+
},
525542
})
526543
const parsed = resultSchema.parse(result)
527544
expect(parsed.data.results.length).toBeGreaterThan(0)
528545
expect(
529546
parsed.data.results.every((row) => row.knowledgeBaseId === ids.knowledgeBaseId)
530547
).toBe(true)
531-
report['deadline.partial'] = { resultCount: parsed.data.results.length }
548+
report[`assistant.deadline.${delayedLegs}`] = { resultCount: parsed.data.results.length }
532549
} finally {
533550
delayed.mockRestore()
534551
}
@@ -539,6 +556,7 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu
539556
it.each(['vector', 'both'] as const)(
540557
'returns incomplete dashboard coverage when %s SQL branches exceed their deadline',
541558
async (delayedLegs) => {
559+
diagnosticLog?.mockClear()
542560
const authenticate = vi.spyOn(internalSessionAuth, 'authenticate').mockResolvedValue({
543561
kind: 'session',
544562
userId: ids.aliceId,
@@ -552,7 +570,7 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu
552570
): Promise<T> {
553571
return query.call(this, stage, async (tx) => {
554572
if (delayedLegs === 'both' || this.leg === 'vector')
555-
await tx.execute(sql`SELECT pg_sleep(9)`)
573+
await tx.execute(sql`SELECT pg_sleep(${delayedLegs === 'both' ? 9 : 4})`)
556574
return run(tx)
557575
}) as Promise<T>
558576
})
@@ -569,6 +587,13 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu
569587
})
570588
)
571589
expect(response.status).toBe(200)
590+
const completed = diagnosticLog?.mock.calls.find(
591+
([message]) => message === 'Knowledge search completed'
592+
)
593+
const diagnostics = diagnosticSchema.parse(completed?.[1])
594+
expect(diagnostics.vectorBudgetMs).toBe(3000)
595+
expect(diagnostics.stages.vector.totalMs).toBeGreaterThan(2500)
596+
expect(diagnostics.stages.vector.totalMs).toBeLessThan(4000)
572597
const data = workspaceKnowledgeSearchDataSchema.parse((await response.json()).data)
573598
expect(data.retrieval).toEqual({
574599
status: 'partial',

apps/sim/lib/knowledge/application/search.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,25 @@ describe('knowledge search application use case', () => {
234234
}
235235
)
236236

237+
it.each([
238+
{ surface: 'dashboard' as const, vectorBudgetMs: 3000 },
239+
{ surface: 'copilot' as const, vectorBudgetMs: undefined },
240+
{ surface: 'workflow' as const, vectorBudgetMs: undefined },
241+
])('forwards only the configured vector budget for $surface', async (options) => {
242+
await searchKnowledge.execute({
243+
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
244+
input: {
245+
knowledgeBaseIds: ['knowledge-1'],
246+
query: 'release',
247+
topK: 10,
248+
...options,
249+
},
250+
})
251+
expect(mocks.executeSearch).toHaveBeenCalledWith(
252+
expect.objectContaining({ vectorBudgetMs: options.vectorBudgetMs })
253+
)
254+
})
255+
237256
describe.each(['workspace', 'organization'] as const)('%s ranking policy', (scope) => {
238257
beforeEach(() => {
239258
if (scope === 'organization') {

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,10 @@ export class KnowledgeSearchProvenanceUnavailableError extends Error {
9090
export type KnowledgeSearchTagFilter = KnowledgeTagNameFilter
9191

9292
export interface SearchKnowledgeInput {
93-
/** Only surfaces displaying retrieval status may accept incomplete evidence. */
93+
/** Allows returning available results when a retrieval leg times out. */
9494
allowPartialResults?: boolean
95+
/** Trusted adapter's vector retrieval budget; omitted callers use the shared default. */
96+
vectorBudgetMs?: number
9597
/** Optional assertion from a trusted adapter or public contract. */
9698
workspaceId?: string
9799
organizationId?: string
@@ -404,6 +406,7 @@ const searchKnowledgeUseCase = defineAuthorizedKnowledgeUseCase({
404406
: input.topK
405407
const retrieved = await measureSearchStage('retrieval', () =>
406408
retrieveKnowledgeSearch({
409+
vectorBudgetMs: input.vectorBudgetMs,
407410
knowledgeBaseIds,
408411
topK: candidateTopK,
409412
filters: input.filters,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export class SearchDeadlineError extends Error {
1919
}
2020
}
2121

22-
/** One leg's state, using the hybrid request's shared deadline across every refill and fallback. */
22+
/** One leg's absolute deadline, reused across every refill and fallback. */
2323
export class SearchBudget {
2424
timedOut = false
2525

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export interface SearchDiagnosticMetadata {
7070
boostRecency?: boolean
7171
embeddingDimensions?: number
7272
vectorRanking?: 'exact' | 'binary-rerank'
73+
vectorBudgetMs?: number
7374
vectorCandidateLimit?: number
7475
vectorCandidateCount?: number
7576
resultCount?: number

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

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@ import {
1515
WORKSPACE_ACCESS_TOKENS,
1616
} from '@/lib/knowledge/access/types'
1717
import { buildTagFilterCondition } from '@/lib/knowledge/documents/tag-filter'
18+
import { SearchBudget } from '@/lib/knowledge/search/budget'
1819
import {
1920
executeKeywordSearch,
2021
getStructuredTagFilters,
2122
handleTagAndVectorSearch,
2223
handleTagOnlySearch,
2324
handleVectorOnlySearch,
25+
retrieveKnowledgeSearch,
2426
type SearchParams,
2527
} from '@/lib/knowledge/search/queries'
2628
import type { StructuredFilter } from '@/lib/knowledge/types'
@@ -36,6 +38,59 @@ const embeddingTable = {
3638
boolean1: 'boolean1',
3739
}
3840

41+
describe('retrieval leg budgets', () => {
42+
afterEach(() => vi.restoreAllMocks())
43+
44+
it.each([undefined, 3000])(
45+
'applies vector budget %s without shortening keyword or tag retrieval',
46+
async (vectorBudgetMs) => {
47+
resetDbChainMock()
48+
vi.spyOn(performance, 'now').mockReturnValue(1000)
49+
const remaining = SearchBudget.prototype.remaining
50+
const deadlines = new Map<string, number>()
51+
vi.spyOn(SearchBudget.prototype, 'remaining').mockImplementation(function (
52+
this: SearchBudget
53+
) {
54+
deadlines.set(this.leg, this.deadline)
55+
return remaining.call(this)
56+
})
57+
const access: UserAccessScope = {
58+
kind: 'user',
59+
userId: 'user-1',
60+
tokens: WORKSPACE_ACCESS_TOKENS,
61+
}
62+
const params = {
63+
knowledgeBaseIds: ['knowledge-1'],
64+
topK: 10,
65+
access,
66+
accessProvider: {
67+
get: async () => access,
68+
getForConnectors: async () => access,
69+
getForDocuments: async () => access,
70+
},
71+
searchMode: 'hybrid' as const,
72+
vectorBudgetMs,
73+
}
74+
await retrieveKnowledgeSearch({
75+
...params,
76+
query: 'release',
77+
queryVector: { vector: '[1,0]', dimensions: 1536 },
78+
})
79+
await retrieveKnowledgeSearch({
80+
...params,
81+
structuredFilters: [
82+
{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'release' },
83+
],
84+
})
85+
expect(Object.fromEntries(deadlines)).toEqual({
86+
vector: 1000 + (vectorBudgetMs ?? 8000),
87+
keyword: 9000,
88+
tags: 9000,
89+
})
90+
}
91+
)
92+
})
93+
3994
/**
4095
* The global `drizzle-orm` mock renders `sql` fragments to a `?`-placeholder
4196
* string via `toSQL()`, so we can assert the exact predicate each filter builds.

0 commit comments

Comments
 (0)