Skip to content

Commit c0761e9

Browse files
committed
fix(knowledge): wait for both days of a custom window, show the chosen range, and keep the default scan while rows are unfilled
- a custom window searches only once both days are chosen; the picker shows the chosen range instead of a fixed label - while the projection still holds rows the backfill has not filled, an on-row walk keeps the default scan cap, since an unfilled row is decided through its document; the answer is read off the unfilled-rows index and remembered for a minute
1 parent 3d5cb80 commit c0761e9

4 files changed

Lines changed: 75 additions & 13 deletions

File tree

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,9 @@ describe('result paging and the custom window', () => {
257257
await render(undefined, '?updated=custom')
258258
expect(mocks.search.mock.calls.at(-1)![1]).toBe('')
259259
expect(container.textContent).toContain('Choose the days to search.')
260+
/** One day alone is not a window either; a deep link with only `from` waits for `to`. */
261+
await render(undefined, '?updated=custom&from=2026-09-01')
262+
expect(mocks.search.mock.calls.at(-1)![1]).toBe('')
260263
})
261264

262265
it('searches a custom window as an inclusive range of days', async () => {

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

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -160,15 +160,17 @@ function SearchResults({ scope, query, onSummarize }: SearchResultsProps) {
160160
...(window?.days
161161
? { modifiedAfter: new Date(searchedAt - window.days * DAY_MS).toISOString() }
162162
: {}),
163-
...(custom && filters.from
164-
? { modifiedAfter: startOfLocalDay(filters.from).toISOString() }
163+
...(custom && filters.from && filters.to
164+
? {
165+
modifiedAfter: startOfLocalDay(filters.from).toISOString(),
166+
modifiedBefore: endOfLocalDay(filters.to).toISOString(),
167+
}
165168
: {}),
166-
...(custom && filters.to ? { modifiedBefore: endOfLocalDay(filters.to).toISOString() } : {}),
167169
}
168170
const filtersKey = JSON.stringify(searchFilters)
169171
const expanded = expandedFor === filtersKey
170-
/** A custom window with no days chosen yet is not "any time": nothing is searched until it has them. */
171-
const awaitingRange = custom && !filters.from && !filters.to
172+
/** A custom window is two-ended: until both days are chosen, nothing is searched. */
173+
const awaitingRange = custom && !(filters.from && filters.to)
172174
const {
173175
data: search,
174176
isPending,
@@ -312,7 +314,7 @@ function SearchResults({ scope, query, onSummarize }: SearchResultsProps) {
312314
{custom && (
313315
<ChipDatePicker
314316
mode='range'
315-
label='Updated between'
317+
placeholder='Updated between'
316318
startDate={filters.from?.toISOString().slice(0, 10)}
317319
endDate={filters.to?.toISOString().slice(0, 10)}
318320
onRangeChange={(start, end) =>

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
import type { SearchStage } from '@/lib/knowledge/search/diagnostics'
3636
import {
3737
executeKeywordSearch,
38+
forgetProjectionFilled,
3839
forgetSearchReach,
3940
getStructuredTagFilters,
4041
handleTagAndVectorSearch,
@@ -1262,6 +1263,7 @@ describe('permitted-document planner', () => {
12621263
indexedSourceRows = []
12631264
forgetIndexedVectorSources()
12641265
forgetSearchReach()
1266+
forgetProjectionFilled()
12651267
dbChainMockFns.execute.mockImplementation(async (query) => {
12661268
const statement = render(query).sql
12671269
if (statement.includes('pg_index')) return indexedSourceRows
@@ -2120,6 +2122,7 @@ describe('filters on a resolved scope', () => {
21202122
resetDbChainMock()
21212123
forgetIndexedVectorSources()
21222124
forgetSearchReach()
2125+
forgetProjectionFilled()
21232126
probeRows = []
21242127
traversedRows = []
21252128
rerankRows = []
@@ -2266,6 +2269,7 @@ describe('filters on a resolved scope', () => {
22662269
expect(probes()).toHaveLength(1)
22672270
estimated = 1_000_000
22682271
forgetSearchReach()
2272+
forgetProjectionFilled()
22692273
dbChainMockFns.execute.mockClear()
22702274
await search()
22712275
expect(probes()).toHaveLength(0)
@@ -2315,6 +2319,29 @@ describe('filters on a resolved scope', () => {
23152319
).toHaveLength(1)
23162320
})
23172321

2322+
it('keeps the default scan while the projection still holds unfilled rows', async () => {
2323+
traversedRows = [{ id: 'a' }]
2324+
rerankRows = [hit('a', 'src-a')]
2325+
queueTableRows(schemaMock.embedding, rerankRows)
2326+
dbChainMockFns.execute.mockImplementation(async (query) => {
2327+
const statement = render(query).sql
2328+
if (statement.includes('AS unfilled')) return [{ unfilled: true }]
2329+
if (isWalk(statement)) return traversedRows
2330+
if (statement.includes('WITH scored_search_candidates')) return rerankRows
2331+
return []
2332+
})
2333+
await handleVectorOnlySearch({
2334+
...params,
2335+
permitted: { kind: 'unbounded', broad: true },
2336+
accessPlan: plan(),
2337+
})
2338+
/** An unfilled row is decided through its document, so the walk keeps the cap sized for that. */
2339+
const caps = statements()
2340+
.filter((query) => query.sql.includes('hnsw.max_scan_tuples'))
2341+
.map((query) => query.params.find((param) => param === '20000' || param === '100000'))
2342+
expect(caps.at(-1)).toBe('20000')
2343+
})
2344+
23182345
it('tests the date through the document inside an on-row walk when the filtered set is unbounded', async () => {
23192346
traversedRows = [{ id: 'a' }]
23202347
rerankRows = [hit('a', 'src-a')]

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

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -92,15 +92,41 @@ const ON_ROW_WALK_SCAN_TUPLES = 100_000
9292

9393
/**
9494
* How far a walk may go when readability is on the row: the on-row cap, unless the walk still
95-
* has to ask the document about each tuple — a tag or date filter — in which case a tuple costs
96-
* what it did before the columns were mirrored, and the default cap keeps a walk through a
97-
* mostly-excluded neighbourhood at a short answer rather than a missed deadline.
95+
* has to ask the document about tuples — a tag or date filter, or rows the backfill has not
96+
* filled yet — in which case such a tuple costs what it did before the columns were mirrored,
97+
* and the default cap keeps a walk through a mostly-excluded neighbourhood at a short answer
98+
* rather than a missed deadline.
9899
*/
99-
function onRowWalkScanTuples(documentCondition: SQL | undefined): number {
100-
return documentCondition === undefined
100+
function onRowWalkScanTuples(
101+
documentCondition: SQL | undefined,
102+
projectionFilled: boolean
103+
): number {
104+
return documentCondition === undefined && projectionFilled
101105
? ON_ROW_WALK_SCAN_TUPLES
102106
: Number(CANDIDATE_HNSW_MAX_SCAN_TUPLES)
103107
}
108+
109+
/** How long a fully filled projection is taken on trust before its unfilled rows are looked for again. */
110+
const PROJECTION_FILLED_TTL_MS = 60_000
111+
112+
/**
113+
* Whether the ranking projection still holds rows the backfill has not filled. Read off the
114+
* unfilled-rows index in microseconds and remembered briefly: the answer only ever changes once.
115+
*/
116+
const projectionFilled = new LRUCache<string, boolean>({
117+
max: 1,
118+
ttl: PROJECTION_FILLED_TTL_MS,
119+
fetchMethod: async () => {
120+
const [row] = await db.execute<{ unfilled: boolean }>(sql`
121+
SELECT EXISTS (SELECT 1 FROM ${embeddingSearch} WHERE ${embeddingSearch.acl} IS NULL) AS unfilled`)
122+
return !row?.unfilled
123+
},
124+
})
125+
126+
/** Forgets whether the projection was filled, after its rows changed. */
127+
export function forgetProjectionFilled(): void {
128+
projectionFilled.clear()
129+
}
104130
/**
105131
* Beam width per iteration. A beam is the granularity of cancellation: pgvector calls
106132
* `CHECK_FOR_INTERRUPTS` only while building an index, never inside `hnswgettuple`, so neither
@@ -1442,6 +1468,8 @@ async function selectSourceVectorCandidates(input: {
14421468
plan: SearchAccessPlan
14431469
tagCondition: SQL | undefined
14441470
documentCondition: SQL | undefined
1471+
/** Whether every projection row carries its mirrored columns, so a walk needs no document. */
1472+
projectionFilled: boolean
14451473
candidateDistance: SQL<number>
14461474
candidateLimit: number
14471475
budget?: SearchBudget
@@ -1490,7 +1518,7 @@ async function selectSourceVectorCandidates(input: {
14901518
ORDER BY ${input.candidateDistance} LIMIT ${input.candidateLimit}`),
14911519
input.budget,
14921520
'vector.source_walk',
1493-
onRowWalkScanTuples(input.documentCondition)
1521+
onRowWalkScanTuples(input.documentCondition, input.projectionFilled)
14941522
)
14951523
const walks: Array<() => RankedChunks> = sources.walked.map((connectorId) =>
14961524
walk(eq(embeddingSearch.connectorId, connectorId))
@@ -1697,6 +1725,7 @@ async function selectVectorResults(params: SearchParams): Promise<SearchResult[]
16971725
}
16981726
let selected: Array<{ id: string }>
16991727
const plan = params.access.kind === 'user' ? params.accessPlan : undefined
1728+
const filled = plan ? ((await projectionFilled.fetch('embedding_search')) ?? false) : false
17001729
/**
17011730
* A source the caller is a member of that has its own index is walked on its own, which
17021731
* beats ranking it exactly once it is large enough to have earned that index.
@@ -1728,6 +1757,7 @@ async function selectVectorResults(params: SearchParams): Promise<SearchResult[]
17281757
access: params.access,
17291758
knowledgeBaseIds: params.knowledgeBaseIds,
17301759
plan,
1760+
projectionFilled: filled,
17311761
tagCondition: candidateTagCondition,
17321762
documentCondition,
17331763
candidateDistance,
@@ -1771,7 +1801,7 @@ async function selectVectorResults(params: SearchParams): Promise<SearchResult[]
17711801
),
17721802
params.budget,
17731803
'vector.candidate_search',
1774-
plan ? onRowWalkScanTuples(documentCondition) : undefined
1804+
plan ? onRowWalkScanTuples(documentCondition, filled) : undefined
17751805
)
17761806
/**
17771807
* A full traversal is already the nearest permitted chunks, so nothing else is worth

0 commit comments

Comments
 (0)