Skip to content

Commit a462b7d

Browse files
committed
improvement(knowledge): rank the vector page on the projection and admit searches in parallel
1 parent 081dd7c commit a462b7d

12 files changed

Lines changed: 406 additions & 202 deletions

File tree

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,8 @@ describe('Knowledge Search Utils', () => {
216216
const statement = (query as { toSQL: () => { sql: string } }).toSQL().sql
217217
if (statement.includes('AS visible')) return []
218218
if (statement.includes(') + 0 LIMIT')) return [{ id: 'first' }, { id: 'second' }]
219-
if (statement.includes('WITH scored_search_candidates'))
219+
/** The page reads the pool slice's identities; the walk's order is kept client-side. */
220+
if (statement.includes('AS "connectorId"') && statement.includes('= ANY('))
220221
return [makeResult('second', 0.2), makeResult('first', 0.1)]
221222
return [{ id: 'doc-first' }, { id: 'doc-second' }]
222223
})
@@ -240,7 +241,7 @@ describe('Knowledge Search Utils', () => {
240241
const exact = dbChainMockFns.execute.mock.calls
241242
.map(([query]) => (query as { toSQL: () => { sql: string; params: unknown[] } }).toSQL())
242243
.find((statement) => statement.sql.includes(') + 0 LIMIT'))!
243-
expect(exact.params).toContain(400)
244+
expect(exact.params).toContain(200)
244245
})
245246

246247
it('should throw error when no filters provided', async () => {

‎apps/sim/lib/billing/core/usage-gate-cache.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@ import { coalesceLocally } from '@/lib/concurrency/singleflight'
1414
* every uncached call. Bulk ingestion re-checks per document and knowledge
1515
* search checks per query. Staleness is bounded by this TTL and fails in the
1616
* harmless direction: a payer who crosses their limit keeps going for at most
17-
* this long, which charges nobody wrongly.
17+
* this long, which charges nobody wrongly. Five minutes: the sum is a few
18+
* hundred milliseconds for a busy payer, and a minute made every search after
19+
* a pause pay it.
1820
*/
19-
export const USAGE_GATE_TTL_MS = 60 * 1000
21+
export const USAGE_GATE_TTL_MS = 5 * 60 * 1000
2022

2123
/**
2224
* Recent gate answers, admitted and refused, with `LRUCache` supplying the TTL

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ describe('API-key KB block fan-out', () => {
143143
expect(matching('hnsw.iterative_scan')).toHaveLength(bases.length)
144144
expect(matching('AS visible')).toHaveLength(bases.length)
145145
expect(matching(') + 0 LIMIT')).toHaveLength(bases.length)
146-
expect(matching('scored_search_candidates')).toHaveLength(bases.length)
146+
expect(matching('"embedding_search"."id" = ANY(')).toHaveLength(bases.length)
147147
/** The probe enumerates visible documents and reports saturation; it never ranks them. */
148148
expect(
149149
statements.filter(

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

Lines changed: 60 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
credentialGroup,
99
document,
1010
embedding,
11+
embeddingSearch,
1112
knowledgeBase,
1213
knowledgeConnector,
1314
knowledgeConnectorMember,
@@ -19,7 +20,7 @@ import {
1920
} from '@sim/db/schema'
2021
import { createLogger, Logger } from '@sim/logger'
2122
import { generateId } from '@sim/utils/id'
22-
import { and, eq, inArray, sql } from 'drizzle-orm'
23+
import { and, eq, inArray, type SQL, sql } from 'drizzle-orm'
2324
import { NextRequest } from 'next/server'
2425
import { afterAll, beforeAll, describe, expect, it, type MockInstance, vi } from 'vitest'
2526
import { z } from 'zod'
@@ -41,13 +42,15 @@ import {
4142
seedKnowledgeMemberFixture,
4243
} from '@/lib/knowledge/__integration__/seed-source-access-fixture'
4344
import { type KnowledgeSearchTagFilter, searchKnowledge } from '@/lib/knowledge/application/search'
45+
import type { KbEmbeddingDimensions } from '@/lib/knowledge/embedding-models'
4446
import {
4547
SearchBudget,
4648
SearchDeadlineError,
4749
type SearchExecutor,
4850
} from '@/lib/knowledge/search/budget'
4951
import type { SearchStage } from '@/lib/knowledge/search/diagnostics'
5052
import type { WorkspaceSearchFilters } from '@/lib/knowledge/search/filters'
53+
import { embeddingCandidateDistance } from '@/lib/knowledge/vector-columns'
5154
import { POST as searchRoute } from '@/app/api/knowledge/search/route'
5255
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
5356

@@ -120,6 +123,23 @@ function topicVector(topic = 0) {
120123
return vector.map((value) => value / magnitude)
121124
}
122125
const queryVector = topicVector()
126+
127+
/**
128+
* The exact nearest chunks on the projection's stored halfvec, which is what the page's order
129+
* is measured against: the walk ranks on that column, and nothing rescores it.
130+
*/
131+
async function exactProjectionNeighbors(vector: number[], limit: number, readerClause?: SQL) {
132+
const distance = embeddingCandidateDistance(
133+
dimensions as KbEmbeddingDimensions,
134+
JSON.stringify(vector),
135+
'text-embedding-3-small'
136+
)
137+
return db.execute<{ id: string }>(sql`SELECT s.id FROM ${embeddingSearch} s
138+
INNER JOIN document d ON d.id = s.document_id
139+
WHERE s.knowledge_base_id = ${ids.knowledgeBaseId} AND s.enabled ${readerClause ?? sql``}
140+
ORDER BY (${distance}) + 0, s.id
141+
LIMIT ${limit}`)
142+
}
123143
const captured: CapturedQuery[] = []
124144
const report: Record<string, unknown> = {
125145
fixture: ids,
@@ -213,6 +233,10 @@ function explainNodes(node: ExplainNode): ExplainNode[] {
213233
* aliases its own lateral `scoped_chunk`, so this cannot match it, and matching on the rendered
214234
* clause casing would silently stop these assertions from running at all.
215235
*/
236+
/** The vector page reads a pool slice's identities from the projection and its documents. */
237+
const VECTOR_PAGE_JOIN =
238+
'INNER JOIN "document" ON "document"."id" = "embedding_search"."document_id"'
239+
216240
function isVectorCandidateQuery(statement: string) {
217241
return statement.toLowerCase().includes(') as visible')
218242
}
@@ -476,12 +500,12 @@ async function sample(
476500
item.query.includes('limit') ||
477501
item.query.includes('CROSS JOIN LATERAL') ||
478502
isVectorCandidateQuery(item.query) ||
479-
item.query.includes('WITH scored_search_candidates') ||
503+
item.query.includes(VECTOR_PAGE_JOIN) ||
480504
item.query.includes('WITH matched_keyword_chunks'))
481505
)
482506
const plans: Array<
483507
CapturedQuery & {
484-
kind: 'keyword' | 'vector' | 'rerank' | 'probe'
508+
kind: 'keyword' | 'vector' | 'page' | 'probe'
485509
plan: z.infer<typeof explainSchema>
486510
}
487511
> = []
@@ -516,9 +540,8 @@ async function sample(
516540
? 'keyword'
517541
: isVectorCandidateQuery(query.query)
518542
? 'vector'
519-
: query.query.includes('order by') ||
520-
query.query.includes('WITH scored_search_candidates')
521-
? 'rerank'
543+
: query.query.includes('order by') || query.query.includes(VECTOR_PAGE_JOIN)
544+
? 'page'
522545
: 'probe',
523546
query: query.query,
524547
parameters: query.parameters,
@@ -1004,13 +1027,10 @@ describe.skipIf(!enabled)('Knowledge search latency on a realistic indexed corpu
10041027
expect(vectorPlans).toHaveLength(1)
10051028
expect(vectorPlans[0].plan[0].Plan['Actual Rows']).toBeGreaterThan(0)
10061029
assertCompactCandidates(vectorPlans[0].plan[0].Plan)
1007-
expect(plans.some((plan) => plan.kind === 'rerank')).toBe(true)
1008-
const rerank = plans.find((plan) => plan.kind === 'rerank')!
1009-
const actual = await db.$client.unsafe(rerank.query, rerank.parameters).values()
1010-
const expected = await db.execute<{ id: string }>(sql`SELECT id FROM embedding
1011-
WHERE knowledge_base_id = ${ids.knowledgeBaseId} AND enabled
1012-
ORDER BY (embedding <=> ${JSON.stringify(queryVector)}::vector) + 0, id
1013-
LIMIT ${actual.length}`)
1030+
expect(plans.some((plan) => plan.kind === 'page')).toBe(true)
1031+
const page = plans.find((plan) => plan.kind === 'page')!
1032+
const actual = await db.$client.unsafe(page.query, page.parameters).values()
1033+
const expected = await exactProjectionNeighbors(queryVector, actual.length)
10141034
const expectedIds = new Set(expected.map(({ id }) => id))
10151035
const recall = actual.filter(([id]) => expectedIds.has(id)).length / expected.length
10161036
expect(recall).toBeGreaterThanOrEqual(0.95)
@@ -1028,12 +1048,9 @@ describe.skipIf(!enabled)('Knowledge search latency on a realistic indexed corpu
10281048
expectCompleteVectorSearch(diagnostics)
10291049
const candidates = plans.find((plan) => plan.kind === 'vector')!
10301050
assertCompactCandidates(candidates.plan[0].Plan)
1031-
const rerank = plans.find((plan) => plan.kind === 'rerank')!
1032-
const actual = await db.$client.unsafe(rerank.query, rerank.parameters).values()
1033-
const expected = await db.execute<{ id: string }>(sql`SELECT id FROM embedding
1034-
WHERE knowledge_base_id = ${ids.knowledgeBaseId} AND enabled
1035-
ORDER BY (embedding <=> ${JSON.stringify(topicVector(topic))}::vector) + 0, id
1036-
LIMIT ${actual.length}`)
1051+
const page = plans.find((plan) => plan.kind === 'page')!
1052+
const actual = await db.$client.unsafe(page.query, page.parameters).values()
1053+
const expected = await exactProjectionNeighbors(topicVector(topic), actual.length)
10371054
const expectedIds = new Set(expected.map(({ id }) => id))
10381055
const recall = actual.filter(([id]) => expectedIds.has(id)).length / expected.length
10391056
expect(recall).toBeGreaterThanOrEqual(0.95)
@@ -1082,15 +1099,14 @@ describe.skipIf(!enabled)('Knowledge search latency on a realistic indexed corpu
10821099
)
10831100
expectCompleteVectorSearch(diagnostics)
10841101
expect(result.data.results).toHaveLength(15)
1085-
const rerank = plans.find((plan) => plan.kind === 'rerank')!
1086-
expect(rerank).toBeDefined()
1087-
const actual = await db.$client.unsafe(rerank.query, rerank.parameters).values()
1088-
const expected = await db.execute<{ id: string }>(sql`SELECT e.id FROM embedding e
1089-
INNER JOIN document d ON d.id = e.document_id
1090-
WHERE e.knowledge_base_id = ${ids.knowledgeBaseId} AND e.enabled
1091-
AND d.acl @> ARRAY[${reader}]::text[]
1092-
ORDER BY (e.embedding <=> ${JSON.stringify(queryVector)}::vector) + 0, e.id
1093-
LIMIT ${actual.length}`)
1102+
const page = plans.find((plan) => plan.kind === 'page')!
1103+
expect(page).toBeDefined()
1104+
const actual = await db.$client.unsafe(page.query, page.parameters).values()
1105+
const expected = await exactProjectionNeighbors(
1106+
queryVector,
1107+
actual.length,
1108+
sql`AND d.acl @> ARRAY[${reader}]::text[]`
1109+
)
10941110
expect(expected.length).toBeGreaterThan(0)
10951111
const expectedIds = new Set(expected.map(({ id }) => id))
10961112
const recall = actual.filter(([id]) => expectedIds.has(id)).length / expected.length
@@ -1128,9 +1144,10 @@ describe.skipIf(!enabled)('Knowledge search latency on a realistic indexed corpu
11281144
expect(probe[0].query).not.toContain('<=>')
11291145
expect(probe[0].plan[0].Plan['Actual Rows']).toBe(12)
11301146
expect(assertIndexedChunkProbe(probe[0].plan[0].Plan)).toBe(documentIds.length)
1131-
const vector = plans.filter((plan) => plan.kind === 'rerank')
1132-
expect(vector).toHaveLength(1)
1133-
expect(vector[0].query).toContain('"embedding"."id" in')
1147+
/** The page reads the bounded ranking's identities from the projection, never the original vectors. */
1148+
const page = plans.filter((plan) => plan.kind === 'page')
1149+
expect(page).toHaveLength(1)
1150+
expect(page[0].query).not.toContain('"embedding"."embedding"')
11341151
}
11351152
} finally {
11361153
await db
@@ -1175,16 +1192,16 @@ describe.skipIf(!enabled)('Knowledge search latency on a realistic indexed corpu
11751192
count < HYBRID_CANDIDATE_LIMIT ? 0 : 1
11761193
)
11771194
if (count > HYBRID_CANDIDATE_LIMIT) {
1178-
const rerank = plans.find((plan) => plan.kind === 'rerank')!
1179-
const actual = await db.$client.unsafe(rerank.query, rerank.parameters).values()
1180-
const expected = await db.execute<{ id: string }>(sql`SELECT id FROM embedding
1181-
WHERE knowledge_base_id = ${ids.knowledgeBaseId} AND enabled
1182-
AND document_id IN (${sql.join(
1183-
documentIds.map((id) => sql`${id}`),
1184-
sql`, `
1185-
)})
1186-
ORDER BY (embedding <=> ${JSON.stringify(queryVector)}::vector) + 0, id
1187-
LIMIT ${actual.length}`)
1195+
const page = plans.find((plan) => plan.kind === 'page')!
1196+
const actual = await db.$client.unsafe(page.query, page.parameters).values()
1197+
const expected = await exactProjectionNeighbors(
1198+
queryVector,
1199+
actual.length,
1200+
sql`AND s.document_id IN (${sql.join(
1201+
documentIds.map((id) => sql`${id}`),
1202+
sql`, `
1203+
)})`
1204+
)
11881205
const expectedIds = new Set(expected.map(({ id }) => id))
11891206
const recall = actual.filter(([id]) => expectedIds.has(id)).length / expected.length
11901207
expect(recall).toBeGreaterThanOrEqual(0.95)
@@ -1352,7 +1369,7 @@ describe.skipIf(!enabled)('Knowledge search latency on a realistic indexed corpu
13521369
)
13531370
expectCompleteVectorSearch(diagnostics)
13541371
expect(diagnostics.accessScopeKind).toBe('workspace')
1355-
expect(diagnostics.vectorRanking).toBe('candidate-rerank')
1372+
expect(diagnostics.vectorRanking).toBe('projection-walk')
13561373
expect(result.data.results).toHaveLength(15)
13571374
expect(plans.some((plan) => plan.kind === 'vector')).toBe(true)
13581375
for (const row of result.data.results) {
@@ -1441,7 +1458,7 @@ describe.skipIf(!enabled)('Knowledge search latency on a realistic indexed corpu
14411458
})
14421459
)
14431460
expectCompleteVectorSearch(tagged.diagnostics)
1444-
expect(tagged.diagnostics.vectorRanking).toBe('candidate-rerank')
1461+
expect(tagged.diagnostics.vectorRanking).toBe('projection-walk')
14451462
expect(tagged.result.data.results).toHaveLength(15)
14461463
for (const row of tagged.result.data.results) {
14471464
const ordinal = Number(row.documentId.split('-doc-')[1])

‎apps/sim/lib/knowledge/access/availability.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,15 @@ vi.mock('@/lib/credential-groups/scoped-availability', () => ({
2929
}))
3030

3131
import {
32+
forgetKnowledgeAccessAvailability,
3233
requireOrganizationSearchAvailable,
3334
resolveKnowledgeAccessAvailability,
3435
} from '@/lib/knowledge/access/availability'
3536

3637
describe('knowledge access availability ownership', () => {
3738
beforeEach(() => {
3839
vi.clearAllMocks()
40+
forgetKnowledgeAccessAvailability()
3941
mocks.featureEnabled.mockResolvedValue(true)
4042
mocks.enterprise.mockResolvedValue(true)
4143
mocks.scopedGroups.mockResolvedValue(true)
@@ -59,6 +61,17 @@ describe('knowledge access availability ownership', () => {
5961
expect(mocks.workspaceGroups).not.toHaveBeenCalled()
6062
})
6163

64+
it('answers the same owner from one read for a minute', async () => {
65+
await resolveKnowledgeAccessAvailability({ organizationId: 'org-1' })
66+
await resolveKnowledgeAccessAvailability({ organizationId: 'org-1' })
67+
expect(mocks.enterprise).toHaveBeenCalledTimes(1)
68+
await resolveKnowledgeAccessAvailability({ organizationId: 'org-2' })
69+
expect(mocks.enterprise).toHaveBeenCalledTimes(2)
70+
forgetKnowledgeAccessAvailability()
71+
await resolveKnowledgeAccessAvailability({ organizationId: 'org-1' })
72+
expect(mocks.enterprise).toHaveBeenCalledTimes(3)
73+
})
74+
6275
it('keeps source mirroring independent from managed identity availability', async () => {
6376
mocks.scopedGroups.mockResolvedValue(false)
6477
await expect(resolveKnowledgeAccessAvailability({ organizationId: 'org-1' })).resolves.toEqual({

‎apps/sim/lib/knowledge/access/availability.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { LRUCache } from 'lru-cache'
12
import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
23
import {
34
getWorkspaceOwnerSubscriptionAccess,
@@ -39,11 +40,44 @@ export interface KnowledgeAccessAvailability {
3940
memberScoped: boolean
4041
}
4142

43+
/**
44+
* How long a resolved availability holds. A search resolves it three times over — the gate, the
45+
* defaults, the reader's scope — each a subscription and a billing read; one read per owner per
46+
* minute answers all of them, and a plan or flag change lands within the minute.
47+
*/
48+
const AVAILABILITY_TTL_MS = 60 * 1000
49+
50+
const availabilityCache = new LRUCache<
51+
string,
52+
KnowledgeAccessAvailability,
53+
KnowledgeMemberAccessContext
54+
>({
55+
max: 10_000,
56+
ttl: AVAILABILITY_TTL_MS,
57+
fetchMethod: (_key, _stale, { context }) => readKnowledgeAccessAvailability(context),
58+
})
59+
4260
export async function resolveKnowledgeAccessAvailability(
4361
context: KnowledgeMemberAccessContext
4462
): Promise<KnowledgeAccessAvailability> {
4563
if (context.organizationId && context.workspaceId)
4664
throw new Error('Knowledge access requires one resource owner')
65+
/** A caller that brings its own billing snapshot is answered from that snapshot, uncached. */
66+
if (context.ownerBilling) return readKnowledgeAccessAvailability(context)
67+
const key = `${context.organizationId ?? ''}|${context.workspaceId ?? ''}|${context.userId ?? ''}`
68+
const availability = await availabilityCache.fetch(key, { context })
69+
if (!availability) throw new Error('Knowledge access availability could not be resolved')
70+
return availability
71+
}
72+
73+
/** Forgets every resolved availability, for tests and for a settings change that must land now. */
74+
export function forgetKnowledgeAccessAvailability(): void {
75+
availabilityCache.clear()
76+
}
77+
78+
async function readKnowledgeAccessAvailability(
79+
context: KnowledgeMemberAccessContext
80+
): Promise<KnowledgeAccessAvailability> {
4781
if (
4882
!(await isFeatureEnabled(
4983
'knowledge-member-access',

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,15 @@ export function projectionCandidateAccessCondition(
357357
documentId: AnyPgColumn | SQL
358358
},
359359
scope: KnowledgeAccessScope | SystemAccessScope,
360-
plan: SearchAccessPlan
360+
plan: SearchAccessPlan,
361+
options: {
362+
/**
363+
* Whether every row of the projection carries its mirrored source and ACL. While the fill
364+
* is under way, a row it has not reached is decided on its document; once it is complete no
365+
* such row exists, and the predicate is the array test alone.
366+
*/
367+
filled?: boolean
368+
} = {}
361369
): SQL {
362370
if (scope.kind === 'system') return sql`true`
363371
if (scope.tokens.length === 0) return sql`false`
@@ -374,12 +382,14 @@ export function projectionCandidateAccessCondition(
374382
const owned = plan.uploads
375383
? sql`(${projection.connectorId} IS NULL OR ${inSources(mirrored)})`
376384
: inSources(mirrored)
385+
const onRow = sql`(${projection.acl} && ${tokens} AND ${owned})`
386+
if (options.filled) return onRow
377387
const unfilled = sql`(${projection.acl} IS NULL AND EXISTS (
378388
SELECT 1 FROM ${document}
379389
WHERE ${document.id} = ${projection.documentId}
380390
AND ${knowledgeCandidateAccessConditionForConnectors(scope, plan)}
381391
))`
382-
return sql`(${unfilled} OR (${projection.acl} && ${tokens} AND ${owned}))`
392+
return sql`(${unfilled} OR ${onRow})`
383393
}
384394

385395
/**

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,8 +356,8 @@ describe('knowledge search application use case', () => {
356356
).rejects.toThrow('Search is not enabled for this organization')
357357
expect(mocks.recordActivity).not.toHaveBeenCalled()
358358
expect(mocks.requireOrganizationSearch).toHaveBeenCalledExactlyOnceWith('org-canonical')
359+
/** The gate runs beside the embedding call; billing and retrieval still never start after a refusal. */
359360
expect(mocks.resolveBilling).not.toHaveBeenCalled()
360-
expect(mocks.generateEmbedding).not.toHaveBeenCalled()
361361
expect(mocks.executeSearch).not.toHaveBeenCalled()
362362
})
363363

0 commit comments

Comments
 (0)