Skip to content

Commit 5c2e6c7

Browse files
committed
fix(knowledge): share compact vector retrieval across access scopes
1 parent cbdb864 commit 5c2e6c7

6 files changed

Lines changed: 554 additions & 454 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ describe('API-key KB block fan-out', () => {
124124
expect(result.rows[0].knowledgeBaseId).toBe(bases[index].id)
125125
expect(result.rows[0].distance).toBeCloseTo(0)
126126
}
127-
expect(statements.filter((query) => query.includes('statement_timeout'))).toHaveLength(36)
127+
expect(statements.filter((query) => query.includes('statement_timeout'))).toHaveLength(54)
128128
expect(statements.filter((query) => query.includes('+ 0'))).toHaveLength(18)
129129
expect(statements.some((query) => query.includes('hnsw.iterative_scan'))).toBe(false)
130130
} finally {

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

Lines changed: 227 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/** Real Assistant tool, application authorization, PostgreSQL/pgvector, and result processing. */
22
import { readFileSync, statSync, writeFileSync } from 'node:fs'
3+
import type { Principal } from '@sim/auth/principal'
34
import { db } from '@sim/db'
45
import {
56
copilotChats,
@@ -8,6 +9,7 @@ import {
89
document,
910
embedding,
1011
knowledgeBase,
12+
knowledgeBaseTagDefinitions,
1113
knowledgeConnector,
1214
knowledgeConnectorMember,
1315
knowledgeDocumentObservation,
@@ -39,6 +41,7 @@ import {
3941
seedKnowledgeAclFixture,
4042
seedKnowledgeMemberFixture,
4143
} from '@/lib/knowledge/__integration__/seed-source-access-fixture'
44+
import { type KnowledgeSearchTagFilter, searchKnowledge } from '@/lib/knowledge/application/search'
4245
import {
4346
SearchBudget,
4447
SearchDeadlineError,
@@ -124,7 +127,7 @@ const report: Record<string, unknown> = {
124127
dimensions,
125128
candidateDimensions,
126129
chunksPerDocument,
127-
sql: 'Captured from the real Assistant tool; no hand-written search query',
130+
sql: 'Captured from real search application adapters; no hand-written retrieval query',
128131
providers:
129132
'Embedding and source-permission HTTP responses are controlled; internal search and authorization code is real',
130133
vectors:
@@ -197,16 +200,21 @@ function explainNodes(node: ExplainNode): ExplainNode[] {
197200
}
198201

199202
/** Broad ranking must stop the ordered ANN scan instead of sorting every accessible chunk. */
200-
function assertIndexedCandidates(plan: ExplainNode, candidateLimit: number) {
203+
function assertIndexedCandidates(
204+
plan: ExplainNode,
205+
candidateLimit: number,
206+
width = candidateDimensions
207+
) {
208+
const indexName =
209+
width === 1536
210+
? 'embedding_search_cosine_hnsw_idx'
211+
: `embedding_search_${width}_cosine_hnsw_idx`
201212
const nodes = explainNodes(plan)
202213
const initial = nodes.find((node) => node['Subplan Name'] === 'CTE initial_candidates')
203214
expect(initial).toBeDefined()
204215
const candidateNodes = explainNodes(initial!)
205216
expect(
206-
candidateNodes.some(
207-
(node) =>
208-
node['Index Name'] === 'embedding_search_512_cosine_hnsw_idx' && node['Actual Loops'] > 0
209-
)
217+
candidateNodes.some((node) => node['Index Name'] === indexName && node['Actual Loops'] > 0)
210218
).toBe(true)
211219
expect(candidateNodes.some((node) => node['Node Type'] === 'Sort')).toBe(false)
212220
expect(
@@ -272,7 +280,7 @@ async function prepareOrganizationSample(label: string) {
272280

273281
const diagnosticSchema = z
274282
.object({
275-
surface: z.enum(['dashboard', 'copilot']),
283+
surface: z.enum(['dashboard', 'copilot', 'workflow', 'api']),
276284
outcome: z.enum(['success', 'partial']),
277285
elapsedMs: z.number(),
278286
vectorBudgetMs: z.number().positive(),
@@ -301,7 +309,12 @@ const resultSchema = z.object({
301309
success: z.literal(true),
302310
data: z.object({
303311
results: z.array(
304-
z.object({ documentId: z.string(), content: z.string(), knowledgeBaseId: z.string() })
312+
z.object({
313+
documentId: z.string(),
314+
content: z.string(),
315+
knowledgeBaseId: z.string(),
316+
embeddingId: z.string().optional(),
317+
})
305318
),
306319
}),
307320
})
@@ -368,6 +381,29 @@ async function searchDashboard(
368381
}
369382
}
370383

384+
async function searchWorkspaceKb(
385+
query = 'Orion deployment',
386+
options: { principal?: Principal; tagFilters?: KnowledgeSearchTagFilter[] } = {}
387+
) {
388+
const result = await searchKnowledge.execute({
389+
principal: options.principal ?? {
390+
kind: 'workspace_api_key',
391+
workspaceId: ids.workspaceId,
392+
keyId: 'fixture-search-key',
393+
},
394+
input: {
395+
workspaceId: ids.workspaceId,
396+
knowledgeBaseIds: [ids.knowledgeBaseId],
397+
query,
398+
topK: 15,
399+
searchMode: 'vector',
400+
surface: 'workflow',
401+
tagFilters: options.tagFilters,
402+
},
403+
})
404+
return resultSchema.parse({ success: true, data: result })
405+
}
406+
371407
/** Allow either index or filtered plans, but require successful retrieval within the real surface budget. */
372408
function expectCompleteVectorSearch(diagnostics: z.infer<typeof diagnosticSchema>) {
373409
const budget = diagnostics.surface === 'dashboard' ? 3000 : 8000
@@ -478,10 +514,12 @@ async function sample(
478514
})
479515
saveReport()
480516
if (query.query.includes('WITH visible_search_documents')) {
481-
expect(query.query).toContain('"embedding_search"."vector_512"')
482-
expect(diagnostics.vectorCandidateDimensions).toBe(candidateDimensions)
517+
const width = diagnostics.vectorCandidateDimensions!
518+
expect(query.query).toContain(
519+
`"embedding_search"."${width === 1536 ? 'vector' : `vector_${width}`}"`
520+
)
483521
expect(diagnostics.vectorCandidateLimit).toBeGreaterThan(0)
484-
assertIndexedCandidates(parsedPlan[0].Plan, diagnostics.vectorCandidateLimit!)
522+
assertIndexedCandidates(parsedPlan[0].Plan, diagnostics.vectorCandidateLimit!, width)
485523
}
486524
if (query.query.includes('WITH visible_keyword_documents')) {
487525
assertScalarKeywordSorts(parsedPlan[0].Plan)
@@ -495,7 +533,7 @@ async function sample(
495533
return { result, plans, diagnostics }
496534
}
497535

498-
describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpus', () => {
536+
describe.skipIf(!enabled)('Knowledge search latency on a realistic indexed corpus', () => {
499537
beforeAll(async () => {
500538
if (
501539
[chunkCount, unrelatedChunkCount].some(
@@ -526,7 +564,7 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu
526564
.object({
527565
input: z.array(z.string()).length(1),
528566
encoding_format: z.literal('base64'),
529-
model: z.literal('text-embedding-3-small'),
567+
model: z.enum(['text-embedding-3-small', 'text-embedding-ada-002']),
530568
})
531569
.parse(JSON.parse(String(init?.body)))
532570
embeddingCalls += body.input.length
@@ -1201,6 +1239,182 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu
12011239
for (const diagnostics of completed) expectCompleteVectorSearch(diagnostics)
12021240
}, 180_000)
12031241

1242+
it('uses compact indexed ranking for workspace KBs with stale estimates and private neighbors', async () => {
1243+
const originalAcl = `u:${ids.aliceId}@fixture.test`
1244+
await db.execute(sql`ALTER TABLE document SET (autovacuum_enabled = false)`)
1245+
try {
1246+
/** Analyze a narrow scope, then grow it without updating the planner's ACL histogram. */
1247+
await db.execute(sql`UPDATE document SET acl = CASE WHEN external_id::int % 10 = 1
1248+
THEN ARRAY['pub'] ELSE ARRAY[${originalAcl}] END
1249+
WHERE knowledge_base_id = ${ids.knowledgeBaseId}`)
1250+
await db.execute(sql`ANALYZE document`)
1251+
await db.execute(sql`UPDATE document SET acl = CASE WHEN external_id::int % 5 <> 0
1252+
THEN ARRAY['pub'] ELSE ARRAY[${originalAcl}] END
1253+
WHERE knowledge_base_id = ${ids.knowledgeBaseId}`)
1254+
for (const topic of [0, 11, 23]) {
1255+
const label = `workspace-kb.topic.${topic}`
1256+
await prepareOrganizationSample(label)
1257+
const { result, plans, diagnostics } = await sample(label, () =>
1258+
searchWorkspaceKb(`Topic ${topic} deployment`)
1259+
)
1260+
expectCompleteVectorSearch(diagnostics)
1261+
expect(diagnostics.accessScopeKind).toBe('workspace')
1262+
expect(diagnostics.vectorRanking).toBe('candidate-rerank')
1263+
expect(result.data.results).toHaveLength(15)
1264+
expect(plans.some((plan) => plan.kind === 'vector')).toBe(true)
1265+
for (const row of result.data.results) {
1266+
expect(row.knowledgeBaseId).toBe(ids.knowledgeBaseId)
1267+
expect(Number(row.documentId.split('-doc-')[1]) % 5).not.toBe(0)
1268+
}
1269+
const expected = await db.execute<{ id: string }>(sql`
1270+
SELECT embedding.id FROM embedding JOIN document ON document.id = embedding.document_id
1271+
WHERE embedding.knowledge_base_id = ${ids.knowledgeBaseId} AND embedding.enabled
1272+
AND document.acl = ARRAY['pub']::text[]
1273+
ORDER BY (embedding.embedding <=> ${JSON.stringify(topicVector(topic))}::vector) + 0, embedding.id
1274+
LIMIT 15
1275+
`)
1276+
const expectedIds = new Set(expected.map(({ id }) => id))
1277+
const recall =
1278+
result.data.results.filter((row) => expectedIds.has(row.embeddingId!)).length /
1279+
expected.length
1280+
expect(recall).toBeGreaterThanOrEqual(0.95)
1281+
report[`${label}.recall`] = { neighbors: expected.length, recall }
1282+
saveReport()
1283+
}
1284+
await db
1285+
.update(knowledgeBase)
1286+
.set({ embeddingModel: 'text-embedding-ada-002' })
1287+
.where(eq(knowledgeBase.id, ids.knowledgeBaseId))
1288+
const fullWidth = await sample('workspace-kb.full-width', () => searchWorkspaceKb())
1289+
expectCompleteVectorSearch(fullWidth.diagnostics)
1290+
expect(fullWidth.diagnostics.vectorCandidateDimensions).toBe(dimensions)
1291+
expect(fullWidth.result.data.results).toHaveLength(15)
1292+
await db
1293+
.update(knowledgeBase)
1294+
.set({ embeddingModel: 'text-embedding-3-small' })
1295+
.where(eq(knowledgeBase.id, ids.knowledgeBaseId))
1296+
1297+
const workflowId = generateId()
1298+
const scheduled: Principal = {
1299+
kind: 'delegated',
1300+
serviceId: 'executor',
1301+
workspaceId: ids.workspaceId,
1302+
delegationId: generateId(),
1303+
audience: 'sim:knowledge',
1304+
issuedAt: new Date(),
1305+
expiresAt: new Date(Date.now() + 60_000),
1306+
delegationContext: {
1307+
kind: 'workflow_execution',
1308+
workflowId,
1309+
principal: {
1310+
kind: 'system',
1311+
serviceId: 'schedule',
1312+
workspaceId: ids.workspaceId,
1313+
workflowId,
1314+
},
1315+
currentWorkflow: { workflowId, mode: 'deployment', deploymentVersionId: generateId() },
1316+
},
1317+
}
1318+
const scheduledResult = await sample('workspace-kb.scheduled', () =>
1319+
searchWorkspaceKb('Orion deployment', { principal: scheduled })
1320+
)
1321+
expectCompleteVectorSearch(scheduledResult.diagnostics)
1322+
expect(scheduledResult.diagnostics.accessScopeKind).toBe('workspace')
1323+
expect(scheduledResult.result.data.results).toHaveLength(15)
1324+
1325+
for (const concurrency of [2, 8]) {
1326+
const label = `workspace-kb.concurrent.${concurrency}`
1327+
await prepareOrganizationSample(label)
1328+
diagnosticLog?.mockClear()
1329+
const started = performance.now()
1330+
const results = await Promise.all(
1331+
Array.from({ length: concurrency }, (_, index) =>
1332+
searchWorkspaceKb(`Topic ${index * 3} deployment`)
1333+
)
1334+
)
1335+
const completed = diagnosticLog!.mock.calls
1336+
.filter(([message]) => message === 'Knowledge search completed')
1337+
.map(([, metadata]) => diagnosticSchema.parse(metadata))
1338+
report[label] = {
1339+
milliseconds: performance.now() - started,
1340+
resultCounts: results.map((result) => result.data.results.length),
1341+
diagnostics: completed,
1342+
}
1343+
saveReport()
1344+
expect(completed).toHaveLength(concurrency)
1345+
for (const result of results) expect(result.data.results).toHaveLength(15)
1346+
for (const diagnostics of completed) expectCompleteVectorSearch(diagnostics)
1347+
}
1348+
1349+
await db.insert(knowledgeBaseTagDefinitions).values({
1350+
id: generateId(),
1351+
knowledgeBaseId: ids.knowledgeBaseId,
1352+
tagSlot: 'tag1',
1353+
displayName: 'Fixture group',
1354+
})
1355+
await db.execute(sql`UPDATE embedding SET tag1 = 'selected' WHERE knowledge_base_id = ${ids.knowledgeBaseId}
1356+
AND document_id IN (SELECT id FROM document WHERE knowledge_base_id = ${ids.knowledgeBaseId} AND external_id::int < 600)`)
1357+
const tagged = await sample('workspace-kb.tagged', () =>
1358+
searchWorkspaceKb('Orion deployment', {
1359+
tagFilters: [{ tagName: 'Fixture group', operator: 'eq', value: 'selected' }],
1360+
})
1361+
)
1362+
expectCompleteVectorSearch(tagged.diagnostics)
1363+
expect(tagged.diagnostics.vectorRanking).toBe('candidate-rerank')
1364+
expect(tagged.result.data.results).toHaveLength(15)
1365+
for (const row of tagged.result.data.results) {
1366+
const ordinal = Number(row.documentId.split('-doc-')[1])
1367+
expect(ordinal).toBeLessThan(600)
1368+
expect(ordinal % 5).not.toBe(0)
1369+
}
1370+
const expectedTagged = await db.execute<{ id: string }>(sql`
1371+
SELECT embedding.id FROM embedding JOIN document ON document.id = embedding.document_id
1372+
WHERE embedding.knowledge_base_id = ${ids.knowledgeBaseId} AND embedding.enabled
1373+
AND document.acl = ARRAY['pub']::text[] AND embedding.tag1 = 'selected'
1374+
ORDER BY (embedding.embedding <=> ${JSON.stringify(queryVector)}::vector) + 0, embedding.id LIMIT 15
1375+
`)
1376+
const taggedIds = new Set(expectedTagged.map(({ id }) => id))
1377+
const taggedRecall =
1378+
tagged.result.data.results.filter((row) => taggedIds.has(row.embeddingId!)).length /
1379+
expectedTagged.length
1380+
expect(taggedRecall).toBeGreaterThanOrEqual(0.95)
1381+
report['workspace-kb.tagged.recall'] = {
1382+
neighbors: expectedTagged.length,
1383+
recall: taggedRecall,
1384+
}
1385+
saveReport()
1386+
1387+
/** Workspace credentials cannot keep reading a source after its access rewrite begins. */
1388+
await db
1389+
.update(knowledgeConnector)
1390+
.set({ accessRewritePending: true })
1391+
.where(eq(knowledgeConnector.id, ids.connectorId))
1392+
const denied = await sample('workspace-kb.revoked', () => searchWorkspaceKb())
1393+
expectCompleteVectorSearch(denied.diagnostics)
1394+
expect(denied.result.data.results).toEqual([])
1395+
} finally {
1396+
await db
1397+
.delete(knowledgeBaseTagDefinitions)
1398+
.where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, ids.knowledgeBaseId))
1399+
await db.execute(
1400+
sql`UPDATE embedding SET tag1 = NULL WHERE knowledge_base_id = ${ids.knowledgeBaseId} AND tag1 = 'selected'`
1401+
)
1402+
await db
1403+
.update(knowledgeBase)
1404+
.set({ embeddingModel: 'text-embedding-3-small' })
1405+
.where(eq(knowledgeBase.id, ids.knowledgeBaseId))
1406+
await db
1407+
.update(knowledgeConnector)
1408+
.set({ accessRewritePending: false })
1409+
.where(eq(knowledgeConnector.id, ids.connectorId))
1410+
await db.execute(
1411+
sql`UPDATE document SET acl = ARRAY[${originalAcl}] WHERE knowledge_base_id = ${ids.knowledgeBaseId}`
1412+
)
1413+
await db.execute(sql`ALTER TABLE document RESET (autovacuum_enabled)`)
1414+
await db.execute(sql`ANALYZE document`)
1415+
}
1416+
}, 180_000)
1417+
12041418
it('checks live reader access on every search, including after revocation', async () => {
12051419
await seedSearchReaderFixture(ids)
12061420
const allowed = await sample('live.allowed', () => search())

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ describe('search SQL deadline', () => {
1414
now = 101
1515
return ['candidate']
1616
})
17-
await budget.query('vector.ann', run)
17+
await budget.query('vector.candidate_search', run)
1818
await expect(budget.query('vector.exact', run)).rejects.toBeInstanceOf(SearchDeadlineError)
1919
expect(run).toHaveBeenCalledTimes(1)
2020
})

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@ export type SearchStage =
4646
| `${RetrievalLeg}.sql`
4747
| 'vector.settings'
4848
| 'vector.probe'
49-
| 'vector.ann'
5049
| 'vector.rerank'
5150
| 'vector.exact'
5251
| 'vector.candidate_search'
@@ -72,6 +71,7 @@ export interface SearchDiagnosticMetadata {
7271
embeddingDimensions?: number
7372
vectorRanking?: 'exact' | 'candidate-rerank'
7473
vectorCandidateStorage?: 'stored-halfvec'
74+
/** Requested strategy, not an assertion about the physical index selected by PostgreSQL. */
7575
vectorCandidateScan?: 'planned' | 'filtered'
7676
vectorBudgetMs?: number
7777
vectorCandidateLimit?: number

0 commit comments

Comments
 (0)