Skip to content

Commit 11cba52

Browse files
fix(knowledge): skip global readiness checks for ordinary KBs (#8212)
* fix(knowledge): skip global readiness checks for ordinary KBs * test(knowledge): align KB search assertions with document pages
1 parent f734ba5 commit 11cba52

3 files changed

Lines changed: 132 additions & 91 deletions

File tree

‎apps/sim/lib/knowledge/__integration__/kb-block-search.integration.ts‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ describe('API-key KB block fan-out', () => {
9393
it.each([false, true])(
9494
'completes 18 concurrent KB searches with access checks intact (tag filter: %s)',
9595
async (withTags) => {
96-
/** The projection-fill memo outlives an iteration; each one must read it once, like a cold process. */
96+
/** A cold process must also skip the global readiness probe for ordinary KBs. */
9797
forgetProjectionFilled()
9898
const previousDebug = db.$client.options.debug
9999
const statements: string[] = []
@@ -135,20 +135,20 @@ describe('API-key KB block fan-out', () => {
135135
statements.filter((query) => query.includes(fragment))
136136
/**
137137
* Every statement runs under the leg's deadline: the candidate search applies it with the
138-
* scan settings in one statement, and the probe, the exact ranking and hydration each
139-
* open with one of their own. The projection-fill read is shared by the searches that
140-
* miss its memo together, so it appears once.
138+
* scan settings in one statement, and the probe, exact ranking, document-backed page
139+
* and hydration each open with one of their own.
141140
*/
142-
expect(matching('statement_timeout')).toHaveLength(bases.length * 4 + 1)
141+
expect(matching('statement_timeout')).toHaveLength(bases.length * 5)
142+
expect(matching('IS NOT NULL AS unfilled')).toHaveLength(0)
143143
/**
144144
* A scope this small leaves the bounded traversal short of its candidate limit, so every
145145
* search probes once and rescues once — never a widening retry loop.
146146
*/
147147
expect(matching('hnsw.iterative_scan')).toHaveLength(bases.length)
148148
expect(matching('AS visible')).toHaveLength(bases.length)
149149
expect(matching(') + 0 LIMIT')).toHaveLength(bases.length)
150-
/** The walk carries each candidate's identities, so a filled projection reads no page. */
151-
expect(matching('"embedding_search"."id" = ANY(')).toHaveLength(0)
150+
/** Ordinary KBs read page identities from documents without requiring a filled projection. */
151+
expect(matching('"embedding_search"."id" = ANY(')).toHaveLength(bases.length)
152152
/** The probe enumerates visible documents and reports saturation; it never ranks them. */
153153
expect(
154154
statements.filter(

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

Lines changed: 120 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,26 @@ describe('workspace-scoped vector retrieval', () => {
441441
vi.useRealTimers()
442442
})
443443

444+
it.each([false, undefined])(
445+
'retrieves ordinary KB results without global projection readiness (searchIndexOnly=%s)',
446+
async (searchIndexOnly) => {
447+
queueTableRows(schemaMock.embedding, [...ranked].reverse())
448+
const result = await retrieveKnowledgeSearch({
449+
...params,
450+
searchIndexOnly,
451+
searchMode: 'vector',
452+
query: 'What is the capital of France?',
453+
})
454+
expect(result.retrieval).toEqual({ status: 'complete', timedOutLegs: [] })
455+
expect(result.rows.map((row) => row.id)).toEqual(['near', 'far'])
456+
expect(statements().filter((query) => query.sql.includes('AS unfilled'))).toHaveLength(0)
457+
expect(statements().some((query) => isPageStatement(query.sql))).toBe(true)
458+
const walk = statements().find((query) => isWalk(query.sql))!
459+
expect(JSON.stringify(walk)).toContain('required_clause')
460+
expect(JSON.stringify(walk)).toContain(String(schemaMock.document.acl))
461+
}
462+
)
463+
444464
it.each([handleVectorOnlySearch, handleTagAndVectorSearch])(
445465
'does not acquire a connection or start SQL after the KB retrieval deadline',
446466
async (search) => {
@@ -752,7 +772,7 @@ describe('workspace-scoped vector retrieval', () => {
752772
statements()
753773
.filter((query) => query.sql.includes('statement_timeout'))
754774
.map((query) => query.params[0])
755-
).toEqual(['100', '100', '40', '20'])
775+
).toEqual(['100', '40', '20'])
756776
})
757777

758778
it('applies the scan settings in the deadline statement rather than one of their own', async () => {
@@ -808,49 +828,53 @@ describe('workspace-scoped vector retrieval', () => {
808828
)
809829
})
810830

811-
it('reports incomplete retrieval for 18 expired pool waiters without starting their SQL later', async () => {
812-
vi.useFakeTimers()
813-
const release: Array<() => void> = []
814-
const transactions: Array<Promise<unknown>> = []
815-
vi.spyOn(db, 'transaction').mockImplementation((callback) => {
816-
const transaction = new Promise<void>((resolve) => release.push(resolve)).then(() =>
817-
callback(db as never)
831+
it.each([false, true])(
832+
'expires pool waiters without starting SQL later (searchIndexOnly=%s)',
833+
async (searchIndexOnly) => {
834+
vi.useFakeTimers()
835+
const release: Array<() => void> = []
836+
const transactions: Array<Promise<unknown>> = []
837+
vi.spyOn(db, 'transaction').mockImplementation((callback) => {
838+
const transaction = new Promise<void>((resolve) => release.push(resolve)).then(() =>
839+
callback(db as never)
840+
)
841+
transactions.push(transaction)
842+
return transaction as ReturnType<typeof db.transaction>
843+
})
844+
const pending = Promise.all(
845+
Array.from({ length: 18 }, (_, index) =>
846+
retrieveKnowledgeSearch({
847+
...params,
848+
knowledgeBaseIds: [`kb-${index}`],
849+
searchIndexOnly,
850+
query: 'fixture policy',
851+
searchMode: 'vector',
852+
vectorBudgetMs: 50,
853+
})
854+
)
818855
)
819-
transactions.push(transaction)
820-
return transaction as ReturnType<typeof db.transaction>
821-
})
822-
const pending = Promise.all(
823-
Array.from({ length: 18 }, (_, index) =>
824-
retrieveKnowledgeSearch({
825-
...params,
826-
knowledgeBaseIds: [`kb-${index}`],
827-
query: 'fixture policy',
828-
searchMode: 'vector',
829-
vectorBudgetMs: 50,
856+
await vi.advanceTimersByTimeAsync(60)
857+
const results = await pending
858+
expect(results).toHaveLength(18)
859+
for (const result of results) {
860+
expect(result).toEqual({
861+
rows: [],
862+
retrieval: { status: 'partial', timedOutLegs: ['vector'] },
830863
})
831-
)
832-
)
833-
await vi.advanceTimersByTimeAsync(60)
834-
const results = await pending
835-
expect(results).toHaveLength(18)
836-
for (const result of results) {
837-
expect(result).toEqual({
838-
rows: [],
839-
retrieval: { status: 'partial', timedOutLegs: ['vector'] },
840-
})
841-
}
842-
for (const resume of release) resume()
843-
const settled = await Promise.allSettled(transactions)
844-
/** The searches that miss the projection-fill memo together share one read; each search's own read is refused at its deadline before it starts. */
845-
expect(settled).toHaveLength(1)
846-
for (const transaction of settled) {
847-
expect(transaction.status).toBe('rejected')
848-
if (transaction.status === 'rejected')
849-
expect(transaction.reason).toBeInstanceOf(SearchDeadlineError)
864+
}
865+
for (const resume of release) resume()
866+
const settled = await Promise.allSettled(transactions)
867+
/** Search indexes share the readiness read; ordinary KBs acquire their own candidate reads. */
868+
expect(settled).toHaveLength(searchIndexOnly ? 1 : 18)
869+
for (const transaction of settled) {
870+
expect(transaction.status).toBe('rejected')
871+
if (transaction.status === 'rejected')
872+
expect(transaction.reason).toBeInstanceOf(SearchDeadlineError)
873+
}
874+
expect(dbChainMockFns.select).not.toHaveBeenCalled()
875+
expect(dbChainMockFns.execute).not.toHaveBeenCalled()
850876
}
851-
expect(dbChainMockFns.select).not.toHaveBeenCalled()
852-
expect(dbChainMockFns.execute).not.toHaveBeenCalled()
853-
})
877+
)
854878
})
855879

856880
describe('workspace search filters before ranking', () => {
@@ -946,6 +970,7 @@ describe('hydration follows ranked candidates', () => {
946970
}
947971
const params: SearchParams = {
948972
knowledgeBaseIds: ['org-index'],
973+
searchIndexOnly: true,
949974
topK: 1,
950975
access: identity,
951976
accessProvider: provider,
@@ -1299,6 +1324,7 @@ describe('permitted-document planner', () => {
12991324
}
13001325
const params: SearchParams = {
13011326
knowledgeBaseIds: ['org-index'],
1327+
searchIndexOnly: true,
13021328
topK: 1,
13031329
access: reader,
13041330
accessProvider: provider,
@@ -2281,6 +2307,7 @@ describe('permitted-document planner', () => {
22812307

22822308
const liveSearch = {
22832309
knowledgeBaseIds: ['org-index'],
2310+
searchIndexOnly: true,
22842311
topK: 1,
22852312
searchMode: 'hybrid' as const,
22862313
query: 'release',
@@ -2367,48 +2394,57 @@ describe('permitted-document planner', () => {
23672394
expect(statements().some((query) => isPageStatement(query.sql))).toBe(false)
23682395
})
23692396

2370-
it('excludes a denied source through its documents while the projection is unfilled', async () => {
2371-
queueTableRows(schemaMock.knowledgeConnector, [
2372-
{
2373-
id: 'gated-src',
2374-
accessMode: 'admin',
2375-
connectorType: 'confluence',
2376-
githubRepository: false,
2377-
},
2378-
])
2379-
dbChainMockFns.execute.mockImplementation(async (query) => {
2380-
const statement = render(query).sql
2381-
/** The fill has not reached every row, so a denied source cannot be read off the row. */
2382-
if (statement.includes('AS unfilled')) return [{ unfilled: true }]
2383-
/** The mock renders nested fragments as parameters, so the marker is found in the whole query. */
2384-
const rebuilt = JSON.stringify(query).includes('/* excluded sources */')
2385-
if (isPageStatement(statement))
2386-
return JSON.stringify(render(query).params).includes('"b"')
2387-
? [hit('b', 'other-src')]
2388-
: [hit('a', 'gated-src')]
2389-
if (isWalk(statement))
2390-
return Array.from({ length: 400 }, (_, i) => ({
2391-
id: i === 0 ? (rebuilt ? 'b' : 'a') : `w-${i}`,
2392-
distance: 0.1,
2393-
}))
2394-
return []
2395-
})
2396-
queueTableRows(schemaMock.embedding, [])
2397-
queueTableRows(schemaMock.embedding, [hit('b', 'other-src')])
2398-
const getForConnectors = vi.fn<KnowledgeAccessProvider['getForConnectors']>(async () => reader)
2399-
const result = await retrieveKnowledgeSearch({
2400-
...liveSearch,
2401-
searchMode: 'vector',
2402-
access: reader,
2403-
accessProvider: { ...provider, getForConnectors },
2404-
})
2405-
expect(result.rows.map((row) => row.id)).toEqual(['b'])
2406-
const walks = statements().filter((query) => isWalk(query.sql))
2407-
expect(walks).toHaveLength(2)
2408-
expect(JSON.stringify(walks[0])).not.toContain('/* excluded sources */')
2409-
expect(JSON.stringify(walks[1])).toContain('NOT EXISTS (SELECT 1 FROM')
2410-
expect(JSON.stringify(walks[1])).toContain('/* excluded sources */')
2411-
})
2397+
it.each([false, true])(
2398+
'excludes a denied source through its documents (searchIndexOnly=%s)',
2399+
async (searchIndexOnly) => {
2400+
queueTableRows(schemaMock.knowledgeConnector, [
2401+
{
2402+
id: 'gated-src',
2403+
accessMode: 'admin',
2404+
connectorType: 'confluence',
2405+
githubRepository: false,
2406+
},
2407+
])
2408+
dbChainMockFns.execute.mockImplementation(async (query) => {
2409+
const statement = render(query).sql
2410+
/** The fill has not reached every row, so a denied source cannot be read off the row. */
2411+
if (statement.includes('AS unfilled')) return [{ unfilled: true }]
2412+
/** The mock renders nested fragments as parameters, so the marker is found in the whole query. */
2413+
const rebuilt = JSON.stringify(query).includes('/* excluded sources */')
2414+
if (isPageStatement(statement))
2415+
return JSON.stringify(render(query).params).includes('"b"')
2416+
? [hit('b', 'other-src')]
2417+
: [hit('a', 'gated-src')]
2418+
if (isWalk(statement))
2419+
return Array.from({ length: 400 }, (_, i) => ({
2420+
id: i === 0 ? (rebuilt ? 'b' : 'a') : `w-${i}`,
2421+
distance: 0.1,
2422+
}))
2423+
return []
2424+
})
2425+
queueTableRows(schemaMock.embedding, [])
2426+
queueTableRows(schemaMock.embedding, [hit('b', 'other-src')])
2427+
const getForConnectors = vi.fn<KnowledgeAccessProvider['getForConnectors']>(
2428+
async () => reader
2429+
)
2430+
const result = await retrieveKnowledgeSearch({
2431+
...liveSearch,
2432+
searchIndexOnly,
2433+
searchMode: 'vector',
2434+
access: reader,
2435+
accessProvider: { ...provider, getForConnectors },
2436+
})
2437+
expect(result.rows.map((row) => row.id)).toEqual(['b'])
2438+
expect(statements().filter((query) => query.sql.includes('AS unfilled'))).toHaveLength(
2439+
searchIndexOnly ? 1 : 0
2440+
)
2441+
const walks = statements().filter((query) => isWalk(query.sql))
2442+
expect(walks).toHaveLength(2)
2443+
expect(JSON.stringify(walks[0])).not.toContain('/* excluded sources */')
2444+
expect(JSON.stringify(walks[1])).toContain('NOT EXISTS (SELECT 1 FROM')
2445+
expect(JSON.stringify(walks[1])).toContain('/* excluded sources */')
2446+
}
2447+
)
24122448

24132449
it('hands back the unread slices of a page a denied source made it rebuild', async () => {
24142450
queueTableRows(schemaMock.knowledgeConnector, [
@@ -2491,6 +2527,7 @@ describe('filters on a resolved scope', () => {
24912527
}
24922528
const params: SearchParams = {
24932529
knowledgeBaseIds: ['org-index'],
2530+
searchIndexOnly: true,
24942531
topK: 1,
24952532
access: reader,
24962533
accessProvider: provider,

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,8 @@ export interface SearchParams {
369369
permitted?: PermittedDocuments
370370
/** Connector state resolved once per search, so no candidate re-derives it. */
371371
accessPlan?: SearchAccessPlan
372+
/** Every searched base is a Sim Search index; ordinary KBs use document-backed pages. */
373+
searchIndexOnly?: boolean
372374
}
373375

374376
/** All valid tag slot keys */
@@ -1832,7 +1834,9 @@ async function selectVectorResults(params: SearchParams): Promise<SearchResult[]
18321834
const plan = params.access.kind === 'user' ? params.accessPlan : undefined
18331835
/** Two remembered facts, read together when neither is remembered. */
18341836
const [filled, plannedIndexedSources] = await Promise.all([
1835-
isProjectionFilled('embedding_search', 'vector.projection_filled', params.budget),
1837+
params.searchIndexOnly === true
1838+
? isProjectionFilled('embedding_search', 'vector.projection_filled', params.budget)
1839+
: false,
18361840
plan?.memberSources.length ? indexedVectorSources(params.budget) : undefined,
18371841
])
18381842
/**

0 commit comments

Comments
 (0)