Skip to content

Commit 27a8603

Browse files
authored
improvement(knowledge): rank the vector page on the projection and admit searches in parallel (#8097)
* improvement(knowledge): rank the vector page on the projection and admit searches in parallel * improvement(knowledge): admit before embedding, budget the fill check, pass over emptied slices * improvement(knowledge): keep billing effects awaited and treat a capped pool as exhausted * improvement(knowledge): exclude a denied source through its documents while the projection is unfilled * improvement(knowledge): mark the excluded-sources clause and reset the fill memo in its test
1 parent 3f4c101 commit 27a8603

12 files changed

Lines changed: 489 additions & 197 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: 62 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,25 @@ 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+
/** Unaliased: the distance expression qualifies its column with the table's own name. */
138+
return db.execute<{ id: string }>(sql`SELECT ${embeddingSearch.id} AS id FROM ${embeddingSearch}
139+
INNER JOIN document d ON d.id = ${embeddingSearch.documentId}
140+
WHERE ${embeddingSearch.knowledgeBaseId} = ${ids.knowledgeBaseId}
141+
AND ${embeddingSearch.enabled} ${readerClause ?? sql``}
142+
ORDER BY (${distance}) + 0, ${embeddingSearch.id}
143+
LIMIT ${limit}`)
144+
}
123145
const captured: CapturedQuery[] = []
124146
const report: Record<string, unknown> = {
125147
fixture: ids,
@@ -213,6 +235,10 @@ function explainNodes(node: ExplainNode): ExplainNode[] {
213235
* aliases its own lateral `scoped_chunk`, so this cannot match it, and matching on the rendered
214236
* clause casing would silently stop these assertions from running at all.
215237
*/
238+
/** The vector page reads a pool slice's identities from the projection and its documents. */
239+
const VECTOR_PAGE_JOIN =
240+
'INNER JOIN "document" ON "document"."id" = "embedding_search"."document_id"'
241+
216242
function isVectorCandidateQuery(statement: string) {
217243
return statement.toLowerCase().includes(') as visible')
218244
}
@@ -476,12 +502,12 @@ async function sample(
476502
item.query.includes('limit') ||
477503
item.query.includes('CROSS JOIN LATERAL') ||
478504
isVectorCandidateQuery(item.query) ||
479-
item.query.includes('WITH scored_search_candidates') ||
505+
item.query.includes(VECTOR_PAGE_JOIN) ||
480506
item.query.includes('WITH matched_keyword_chunks'))
481507
)
482508
const plans: Array<
483509
CapturedQuery & {
484-
kind: 'keyword' | 'vector' | 'rerank' | 'probe'
510+
kind: 'keyword' | 'vector' | 'page' | 'probe'
485511
plan: z.infer<typeof explainSchema>
486512
}
487513
> = []
@@ -516,9 +542,8 @@ async function sample(
516542
? 'keyword'
517543
: isVectorCandidateQuery(query.query)
518544
? 'vector'
519-
: query.query.includes('order by') ||
520-
query.query.includes('WITH scored_search_candidates')
521-
? 'rerank'
545+
: query.query.includes('order by') || query.query.includes(VECTOR_PAGE_JOIN)
546+
? 'page'
522547
: 'probe',
523548
query: query.query,
524549
parameters: query.parameters,
@@ -1004,13 +1029,10 @@ describe.skipIf(!enabled)('Knowledge search latency on a realistic indexed corpu
10041029
expect(vectorPlans).toHaveLength(1)
10051030
expect(vectorPlans[0].plan[0].Plan['Actual Rows']).toBeGreaterThan(0)
10061031
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}`)
1032+
expect(plans.some((plan) => plan.kind === 'page')).toBe(true)
1033+
const page = plans.find((plan) => plan.kind === 'page')!
1034+
const actual = await db.$client.unsafe(page.query, page.parameters).values()
1035+
const expected = await exactProjectionNeighbors(queryVector, actual.length)
10141036
const expectedIds = new Set(expected.map(({ id }) => id))
10151037
const recall = actual.filter(([id]) => expectedIds.has(id)).length / expected.length
10161038
expect(recall).toBeGreaterThanOrEqual(0.95)
@@ -1028,12 +1050,9 @@ describe.skipIf(!enabled)('Knowledge search latency on a realistic indexed corpu
10281050
expectCompleteVectorSearch(diagnostics)
10291051
const candidates = plans.find((plan) => plan.kind === 'vector')!
10301052
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}`)
1053+
const page = plans.find((plan) => plan.kind === 'page')!
1054+
const actual = await db.$client.unsafe(page.query, page.parameters).values()
1055+
const expected = await exactProjectionNeighbors(topicVector(topic), actual.length)
10371056
const expectedIds = new Set(expected.map(({ id }) => id))
10381057
const recall = actual.filter(([id]) => expectedIds.has(id)).length / expected.length
10391058
expect(recall).toBeGreaterThanOrEqual(0.95)
@@ -1082,15 +1101,14 @@ describe.skipIf(!enabled)('Knowledge search latency on a realistic indexed corpu
10821101
)
10831102
expectCompleteVectorSearch(diagnostics)
10841103
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}`)
1104+
const page = plans.find((plan) => plan.kind === 'page')!
1105+
expect(page).toBeDefined()
1106+
const actual = await db.$client.unsafe(page.query, page.parameters).values()
1107+
const expected = await exactProjectionNeighbors(
1108+
queryVector,
1109+
actual.length,
1110+
sql`AND d.acl @> ARRAY[${reader}]::text[]`
1111+
)
10941112
expect(expected.length).toBeGreaterThan(0)
10951113
const expectedIds = new Set(expected.map(({ id }) => id))
10961114
const recall = actual.filter(([id]) => expectedIds.has(id)).length / expected.length
@@ -1128,9 +1146,10 @@ describe.skipIf(!enabled)('Knowledge search latency on a realistic indexed corpu
11281146
expect(probe[0].query).not.toContain('<=>')
11291147
expect(probe[0].plan[0].Plan['Actual Rows']).toBe(12)
11301148
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')
1149+
/** The page reads the bounded ranking's identities from the projection, never the original vectors. */
1150+
const page = plans.filter((plan) => plan.kind === 'page')
1151+
expect(page).toHaveLength(1)
1152+
expect(page[0].query).not.toContain('"embedding"."embedding"')
11341153
}
11351154
} finally {
11361155
await db
@@ -1175,16 +1194,16 @@ describe.skipIf(!enabled)('Knowledge search latency on a realistic indexed corpu
11751194
count < HYBRID_CANDIDATE_LIMIT ? 0 : 1
11761195
)
11771196
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}`)
1197+
const page = plans.find((plan) => plan.kind === 'page')!
1198+
const actual = await db.$client.unsafe(page.query, page.parameters).values()
1199+
const expected = await exactProjectionNeighbors(
1200+
queryVector,
1201+
actual.length,
1202+
sql`AND ${embeddingSearch.documentId} IN (${sql.join(
1203+
documentIds.map((id) => sql`${id}`),
1204+
sql`, `
1205+
)})`
1206+
)
11881207
const expectedIds = new Set(expected.map(({ id }) => id))
11891208
const recall = actual.filter(([id]) => expectedIds.has(id)).length / expected.length
11901209
expect(recall).toBeGreaterThanOrEqual(0.95)
@@ -1352,7 +1371,7 @@ describe.skipIf(!enabled)('Knowledge search latency on a realistic indexed corpu
13521371
)
13531372
expectCompleteVectorSearch(diagnostics)
13541373
expect(diagnostics.accessScopeKind).toBe('workspace')
1355-
expect(diagnostics.vectorRanking).toBe('candidate-rerank')
1374+
expect(diagnostics.vectorRanking).toBe('projection-walk')
13561375
expect(result.data.results).toHaveLength(15)
13571376
expect(plans.some((plan) => plan.kind === 'vector')).toBe(true)
13581377
for (const row of result.data.results) {
@@ -1441,7 +1460,7 @@ describe.skipIf(!enabled)('Knowledge search latency on a realistic indexed corpu
14411460
})
14421461
)
14431462
expectCompleteVectorSearch(tagged.diagnostics)
1444-
expect(tagged.diagnostics.vectorRanking).toBe('candidate-rerank')
1463+
expect(tagged.diagnostics.vectorRanking).toBe('projection-walk')
14451464
expect(tagged.result.data.results).toHaveLength(15)
14461465
for (const row of tagged.result.data.results) {
14471466
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
/**

0 commit comments

Comments
 (0)