Skip to content

Commit 4d3e75d

Browse files
committed
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
1 parent 68b402c commit 4d3e75d

9 files changed

Lines changed: 137 additions & 46 deletions

File tree

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

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,6 @@ import { NextRequest } from 'next/server'
66
import { beforeEach, describe, expect, it, vi } from 'vitest'
77

88
const mocks = vi.hoisted(() => ({ search: vi.fn() }))
9-
const { mockHasRotatingApiKey } = vi.hoisted(() => ({ mockHasRotatingApiKey: vi.fn(() => true) }))
10-
vi.mock('@/lib/core/config/api-keys', () => ({ hasRotatingApiKey: mockHasRotatingApiKey }))
11-
129
vi.mock('@/lib/knowledge/application/workspace-search', () => ({
1310
searchScopedKnowledge: { operation: { id: 'knowledge.search' }, execute: mocks.search },
1411
}))
@@ -50,8 +47,7 @@ describe('workspace search route', () => {
5047
expect(call.input.signal).toBe(request.signal)
5148
expect(call.input.allowPartialResults).toBe(true)
5249
expect(call.input.vectorBudgetMs).toBe(3000)
53-
/** A person's search is reranked by the platform's cross-encoder whenever one is configured. */
54-
expect(mockHasRotatingApiKey).toHaveBeenCalledWith('cohere')
50+
/** A person's search asks for reranking; the use case reranks when a credential exists. */
5551
expect(call.input.rerankerEnabled).toBe(true)
5652
expect(call.input.rerankerModel).toBe('rerank-v4.0-fast')
5753
controller.abort()

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import {
44
internalRateLimits,
55
internalSessionAuth,
66
} from '@/lib/api/server/routes'
7-
import { hasRotatingApiKey } from '@/lib/core/config/api-keys'
87
import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies'
98
import { knowledgeOperations } from '@/lib/knowledge/application/operations'
109
import { searchScopedKnowledge } from '@/lib/knowledge/application/workspace-search'
@@ -31,10 +30,11 @@ export const POST = defineInternalJsonRoute({
3130
allowPartialResults: true,
3231
vectorBudgetMs: DIRECT_SEARCH_VECTOR_BUDGET_MS,
3332
/**
34-
* A person's search is reranked by the platform's cross-encoder when one is configured;
35-
* reranking is best-effort, so a provider outage leaves the fused order in place.
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.
3636
*/
37-
rerankerEnabled: hasRotatingApiKey('cohere'),
37+
rerankerEnabled: true,
3838
rerankerModel: DEFAULT_RERANKER_MODEL,
3939
surface: 'dashboard' as const,
4040
signal: request.signal,

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

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,35 @@ describe('result paging and the custom window', () => {
196196
expect(more()).toBeUndefined()
197197
})
198198

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+
199228
it('searches a custom window as an inclusive range of days', async () => {
200229
mocks.overview.mockReturnValue({ data: { providers: [], hasSearchableDocuments: true } })
201230
mocks.search.mockReturnValue({
@@ -208,7 +237,8 @@ describe('result paging and the custom window', () => {
208237
})
209238
await render(undefined, '?updated=custom&from=2026-09-01&to=2026-09-10')
210239
const filters = mocks.search.mock.calls.at(-1)![2]
211-
expect(filters.modifiedAfter).toBe('2026-09-01T00:00:00.000Z')
212-
expect(filters.modifiedBefore).toBe('2026-09-10T23:59:59.999Z')
240+
/** The days are the reader's own: local midnight to the last millisecond of the local day. */
241+
expect(filters.modifiedAfter).toBe(new Date(2026, 8, 1).toISOString())
242+
expect(filters.modifiedBefore).toBe(new Date(2026, 8, 11, 0, 0, 0, -1).toISOString())
213243
})
214244
})

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

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,17 @@ import { useSearchIndex, useSearchSourceOverview } from '@/hooks/queries/kb/conn
2828
import { useWorkspaceKnowledgeSearch } from '@/hooks/queries/kb/knowledge'
2929

3030
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+
}
3142
/** Every result without a connector is an upload; the filter names them so. */
3243
const UPLOAD_SOURCE = 'upload'
3344

@@ -128,8 +139,11 @@ interface SearchResultsProps {
128139
function SearchResults({ scope, query, onSummarize }: SearchResultsProps) {
129140
const [hasShownFilters, setHasShownFilters] = useState(false)
130141
const [searchedAt] = useState(Date.now)
131-
/** More results are a second, wider search: the first paint stays as quick as it is. */
132-
const [expanded, setExpanded] = useState(false)
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)
133147
const {
134148
data: index,
135149
isPending: basesPending,
@@ -146,11 +160,13 @@ function SearchResults({ scope, query, onSummarize }: SearchResultsProps) {
146160
...(window?.days
147161
? { modifiedAfter: new Date(searchedAt - window.days * DAY_MS).toISOString() }
148162
: {}),
149-
...(custom && filters.from ? { modifiedAfter: filters.from.toISOString() } : {}),
150-
...(custom && filters.to
151-
? { modifiedBefore: new Date(filters.to.getTime() + DAY_MS - 1).toISOString() }
163+
...(custom && filters.from
164+
? { modifiedAfter: startOfLocalDay(filters.from).toISOString() }
152165
: {}),
166+
...(custom && filters.to ? { modifiedBefore: endOfLocalDay(filters.to).toISOString() } : {}),
153167
}
168+
const filtersKey = JSON.stringify(searchFilters)
169+
const expanded = expandedFor === filtersKey
154170
const {
155171
data: search,
156172
isPending,
@@ -323,7 +339,11 @@ function SearchResults({ scope, query, onSummarize }: SearchResultsProps) {
323339
})}
324340
{mayHaveMore && (
325341
<div className='flex px-2 py-2'>
326-
<Chip variant='border' disabled={isFetching} onClick={() => setExpanded(true)}>
342+
<Chip
343+
variant='border'
344+
disabled={isFetching}
345+
onClick={() => setExpandedFor(filtersKey)}
346+
>
327347
Show more
328348
</Chip>
329349
</div>

‎apps/sim/lib/api/contracts/knowledge/search.test.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { describe, expect, it } from 'vitest'
2-
import { internalKnowledgeSearchBodySchema } from '@/lib/api/contracts/knowledge/search'
2+
import {
3+
internalKnowledgeSearchBodySchema,
4+
workspaceKnowledgeSearchBodySchema,
5+
workspaceSearchFiltersSchema,
6+
} from '@/lib/api/contracts/knowledge/search'
37
import { RESOLVED_SECRET_PROVENANCE_FIELD } from '@/lib/execution/private-tool-metadata'
48

59
describe('internal Knowledge search contract', () => {
@@ -20,3 +24,22 @@ describe('internal Knowledge search contract', () => {
2024
).toMatchObject({ [RESOLVED_SECRET_PROVENANCE_FIELD]: provenance })
2125
})
2226
})
27+
28+
describe('workspaceKnowledgeSearchBodySchema', () => {
29+
it('refuses a custom window whose end precedes its start, on the request rather than the filters', () => {
30+
const parsed = workspaceKnowledgeSearchBodySchema.safeParse({
31+
workspaceId: 'workspace-1',
32+
query: 'launch',
33+
filters: {
34+
modifiedAfter: '2026-09-10T00:00:00.000Z',
35+
modifiedBefore: '2026-09-01T00:00:00.000Z',
36+
},
37+
})
38+
expect(parsed.success).toBe(false)
39+
if (!parsed.success) {
40+
expect(parsed.error.issues[0]?.path).toEqual(['filters', 'modifiedBefore'])
41+
}
42+
/** The filters schema stays a plain object, so the Assistant's search input can still extend it. */
43+
expect(typeof workspaceSearchFiltersSchema.extend).toBe('function')
44+
})
45+
})

‎apps/sim/lib/api/contracts/knowledge/search.ts‎

Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -172,38 +172,36 @@ export const workspaceKnowledgeSearchResultSchema = z.object({
172172
})
173173
export type WorkspaceKnowledgeSearchResult = z.output<typeof workspaceKnowledgeSearchResultSchema>
174174

175-
export const workspaceSearchFiltersSchema = z
176-
.object({
177-
source: z.string().trim().min(1, 'Source cannot be empty').max(100).optional(),
178-
modifiedAfter: z.string().datetime({ offset: true }).optional(),
179-
modifiedBefore: z.string().datetime({ offset: true }).optional(),
180-
documentIds: z.array(z.string().min(1).max(200)).min(1).max(20).optional(),
181-
})
182-
.superRefine((filters, ctx) => {
183-
if (
184-
filters.modifiedAfter &&
185-
filters.modifiedBefore &&
186-
Date.parse(filters.modifiedBefore) < Date.parse(filters.modifiedAfter)
187-
) {
188-
ctx.addIssue({
189-
code: 'custom',
190-
path: ['modifiedBefore'],
191-
message: 'modifiedBefore must not precede modifiedAfter',
192-
})
193-
}
194-
})
175+
/** A plain object, so the Assistant's search input may still extend it; the window's order is checked on the request. */
176+
export const workspaceSearchFiltersSchema = z.object({
177+
source: z.string().trim().min(1, 'Source cannot be empty').max(100).optional(),
178+
modifiedAfter: z.string().datetime({ offset: true }).optional(),
179+
modifiedBefore: z.string().datetime({ offset: true }).optional(),
180+
documentIds: z.array(z.string().min(1).max(200)).min(1).max(20).optional(),
181+
})
195182
export type WorkspaceSearchFilters = z.output<typeof workspaceSearchFiltersSchema>
196183

197184
/** Chunks a search asks for at first paint, and once the reader asks for more; both within `topK`'s bound. */
198185
export const WORKSPACE_KNOWLEDGE_SEARCH_LIMITS = { initial: 20, expanded: 50 } as const
199186
export type WorkspaceKnowledgeSearchLimit =
200187
(typeof WORKSPACE_KNOWLEDGE_SEARCH_LIMITS)[keyof typeof WORKSPACE_KNOWLEDGE_SEARCH_LIMITS]
201188

202-
export const workspaceKnowledgeSearchBodySchema = resourceOwnerSchema.safeExtend({
203-
filters: workspaceSearchFiltersSchema.optional(),
204-
query: z.string().trim().min(1, 'A search query is required').max(2000, 'Query is too long'),
205-
topK: z.number().int().min(1).max(50).optional().default(20),
206-
})
189+
export const workspaceKnowledgeSearchBodySchema = resourceOwnerSchema
190+
.safeExtend({
191+
filters: workspaceSearchFiltersSchema.optional(),
192+
query: z.string().trim().min(1, 'A search query is required').max(2000, 'Query is too long'),
193+
topK: z.number().int().min(1).max(50).optional().default(20),
194+
})
195+
.superRefine((body, ctx) => {
196+
const { modifiedAfter, modifiedBefore } = body.filters ?? {}
197+
if (modifiedAfter && modifiedBefore && Date.parse(modifiedBefore) < Date.parse(modifiedAfter)) {
198+
ctx.addIssue({
199+
code: 'custom',
200+
path: ['filters', 'modifiedBefore'],
201+
message: 'modifiedBefore must not precede modifiedAfter',
202+
})
203+
}
204+
})
207205
export type WorkspaceKnowledgeSearchBody = z.input<typeof workspaceKnowledgeSearchBodySchema>
208206

209207
export const workspaceKnowledgeSearchDataSchema = z.object({

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
88
import { OrchestrationError } from '@/lib/core/orchestration/types'
99

1010
const mocks = vi.hoisted(() => ({
11+
hasRerankerCredential: vi.fn(async () => true),
1112
resolveWorkspace: vi.fn(),
1213
resolveOrganization: vi.fn(),
1314
requireOrganizationSearch: vi.fn(),
@@ -39,6 +40,7 @@ vi.mock('@/lib/knowledge/search/activity', () => ({
3940
}))
4041

4142
vi.mock('@/lib/knowledge/reranker', () => ({
43+
hasRerankerCredential: mocks.hasRerankerCredential,
4244
rerank: mocks.rerank,
4345
}))
4446

@@ -866,6 +868,16 @@ describe('knowledge search application use case', () => {
866868
* `rerank`, the use case swallowed it, and the caller got a 200 whose results
867869
* were byte-identical to an unreranked search with nothing to distinguish them.
868870
*/
871+
it('never calls the reranker when neither the workspace nor the platform holds a key', async () => {
872+
mocks.hasRerankerCredential.mockResolvedValueOnce(false)
873+
874+
const result = await rerankedSearch(true)
875+
876+
expect(mocks.rerank).not.toHaveBeenCalled()
877+
expect(result.rerankerStatus).toBe('unavailable')
878+
expect(result.results[0]).not.toHaveProperty('rerankerScore')
879+
})
880+
869881
it('reports unavailable rather than silently falling back to vector ordering', async () => {
870882
mocks.rerank.mockRejectedValueOnce(new Error('No Cohere API key configured.'))
871883

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants'
3434
import { getEmbeddingModelInfo, toKbEmbeddingDimensions } from '@/lib/knowledge/embedding-models'
3535
import { generateSearchEmbedding, type KbEmbeddingTarget } from '@/lib/knowledge/embeddings'
3636
import { runWithKnowledgeModelInputProvenance } from '@/lib/knowledge/model-input-provenance'
37-
import { rerank } from '@/lib/knowledge/reranker'
37+
import { hasRerankerCredential, rerank } from '@/lib/knowledge/reranker'
3838
import type { RerankerStatus } from '@/lib/knowledge/reranker-models'
3939
import { recordOrganizationSearchActivity } from '@/lib/knowledge/search/activity'
4040
import { SearchDeadlineError } from '@/lib/knowledge/search/budget'
@@ -391,7 +391,10 @@ const searchKnowledgeUseCase = defineAuthorizedKnowledgeUseCase({
391391
boostRecency: searchDefaults.boostRecency,
392392
embeddingDimensions: embeddingTarget?.dimensions,
393393
})
394-
const useReranker = Boolean(input.rerankerEnabled && hasQuery)
394+
/** A surface may ask to rerank; without a key for the workspace or the platform there is nothing to ask. */
395+
const useReranker =
396+
Boolean(input.rerankerEnabled && hasQuery) &&
397+
(Boolean(input.rerankerApiKey) || (await hasRerankerCredential(context.workspaceId)))
395398
const candidateTopK = useReranker
396399
? input.rerankerInputCount !== undefined
397400
? Math.min(

‎apps/sim/lib/knowledge/reranker.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger'
22
import { sha256Hex } from '@sim/security/hash'
33
import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry'
44
import { getBYOKKey } from '@/lib/api-key/byok'
5-
import { getRotatingApiKey } from '@/lib/core/config/api-keys'
5+
import { getRotatingApiKey, hasRotatingApiKey } from '@/lib/core/config/api-keys'
66
import { env } from '@/lib/core/config/env'
77
import { isHosted } from '@/lib/core/config/env-flags'
88
import {
@@ -30,6 +30,15 @@ const logger = createLogger('Reranker')
3030

3131
const RERANK_OPERATION_TIMEOUT_MS = 30_000
3232

33+
/**
34+
* Whether a search for this workspace could be reranked at all: a workspace key, or one of the
35+
* platform's. A surface that reranks "when configured" asks this before spending a call on it.
36+
*/
37+
export async function hasRerankerCredential(workspaceId?: string): Promise<boolean> {
38+
if (workspaceId && (await getBYOKKey(workspaceId, 'cohere'))) return true
39+
return Boolean(env.COHERE_API_KEY) || hasRotatingApiKey('cohere')
40+
}
41+
3342
/**
3443
* Cohere bills per "search unit" = one query with up to 100 documents.
3544
* We cap at 100 so each rerank call costs exactly 1 unit and matches

0 commit comments

Comments
 (0)