Skip to content

Commit 9573ecc

Browse files
authored
improvement(knowledge): resolve connector and member state once per search (#8040)
* improvement(knowledge): resolve connectors once and rank each source in its own index Two costs dominated organization search, and both came from deriving per candidate what is true of a whole source. Connector state — deletion, archival, a pending access rewrite, the organization's integration approval, the access mode — is a fact about a connector, so a search resolves it once and filters candidates by the resulting ids. For a member reading ~25k documents, the candidate predicate's connector lookup drops from one per document examined to one per document of a source that still needs this request's live proof: 24,571 evaluations to 610, and the access check from ~390ms to ~160ms. Ranking then follows sources. pgvector post-filters, so one walk over every source spends its scan budget on the sources a caller cannot read: measured recall for a member reading half an index ranged from 0.00 to 1.00, averaging 0.80 over nine queries, two of which returned none of the exact page. A member of a source reads essentially all of it, so that source is walked through an index covering it alone, built after a sync grows it past the threshold and dropped with the connector. Every other source is sliced — mirrored permissions give a caller their own mail, their own files — and those slices are ranked exactly in one statement. Recall over the same queries rises to 0.95 with none below 0.75. Retrieval never waits on an index existing: a source without one is ranked exactly, so a build that is skipped, fails, or has not happened yet costs recall nothing. * improvement(knowledge): resolve the caller's member identities with the rest of the plan A members-mode document is readable while one of the caller's active members observes it, freshly, and which members those are is a fact about the caller. Resolving them with the connectors — one query, one plan — turns each candidate's check into a lookup on the observation key instead of a join to the member behind it, and lets the vector planner read the sources the caller belongs to from the same resolution rather than asking again. * chore(knowledge): drop the stage the access plan made unnecessary * improvement(knowledge): resolve live source grants once per search - ask a live-proof source for the caller's grants once, before either leg, and only when the scope actually reads one, instead of per candidate page - drop the per-page source exclusion and refill loop that followed from the per-page proof - read search result metadata under the stored predicate the rows already passed - end candidate paging on a short page whether or not the leg reorders its results - batch the embedding_search.connector_id backfill in independently committed keyset pages, matching the other projection backfills * test(db): register the projection-source script migration in the push and registry expectations * fix(knowledge): walk a saturated slice and re-read connector state at hydration - fall back to a graph walk when the sliced sources hold more readable documents than one exact ranking may enumerate, since that enumeration has no order and would otherwise rank an arbitrary subset - read content under the full predicate, which re-reads each connector's own lifecycle and approval, so a source deleted, archived or unapproved mid-search stops answering at the gate that returns content * improvement(knowledge): ask a live source for grants only when one of its candidates is read Resolving every gated source's grants up front charged a search for sources it never ranked. The grants are now resolved on first need — when a page of ranked candidates actually contains one of those sources — and memoized for the rest of the search, so a scope that ranks none never asks and one that ranks many asks once. * fix(knowledge): rank uploads without a sliced source and bound the grant resolution - always run the slice that carries connectorless uploads; a caller who is a member of every indexed source had no sliced source beside them, and their uploaded documents were never ranked - resolve live source grants in the provider's bounded pages, so a scope holding more gated sources than one page no longer fails - drop a source's index only while it is invalid, so overlapping syncs cannot have one remove the index the other just built - fan a document's new source out to the chunks a search can reach, which the document lookup index covers, and take the source again when a chunk is enabled * fix(knowledge): refill after a denied gated source, reserve the index build's session, lock the backfill's documents - once a live proof shows the caller does not hold a gated source, rebuild the candidate pages without it, so its candidates no longer hold the slots of sources the caller can read - run the source index build on one reserved connection, so its memory setting and its reset apply to the session that builds - share-lock a page's documents before copying their source, so a detachment in flight waits for the page and then fans its own change out through the trigger * fix(knowledge): start the scan budget over when the pages are rebuilt The candidates already seen before a denied source was known are skipped when the pages are rebuilt, but they still counted against the scan budget, so a refill could exhaust it before reaching the readable candidates ranked after the denied source. * fix(knowledge): serialize the projection source with its document - the set trigger reads the document under FOR SHARE, so a projection write and a document's connector change cannot interleave and leave the older value - the backfill writes a chunk's source only for the document it read that source from; a chunk moved meanwhile is left to its new document's trigger * improvement(knowledge): choose the vector plan by the caller's reach - a caller who reaches at least a quarter of the bases' documents walks the whole graph once, as before: pgvector post-filters, so at that share almost every neighbour the graph visits is theirs and one walk is the cheapest exact answer; searching each source on its own only helps a caller whose reach is a small share of the index - the share is counted once against that bound when the probe saturates, and remembered with the saturation - a bounded caller who is a member of an indexed source walks that source instead of ranking it exactly with the rest * fix(knowledge): search a broad caller's sources when their whole-graph walk comes back short Reach is counted from token overlap, which every readable document has but which connector state, requirement clauses or observations can still refuse. A caller the count called broad whose walk then underfills was not broad: their sources are searched on their own instead, so the misjudgement costs one walk rather than their neighbours. * fix(knowledge): read result metadata under the scope the results were read under Retrieval reports the scope its content was read under — with the live grants it resolved, when a gated source's candidate was read — and the metadata lookup applies the full read predicate under that scope. A gated source's result keeps its name and URL; a revoked one loses them here as it did at hydration. * fix(knowledge): keep what a short broad walk found when searching its sources runs out of budget The per-source search adds to the walk's candidates rather than replacing them, and if it is cancelled by the leg's budget the walk's candidates still stand. * fix(api): fail a v1 knowledge search whose retrieval ran out of time v1 cannot express an incomplete search, so a leg that reached its deadline fails the request, as it did before the route read the retrieval result directly. * improvement(knowledge): mirror each chunk's source and ACL onto the ranking projections - embedding_search and embedding_keyword_tin carry connector_id and acl from their document, set by each projection's own trigger under a share lock and fanned out when a document changes hands or its ACL is rewritten - script migration 0021 installs both, backfills both projections in independently committed keyset pages, and builds a partial GIN index on each projection's acl concurrently - projectionCandidateAccessCondition decides candidate readability on the projection row alone, admitting a superset of the per-row document predicate; a real-database test asserts it never refuses what that predicate admits * improvement(knowledge): decide candidate readability on the ranking row Once a search has resolved its connectors, the vector walks and the Tin keyword window test the caller's access on the row they score — the source and ACL are mirrored there — instead of joining document per visited or ranked chunk. The graph is no longer stalled by a document lookup per candidate, and a keyword window of mostly unreadable chunks costs an array test per row. Content is still read under the full predicate at hydration. * improvement(knowledge): rank the sliced sources on the projection row The sliced sources' readable chunks are enumerated by the projection's ACL index and ranked exactly in one statement, with the saturation bound counted in the same statement; the per-document enumeration through document is gone from the per-source path. * improvement(knowledge): let a resolved scope's reach choose its plan without enumerating documents - with readability decided on the projection row, a resolved scope never needs its readable documents enumerated ahead of ranking: its reach alone chooses between one walk over the whole graph and a search of each source, and is remembered per bases and tokens - the reach share's denominator is the planner's estimate of the bases' documents rather than a count of them - a resolved scope's keyword leg ranks with Tin and decides readability on the ranked row whether or not its reach is bounded * improvement(knowledge): walk every source a caller reads whole; one keyword window for a resolved scope - a source earns its own vector index at a thousand documents: once readability is decided on the row a walk costs a few milliseconds whatever the source's size, where ranking it exactly grows with it - a resolved scope's keyword page takes one wide Tin window and leaves a short page short, rather than widening through narrower windows and then ranking every match * improvement(knowledge): widen a broad reader's short walk; keyword windows follow reach - a broad reader whose whole-graph walk comes back short walks again with a wider scan instead of searching each source: their own sources are already in the walk, and enumerating their readable chunks is a bitmap over most of the index - a broad reader's keyword page widens through the narrow windows as before; only a narrow reader takes the wide window alone * improvement(knowledge): index the projection by source; rank a narrow reader's keyword window once - a partial btree on embedding_search.connector_id lets the planner lead with a few sources where the caller's tokens alone would match most of the index, which is what made a small slice cost a bitmap over all of it - a narrow reader's keyword page returns several pages' worth of readable rows from one wide ranking, instead of ranking the window again for each page * improvement(knowledge): widen a broad walk only when it is short of the result need; a narrower keyword window for narrow readers A walk that found hundreds of candidates needs no rescue; widening it spent the leg's budget and lost them. The wider walk now runs only when the first found fewer candidates than there are results to return. A narrow reader's one keyword window is 20k rows: a few percent of it fills their page several times over, at less than half the widest window's cost. * fix(knowledge): hydrate an oversized ranking page in slices, only as far as the results need A ranking may hand back more candidates than one hydration should read — a narrow reader's keyword window is ranked once for several pages' worth — so a page is drained in slices of the result need and what is left waits, unread, until the results still need it. * fix(knowledge): count a candidate as considered only once its slice is read The slices a refill discards were never read, so the rebuilt pages may hand their readable candidates back. * fix(knowledge): pair an observer with its connector, settle the per-source fan-out, serialize a source's index build - an observation vouches for a document only from a member of the document's own connector: a document that changed hands keeps its old observations, which no longer carry it as a candidate - a source whose search runs out of budget marks the leg partial while the other sources' results stand - a source's index is built under a session lock on the one reserved connection that also builds it, so two syncs cannot interleave the invalidity check and the drop; 0021 drops an invalid leftover before building, and analyzes both projections after the backfill - a broad reader's walk widens when it found fewer candidates than the smallest pool worth reranking * fix(knowledge): give a broad reader's wider walk half of what the leg has left The rerank and hydration of whatever is found — the first walk's candidates at least — keep the rest, so a wider walk that runs out of its share can no longer take the leg's results with it. * improvement(knowledge): let an on-row walk run to its cap, widen a narrow reader's keyword window stepwise, apply scan settings with the deadline - an on-row walk keeps walking, up to a 100k-tuple cap, until its limit is met; the separate wider walk and its diagnostic are gone - the on-row predicate tests the mirrored ACL alone — a member's source is no longer admitted whole, so a document re-owned after its chunk was mirrored is refused at the row - a reach count that ran out of time decides that search only; it is not remembered - a narrow reader ranks the narrowest keyword window first and widens to the wide one only when the page is short; resolved scopes leave the widest window short instead of ranking every match - the HNSW scan settings ride in the deadline statement, one round trip fewer per vector statement * test(knowledge): expect the scan settings to share the deadline statement
1 parent 751887f commit 9573ecc

29 files changed

Lines changed: 59212 additions & 604 deletions

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

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
1414
const {
1515
mockResolveV1KnowledgeReadAccess,
1616
mockExecuteKnowledgeSearch,
17+
mockRetrievalStatus,
1718
mockGenerateSearchEmbedding,
1819
mockGetDocumentMetadataByIds,
1920
mockGetDocumentTagDefinitions,
@@ -25,6 +26,7 @@ const {
2526
} = vi.hoisted(() => ({
2627
mockResolveV1KnowledgeReadAccess: vi.fn(),
2728
mockExecuteKnowledgeSearch: vi.fn(),
29+
mockRetrievalStatus: vi.fn(() => ({ status: 'complete', timedOutLegs: [] })),
2830
mockGenerateSearchEmbedding: vi.fn(),
2931
mockGetDocumentMetadataByIds: vi.fn(),
3032
mockGetDocumentTagDefinitions: vi.fn(),
@@ -54,7 +56,12 @@ vi.mock('@/lib/knowledge/access/availability', () => ({
5456
}))
5557

5658
vi.mock('@/lib/knowledge/search/queries', () => ({
57-
executeKnowledgeSearch: mockExecuteKnowledgeSearch,
59+
/** The route reads the retrieval result; the rows come from the same mock the tests drive. */
60+
retrieveKnowledgeSearch: async (params: { access: unknown }) => ({
61+
rows: await mockExecuteKnowledgeSearch(params),
62+
retrieval: mockRetrievalStatus(),
63+
readAccess: params.access,
64+
}),
5865
getDocumentMetadataByIds: mockGetDocumentMetadataByIds,
5966
}))
6067

@@ -139,6 +146,27 @@ describe('v1 knowledge search route — per-KB embedding model', () => {
139146
mockRecordSearchEmbeddingUsage.mockResolvedValue(undefined)
140147
})
141148

149+
it('fails a search whose retrieval ran out of time instead of returning partial rows', async () => {
150+
const access = { kind: 'user' as const, userId: 'user-1', tokens: ['reader-token'] }
151+
mockResolveV1KnowledgeReadAccess.mockResolvedValue({
152+
get: vi.fn().mockResolvedValue(access),
153+
getForConnectors: vi.fn(),
154+
getForDocuments: vi.fn(),
155+
})
156+
mockCheckKnowledgeBaseAccess.mockResolvedValueOnce({
157+
hasAccess: true,
158+
knowledgeBase: baseKb('kb-1', 'text-embedding-3-small'),
159+
})
160+
mockRetrievalStatus.mockReturnValueOnce({ status: 'partial', timedOutLegs: ['vector'] })
161+
mockExecuteKnowledgeSearch.mockResolvedValue([])
162+
const response = await POST(
163+
createMockRequest('POST', { workspaceId: 'ws-1', knowledgeBaseIds: 'kb-1', query: 'hello' })
164+
)
165+
expect(mockExecuteKnowledgeSearch).toHaveBeenCalledOnce()
166+
expect(response.status).toBe(500)
167+
expect(mockGetDocumentMetadataByIds).not.toHaveBeenCalled()
168+
})
169+
142170
it('retains the reader provider for ranked results and returned document metadata', async () => {
143171
const access = { kind: 'user' as const, userId: 'user-1', tokens: ['reader-token'] }
144172
const provider = {
@@ -165,7 +193,7 @@ describe('v1 knowledge search route — per-KB embedding model', () => {
165193
accessProvider: provider,
166194
})
167195
)
168-
expect(mockGetDocumentMetadataByIds).toHaveBeenCalledWith([], access, provider)
196+
expect(mockGetDocumentMetadataByIds).toHaveBeenCalledWith([], access)
169197
})
170198

171199
it.each([
@@ -224,8 +252,7 @@ describe('v1 knowledge search route — per-KB embedding model', () => {
224252
expect(response.status).toBe(200)
225253
expect(mockGetDocumentMetadataByIds).toHaveBeenCalledWith(
226254
['revoked-document', 'allowed-document'],
227-
access,
228-
provider
255+
access
229256
)
230257
expect(body.data.results).toEqual(
231258
allDenied

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

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@ import {
1414
type KbEmbeddingTarget,
1515
recordSearchEmbeddingUsage,
1616
} from '@/lib/knowledge/embeddings'
17+
import { SearchDeadlineError } from '@/lib/knowledge/search/budget'
1718
import { resolveKnowledgeSearchDefaults } from '@/lib/knowledge/search/defaults'
1819
import {
19-
executeKnowledgeSearch,
2020
getDocumentMetadataByIds,
21+
type KnowledgeRetrievalResult,
22+
retrieveKnowledgeSearch,
2123
type SearchResult,
2224
} from '@/lib/knowledge/search/queries'
2325
import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service'
@@ -226,7 +228,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
226228
}
227229
: undefined
228230

229-
let results: SearchResult[]
231+
let retrieved: KnowledgeRetrievalResult
230232
let queryEmbeddingIsBYOK: boolean | null = null
231233
const [readAccess, { searchMode, boostRecency }] = await Promise.all([
232234
resolveV1KnowledgeReadAccess(userId, rateLimit, workspaceId),
@@ -242,7 +244,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
242244
const access = 'get' in readAccess ? await readAccess.get() : readAccess
243245

244246
if (!hasQuery && hasFilters) {
245-
results = await executeKnowledgeSearch({
247+
retrieved = await retrieveKnowledgeSearch({
246248
knowledgeBaseIds: accessibleKbIds,
247249
topK,
248250
access,
@@ -258,7 +260,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
258260
workspaceId
259261
)
260262
queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK
261-
results = await executeKnowledgeSearch({
263+
retrieved = await retrieveKnowledgeSearch({
262264
knowledgeBaseIds: accessibleKbIds,
263265
topK,
264266
access,
@@ -311,8 +313,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
311313
tagDefinitionsMap[kbId] = map
312314
})
313315

316+
/** v1 cannot express an incomplete search, so a leg that ran out of time fails the request. */
317+
if (retrieved.retrieval.status === 'partial') throw new SearchDeadlineError()
318+
const results = retrieved.rows
314319
const documentIds = results.map((r) => r.documentId)
315-
const documentMetadataMap = await getDocumentMetadataByIds(documentIds, access, accessProvider)
320+
const documentMetadataMap = await getDocumentMetadataByIds(documentIds, retrieved.readAccess)
316321
const readableResults = results.filter((result) => documentMetadataMap[result.documentId])
317322

318323
return NextResponse.json({

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,11 +131,11 @@ describe('API-key KB block fan-out', () => {
131131
const matching = (fragment: string) =>
132132
statements.filter((query) => query.includes(fragment))
133133
/**
134-
* Every statement runs under the leg's deadline: the candidate search reinstates it after
135-
* tuning the scan, and the probe, the exact ranking, the rerank and hydration each open
136-
* with one of their own.
134+
* Every statement runs under the leg's deadline: the candidate search applies it with the
135+
* scan settings in one statement, and the probe, the exact ranking, the rerank and
136+
* hydration each open with one of their own.
137137
*/
138-
expect(matching('statement_timeout')).toHaveLength(bases.length * 6)
138+
expect(matching('statement_timeout')).toHaveLength(bases.length * 5)
139139
/**
140140
* A scope this small leaves the bounded traversal short of its candidate limit, so every
141141
* search probes once and rescues once — never a widening retry loop.
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { db } from '@sim/db'
2+
import { knowledgeConnector, knowledgeConnectorMember } from '@sim/db/schema'
3+
import { and, eq, inArray, isNull, sql } from 'drizzle-orm'
4+
import { SOURCE_ACL_MAX_AGE_MS } from '@/lib/knowledge/access/freshness'
5+
import {
6+
type KnowledgeConnectorEligibility,
7+
type KnowledgeMemberObserver,
8+
type KnowledgeMemberObservers,
9+
type SearchAccessPlan,
10+
textArrayLiteral,
11+
} from '@/lib/knowledge/access/predicate'
12+
import type { KnowledgeAccessScope } from '@/lib/knowledge/access/types'
13+
import { searchIntegrationAccessCondition } from '@/lib/knowledge/search/integration-policy'
14+
15+
/**
16+
* The connectors a search may read from, grouped by access mode, with the ones whose reader access
17+
* must be proven live marked.
18+
*
19+
* Deletion, archival, a pending access rewrite and the organization's integration approval are
20+
* facts about a connector. Resolving them once per query — there are tens of connectors against
21+
* hundreds of thousands of documents — leaves each candidate its own columns to check.
22+
*/
23+
async function resolveConnectorEligibility(
24+
knowledgeBaseIds: readonly string[]
25+
): Promise<KnowledgeConnectorEligibility> {
26+
const eligibility: {
27+
workspace: string[]
28+
admin: string[]
29+
members: string[]
30+
liveProofRequired: string[]
31+
} = { workspace: [], admin: [], members: [], liveProofRequired: [] }
32+
if (knowledgeBaseIds.length === 0) return eligibility
33+
const rows = await db
34+
.select({
35+
id: knowledgeConnector.id,
36+
accessMode: knowledgeConnector.accessMode,
37+
connectorType: knowledgeConnector.connectorType,
38+
/** A GitHub connector is gated only where it names the immutable repository behind a grant. */
39+
githubRepository: sql<boolean>`${knowledgeConnector.sourceConfig}::jsonb ? 'githubRepositoryId'`,
40+
})
41+
.from(knowledgeConnector)
42+
.where(
43+
and(
44+
inArray(knowledgeConnector.knowledgeBaseId, [...knowledgeBaseIds]),
45+
isNull(knowledgeConnector.deletedAt),
46+
isNull(knowledgeConnector.archivedAt),
47+
eq(knowledgeConnector.accessRewritePending, false),
48+
searchIntegrationAccessCondition()
49+
)
50+
)
51+
for (const row of rows) {
52+
if (row.accessMode === 'workspace') eligibility.workspace.push(row.id)
53+
else if (row.accessMode === 'admin') eligibility.admin.push(row.id)
54+
else if (row.accessMode === 'members') eligibility.members.push(row.id)
55+
else continue
56+
const live =
57+
(row.connectorType === 'github' && row.githubRepository) ||
58+
(row.connectorType === 'confluence' && row.accessMode === 'admin')
59+
if (live) eligibility.liveProofRequired.push(row.id)
60+
}
61+
return eligibility
62+
}
63+
64+
/**
65+
* The caller's own member identities on these connectors, split by whether the member's change
66+
* feed is itself current.
67+
*
68+
* A members-mode document is readable while one of the caller's active members observes it,
69+
* freshly — and which members those are is a fact about the caller, not about any document. A
70+
* member whose feed drained recently confirms every observation it holds, so its observations need
71+
* no age check at all; the rest are checked against the age of the observation itself. Resolved
72+
* once, the per-document check becomes one lookup on the observation key, with no join to the
73+
* member behind it.
74+
*/
75+
async function resolveMemberObservers(
76+
access: KnowledgeAccessScope,
77+
connectorIds: readonly string[]
78+
): Promise<{ observers: KnowledgeMemberObservers; memberSources: string[] }> {
79+
if (access.kind !== 'user' || connectorIds.length === 0 || access.tokens.length === 0) {
80+
return { observers: { confirmed: [], observed: [] }, memberSources: [] }
81+
}
82+
const rows = await db
83+
.select({
84+
id: knowledgeConnectorMember.id,
85+
connectorId: knowledgeConnectorMember.connectorId,
86+
syncedThrough: knowledgeConnectorMember.memberSyncedThrough,
87+
})
88+
.from(knowledgeConnectorMember)
89+
.where(
90+
and(
91+
inArray(knowledgeConnectorMember.connectorId, [...connectorIds]),
92+
eq(knowledgeConnectorMember.status, 'active'),
93+
sql`${knowledgeConnectorMember.subjectToken} = ANY(${textArrayLiteral([...access.tokens])})`
94+
)
95+
)
96+
const cutoff = Date.now() - SOURCE_ACL_MAX_AGE_MS
97+
const confirmed: KnowledgeMemberObserver[] = []
98+
const observed: KnowledgeMemberObserver[] = []
99+
const memberSources = new Set<string>()
100+
for (const row of rows) {
101+
const member = { id: row.id, connectorId: row.connectorId }
102+
if (row.syncedThrough !== null && row.syncedThrough.getTime() > cutoff) confirmed.push(member)
103+
else observed.push(member)
104+
memberSources.add(row.connectorId)
105+
}
106+
return { observers: { confirmed, observed }, memberSources: [...memberSources] }
107+
}
108+
109+
/**
110+
* Everything a search needs to know about its sources and the caller's standing in them, resolved
111+
* once: which connectors it may read, the caller's member identities there, and the sources they
112+
* are a member of. Each is a fact about a connector or a caller, so deriving them per candidate
113+
* document is what made retrieval cost grow with the size of what someone may read.
114+
*/
115+
export async function resolveSearchAccessPlan(
116+
knowledgeBaseIds: readonly string[],
117+
access: KnowledgeAccessScope
118+
): Promise<SearchAccessPlan> {
119+
const connectors = await resolveConnectorEligibility(knowledgeBaseIds)
120+
const { observers, memberSources } = await resolveMemberObservers(access, connectors.members)
121+
return { connectors, observers, memberSources }
122+
}

0 commit comments

Comments
 (0)