Skip to content

Commit 1d08664

Browse files
fix(knowledge): bound KB block vector retrieval (#7903)
* fix(knowledge): bound KB block vector retrieval * fix(knowledge): align search fixtures with scoped probing
1 parent bf37391 commit 1d08664

5 files changed

Lines changed: 461 additions & 91 deletions

File tree

.github/workflows/test-build.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ jobs:
221221
lib/knowledge/__integration__/search-source-progress.integration.ts
222222
lib/knowledge/__integration__/search-source-pagination.integration.ts
223223
lib/knowledge/__integration__/search-reference-batching.integration.ts
224+
lib/knowledge/__integration__/kb-block-search.integration.ts
224225
lib/core/outbox/service.integration.ts
225226
lib/knowledge/__integration__/connector-upload.integration.ts
226227
lib/uploads/contexts/organization-logo/application.integration.ts

apps/sim/app/api/knowledge/search/utils.test.ts

Lines changed: 31 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,10 @@ describe('Knowledge Search Utils', () => {
212212
describe('handleTagAndVectorSearch', () => {
213213
it('returns only bounded ranked rows without first materializing every matching tag ID', async () => {
214214
resetDbChainMock()
215+
queueTableRows(
216+
schemaMock.embedding,
217+
Array.from({ length: 201 }, (_, index) => ({ id: `candidate-${index}` }))
218+
)
215219
queueTableRows(schemaMock.embedding, [makeResult('second', 0.2), makeResult('first', 0.1)])
216220

217221
const results = await handleTagAndVectorSearch({
@@ -226,9 +230,11 @@ describe('Knowledge Search Utils', () => {
226230
})
227231

228232
expect(results.map((row) => row.id)).toEqual(['first', 'second'])
229-
expect(dbChainMockFns.select).toHaveBeenCalledTimes(2)
233+
expect(dbChainMockFns.select).toHaveBeenCalledTimes(3)
230234
expect(dbChainMockFns.as).toHaveBeenCalledWith('ranked_embeddings')
231-
expect(dbChainMockFns.select.mock.calls[0][0]).toHaveProperty('distance')
235+
expect(Object.keys(dbChainMockFns.select.mock.calls[0][0])).toEqual(['id'])
236+
expect(dbChainMockFns.limit).toHaveBeenNthCalledWith(1, 201)
237+
expect(dbChainMockFns.select.mock.calls[1][0]).toHaveProperty('distance')
232238
expect(dbChainMockFns.limit).toHaveBeenCalledWith(2)
233239
})
234240

@@ -536,6 +542,7 @@ describe('Knowledge Search Utils', () => {
536542
})
537543

538544
it('runs a single retrieval leg in vector mode', async () => {
545+
queueTableRows(schemaMock.embedding, [{ id: 'vector-hit' }])
539546
queueTableRows(schemaMock.embedding, [makeResult('vector-hit')])
540547

541548
const results = await executeKnowledgeSearch({
@@ -548,20 +555,19 @@ describe('Knowledge Search Utils', () => {
548555
})
549556

550557
expect(results.map((r) => r.id)).toEqual(['vector-hit'])
551-
expect(dbChainMockFns.select).toHaveBeenCalledTimes(2)
558+
expect(dbChainMockFns.select).toHaveBeenCalledTimes(3)
552559
expect(dbChainMockFns.as).toHaveBeenCalledWith('ranked_embeddings')
553560
})
554561

555562
it('runs both legs and fuses them in hybrid mode', async () => {
556563
/**
557-
* Chains dequeue in creation order. Hybrid legs over-fetch past the
558-
* plain scan's candidate pool, so the vector leg opens its transaction
559-
* and applies the scan settings before selecting: the keyword ranking
560-
* pass is built first, then the vector select, then hydration.
564+
* Chains dequeue in creation order: keyword ranking, the budgeted vector
565+
* probe, keyword hydration, then vector ranking and hydration in one query.
561566
*/
562567
queueTableRows(schemaMock.embedding, [{ id: 'keyword-hit', keywordRank: 0.9 }])
563-
queueTableRows(schemaMock.embedding, [makeResult('vector-hit')])
568+
queueTableRows(schemaMock.embedding, [{ id: 'vector-hit' }])
564569
queueTableRows(schemaMock.embedding, [makeResult('keyword-hit')])
570+
queueTableRows(schemaMock.embedding, [makeResult('vector-hit')])
565571

566572
const results = await executeKnowledgeSearch({
567573
knowledgeBaseIds: ['kb-123'],
@@ -573,39 +579,31 @@ describe('Knowledge Search Utils', () => {
573579
})
574580

575581
expect(results.map((r) => r.id).sort()).toEqual(['keyword-hit', 'vector-hit'])
576-
expect(dbChainMockFns.select).toHaveBeenCalledTimes(4)
582+
expect(dbChainMockFns.select).toHaveBeenCalledTimes(5)
577583
})
578584

579-
it('falls back to vector results when the keyword leg fails', async () => {
585+
it('propagates unexpected keyword errors after the vector leg finishes', async () => {
580586
/** The failing ranking chain is still built first and takes the first queued set. */
581587
queueTableRows(schemaMock.embedding, [{ id: 'never-ranked', keywordRank: 0 }])
588+
queueTableRows(schemaMock.embedding, [{ id: 'vector-hit' }])
582589
queueTableRows(schemaMock.embedding, [makeResult('vector-hit')])
583590

584-
/**
585-
* Both legs share one `orderBy` spy, so target the keyword leg by its
586-
* ranking expression. Calling the untouched spy first captures the
587-
* sentinel that tells the mock to build its normal chain, which the
588-
* vector leg still needs.
589-
*/
590-
const chainDefault = dbChainMockFns.orderBy()
591-
dbChainMockFns.orderBy.mockImplementation((fragment: unknown) => {
592-
const text = (fragment as { strings?: string[] })?.strings?.join('') ?? ''
593-
if (text.includes('ts_rank_cd')) {
594-
throw new Error('tsquery blew up')
595-
}
596-
return chainDefault
597-
})
598-
599-
const results = await executeKnowledgeSearch({
600-
knowledgeBaseIds: ['kb-123'],
601-
access: WORKSPACE_ACCESS_SCOPE,
602-
topK: 10,
603-
searchMode: 'hybrid',
604-
query: 'PROJ-1234',
605-
queryVector: JSON.stringify([0.1, 0.2, 0.3]),
591+
const failure = new Error('tsquery failed')
592+
dbChainMockFns.orderBy.mockImplementationOnce(() => {
593+
throw failure
606594
})
607595

608-
expect(results.map((r) => r.id)).toEqual(['vector-hit'])
596+
await expect(
597+
executeKnowledgeSearch({
598+
knowledgeBaseIds: ['kb-123'],
599+
access: WORKSPACE_ACCESS_SCOPE,
600+
topK: 10,
601+
searchMode: 'hybrid',
602+
query: 'PROJ-1234',
603+
queryVector: JSON.stringify([0.1, 0.2, 0.3]),
604+
})
605+
).rejects.toBe(failure)
606+
expect(dbChainMockFns.as).toHaveBeenCalledWith('ranked_embeddings')
609607
})
610608

611609
it('skips both query legs when only tag filters are provided', async () => {
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/** KB block retrieval against disposable PostgreSQL, using a workspace API-key identity. */
2+
import type { Principal } from '@sim/auth/principal'
3+
import { db } from '@sim/db'
4+
import { document, embedding, knowledgeBase, organization, user, workspace } from '@sim/db/schema'
5+
import { generateId } from '@sim/utils/id'
6+
import { eq, inArray } from 'drizzle-orm'
7+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
8+
import {
9+
createKnowledgeAclFixtureIds,
10+
seedKnowledgeAclFixture,
11+
} from '@/lib/knowledge/__integration__/seed-source-access-fixture'
12+
import { createKnowledgeAccessProvider } from '@/lib/knowledge/access/scope'
13+
import { retrieveKnowledgeSearch } from '@/lib/knowledge/search/queries'
14+
import { embeddingVectorValues } from '@/lib/knowledge/vector-columns'
15+
16+
describe('API-key KB block fan-out', () => {
17+
const ids = createKnowledgeAclFixtureIds()
18+
const bases = Array.from({ length: 18 }, () => ({
19+
id: generateId(),
20+
visible: generateId(),
21+
denied: generateId(),
22+
excluded: generateId(),
23+
}))
24+
const principal: Principal = {
25+
kind: 'workspace_api_key',
26+
workspaceId: ids.workspaceId,
27+
keyId: 'fixture-key',
28+
}
29+
const vector = [1, ...Array<number>(1535).fill(0)]
30+
const queryVector = {
31+
vector: JSON.stringify(vector),
32+
dimensions: 1536 as const,
33+
model: 'text-embedding-3-small',
34+
}
35+
36+
beforeAll(async () => {
37+
await seedKnowledgeAclFixture(ids, { connectorType: 'google_drive' })
38+
await db.insert(knowledgeBase).values(
39+
bases.map((base, index) => ({
40+
id: base.id,
41+
userId: ids.aliceId,
42+
workspaceId: ids.workspaceId,
43+
name: `KB block ${index}`,
44+
}))
45+
)
46+
await db.insert(document).values(
47+
bases.flatMap((base) =>
48+
(['visible', 'denied', 'excluded'] as const).map((kind) => ({
49+
id: base[kind],
50+
knowledgeBaseId: base.id,
51+
filename: kind,
52+
fileUrl: `https://fixture.invalid/${base[kind]}`,
53+
fileSize: 12,
54+
mimeType: 'text/plain',
55+
processingStatus: 'completed',
56+
acl: kind === 'denied' ? [`u:${ids.aliceId}@fixture.test`] : ['ws'],
57+
userExcluded: kind === 'excluded',
58+
}))
59+
)
60+
)
61+
await db.insert(embedding).values(
62+
bases.flatMap((base) =>
63+
(['visible', 'denied', 'excluded'] as const).map((kind) => ({
64+
id: generateId(),
65+
documentId: base[kind],
66+
knowledgeBaseId: base.id,
67+
chunkIndex: 0,
68+
chunkHash: base[kind],
69+
content: `Fixture policy ${kind}`,
70+
contentLength: 24,
71+
tokenCount: 5,
72+
startOffset: 0,
73+
endOffset: 24,
74+
tag1: 'policy',
75+
...embeddingVectorValues(1536, vector),
76+
}))
77+
)
78+
)
79+
})
80+
81+
afterAll(async () => {
82+
await db.delete(workspace).where(eq(workspace.id, ids.workspaceId))
83+
await db.delete(organization).where(eq(organization.id, ids.organizationId))
84+
await db.delete(user).where(inArray(user.id, [ids.aliceId, ids.bobId]))
85+
await db.$client.end()
86+
})
87+
88+
it.each([false, true])(
89+
'completes 18 concurrent KB searches with access checks intact (tag filter: %s)',
90+
async (withTags) => {
91+
const previousDebug = db.$client.options.debug
92+
const statements: string[] = []
93+
db.$client.options.debug = (_connection, query) => {
94+
if (statements.length < 250) statements.push(query)
95+
}
96+
try {
97+
const results = await Promise.all(
98+
bases.map(async (base) => {
99+
const accessProvider = createKnowledgeAccessProvider(principal, {
100+
workspaceId: ids.workspaceId,
101+
knowledgeBaseIds: [base.id],
102+
})
103+
const access = await accessProvider.get()
104+
expect(access.kind).toBe('workspace')
105+
return retrieveKnowledgeSearch({
106+
knowledgeBaseIds: [base.id],
107+
topK: 2,
108+
access,
109+
accessProvider,
110+
searchMode: 'vector',
111+
query: 'Find the fixture policy',
112+
queryVector,
113+
...(withTags && {
114+
structuredFilters: [
115+
{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'policy' },
116+
],
117+
}),
118+
})
119+
})
120+
)
121+
for (const [index, result] of results.entries()) {
122+
expect(result.retrieval).toEqual({ status: 'complete', timedOutLegs: [] })
123+
expect(result.rows.map((row) => row.documentId)).toEqual([bases[index].visible])
124+
expect(result.rows[0].knowledgeBaseId).toBe(bases[index].id)
125+
expect(result.rows[0].distance).toBeCloseTo(0)
126+
}
127+
expect(statements.filter((query) => query.includes('statement_timeout'))).toHaveLength(36)
128+
expect(statements.filter((query) => query.includes('+ 0'))).toHaveLength(18)
129+
expect(statements.some((query) => query.includes('hnsw.iterative_scan'))).toBe(false)
130+
} finally {
131+
db.$client.options.debug = previousDebug
132+
}
133+
}
134+
)
135+
})

0 commit comments

Comments
 (0)