Skip to content

Commit ea31ca7

Browse files
authored
improvement(knowledge): page search results, take a custom date window, and rerank a person's search (#8060)
* improvement(knowledge): page search results, take a custom date window, and rerank a person's search - the Search page asks for 50 chunks, collapses them to one card per document, shows ten at a time and reveals more on request - `updated` gains a custom range: `from`/`to` days in the URL, sent as an inclusive `modifiedAfter`/`modifiedBefore` window; `modifiedBefore` joins the filter contract, the filter intersection, the document conditions, the on-row date test, the filtered-set estimate and the bounded probe - the dashboard search opts into the platform's cross-encoder reranker whenever a Cohere key is configured; reranking stays best-effort * improvement(knowledge): widen a search to 50 chunks only when the reader asks for more * improvement(knowledge): give a filter-first probe the budget its index-driven read can use * improvement(knowledge): keep the default scan for a walk that asks the document per tuple * fix(knowledge): check the window on the request, rerank with any key, and start a refined search at its first page - the filters schema stays a plain object so the Assistant's search input can still extend it; the window's order is checked on the request body - the dashboard asks for reranking outright, and the use case reranks only when the workspace or the platform holds a key - a refinement of the filters starts over at the first page after the reader asked for more - a custom window's days are the reader's local days * fix(knowledge): judge a caller's reranker key by the resolver's policy, and let a custom window be cleared - the credential check tries the platform key before a workspace lookup, and counts a caller's own key only off hosted Sim, as the resolver does - a custom window with no days yet searches nothing and says so; another window drops the days; the picker's Clear reaches the URL through a forwarded `onClear` * test(knowledge): assert only the dropped days, not the URL adapter's next flush * chore(knowledge): narrow the filters once before estimating them * 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 * fix(knowledge): show the filters while a custom window waits for its days
1 parent bc97b72 commit ea31ca7

21 files changed

Lines changed: 493 additions & 54 deletions

File tree

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ describe('workspace search route', () => {
4747
expect(call.input.signal).toBe(request.signal)
4848
expect(call.input.allowPartialResults).toBe(true)
4949
expect(call.input.vectorBudgetMs).toBe(3000)
50+
/** A person's search asks for reranking; the use case reranks when a credential exists. */
51+
expect(call.input.rerankerEnabled).toBe(true)
52+
expect(call.input.rerankerModel).toBe('rerank-v4.0-fast')
5053
controller.abort()
5154
expect(call.input.signal.aborted).toBe(true)
5255
await expect(response.json()).resolves.toEqual({

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies'
88
import { knowledgeOperations } from '@/lib/knowledge/application/operations'
99
import { searchScopedKnowledge } from '@/lib/knowledge/application/workspace-search'
10+
import { DEFAULT_RERANKER_MODEL } from '@/lib/knowledge/reranker-models'
1011
import { sourceAuthor } from '@/lib/knowledge/search/author'
1112

1213
const DIRECT_SEARCH_VECTOR_BUDGET_MS = 3000
@@ -28,6 +29,13 @@ export const POST = defineInternalJsonRoute({
2829
topK: body.topK,
2930
allowPartialResults: true,
3031
vectorBudgetMs: DIRECT_SEARCH_VECTOR_BUDGET_MS,
32+
/**
33+
* A person's search is reranked by a cross-encoder whenever the workspace or the platform
34+
* holds a key for one; the use case checks that before spending a call, and reranking stays
35+
* best-effort, so a provider outage leaves the fused order in place.
36+
*/
37+
rerankerEnabled: true,
38+
rerankerModel: DEFAULT_RERANKER_MODEL,
3139
surface: 'dashboard' as const,
3240
signal: request.signal,
3341
}),

‎apps/sim/app/o/[organizationId]/search/search.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ async function editDraft(value: string) {
135135
function expectVisibleQuery(query: string) {
136136
expect(searchInput().value).toBe(query)
137137
expect(container.querySelector('a[data-source-link]')?.textContent).toBe(`${query} launch plan`)
138-
expect(mocks.search).toHaveBeenLastCalledWith(scope, query, {})
138+
expect(mocks.search).toHaveBeenLastCalledWith(scope, query, {}, 20)
139139
expect(document.activeElement).toBe(searchInput())
140140
}
141141

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

Lines changed: 135 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,13 @@ afterEach(() => {
4848
act(() => root.unmount())
4949
vi.unstubAllGlobals()
5050
})
51-
async function render(scope: ResourceScope = { kind: 'workspace', workspaceId: 'workspace' }) {
51+
async function render(
52+
scope: ResourceScope = { kind: 'workspace', workspaceId: 'workspace' },
53+
searchParams = ''
54+
) {
5255
await act(async () =>
5356
root.render(
54-
<NuqsTestingAdapter>
57+
<NuqsTestingAdapter searchParams={searchParams}>
5558
<KnowledgeSearchResults scope={scope} query='launch' onSummarize={vi.fn()} />
5659
</NuqsTestingAdapter>
5760
)
@@ -148,3 +151,133 @@ describe('source setup navigation', () => {
148151
expect(container.querySelector('a')?.getAttribute('href')).toBe(href)
149152
})
150153
})
154+
155+
describe('result paging and the custom window', () => {
156+
const result = (n: number) => ({
157+
documentId: `doc-${n}`,
158+
knowledgeBaseId: 'kb',
159+
knowledgeBaseName: 'Index',
160+
documentName: `Document ${n}`,
161+
sourceUrl: null,
162+
connectorType: 'slack',
163+
sourceModifiedAt: null,
164+
author: null,
165+
content: 'launch notes',
166+
chunkIndex: 0,
167+
similarity: 0.5,
168+
})
169+
170+
it('offers more only after a full first page, and asks for the wider search on request', async () => {
171+
mocks.overview.mockReturnValue({ data: { providers: [], hasSearchableDocuments: true } })
172+
const page = (length: number) => ({
173+
data: {
174+
query: 'launch',
175+
results: Array.from({ length }, (_, n) => result(n)),
176+
retrieval: { status: 'complete', timedOutLegs: [] },
177+
},
178+
isPending: false,
179+
isFetching: false,
180+
isPlaceholderData: false,
181+
isError: false,
182+
refetch: mocks.retry,
183+
})
184+
mocks.search.mockReturnValue(page(20))
185+
await render()
186+
expect(mocks.search.mock.calls.at(-1)![3]).toBe(20)
187+
const more = () =>
188+
[...container.querySelectorAll('button')].find((b) => b.textContent === 'Show more')
189+
expect(more()).toBeDefined()
190+
await act(async () => more()!.click())
191+
/** The wider search is its own request; the first paint was never widened. */
192+
expect(mocks.search.mock.calls.at(-1)![3]).toBe(50)
193+
expect(more()).toBeUndefined()
194+
mocks.search.mockReturnValue(page(7))
195+
await render()
196+
expect(more()).toBeUndefined()
197+
})
198+
199+
it('starts a refined search over at the first page after the reader asked for more', async () => {
200+
mocks.overview.mockReturnValue({
201+
data: {
202+
providers: [{ connectorType: 'slack', isSyncing: false }],
203+
hasSearchableDocuments: true,
204+
},
205+
})
206+
mocks.search.mockReturnValue({
207+
data: {
208+
query: 'launch',
209+
results: Array.from({ length: 20 }, (_, n) => result(n)),
210+
retrieval: { status: 'complete', timedOutLegs: [] },
211+
},
212+
isPending: false,
213+
isFetching: false,
214+
isPlaceholderData: false,
215+
isError: false,
216+
refetch: mocks.retry,
217+
})
218+
await render()
219+
const button = (label: string) =>
220+
[...container.querySelectorAll('button')].find((b) => b.textContent === label)!
221+
await act(async () => button('Show more').click())
222+
expect(mocks.search.mock.calls.at(-1)![3]).toBe(50)
223+
await act(async () => button('Slack').click())
224+
expect(mocks.search.mock.calls.at(-1)![2]).toEqual({ source: 'slack' })
225+
expect(mocks.search.mock.calls.at(-1)![3]).toBe(20)
226+
})
227+
228+
it('drops the custom days when another window is chosen', async () => {
229+
mocks.overview.mockReturnValue({ data: { providers: [], hasSearchableDocuments: true } })
230+
mocks.search.mockReturnValue({
231+
data: { query: 'launch', results: [], retrieval: { status: 'complete', timedOutLegs: [] } },
232+
isPending: false,
233+
isFetching: false,
234+
isPlaceholderData: false,
235+
isError: false,
236+
refetch: mocks.retry,
237+
})
238+
await render(undefined, '?updated=custom&from=2026-09-01&to=2026-09-10')
239+
expect(mocks.search.mock.calls.at(-1)![2]).toHaveProperty('modifiedBefore')
240+
const anyTime = [...container.querySelectorAll('button')].find(
241+
(b) => b.textContent === 'Any time'
242+
)!
243+
await act(async () => anyTime.click())
244+
expect(mocks.search.mock.calls.at(-1)![2]).toEqual({})
245+
})
246+
247+
it('searches nothing while a custom window has no days yet', async () => {
248+
mocks.overview.mockReturnValue({ data: { providers: [], hasSearchableDocuments: true } })
249+
mocks.search.mockReturnValue({
250+
data: undefined,
251+
isPending: true,
252+
isFetching: false,
253+
isPlaceholderData: false,
254+
isError: false,
255+
refetch: mocks.retry,
256+
})
257+
await render(undefined, '?updated=custom')
258+
expect(mocks.search.mock.calls.at(-1)![1]).toBe('')
259+
expect(container.textContent).toContain('Choose the days to search.')
260+
/** The filters, and the picker among them, are shown so the days can be chosen. */
261+
expect(container.textContent).toContain('Updated between')
262+
/** One day alone is not a window either; a deep link with only `from` waits for `to`. */
263+
await render(undefined, '?updated=custom&from=2026-09-01')
264+
expect(mocks.search.mock.calls.at(-1)![1]).toBe('')
265+
})
266+
267+
it('searches a custom window as an inclusive range of days', async () => {
268+
mocks.overview.mockReturnValue({ data: { providers: [], hasSearchableDocuments: true } })
269+
mocks.search.mockReturnValue({
270+
data: { query: 'launch', results: [], retrieval: { status: 'complete', timedOutLegs: [] } },
271+
isPending: false,
272+
isFetching: false,
273+
isPlaceholderData: false,
274+
isError: false,
275+
refetch: mocks.retry,
276+
})
277+
await render(undefined, '?updated=custom&from=2026-09-01&to=2026-09-10')
278+
const filters = mocks.search.mock.calls.at(-1)![2]
279+
/** The days are the reader's own: local midnight to the last millisecond of the local day. */
280+
expect(filters.modifiedAfter).toBe(new Date(2026, 8, 1).toISOString())
281+
expect(filters.modifiedBefore).toBe(new Date(2026, 8, 11, 0, 0, 0, -1).toISOString())
282+
})
283+
})

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

Lines changed: 84 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
'use client'
22

33
import { useState } from 'react'
4-
import { Chip, ChipLink, cn } from '@sim/emcn'
4+
import { Chip, ChipDatePicker, ChipLink, cn } from '@sim/emcn'
55
import { useQueryStates } from 'nuqs'
66
import { ActivityStatus } from '@/components/ui/activity-status'
7-
import type {
8-
WorkspaceKnowledgeSearchResult,
9-
WorkspaceSearchFilters,
7+
import {
8+
WORKSPACE_KNOWLEDGE_SEARCH_LIMITS,
9+
type WorkspaceKnowledgeSearchResult,
10+
type WorkspaceSearchFilters,
1011
} from '@/lib/api/contracts/knowledge'
1112
import { useSession } from '@/lib/auth/auth-client'
1213
import { type ResourceScope, resourceScopeKey } from '@/lib/core/resource-scope'
@@ -27,6 +28,17 @@ import { useSearchIndex, useSearchSourceOverview } from '@/hooks/queries/kb/conn
2728
import { useWorkspaceKnowledgeSearch } from '@/hooks/queries/kb/knowledge'
2829

2930
const DAY_MS = 24 * 60 * 60 * 1000
31+
32+
/**
33+
* The picker names calendar days; the URL keeps them as dates. A day's bounds are its local
34+
* midnight and the last millisecond before the next, so "September 1" means the reader's own day.
35+
*/
36+
function startOfLocalDay(day: Date): Date {
37+
return new Date(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate())
38+
}
39+
function endOfLocalDay(day: Date): Date {
40+
return new Date(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate() + 1, 0, 0, 0, -1)
41+
}
3042
/** Every result without a connector is an upload; the filter names them so. */
3143
const UPLOAD_SOURCE = 'upload'
3244

@@ -127,6 +139,11 @@ interface SearchResultsProps {
127139
function SearchResults({ scope, query, onSummarize }: SearchResultsProps) {
128140
const [hasShownFilters, setHasShownFilters] = useState(false)
129141
const [searchedAt] = useState(Date.now)
142+
/**
143+
* More results are a second, wider search: the first paint stays as quick as it is, and a
144+
* refinement of the filters starts over at the first page.
145+
*/
146+
const [expandedFor, setExpandedFor] = useState<string | null>(null)
130147
const {
131148
data: index,
132149
isPending: basesPending,
@@ -136,20 +153,42 @@ function SearchResults({ scope, query, onSummarize }: SearchResultsProps) {
136153
} = useSearchIndex(scope)
137154
const [filters, setFilters] = useQueryStates(searchFilterParsers, resourceUrlKeys)
138155
const window = UPDATED_WINDOWS.find((entry) => entry.id === filters.updated)
156+
/** A custom window is inclusive of both days; `to` runs to the end of its day. */
157+
const custom = filters.updated === 'custom'
139158
const searchFilters: WorkspaceSearchFilters = {
140159
...(filters.source ? { source: filters.source } : {}),
141160
...(window?.days
142161
? { modifiedAfter: new Date(searchedAt - window.days * DAY_MS).toISOString() }
143162
: {}),
163+
...(custom && filters.from && filters.to
164+
? {
165+
modifiedAfter: startOfLocalDay(filters.from).toISOString(),
166+
modifiedBefore: endOfLocalDay(filters.to).toISOString(),
167+
}
168+
: {}),
144169
}
170+
const filtersKey = JSON.stringify(searchFilters)
171+
const expanded = expandedFor === filtersKey
172+
/** A custom window is two-ended: until both days are chosen, nothing is searched. */
173+
const awaitingRange = custom && !(filters.from && filters.to)
145174
const {
146175
data: search,
147176
isPending,
148177
isFetching,
149178
isPlaceholderData,
150179
isError: searchFailed,
151180
refetch: refetchSearch,
152-
} = useWorkspaceKnowledgeSearch(scope, query, searchFilters)
181+
} = useWorkspaceKnowledgeSearch(
182+
scope,
183+
awaitingRange ? '' : query,
184+
searchFilters,
185+
expanded
186+
? WORKSPACE_KNOWLEDGE_SEARCH_LIMITS.expanded
187+
: WORKSPACE_KNOWLEDGE_SEARCH_LIMITS.initial
188+
)
189+
/** A full first page may collapse to few cards, yet more documents may still match. */
190+
const mayHaveMore =
191+
!expanded && (search?.results.length ?? 0) >= WORKSPACE_KNOWLEDGE_SEARCH_LIMITS.initial
153192
const { data: overview } = useSearchSourceOverview(scope)
154193
const indexing = (overview?.providers ?? [])
155194
.filter((provider) => provider.isSyncing)
@@ -175,8 +214,12 @@ function SearchResults({ scope, query, onSummarize }: SearchResultsProps) {
175214
: null
176215

177216
const showResults = !noSources && !failed && !basesPending && documents.length > 0
217+
/** A custom window waiting for its days must show the filters, or the picker is unreachable. */
178218
const showFilters =
179-
hasShownFilters || showResults || (!noSources && !pending && !failed && !!search && !partial)
219+
hasShownFilters ||
220+
showResults ||
221+
awaitingRange ||
222+
(!noSources && !pending && !failed && !!search && !partial)
180223
if (showFilters && !hasShownFilters) setHasShownFilters(true)
181224

182225
return noSources ? (
@@ -196,7 +239,11 @@ function SearchResults({ scope, query, onSummarize }: SearchResultsProps) {
196239
<div className='flex flex-col'>
197240
<div className='flex items-center gap-2 px-2 py-2'>
198241
<div className='min-w-0 flex-1'>
199-
{fetching || (pending && !failed) ? (
242+
{awaitingRange ? (
243+
<p role='status' className='text-[var(--text-muted)] text-caption'>
244+
Choose the days to search.
245+
</p>
246+
) : fetching || (pending && !failed) ? (
200247
<ActivityStatus label={pending ? 'Searching…' : 'Updating results…'} isActive />
201248
) : (
202249
<p role='status' className='text-[var(--text-muted)] text-caption'>
@@ -257,11 +304,29 @@ function SearchResults({ scope, query, onSummarize }: SearchResultsProps) {
257304
shape='round'
258305
active={filters.updated === window.id}
259306
aria-pressed={filters.updated === window.id}
260-
onClick={() => setFilters({ updated: window.id })}
307+
onClick={() =>
308+
setFilters(
309+
window.id === 'custom'
310+
? { updated: window.id }
311+
: { updated: window.id, from: null, to: null }
312+
)
313+
}
261314
>
262315
{window.label}
263316
</Chip>
264317
))}
318+
{custom && (
319+
<ChipDatePicker
320+
mode='range'
321+
placeholder='Updated between'
322+
startDate={filters.from?.toISOString().slice(0, 10)}
323+
endDate={filters.to?.toISOString().slice(0, 10)}
324+
onRangeChange={(start, end) =>
325+
void setFilters({ from: new Date(start), to: new Date(end) })
326+
}
327+
onClear={() => void setFilters({ from: null, to: null })}
328+
/>
329+
)}
265330
</div>
266331
)}
267332
{showResults && (
@@ -291,6 +356,17 @@ function SearchResults({ scope, query, onSummarize }: SearchResultsProps) {
291356
/>
292357
)
293358
})}
359+
{mayHaveMore && (
360+
<div className='flex px-2 py-2'>
361+
<Chip
362+
variant='border'
363+
disabled={isFetching}
364+
onClick={() => setExpandedFor(filtersKey)}
365+
>
366+
Show more
367+
</Chip>
368+
</div>
369+
)}
294370
</div>
295371
)}
296372
</div>

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ describe('search refinement with the real query cache and URL state', () => {
212212
expect(requests.at(-1)?.body).toEqual({
213213
organizationId: 'organization',
214214
query: 'launch',
215+
topK: 20,
215216
filters: expectedFilters,
216217
})
217218
expect(container.querySelector('h1')).toBeNull()

‎apps/sim/app/workspace/[workspaceId]/home/search-params.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { parseAsString, parseAsStringLiteral } from 'nuqs/server'
1+
import { parseAsIsoDate, parseAsString, parseAsStringLiteral } from 'nuqs/server'
22

33
/**
44
* Co-located, typed URL query-param definition for the home/Chat surface.
@@ -26,19 +26,23 @@ export const resourceUrlKeys = {
2626
clearOnDefault: true,
2727
} as const
2828

29-
/** The recency windows a search can be narrowed to. */
29+
/** The recency windows a search can be narrowed to; `custom` reads its bounds from `from` and `to`. */
3030
export const UPDATED_WINDOWS = [
3131
{ id: 'any', label: 'Any time', days: null },
3232
{ id: '7d', label: 'Past week', days: 7 },
3333
{ id: '30d', label: 'Past month', days: 30 },
34+
{ id: 'custom', label: 'Custom range', days: null },
3435
] as const
3536
const UPDATED_WINDOW_IDS = UPDATED_WINDOWS.map((window) => window.id)
3637

3738
/**
3839
* Shared result filters for organization search. `source` is a connector type
39-
* or `upload`, absent for every source.
40+
* or `upload`, absent for every source; `from` and `to` are the days of a custom
41+
* window, inclusive, and mean nothing unless `updated` is `custom`.
4042
*/
4143
export const searchFilterParsers = {
4244
source: parseAsString,
4345
updated: parseAsStringLiteral(UPDATED_WINDOW_IDS).withDefault('any'),
46+
from: parseAsIsoDate,
47+
to: parseAsIsoDate,
4448
} as const

0 commit comments

Comments
 (0)