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()