Skip to content

Commit 48da9fc

Browse files
committed
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
1 parent 9b57644 commit 48da9fc

3 files changed

Lines changed: 29 additions & 10 deletions

File tree

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1574,10 +1574,11 @@ describe('permitted-document planner', () => {
15741574
expect(statement).toContain('ranked_tin_chunks.acl')
15751575
})
15761576

1577-
it('takes one wide window for a resolved scope and leaves a short page short', async () => {
1577+
it('takes one wide window for a narrow resolved scope and leaves a short page short', async () => {
15781578
tinPages = [{ ranked: 50_000, candidates: [hit('a', 'src-a')] }]
15791579
queueTableRows(schemaMock.embedding, [{ ...hit('a', 'src-a'), content: 'release notes' }])
15801580
const results = await keyword({
1581+
permitted: { kind: 'unbounded', broad: false },
15811582
accessPlan: {
15821583
connectors: { workspace: [], admin: ['src-a'], members: [] },
15831584
observers: { confirmed: [], observed: [] },
@@ -1589,6 +1590,8 @@ describe('permitted-document planner', () => {
15891590
expect(tinStatements()).toHaveLength(1)
15901591
expect(JSON.stringify(tinStatements()[0])).toContain('50000')
15911592
expect(ginStatements()).toHaveLength(0)
1593+
/** The window is ranked once for several pages' worth of readable rows, not once per page. */
1594+
expect(JSON.stringify(tinStatements()[0])).toContain('1000')
15921595
})
15931596

15941597
it('widens the ranked window while too few ranked chunks are readable', async () => {

‎apps/sim/lib/knowledge/search/queries.ts‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,9 @@ const FTS_CONFIG = 'english'
516516
*/
517517
const TIN_KEYWORD_WINDOWS = [2000, 10_000, 50_000] as const
518518

519+
/** Readable rows one wide window returns for a narrow reader: several pages' worth, ranked once. */
520+
const NARROW_KEYWORD_PAGE = 1000
521+
519522
/**
520523
* Row visibility predicates shared by every search leg: a chunk is only
521524
* retrievable when both it and its document are enabled, the document finished
@@ -1837,10 +1840,17 @@ export async function executeKeywordSearch(params: KeywordSearchParams): Promise
18371840
* one, so that is the only window tried. Either way what a resolved scope cannot fill is
18381841
* left short rather than handed to a ranking over every match.
18391842
*/
1840-
const windows =
1841-
accessPlan && params.permitted?.kind === 'unbounded' && !params.permitted.broad
1842-
? [TIN_KEYWORD_WINDOWS.at(-1)!]
1843-
: TIN_KEYWORD_WINDOWS
1843+
const narrow =
1844+
accessPlan !== undefined &&
1845+
params.permitted?.kind === 'unbounded' &&
1846+
!params.permitted.broad
1847+
const windows = narrow ? [TIN_KEYWORD_WINDOWS.at(-1)!] : TIN_KEYWORD_WINDOWS
1848+
/**
1849+
* A narrow reader's page is the readable remainder of a wide ranking, and that ranking is
1850+
* the cost: each page would rank the window again to find the next few readable rows, so one
1851+
* statement returns as many as several pages could ask for.
1852+
*/
1853+
const pageLimit = narrow ? Math.max(limit, NARROW_KEYWORD_PAGE) : limit
18441854
for (const window of windows) {
18451855
if (window < offset + limit) continue
18461856
const [page] = await runSearchQuery(params.budget, 'keyword.tin', (executor) =>
@@ -1868,7 +1878,7 @@ export async function executeKeywordSearch(params: KeywordSearchParams): Promise
18681878
FROM ranked_tin_chunks /* on-row visibility */
18691879
WHERE ranked_tin_chunks.enabled AND ${onRowKeywordVisibility(excludedSources)}
18701880
ORDER BY ranked_tin_chunks.keyword_rank DESC, ranked_tin_chunks.id
1871-
LIMIT ${limit} OFFSET ${offset}`
1881+
LIMIT ${pageLimit} OFFSET ${offset}`
18721882
: sql`
18731883
SELECT ranked_tin_chunks.id, ${document.id} AS "documentId",
18741884
${document.connectorId} AS "connectorId",
@@ -1878,7 +1888,7 @@ export async function executeKeywordSearch(params: KeywordSearchParams): Promise
18781888
WHERE ranked_tin_chunks.enabled
18791889
AND ${and(...candidateDocumentConditions(knowledgeBaseIds, access, params.filters, knowledgeAccessCondition(access)), excludeSearchSources(excludedSources))}
18801890
ORDER BY ranked_tin_chunks.keyword_rank DESC, ranked_tin_chunks.id
1881-
LIMIT ${limit} OFFSET ${offset}`
1891+
LIMIT ${pageLimit} OFFSET ${offset}`
18821892
}
18831893
)
18841894
SELECT (SELECT count(*)::int FROM ranked_tin_chunks) AS ranked,

‎packages/db/script-migrations/0021_embedding_search_connector.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,9 +129,11 @@ export async function backfillProjectionSourceAcl(
129129
}
130130

131131
/**
132-
* The ACL index each projection needs for exact ranking of a small readable set, built after the bulk
133-
* load and concurrently, so the triggers keep writing. `CONCURRENTLY` cannot run in a transaction, and
134-
* the pool's lock timeout would cancel a build that merely waits for a long transaction to finish.
132+
* The indexes exact ranking of a readable set needs: the ACL index on each projection, and on the
133+
* vector projection the source index that lets the planner lead with a few sources when the
134+
* caller's tokens alone would match most of the index. Built after the bulk load and concurrently,
135+
* so the triggers keep writing. `CONCURRENTLY` cannot run in a transaction, and the pool's lock
136+
* timeout would cancel a build that merely waits for a long transaction to finish.
135137
*/
136138
export async function indexProjectionAcl(sql: Sql): Promise<void> {
137139
const [{ timeout }] = await sql`SELECT current_setting('lock_timeout') AS timeout`
@@ -143,6 +145,10 @@ export async function indexProjectionAcl(sql: Sql): Promise<void> {
143145
ON ${projection} USING gin (acl) WHERE enabled`
144146
)
145147
}
148+
await sql.unsafe(
149+
`CREATE INDEX CONCURRENTLY IF NOT EXISTS embedding_search_source_idx
150+
ON embedding_search (connector_id) WHERE enabled`
151+
)
146152
} finally {
147153
await sql`SELECT set_config('lock_timeout', ${timeout}, false)`
148154
}

0 commit comments

Comments
 (0)