diff --git a/apps/sim/app/api/knowledge/search/route.test.ts b/apps/sim/app/api/knowledge/search/route.test.ts
index e02b67e4203..46e4a113eef 100644
--- a/apps/sim/app/api/knowledge/search/route.test.ts
+++ b/apps/sim/app/api/knowledge/search/route.test.ts
@@ -19,7 +19,11 @@ describe('workspace search route', () => {
user: { id: 'user-1', email: 'reader@fixture.test', name: 'Reader' },
session: { id: 'session-1' },
})
- mocks.search.mockResolvedValue({ results: [], knowledgeBases: [] })
+ mocks.search.mockResolvedValue({
+ results: [],
+ knowledgeBases: [],
+ retrieval: { status: 'complete', timedOutLegs: [] },
+ })
})
it('passes the authenticated request cancellation signal through the existing operation', async () => {
@@ -41,11 +45,48 @@ describe('workspace search route', () => {
expect(call.input).not.toHaveProperty('knowledgeBaseIds')
expect(call.input.filters).toEqual({ source: 'slack', documentIds: ['doc-1'] })
expect(call.input.signal).toBe(request.signal)
+ expect(call.input.allowPartialResults).toBe(true)
controller.abort()
expect(call.input.signal.aborted).toBe(true)
await expect(response.json()).resolves.toEqual({
success: true,
- data: { query: 'Orion', results: [] },
+ data: { query: 'Orion', results: [], retrieval: { status: 'complete', timedOutLegs: [] } },
+ })
+ })
+
+ it('preserves usable matches and incomplete coverage when one retrieval leg times out', async () => {
+ mocks.search.mockResolvedValueOnce({
+ knowledgeBases: [{ id: 'knowledge-1', name: 'Search index' }],
+ retrieval: { status: 'partial', timedOutLegs: ['vector'] },
+ results: [
+ {
+ documentId: 'document-1',
+ knowledgeBaseId: 'knowledge-1',
+ documentName: 'Release plan',
+ sourceUrl: null,
+ connectorType: null,
+ sourceModifiedAt: null,
+ metadata: {},
+ content: 'Orion release',
+ chunkIndex: 0,
+ similarity: 0.9,
+ },
+ ],
+ })
+ const response = await POST(
+ new NextRequest('http://localhost/api/knowledge/search', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ organizationId: 'organization-1', query: 'Orion' }),
+ })
+ )
+ expect(response.status).toBe(200)
+ await expect(response.json()).resolves.toMatchObject({
+ success: true,
+ data: {
+ results: [{ documentId: 'document-1', content: 'Orion release' }],
+ retrieval: { status: 'partial', timedOutLegs: ['vector'] },
+ },
})
})
diff --git a/apps/sim/app/api/knowledge/search/route.ts b/apps/sim/app/api/knowledge/search/route.ts
index cc9ae79fb8b..178027a04b4 100644
--- a/apps/sim/app/api/knowledge/search/route.ts
+++ b/apps/sim/app/api/knowledge/search/route.ts
@@ -24,16 +24,18 @@ export const POST = defineInternalJsonRoute({
filters: body.filters,
query: body.query,
topK: body.topK,
+ allowPartialResults: true,
surface: 'dashboard' as const,
signal: request.signal,
}),
useCase: searchScopedKnowledge,
- present: ({ results, knowledgeBases }, { input }) => {
+ present: ({ results, knowledgeBases, retrieval }, { input }) => {
const knowledgeBaseNames = new Map(knowledgeBases.map((kb) => [kb.id, kb.name]))
return {
success: true as const,
data: {
query: input.query ?? '',
+ retrieval,
results: results.map((result) => ({
documentId: result.documentId,
knowledgeBaseId: result.knowledgeBaseId,
diff --git a/apps/sim/app/o/[organizationId]/search/search.test.tsx b/apps/sim/app/o/[organizationId]/search/search.test.tsx
index 41db4871567..9b9a6f78714 100644
--- a/apps/sim/app/o/[organizationId]/search/search.test.tsx
+++ b/apps/sim/app/o/[organizationId]/search/search.test.tsx
@@ -90,7 +90,12 @@ beforeEach(() => {
chunkIndex: 0,
similarity: 1,
}
- return { data: [result], isPending: false, isFetching: false, isError: false }
+ return {
+ data: { query, results: [result], retrieval: { status: 'complete', timedOutLegs: [] } },
+ isPending: false,
+ isFetching: false,
+ isError: false,
+ }
})
container = document.createElement('div')
document.body.appendChild(container)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.test.tsx
index f41648b121d..7825d523178 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.test.tsx
@@ -4,22 +4,17 @@ import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-const mocks = vi.hoisted(() => ({ overview: vi.fn() }))
+const mocks = vi.hoisted(() => ({ overview: vi.fn(), search: vi.fn(), retry: vi.fn() }))
vi.mock('@/hooks/queries/kb/connectors', () => ({
useSearchIndex: () => ({ data: { knowledgeBaseId: 'index' }, isPending: false }),
useSearchSourceOverview: mocks.overview,
}))
vi.mock('@/hooks/queries/kb/knowledge', () => ({
- useWorkspaceKnowledgeSearch: () => ({
- data: [],
- isPending: false,
- isFetching: false,
- isError: false,
- }),
+ useWorkspaceKnowledgeSearch: mocks.search,
}))
vi.mock(
'@/app/workspace/[workspaceId]/home/components/message-content/components/source-card',
- () => ({ SourceCard: () => null })
+ () => ({ SourceCard: ({ source }: { source: { title: string } }) => {source.title} })
)
import { KnowledgeSearchResults } from '@/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results'
@@ -28,6 +23,13 @@ let root: Root
let container: HTMLDivElement
beforeEach(() => {
vi.clearAllMocks()
+ mocks.search.mockReturnValue({
+ data: { query: 'launch', results: [], retrieval: { status: 'complete', timedOutLegs: [] } },
+ isPending: false,
+ isFetching: false,
+ isError: false,
+ refetch: mocks.retry,
+ })
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
container = document.createElement('div')
root = createRoot(container)
@@ -69,3 +71,52 @@ describe('source indexing context in search results', () => {
expect(container.textContent).toContain('No documents you can read match')
})
})
+
+describe('incomplete search coverage', () => {
+ it.each([false, true])(
+ 'keeps matches and offers a retry without claiming absence (hasResults=%s)',
+ async (hasResults) => {
+ mocks.search.mockReturnValue({
+ data: {
+ query: 'launch',
+ results: hasResults
+ ? [
+ {
+ documentId: 'document-1',
+ knowledgeBaseId: 'index',
+ knowledgeBaseName: 'Search index',
+ documentName: 'Release plan',
+ sourceUrl: 'https://fixture.test/release',
+ connectorType: null,
+ sourceModifiedAt: null,
+ author: null,
+ content: 'launch details',
+ chunkIndex: 0,
+ similarity: 0.9,
+ },
+ ]
+ : [],
+ retrieval: { status: 'partial', timedOutLegs: ['vector'] },
+ },
+ isPending: false,
+ isFetching: false,
+ isError: false,
+ refetch: mocks.retry,
+ })
+ await render()
+ expect(container.textContent).not.toContain('Search couldn’t run')
+ expect(container.textContent).not.toContain('No documents')
+ expect(container.textContent).not.toContain('0 documents')
+ expect(container.textContent).toContain(
+ hasResults ? 'Some results may be missing.' : 'Search is incomplete.'
+ )
+ if (hasResults) expect(container.textContent).toContain('Release plan')
+ const retry = [...container.querySelectorAll('button')].find(
+ (button) => button.textContent === 'Try again'
+ )
+ expect(retry).toBeDefined()
+ await act(async () => retry!.click())
+ expect(mocks.retry).toHaveBeenCalledOnce()
+ }
+ )
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx
index 0c8395fd1cb..853d3f5f7e0 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx
@@ -133,7 +133,7 @@ export function KnowledgeSearchResults({
}
}, [filters.source, filters.updated])
const {
- data: results,
+ data: search,
isPending,
isFetching,
isError: searchFailed,
@@ -143,7 +143,8 @@ export function KnowledgeSearchResults({
const indexing = (overview?.providers ?? [])
.filter((provider) => provider.isSyncing)
.map((provider) => connectorDisplayName(provider.connectorType))
- const documents = useMemo(() => groupResultsByDocument(results ?? []), [results])
+ const documents = useMemo(() => groupResultsByDocument(search?.results ?? []), [search?.results])
+ const incomplete = search?.retrieval.status === 'partial'
const sourceTypes = [
...new Set([
...(filters.source ? [filters.source] : []),
@@ -155,8 +156,7 @@ export function KnowledgeSearchResults({
const showFilters =
filtersActive || (documents.length >= FILTERS_MIN_RESULTS && sourceTypes.length > 1)
- /* A failed search says so in one quiet line and offers to run again; the cause is
- the server's to log, never the reader's to parse. */
+ /** A failed search offers a retry; server diagnostics carry the cause. */
if (basesFailed || searchFailed) {
const retrying = basesFetching || isFetching
return (
@@ -188,7 +188,7 @@ export function KnowledgeSearchResults({
)
}
- if (isPending || (isFetching && !results)) {
+ if (isPending || (isFetching && !search)) {
return (
@@ -205,12 +205,24 @@ export function KnowledgeSearchResults({
-
- {documents.length === 1 ? '1 document' : `${documents.length} documents`}
-
- {' · searched as you'}
+ {incomplete && documents.length === 0 ? (
+ 'Search is incomplete.'
+ ) : (
+ <>
+
+ {documents.length === 1 ? '1 document' : `${documents.length} documents`}
+
+ {' · searched as you'}
+ {incomplete && Some results may be missing.}
+ >
+ )}
{indexingNote && {indexingNote}}
+ {incomplete && (
+ void refetchSearch()}>
+ {isFetching ? 'Retrying…' : 'Try again'}
+
+ )}
{showFilters && (
@@ -245,11 +257,13 @@ export function KnowledgeSearchResults({
)}
{documents.length === 0 ? (
-
- {filtersActive
- ? 'No documents match these filters.'
- : `No documents you can read match “${query}”.`}
-
+ !incomplete && (
+
+ {filtersActive
+ ? 'No documents match these filters.'
+ : `No documents you can read match “${query}”.`}
+
+ )
) : (
{documents.map((result) => {
diff --git a/apps/sim/hooks/queries/kb/knowledge.test.ts b/apps/sim/hooks/queries/kb/knowledge.test.ts
index 2d408adf15c..71729ec1bde 100644
--- a/apps/sim/hooks/queries/kb/knowledge.test.ts
+++ b/apps/sim/hooks/queries/kb/knowledge.test.ts
@@ -132,12 +132,17 @@ describe('knowledge query placeholder scope', () => {
})
it('forwards search cancellation and leaves provider retries to the server', async () => {
- mocks.requestJson.mockResolvedValueOnce({ data: { results: [] } })
+ const data = {
+ query: 'query',
+ results: [],
+ retrieval: { status: 'partial', timedOutLegs: ['vector'] },
+ }
+ mocks.requestJson.mockResolvedValueOnce({ data })
const query = captureQuery(() =>
useWorkspaceKnowledgeSearch('workspace-1', ' query ', { source: 'slack' })
)
const controller = new AbortController()
- await query.queryFn({ signal: controller.signal })
+ await expect(query.queryFn({ signal: controller.signal })).resolves.toEqual(data)
expect(query.retry).toBe(false)
expect(mocks.requestJson).toHaveBeenLastCalledWith(
diff --git a/apps/sim/hooks/queries/kb/knowledge.ts b/apps/sim/hooks/queries/kb/knowledge.ts
index 5870972170e..bb0a023bcda 100644
--- a/apps/sim/hooks/queries/kb/knowledge.ts
+++ b/apps/sim/hooks/queries/kb/knowledge.ts
@@ -52,7 +52,7 @@ import {
updateKnowledgeDocumentContract,
updateKnowledgeDocumentTagsContract,
type WorkspaceKnowledgeSearchBody,
- type WorkspaceKnowledgeSearchResult,
+ type WorkspaceKnowledgeSearchData,
} from '@/lib/api/contracts/knowledge'
import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge/search'
import type { ChunkingStrategy, StrategyOptions } from '@/lib/chunkers/types'
@@ -1197,9 +1197,9 @@ export function useBulkDeleteKnowledgeBases(workspaceId: string) {
async function searchWorkspaceKnowledge(
body: WorkspaceKnowledgeSearchBody,
signal?: AbortSignal
-): Promise {
+): Promise {
const data = await requestJson(searchWorkspaceKnowledgeContract, { body, signal })
- return data.data.results
+ return data.data
}
/** Searches the canonical index under the signed-in person's ACLs. */
diff --git a/apps/sim/lib/api/contracts/knowledge/search.ts b/apps/sim/lib/api/contracts/knowledge/search.ts
index 63c86baeaf6..912ca4690aa 100644
--- a/apps/sim/lib/api/contracts/knowledge/search.ts
+++ b/apps/sim/lib/api/contracts/knowledge/search.ts
@@ -186,6 +186,16 @@ export const workspaceKnowledgeSearchBodySchema = resourceOwnerSchema.safeExtend
})
export type WorkspaceKnowledgeSearchBody = z.input
+export const workspaceKnowledgeSearchDataSchema = z.object({
+ query: z.string(),
+ results: z.array(workspaceKnowledgeSearchResultSchema),
+ retrieval: z.object({
+ status: z.enum(['complete', 'partial']),
+ timedOutLegs: z.array(z.enum(['vector', 'keyword', 'tags'])).max(3),
+ }),
+})
+export type WorkspaceKnowledgeSearchData = z.output
+
/**
* The search a signed-in person runs from the composer: what their own
* account may read across the workspace's knowledge bases, presented as
@@ -199,10 +209,7 @@ export const searchWorkspaceKnowledgeContract = defineRouteContract({
mode: 'json',
schema: z.object({
success: z.literal(true),
- data: z.object({
- query: z.string(),
- results: z.array(workspaceKnowledgeSearchResultSchema),
- }),
+ data: workspaceKnowledgeSearchDataSchema,
}),
},
})
diff --git a/apps/sim/lib/knowledge/__integration__/search-latency.integration.ts b/apps/sim/lib/knowledge/__integration__/search-latency.integration.ts
index 7f62c21dae8..919cce00ac4 100644
--- a/apps/sim/lib/knowledge/__integration__/search-latency.integration.ts
+++ b/apps/sim/lib/knowledge/__integration__/search-latency.integration.ts
@@ -17,8 +17,11 @@ import {
import { createLogger, Logger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, eq, inArray, sql } from 'drizzle-orm'
+import { NextRequest } from 'next/server'
import { afterAll, beforeAll, describe, expect, it, type MockInstance, vi } from 'vitest'
import { z } from 'zod'
+import { workspaceKnowledgeSearchDataSchema } from '@/lib/api/contracts/knowledge/search'
+import { internalSessionAuth } from '@/lib/api/server/routes'
import type {
MothershipStreamV1CheckpointPausePayload,
MothershipStreamV1ToolCallDescriptor,
@@ -41,6 +44,7 @@ import {
} from '@/lib/knowledge/search/budget'
import type { SearchStage } from '@/lib/knowledge/search/diagnostics'
import type { WorkspaceSearchFilters } from '@/lib/knowledge/search/filters'
+import { POST as searchRoute } from '@/app/api/knowledge/search/route'
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
/** Initialize controlled provider configuration before the real application modules load. */
@@ -532,6 +536,60 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu
30_000
)
+ it.each(['vector', 'both'] as const)(
+ 'returns incomplete dashboard coverage when %s SQL branches exceed their deadline',
+ async (delayedLegs) => {
+ const authenticate = vi.spyOn(internalSessionAuth, 'authenticate').mockResolvedValue({
+ kind: 'session',
+ userId: ids.aliceId,
+ sessionId: 'fixture-dashboard',
+ })
+ const query = SearchBudget.prototype.query
+ const delayed = vi.spyOn(SearchBudget.prototype, 'query').mockImplementation(function (
+ this: SearchBudget,
+ stage: SearchStage,
+ run: (executor: SearchExecutor) => PromiseLike
+ ): Promise {
+ return query.call(this, stage, async (tx) => {
+ if (delayedLegs === 'both' || this.leg === 'vector')
+ await tx.execute(sql`SELECT pg_sleep(9)`)
+ return run(tx)
+ }) as Promise
+ })
+ try {
+ const response = await searchRoute(
+ new NextRequest('http://localhost/api/knowledge/search', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({
+ workspaceId: ids.workspaceId,
+ query: 'Orion deployment',
+ topK: 15,
+ }),
+ })
+ )
+ expect(response.status).toBe(200)
+ const data = workspaceKnowledgeSearchDataSchema.parse((await response.json()).data)
+ expect(data.retrieval).toEqual({
+ status: 'partial',
+ timedOutLegs: delayedLegs === 'both' ? ['vector', 'keyword'] : ['vector'],
+ })
+ if (delayedLegs === 'both') expect(data.results).toEqual([])
+ else {
+ expect(data.results.length).toBeGreaterThan(0)
+ expect(
+ data.results.every((result) => result.knowledgeBaseId === ids.knowledgeBaseId)
+ ).toBe(true)
+ }
+ report[`dashboard.deadline.${delayedLegs}`] = { resultCount: data.results.length }
+ } finally {
+ delayed.mockRestore()
+ authenticate.mockRestore()
+ }
+ },
+ 30_000
+ )
+
it('records first and repeated application searches with the actual SQL plans', async () => {
const before = embeddingCalls
for (let iteration = 0; iteration < 2; iteration++) {
diff --git a/apps/sim/lib/knowledge/application/search.ts b/apps/sim/lib/knowledge/application/search.ts
index 8d2e1dc2057..cdf61bfc5fd 100644
--- a/apps/sim/lib/knowledge/application/search.ts
+++ b/apps/sim/lib/knowledge/application/search.ts
@@ -423,12 +423,12 @@ const searchKnowledgeUseCase = defineAuthorizedKnowledgeUseCase({
})
)
- if (retrieved.retrieval.status === 'partial' && !input.allowPartialResults)
- throw new SearchDeadlineError()
annotateSearchDiagnostics({
retrievalStatus: retrieved.retrieval.status,
timedOutLegs: retrieved.retrieval.timedOutLegs,
})
+ if (retrieved.retrieval.status === 'partial' && !input.allowPartialResults)
+ throw new SearchDeadlineError()
let rows = retrieved.rows
input.signal?.throwIfAborted()
/** Public callers have no input envelope, but persisted reranker inputs still need provenance. */
diff --git a/apps/sim/lib/knowledge/search/diagnostics.ts b/apps/sim/lib/knowledge/search/diagnostics.ts
index f4c506ca629..4265db0c886 100644
--- a/apps/sim/lib/knowledge/search/diagnostics.ts
+++ b/apps/sim/lib/knowledge/search/diagnostics.ts
@@ -70,6 +70,7 @@ export interface SearchDiagnosticMetadata {
boostRecency?: boolean
embeddingDimensions?: number
vectorRanking?: 'exact' | 'binary-rerank'
+ vectorCandidateLimit?: number
vectorCandidateCount?: number
resultCount?: number
/** Tool output before the executor's final egress projection; counts only, never content. */
diff --git a/apps/sim/lib/knowledge/search/queries.test.ts b/apps/sim/lib/knowledge/search/queries.test.ts
index 168c64609ed..f73f148ff63 100644
--- a/apps/sim/lib/knowledge/search/queries.test.ts
+++ b/apps/sim/lib/knowledge/search/queries.test.ts
@@ -553,7 +553,10 @@ describe('live repository authorization follows ranked candidates', () => {
expect(dbChainMockFns.limit.mock.invocationCallOrder[1]).toBeLessThan(
dbChainMockFns.select.mock.invocationCallOrder[2]
)
- expect(JSON.stringify(dbChainMockFns.where.mock.calls[1][0])).toContain('OFFSET 0')
+ const visibilityJoin = render(dbChainMockFns.innerJoin.mock.calls[1][0])
+ expect(visibilityJoin.sql).toContain('LATERAL')
+ expect(visibilityJoin.sql).toContain('OFFSET 0')
+ expect(JSON.stringify(dbChainMockFns.innerJoin.mock.calls[1][0])).toContain('required_clause')
expect(getForConnectors).toHaveBeenCalledExactlyOnceWith(['allowed-source'], undefined)
expect(JSON.stringify(dbChainMockFns.where.mock.calls[3][0])).toContain('github_read_grant')
})
diff --git a/apps/sim/lib/knowledge/search/queries.ts b/apps/sim/lib/knowledge/search/queries.ts
index 2e349830dea..af35de09073 100644
--- a/apps/sim/lib/knowledge/search/queries.ts
+++ b/apps/sim/lib/knowledge/search/queries.ts
@@ -793,10 +793,10 @@ export async function handleVectorOnlySearch(params: SearchParams): Promise candidate.id))
}
if (useExactRanking) return exactPage()
+ annotateSearchDiagnostics({
+ vectorRanking: 'binary-rerank',
+ vectorCandidateLimit: candidateLimit,
+ })
const identities = await withVectorScanSettings(
(executor) =>
executor
.select({ id: embedding.id })
.from(embedding)
- .where(
- and(
- inArray(embedding.knowledgeBaseId, params.knowledgeBaseIds),
- sql`EXISTS (
- SELECT 1 FROM ${document}
- WHERE ${and(eq(document.id, embedding.documentId), ...visibility)}
- OFFSET 0
- )`
- )
+ .innerJoin(
+ sql`LATERAL (
+ SELECT 1 FROM ${document}
+ WHERE ${and(eq(document.id, embedding.documentId), ...visibility)}
+ OFFSET 0
+ ) AS visible_document`,
+ sql`true`
)
+ .where(inArray(embedding.knowledgeBaseId, params.knowledgeBaseIds))
.orderBy(candidateDistance)
.limit(candidateLimit),
params.budget,
'binary'
)
annotateSearchDiagnostics({
- vectorRanking: 'binary-rerank',
vectorCandidateCount: identities.length,
})
if (!identities.length) return exactPage()