Skip to content

Commit 9294beb

Browse files
fix(search): use compact candidates for filtered vector retrieval (#7794)
1 parent 1d4c004 commit 9294beb

15 files changed

Lines changed: 26700 additions & 47 deletions

File tree

apps/sim/lib/copilot/tools/server/knowledge/workspace-search.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,29 @@ describe('Assistant retrieval tools', () => {
8484
next: null,
8585
})
8686
})
87+
it('returns empty incomplete retrieval as a recoverable search outcome and logs coverage', async () => {
88+
mocks.search.mockResolvedValue({
89+
retrieval: { status: 'partial', timedOutLegs: ['vector', 'keyword'] },
90+
knowledgeBases: [{ id: 'index', name: 'Enterprise Search' }],
91+
results: [],
92+
})
93+
94+
const result = await searchWorkspaceServerTool.execute({ query: 'canaries' }, context)
95+
96+
expect(result).toMatchObject({
97+
success: true,
98+
message: expect.stringContaining('cannot establish absence or completeness'),
99+
data: {
100+
retrieval: { status: 'partial', timedOutLegs: ['vector', 'keyword'] },
101+
results: [],
102+
},
103+
})
104+
expect(result).not.toHaveProperty('error')
105+
expect(mocks.info).toHaveBeenCalledWith(
106+
'Knowledge search completed',
107+
expect.objectContaining({ passageBytes: 0, originalPassageBytes: 0, outcome: 'success' })
108+
)
109+
})
87110
it('pins organization and private chat while reusing the canonical search index and citations', async () => {
88111
const orgContext = {
89112
...context,

apps/sim/lib/copilot/tools/server/knowledge/workspace-search.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ export const searchWorkspaceServerTool: BaseServerTool = {
9393
const names = new Map(result.knowledgeBases.map((base) => [base.id, base.name]))
9494
const output = {
9595
success: true,
96-
message: `${result.retrieval.status === 'partial' ? 'Partial search: a retrieval branch reached its deadline. These results cannot establish absence or completeness. ' : ''}Found ${result.results.length} passage previews. Read a document at its chunkIndex for more context. ${CITATION_INSTRUCTION}`,
96+
message: `${result.retrieval.status === 'partial' ? 'Search coverage is incomplete. Continue with a more specific query or source filter; these results cannot establish absence or completeness. ' : ''}Found ${result.results.length} passage previews. Read a document at its chunkIndex for more context. ${CITATION_INSTRUCTION}`,
9797
data: {
9898
query: safeQuery,
9999
retrieval: result.retrieval,

apps/sim/lib/knowledge/__integration__/search-latency.integration.ts

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,11 @@ const reused = reuseFile ? readFixtureReport(reuseFile) : undefined
8383
const ids = reused?.fixture ?? createKnowledgeAclFixtureIds()
8484
const unrelated = reused?.unrelatedFixture ?? createKnowledgeAclFixtureIds()
8585
const organizationChatId = generateId()
86-
const queryVector = Array.from({ length: dimensions }, (_, index) => (index === 0 ? 1 : 0))
86+
const queryVector = Array.from({ length: dimensions }, (_, index) =>
87+
Math.sin((index + 1) * 12.9898)
88+
)
89+
const queryMagnitude = Math.hypot(...queryVector)
90+
for (let index = 0; index < queryVector.length; index++) queryVector[index] /= queryMagnitude
8791
const captured: CapturedQuery[] = []
8892
const report: Record<string, unknown> = {
8993
fixture: ids,
@@ -133,6 +137,7 @@ const explainSchema = z.array(z.object({ Plan: explainNodeSchema }).passthrough(
133137

134138
function usesVectorIndex(node: ExplainNode): boolean {
135139
return (
140+
node['Index Name'] === 'embedding_binary_hnsw_idx' ||
136141
node['Index Name'] === 'embedding_vector_hnsw_idx' ||
137142
(node.Plans?.some(usesVectorIndex) ?? false)
138143
)
@@ -237,14 +242,21 @@ async function sample(label: string, run: () => ReturnType<typeof search>) {
237242
const plan = await db.$client.begin(async (tx) => {
238243
await tx.unsafe("SET LOCAL hnsw.iterative_scan = 'relaxed_order'")
239244
await tx.unsafe('SET LOCAL hnsw.max_scan_tuples = 20000')
245+
if (query.query.includes('binary_quantize')) {
246+
await tx.unsafe('SET LOCAL hnsw.max_scan_tuples = 100000')
247+
await tx.unsafe('SET LOCAL hnsw.ef_search = 200')
248+
await tx.unsafe('SET LOCAL hnsw.scan_mem_multiplier = 4')
249+
}
240250
return tx.unsafe(`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${query.query}`, query.parameters)
241251
})
242252
plans.push({
243253
kind: query.query.includes('keyword_rank')
244254
? 'keyword'
245-
: query.query.includes('order by')
255+
: query.query.includes('binary_quantize')
246256
? 'vector'
247-
: 'probe',
257+
: query.query.includes('order by')
258+
? 'rerank'
259+
: 'probe',
248260
query: query.query,
249261
parameters: query.parameters,
250262
plan: explainSchema.parse(plan[0]['QUERY PLAN']),
@@ -378,8 +390,8 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu
378390
CASE WHEN n % 8 = 0 THEN 'Orion deployment reference. ' ELSE 'Engineering operations reference. ' END ||
379391
(SELECT string_agg(md5(n::text || ':' || paragraph::text), ' ') FROM generate_series(1, 90) paragraph),
380392
3000, 750, 0, 3000,
381-
l2_normalize(ARRAY(SELECT (CASE WHEN coordinate = n % 32 + 1 THEN 1 ELSE 0 END +
382-
0.025 * sin(n::double precision * coordinate * 12.9898 + coordinate * 78.233))::real
393+
l2_normalize(ARRAY(SELECT (sin(coordinate * (n % 32 + 1) * 12.9898) +
394+
0.25 * sin(n::double precision * coordinate * 12.9898 + coordinate * 78.233))::real
383395
FROM generate_series(1, ${dimensions}) coordinate)::vector(1536))
384396
FROM generate_series(${first}::int, ${last}::int) n`)
385397
})
@@ -494,7 +506,13 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu
494506
}
495507
)
496508
if (delayedLegs === 'both') {
497-
expect(result).toMatchObject({ success: false, retryable: true })
509+
expect(result).toMatchObject({
510+
success: true,
511+
data: {
512+
retrieval: { status: 'partial', timedOutLegs: ['vector', 'keyword'] },
513+
results: [],
514+
},
515+
})
498516
return
499517
}
500518
expect(result).toMatchObject({
@@ -526,6 +544,18 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu
526544
const vectorPlans = plans.filter((plan) => plan.kind === 'vector')
527545
expect(vectorPlans).toHaveLength(1)
528546
expect(usesVectorIndex(vectorPlans[0].plan[0].Plan)).toBe(true)
547+
expect(plans.some((plan) => plan.kind === 'rerank')).toBe(true)
548+
const rerank = plans.find((plan) => plan.kind === 'rerank')!
549+
const actual = await db.$client.unsafe(rerank.query, rerank.parameters).values()
550+
const expected = await db.execute<{ id: string }>(sql`SELECT id FROM embedding
551+
WHERE knowledge_base_id = ${ids.knowledgeBaseId} AND enabled
552+
ORDER BY (embedding <=> ${JSON.stringify(queryVector)}::vector) + 0, id
553+
LIMIT ${actual.length}`)
554+
const expectedIds = new Set(expected.map(({ id }) => id))
555+
const recall = actual.filter(([id]) => expectedIds.has(id)).length / expected.length
556+
expect(recall).toBeGreaterThanOrEqual(0.95)
557+
report[`recall.${iteration}`] = { neighbors: expected.length, recall }
558+
saveReport()
529559
}
530560
expect(embeddingCalls - before).toBe(2)
531561
}, 180_000)
@@ -578,7 +608,7 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu
578608
expect(probe).toHaveLength(1)
579609
expect(probe[0].query).not.toContain('<=>')
580610
expect(probe[0].plan[0].Plan['Actual Rows']).toBe(12)
581-
const vector = plans.filter((plan) => plan.kind === 'vector')
611+
const vector = plans.filter((plan) => plan.kind === 'rerank')
582612
expect(vector).toHaveLength(1)
583613
expect(vector[0].query).toContain('"embedding"."id" in')
584614
} finally {
@@ -672,7 +702,7 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu
672702
}, 180_000)
673703
/** Opt in with local Sim and Go URLs; uses the real configured provider, billing adapter, and async resume protocol. */
674704
it.skipIf(!process.env.KNOWLEDGE_SEARCH_ASSISTANT_URL)(
675-
'answers through local Go Assistant with progressive reads and citations',
705+
'recovers quietly from incomplete search through local Go Assistant, then reads and cites evidence',
676706
async () => {
677707
const assistantUrl = new URL(process.env.KNOWLEDGE_SEARCH_ASSISTANT_URL!)
678708
const simUrl = new URL(process.env.KNOWLEDGE_SEARCH_SIM_URL!)
@@ -708,6 +738,18 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu
708738
bytes: number
709739
}> = []
710740
let answer = ''
741+
let incompleteSearch = true
742+
const query = SearchBudget.prototype.query
743+
const delayed = vi.spyOn(SearchBudget.prototype, 'query').mockImplementation(function <T>(
744+
this: SearchBudget,
745+
stage: SearchStage,
746+
run: (executor: SearchExecutor) => PromiseLike<T>
747+
): Promise<T> {
748+
return query.call(this, stage, async (tx) => {
749+
if (incompleteSearch) await tx.execute(sql`SELECT pg_sleep(9)`)
750+
return run(tx)
751+
}) as Promise<T>
752+
})
711753
const started = performance.now()
712754
try {
713755
await db
@@ -808,9 +850,20 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu
808850
bytes: Buffer.byteLength(JSON.stringify(result)),
809851
})
810852
const { success } = z.object({ success: z.boolean() }).parse(result)
853+
expect(success).toBe(true)
854+
if (incompleteSearch) {
855+
expect(call.toolName).toBe('search_workspace')
856+
expect(result).toMatchObject({
857+
data: {
858+
retrieval: { status: 'partial', timedOutLegs: ['vector', 'keyword'] },
859+
results: [],
860+
},
861+
})
862+
}
811863
return { callId, name: call.toolName, success, data: result }
812864
})
813865
)
866+
incompleteSearch = false
814867
path = '/api/tools/resume'
815868
body = {
816869
checkpointId: checkpoint.checkpointId,
@@ -828,10 +881,12 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu
828881
expect(answer).toContain('SILVER COMET')
829882
expect(answer).toContain('K7M2-84')
830883
expect(answer).toContain('<source>')
884+
expect(answer).not.toMatch(/timed?\s*out|timeout|internal retr(?:y|ies)/i)
831885
expect(calls.some((call) => call.name === 'read_document')).toBe(true)
832-
expect(calls.some((call) => call.name === 'search_workspace')).toBe(true)
886+
expect(calls.filter((call) => call.name === 'search_workspace').length).toBeGreaterThan(1)
833887
expect(calls.every((call) => call.bytes < 40000)).toBe(true)
834888
} finally {
889+
delayed.mockRestore()
835890
await db.update(embedding).set(original).where(eq(embedding.id, chunkId))
836891
}
837892
},

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

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const mocks = vi.hoisted(() => ({
1919
checkActorUsage: vi.fn(),
2020
generateEmbedding: vi.fn(),
2121
executeSearch: vi.fn(),
22+
retrieval: vi.fn(),
2223
getDocumentMetadata: vi.fn(),
2324
getTagDefinitions: vi.fn(),
2425
getTagDefinitionsBatch: vi.fn(),
@@ -91,7 +92,7 @@ vi.mock('@/lib/knowledge/search/queries', () => ({
9192
generateSearchEmbedding: mocks.generateEmbedding,
9293
retrieveKnowledgeSearch: async (...args: unknown[]) => ({
9394
rows: await mocks.executeSearch(...args),
94-
retrieval: { status: 'complete', timedOutLegs: [] },
95+
retrieval: mocks.retrieval(),
9596
}),
9697
getDocumentMetadataByIds: mocks.getDocumentMetadata,
9798
}))
@@ -129,6 +130,7 @@ const knowledgeBase = {
129130

130131
describe('knowledge search application use case', () => {
131132
beforeEach(() => {
133+
mocks.retrieval.mockReturnValue({ status: 'complete', timedOutLegs: [] })
132134
vi.clearAllMocks()
133135
mocks.rerank.mockReset()
134136
resetDbChainMock()
@@ -204,6 +206,34 @@ describe('knowledge search application use case', () => {
204206
expect(result.totalResults).toBe(0)
205207
})
206208

209+
it.each([false, true])(
210+
'requires explicit partial-result support for empty incomplete searches (allowPartialResults=%s)',
211+
async (allowPartialResults) => {
212+
mocks.retrieval.mockReturnValue({ status: 'partial', timedOutLegs: ['vector', 'keyword'] })
213+
mocks.executeSearch.mockResolvedValue([])
214+
const result = searchKnowledge.execute({
215+
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
216+
input: {
217+
workspaceId: 'workspace-1',
218+
knowledgeBaseIds: ['knowledge-1'],
219+
query: 'canaries',
220+
topK: 20,
221+
allowPartialResults,
222+
},
223+
})
224+
225+
if (!allowPartialResults) {
226+
await expect(result).rejects.toThrow('retrieval deadline')
227+
return
228+
}
229+
await expect(result).resolves.toMatchObject({
230+
results: [],
231+
totalResults: 0,
232+
retrieval: { status: 'partial', timedOutLegs: ['vector', 'keyword'] },
233+
})
234+
}
235+
)
236+
207237
describe.each(['workspace', 'organization'] as const)('%s ranking policy', (scope) => {
208238
beforeEach(() => {
209239
if (scope === 'organization') {

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

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -709,9 +709,6 @@ const searchKnowledgeUseCase = defineAuthorizedKnowledgeUseCase({
709709
}
710710
}
711711
annotateSearchDiagnostics({ resultCount: results.length })
712-
if (retrieved.retrieval.status === 'partial' && results.length === 0) {
713-
throw new SearchDeadlineError()
714-
}
715712
const cost = baseCost
716713
? {
717714
input: baseCost.input,

apps/sim/lib/knowledge/search/diagnostics.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export type SearchStage =
4747
| 'vector.settings'
4848
| 'vector.probe'
4949
| 'vector.ann'
50+
| 'vector.rerank'
5051
| 'vector.exact'
5152

5253
/** Fixed, content-free fields. Never pass queries, filters, document identities, SQL, or errors. */
@@ -68,6 +69,8 @@ export interface SearchDiagnosticMetadata {
6869
searchMode?: 'hybrid' | 'vector'
6970
boostRecency?: boolean
7071
embeddingDimensions?: number
72+
vectorRanking?: 'exact' | 'binary-rerank'
73+
vectorCandidateCount?: number
7174
resultCount?: number
7275
/** Tool output before the executor's final egress projection; counts only, never content. */
7376
toolResultBytes?: number

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

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,7 @@ describe('live repository authorization follows ranked candidates', () => {
528528
schemaMock.embedding,
529529
Array.from({ length: 200 }, (_, index) => candidate(`probe-${index}`, 'allowed-source'))
530530
)
531+
queueTableRows(schemaMock.embedding, [{ id: 'far' }, { id: 'near' }])
531532
queueTableRows(schemaMock.embedding, [
532533
{ ...candidate('far', 'allowed-source'), distance: 0.3 },
533534
{ ...candidate('near', 'allowed-source'), distance: 0.1 },
@@ -544,13 +545,17 @@ describe('live repository authorization follows ranked candidates', () => {
544545
})
545546
expect(rows.map((row) => row.id)).toEqual(['near'])
546547
expect(dbChainMockFns.orderBy.mock.calls[0]).toHaveLength(1)
547-
expect(Object.keys(dbChainMockFns.select.mock.calls[1][0])).toEqual(['id', 'distance'])
548+
expect(Object.keys(dbChainMockFns.select.mock.calls[1][0])).toEqual(['id'])
549+
expect(render(dbChainMockFns.orderBy.mock.calls[0][0]).sql).toContain('binary_quantize')
550+
expect(JSON.stringify(dbChainMockFns.orderBy.mock.calls[1][0])).toContain('<=>')
551+
expect(dbChainMockFns.limit.mock.calls[1]).toEqual([4000])
552+
expect(JSON.stringify(dbChainMockFns.where.mock.calls[1][0])).not.toContain('<=>')
548553
expect(dbChainMockFns.limit.mock.invocationCallOrder[1]).toBeLessThan(
549554
dbChainMockFns.select.mock.invocationCallOrder[2]
550555
)
551556
expect(JSON.stringify(dbChainMockFns.where.mock.calls[1][0])).toContain('OFFSET 0')
552557
expect(getForConnectors).toHaveBeenCalledExactlyOnceWith(['allowed-source'], undefined)
553-
expect(JSON.stringify(dbChainMockFns.where.mock.calls[2][0])).toContain('github_read_grant')
558+
expect(JSON.stringify(dbChainMockFns.where.mock.calls[3][0])).toContain('github_read_grant')
554559
})
555560

556561
it('finishes empty scopes after the bounded probe without scanning HNSW or calling providers', async () => {
@@ -592,6 +597,7 @@ describe('live repository authorization follows ranked candidates', () => {
592597
schemaMock.embedding,
593598
Array.from({ length: 200 }, (_, index) => candidate(`probe-${index}`, 'allowed-source'))
594599
)
600+
queueTableRows(schemaMock.embedding, [{ id: 'partial' }])
595601
queueTableRows(schemaMock.embedding, [candidate('partial', 'allowed-source')])
596602
queueTableRows(schemaMock.embedding, [candidate('selected', 'allowed-source')])
597603
queueTableRows(schemaMock.embedding, [
@@ -614,6 +620,10 @@ describe('live repository authorization follows ranked candidates', () => {
614620
candidate(`approximate-${index}`, 'allowed-source')
615621
)
616622
queueTableRows(schemaMock.embedding, probe)
623+
queueTableRows(
624+
schemaMock.embedding,
625+
approximate.map(({ id }) => ({ id }))
626+
)
617627
queueTableRows(schemaMock.embedding, approximate)
618628
queueTableRows(schemaMock.embedding, [])
619629
queueTableRows(schemaMock.embedding, probe)
@@ -628,7 +638,7 @@ describe('live repository authorization follows ranked candidates', () => {
628638
const rows = await handleVectorOnlySearch({ ...params, structuredFilters: undefined })
629639

630640
expect(rows.map((row) => row.id)).toEqual(['selected'])
631-
expect(dbChainMockFns.offset.mock.calls).toEqual([[0], [20], [0], [20]])
641+
expect(dbChainMockFns.offset.mock.calls).toEqual([[0], [0], [20]])
632642
expect(getForConnectors).toHaveBeenCalledTimes(2)
633643
expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(2)
634644
})
@@ -638,6 +648,7 @@ describe('live repository authorization follows ranked candidates', () => {
638648
candidate(`probe-${index}`, 'allowed-source')
639649
)
640650
queueTableRows(schemaMock.embedding, probe)
651+
queueTableRows(schemaMock.embedding, [{ id: 'far' }])
641652
queueTableRows(schemaMock.embedding, [
642653
{ ...candidate('far', 'allowed-source'), distance: 0.7 },
643654
...Array.from({ length: 19 }, (_, index) => candidate(`hidden-${index}`, 'allowed-source')),
@@ -662,7 +673,7 @@ describe('live repository authorization follows ranked candidates', () => {
662673
})
663674

664675
expect(rows.map((row) => row.id)).toEqual(['nearer', 'near'])
665-
expect(dbChainMockFns.offset.mock.calls).toEqual([[0], [20], [0]])
676+
expect(dbChainMockFns.offset.mock.calls).toEqual([[0], [0]])
666677
expect(
667678
hasMockCondition(
668679
dbChainMockFns.where.mock.calls.at(-1)![0],

0 commit comments

Comments
 (0)