Skip to content

Commit 9b9717c

Browse files
committed
fix(search): bound vector retrieval under selective permissions
1 parent 5a7b924 commit 9b9717c

22 files changed

Lines changed: 27364 additions & 162 deletions

apps/sim/app/api/v1/knowledge/search/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
269269
queryVector: {
270270
vector: JSON.stringify(queryEmbeddingResult.embedding),
271271
dimensions: queryEmbeddingTarget!.dimensions,
272+
model: queryEmbeddingTarget!.model,
272273
},
273274
structuredFilters: hasFilters ? structuredFilters : undefined,
274275
})

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ describe.each([384, 768, 1024, 1536, 3072] as const)(
139139
queryVector: {
140140
vector: JSON.stringify([1, ...Array<number>(dimensions - 1).fill(0)]),
141141
dimensions,
142+
model: embeddingModel,
142143
},
143144
structuredFilters: [
144145
{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'common' },

apps/sim/lib/knowledge/__integration__/scale.integration.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -453,7 +453,7 @@ describe.skipIf(!enabled)('knowledge scale: isolated real PostgreSQL, no provide
453453
access: { kind: 'workspace', tokens: WORKSPACE_ACCESS_TOKENS },
454454
searchMode: 'hybrid',
455455
query: 'Orion',
456-
queryVector: { vector, dimensions: DIMENSIONS },
456+
queryVector: { vector, dimensions: DIMENSIONS, model: 'text-embedding-3-small' },
457457
})
458458
)
459459
expect(workspaceResults).toEqual([])
@@ -485,7 +485,11 @@ describe.skipIf(!enabled)('knowledge scale: isolated real PostgreSQL, no provide
485485
access,
486486
searchMode: mode,
487487
query: 'Orion',
488-
queryVector: { vector, dimensions: DIMENSIONS },
488+
queryVector: {
489+
vector,
490+
dimensions: DIMENSIONS,
491+
model: 'text-embedding-3-small',
492+
},
489493
structuredFilters: filters,
490494
})
491495
)

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

Lines changed: 108 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ vi.hoisted(() => {
5252
if (process.env.KNOWLEDGE_SEARCH_PERFORMANCE_TEST === 'true') {
5353
Object.assign(process.env, {
5454
OPENAI_API_KEY: 'isolated-embedding-http-fixture',
55+
GEMINI_API_KEY: 'isolated-gemini-http-fixture',
5556
CONFLUENCE_CLIENT_ID: 'isolated-confluence-fixture-client',
5657
CONFLUENCE_CLIENT_SECRET: 'isolated-confluence-fixture-secret',
5758
})
@@ -78,7 +79,8 @@ const fixtureSchema = z.object({
7879
})
7980
const reuseFile = enabled ? process.env.KNOWLEDGE_SEARCH_PERFORMANCE_REUSE_REPORT_FILE : undefined
8081
function readFixtureReport(file: string) {
81-
if (statSync(file).size > 8 * 1024 * 1024) throw new Error('Fixture report exceeds 8 MiB')
82+
/** Captured SQL plans include repeated high-dimensional query parameters. */
83+
if (statSync(file).size > 64 * 1024 * 1024) throw new Error('Fixture report exceeds 64 MiB')
8284
return z
8385
.object({ fixture: fixtureSchema, unrelatedFixture: fixtureSchema })
8486
.parse(JSON.parse(readFileSync(file, 'utf8')))
@@ -146,14 +148,6 @@ const explainNodeSchema: z.ZodType<ExplainNode> = z.lazy(() =>
146148
)
147149
const explainSchema = z.array(z.object({ Plan: explainNodeSchema }).passthrough()).length(1)
148150

149-
function usesVectorIndex(node: ExplainNode): boolean {
150-
return (
151-
node['Index Name'] === 'embedding_search_binary_hnsw_idx' ||
152-
node['Index Name'] === 'embedding_vector_hnsw_idx' ||
153-
(node.Plans?.some(usesVectorIndex) ?? false)
154-
)
155-
}
156-
157151
/** The ANN stage must not fetch full vectors, even for planner-added sort projections. */
158152
function assertCompactCandidates(node: ExplainNode) {
159153
expect(node['Relation Name']).not.toBe('embedding')
@@ -253,18 +247,27 @@ async function sample(label: string, run: () => ReturnType<typeof search>) {
253247
expect(captured.length).toBeLessThan(300)
254248
const searches = captured.filter(
255249
(item) =>
256-
(item.query.includes('from "embedding"') || item.query.includes('from "embedding_search"')) &&
257-
(item.query.includes('order by') || item.query.includes('limit'))
250+
(item.query.includes('from "embedding"') ||
251+
item.query.includes('FROM "embedding"') ||
252+
item.query.includes('from "embedding_search"') ||
253+
item.query.includes('FROM "embedding_search"')) &&
254+
(item.query.includes('order by') ||
255+
item.query.includes('limit') ||
256+
item.query.includes('WITH visible_search_documents') ||
257+
item.query.includes('WITH scored_search_candidates'))
258258
)
259259
const plans = []
260260
for (const query of searches) {
261261
const plan = await db.$client.begin(async (tx) => {
262262
await tx.unsafe("SET LOCAL hnsw.iterative_scan = 'relaxed_order'")
263263
await tx.unsafe('SET LOCAL hnsw.max_scan_tuples = 20000')
264-
if (query.query.includes('binary_quantize')) {
265-
await tx.unsafe('SET LOCAL hnsw.max_scan_tuples = 100000')
266-
await tx.unsafe('SET LOCAL hnsw.ef_search = 200')
267-
await tx.unsafe('SET LOCAL hnsw.scan_mem_multiplier = 4')
264+
if (
265+
query.query.includes('WITH visible_search_documents') ||
266+
(query.query.includes('from "embedding_search"') && query.query.includes('order by'))
267+
) {
268+
await tx.unsafe('SET LOCAL hnsw.max_scan_tuples = 1000')
269+
await tx.unsafe('SET LOCAL hnsw.ef_search = 1000')
270+
await tx.unsafe('SET LOCAL hnsw.scan_mem_multiplier = 2')
268271
}
269272
return tx.unsafe(
270273
`EXPLAIN (ANALYZE, BUFFERS, VERBOSE, FORMAT JSON) ${query.query}`,
@@ -274,9 +277,11 @@ async function sample(label: string, run: () => ReturnType<typeof search>) {
274277
plans.push({
275278
kind: query.query.includes('keyword_rank')
276279
? 'keyword'
277-
: query.query.includes('binary_quantize')
280+
: query.query.includes('WITH visible_search_documents') ||
281+
(query.query.includes('from "embedding_search"') && query.query.includes('order by'))
278282
? 'vector'
279-
: query.query.includes('order by')
283+
: query.query.includes('order by') ||
284+
query.query.includes('WITH scored_search_candidates')
280285
? 'rerank'
281286
: 'probe',
282287
query: query.query,
@@ -322,6 +327,29 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu
322327
? new Response(null, { status: 403 })
323328
: Response.json({ type: 'known', accountId: ids.aliceId })
324329
}
330+
if (
331+
url ===
332+
'https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:batchEmbedContents'
333+
) {
334+
const body = z
335+
.object({
336+
requests: z
337+
.array(
338+
z.object({
339+
content: z.object({ parts: z.array(z.object({ text: z.string() })).length(1) }),
340+
})
341+
)
342+
.length(1),
343+
})
344+
.parse(JSON.parse(String(init?.body)))
345+
embeddingCalls++
346+
const text = body.requests[0].content.parts[0].text
347+
const topic = Number(/^Topic (\d+) deployment$/.exec(text)?.[1] ?? 0)
348+
return Response.json({
349+
embeddings: [{ values: topicVector(topic) }],
350+
usageMetadata: { promptTokenCount: 4 },
351+
})
352+
}
325353
if (url !== 'https://api.openai.com/v1/embeddings')
326354
throw new Error(`Unexpected outbound request in search fixture: ${new URL(url).origin}`)
327355
const body = z
@@ -355,7 +383,11 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu
355383
}
356384
await db
357385
.update(knowledgeBase)
358-
.set({ workspaceId: ids.workspaceId, organizationId: null })
386+
.set({
387+
workspaceId: ids.workspaceId,
388+
organizationId: null,
389+
embeddingModel: 'gemini-embedding-001',
390+
})
359391
.where(eq(knowledgeBase.id, ids.knowledgeBaseId))
360392
await db
361393
.update(knowledgeConnector)
@@ -380,6 +412,11 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu
380412
} else {
381413
await seedKnowledgeAclFixture(ids, { connectorType: 'google_drive' })
382414
await seedKnowledgeAclFixture(unrelated, { connectorType: 'google_drive' })
415+
/** These arbitrary dense vectors are not trained for prefix shortening. */
416+
await db
417+
.update(knowledgeBase)
418+
.set({ embeddingModel: 'gemini-embedding-001' })
419+
.where(inArray(knowledgeBase.id, [ids.knowledgeBaseId, unrelated.knowledgeBaseId]))
383420
await db
384421
.update(knowledgeBase)
385422
.set({ isSearchIndex: true })
@@ -646,7 +683,7 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu
646683
expect(plans.length).toBeGreaterThanOrEqual(2)
647684
const vectorPlans = plans.filter((plan) => plan.kind === 'vector')
648685
expect(vectorPlans).toHaveLength(1)
649-
expect(usesVectorIndex(vectorPlans[0].plan[0].Plan)).toBe(true)
686+
expect(vectorPlans[0].plan[0].Plan['Actual Rows']).toBeGreaterThan(0)
650687
assertCompactCandidates(vectorPlans[0].plan[0].Plan)
651688
expect(plans.some((plan) => plan.kind === 'rerank')).toBe(true)
652689
const rerank = plans.find((plan) => plan.kind === 'rerank')!
@@ -711,14 +748,65 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu
711748
expect(assistantVector).toHaveLength(1)
712749
expect(dashboardVector[0].query).toBe(assistantVector[0].query)
713750
expect(dashboardVector[0].parameters).toEqual(assistantVector[0].parameters)
714-
expect(usesVectorIndex(dashboardVector[0].plan[0].Plan)).toBe(true)
751+
expect(dashboardVector[0].plan[0].Plan['Actual Rows']).toBeGreaterThan(0)
715752
}, 180_000)
716753

717754
it('keeps inaccessible content out of an otherwise identical search', async () => {
718755
const { result } = await sample('denied', () => search(ids.bobId))
719756
expect(result.data.results).toEqual([])
720757
}, 180_000)
721758

759+
it('preserves recall when the nearest topic is mostly inaccessible within a broad permission scope', async () => {
760+
const reader = `u:${ids.aliceId}@fixture.test`
761+
/** Four consecutive topics share each document; hide 99% of the query's topic cluster. */
762+
await db.execute(sql`UPDATE document
763+
SET acl = ARRAY[${`u:${ids.bobId}@fixture.test`}]
764+
WHERE knowledge_base_id = ${ids.knowledgeBaseId}
765+
AND external_id::int % 8 = 0 AND external_id::int % 800 <> 0`)
766+
try {
767+
for (const surface of ['copilot', 'dashboard'] as const) {
768+
const { result, plans, diagnostics } = await sample(
769+
`filtered-neighborhood.${surface}`,
770+
async () => {
771+
if (surface === 'copilot') return search()
772+
const data = await searchScopedKnowledge.execute({
773+
principal: { kind: 'session', userId: ids.aliceId, sessionId: 'fixture-dashboard' },
774+
input: {
775+
workspaceId: ids.workspaceId,
776+
query: 'Orion deployment',
777+
topK: 15,
778+
surface,
779+
},
780+
})
781+
return resultSchema.parse({ success: true, data })
782+
}
783+
)
784+
expect(diagnostics).toMatchObject({ retrievalStatus: 'complete', timedOutLegs: [] })
785+
expect(result.data.results).toHaveLength(15)
786+
const rerank = plans.find((plan) => plan.kind === 'rerank')!
787+
expect(rerank).toBeDefined()
788+
const actual = await db.$client.unsafe(rerank.query, rerank.parameters).values()
789+
const expected = await db.execute<{ id: string }>(sql`SELECT e.id FROM embedding e
790+
INNER JOIN document d ON d.id = e.document_id
791+
WHERE e.knowledge_base_id = ${ids.knowledgeBaseId} AND e.enabled
792+
AND d.acl @> ARRAY[${reader}]::text[]
793+
ORDER BY (e.embedding <=> ${JSON.stringify(queryVector)}::vector) + 0, e.id
794+
LIMIT ${actual.length}`)
795+
expect(expected.length).toBeGreaterThan(0)
796+
const expectedIds = new Set(expected.map(({ id }) => id))
797+
const recall = actual.filter(([id]) => expectedIds.has(id)).length / expected.length
798+
expect(recall).toBeGreaterThanOrEqual(0.95)
799+
report[`recall.filtered-neighborhood.${surface}`] = { neighbors: expected.length, recall }
800+
saveReport()
801+
}
802+
} finally {
803+
await db
804+
.update(document)
805+
.set({ acl: [reader] })
806+
.where(eq(document.knowledgeBaseId, ids.knowledgeBaseId))
807+
}
808+
}, 180_000)
809+
722810
it('ranks a small permission scope by its bounded IDs without a corpus-wide vector probe', async () => {
723811
const documentIds = [0, 8, 16].map((index) => `${ids.workspaceId}-doc-${index}`)
724812
await db

apps/sim/lib/knowledge/access/predicate.postgres.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ describe.runIf(Boolean(databaseUrl))('knowledge ACLs in PostgreSQL', () => {
5555
await client.unsafe(approvalMigration.replaceAll('"public".', `"${schemaName}".`))
5656
await client.unsafe(`
5757
ALTER TABLE knowledge_connector ADD COLUMN credential_id text,
58+
ADD COLUMN access_rewrite_pending boolean NOT NULL DEFAULT false,
5859
ADD COLUMN source_config json NOT NULL DEFAULT '{}',
5960
ADD COLUMN credential_group_id text, ADD COLUMN credential_group_option_id text;
6061
ALTER TABLE credential ADD COLUMN revoked_at timestamp, ADD COLUMN credential_group_option_id text;

apps/sim/lib/knowledge/access/predicate.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -224,13 +224,11 @@ function storedKnowledgeAccessCondition(
224224
(${knowledgeConnector.accessMode} = 'workspace' AND ${document.acl} = ARRAY['ws']::text[])
225225
OR (${document.acl} <> ARRAY['ws']::text[] AND (
226226
(${knowledgeConnector.accessMode} = 'admin' AND ${document.aclVerifiedAt} > ${cutoff})
227-
OR (${knowledgeConnector.accessMode} = 'members' AND EXISTS (
228-
SELECT 1 FROM ${knowledgeDocumentObservation}
227+
OR (${knowledgeConnector.accessMode} = 'members' AND (${document.id}, ${document.connectorId}) IN (
228+
SELECT ${knowledgeDocumentObservation.documentId}, ${knowledgeConnectorMember.connectorId} FROM ${knowledgeDocumentObservation}
229229
JOIN ${knowledgeConnectorMember}
230230
ON ${knowledgeConnectorMember.id} = ${knowledgeDocumentObservation.memberId}
231-
WHERE ${knowledgeDocumentObservation.documentId} = ${document.id}
232-
AND ${knowledgeConnectorMember.connectorId} = ${document.connectorId}
233-
AND ${knowledgeConnectorMember.status} = 'active'
231+
WHERE ${knowledgeConnectorMember.status} = 'active'
234232
AND ${knowledgeConnectorMember.subjectToken} = ANY(${tokens})
235233
AND GREATEST(${knowledgeDocumentObservation.lastSeenAt}, ${knowledgeConnectorMember.memberSyncedThrough}) > ${cutoff}
236234
))

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,7 @@ const searchKnowledgeUseCase = defineAuthorizedKnowledgeUseCase({
420420
? {
421421
vector: JSON.stringify(queryEmbedding?.embedding ?? null),
422422
dimensions: embeddingTarget!.dimensions,
423+
model: embeddingTarget!.model,
423424
}
424425
: undefined,
425426
structuredFilters: structuredFilters.length > 0 ? structuredFilters : undefined,

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export type SearchStage =
4949
| 'vector.ann'
5050
| 'vector.rerank'
5151
| 'vector.exact'
52+
| 'vector.candidate_search'
5253

5354
/** Fixed, content-free fields. Never pass queries, filters, document identities, SQL, or errors. */
5455
export interface SearchDiagnosticMetadata {
@@ -69,11 +70,14 @@ export interface SearchDiagnosticMetadata {
6970
searchMode?: 'hybrid' | 'vector'
7071
boostRecency?: boolean
7172
embeddingDimensions?: number
72-
vectorRanking?: 'exact' | 'binary-rerank'
73-
vectorCandidateStorage?: 'stored-binary'
73+
vectorRanking?: 'exact' | 'candidate-rerank'
74+
vectorCandidateStorage?: 'stored-halfvec'
75+
vectorCandidateScan?: 'planned' | 'filtered'
7476
vectorBudgetMs?: number
7577
vectorCandidateLimit?: number
7678
vectorCandidateCount?: number
79+
vectorCandidateDimensions?: number
80+
vectorInitialCandidateCount?: number
7781
resultCount?: number
7882
/** Tool output before the executor's final egress projection; counts only, never content. */
7983
toolResultBytes?: number

0 commit comments

Comments
 (0)