Skip to content

Commit 37ba54e

Browse files
committed
fix(knowledge): walk a large bounded set on the row before ranking it exactly
A member reading most of a large source, with that source selected as a filter, enumerated a bounded permitted set of tens of thousands of documents and then ranked every chunk of it exactly on both legs: the vector leg read every chunk's projected vector, and the keyword leg materialized every chunk of the set before it matched the term. Cold, each leg outran its budget and the search returned nothing. A bounded set past a size limit is now walked on the row first, where the plan's source and ACL decide readability and the walk stops at its tuple cap, and ranked exactly only when the walk cannot fill its pool, so recall is never below the exact ranking's. The keyword leg treats the same set as a narrow on-row reader: Tin windows where Tin serves, otherwise the GIN shape whose cost follows the term's matches. Sets under the limit keep their exact paths.
1 parent 6151605 commit 37ba54e

2 files changed

Lines changed: 185 additions & 50 deletions

File tree

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

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
handleTagAndVectorSearch,
4242
handleTagOnlySearch,
4343
handleVectorOnlySearch,
44+
PERMITTED_EXACT_DOCUMENT_LIMIT,
4445
type PermittedDocuments,
4546
resolvePermittedDocuments,
4647
resolveReach,
@@ -1794,6 +1795,51 @@ describe('permitted-document planner', () => {
17941795
expect(ginStatements()).toHaveLength(0)
17951796
})
17961797

1798+
describe('a bounded set past the exact-ranking size', () => {
1799+
const large = Array.from({ length: PERMITTED_EXACT_DOCUMENT_LIMIT }, (_, index) => ({
1800+
id: `doc-${index}`,
1801+
connectorId: 'src-a',
1802+
}))
1803+
const accessPlan = {
1804+
connectors: { workspace: [], admin: ['src-a'], members: [], liveProofRequired: [] },
1805+
observers: { confirmed: [], observed: [] },
1806+
memberSources: [],
1807+
connectorTypes: new Map(),
1808+
uploads: true,
1809+
}
1810+
1811+
it('ranks with Tin as a narrow reader, decided on the row', async () => {
1812+
tinPages = [{ ranked: 1500, candidates: [hit('a', 'src-a')] }]
1813+
queueTableRows(schemaMock.embedding, [{ ...hit('a', 'src-a'), content: 'release notes' }])
1814+
const results = await keyword({
1815+
permitted: { kind: 'bounded', documents: large },
1816+
accessPlan,
1817+
})
1818+
expect(results.map((row) => row.id)).toEqual(['a'])
1819+
expect(mockResolveTinKeywordQuery).toHaveBeenCalledTimes(1)
1820+
expect(tinStatements()).toHaveLength(1)
1821+
expect(JSON.stringify(tinStatements()[0])).toContain('2000')
1822+
expect(JSON.stringify(tinStatements()[0])).not.toContain('doc-4999')
1823+
expect(ginStatements()).toHaveLength(0)
1824+
})
1825+
1826+
it('falls back to a GIN ranking that reads what the term matches, not every chunk of the set', async () => {
1827+
mockResolveTinKeywordQuery.mockResolvedValue(null)
1828+
queueTableRows(schemaMock.embedding, [{ ...hit('a', 'src-a'), content: 'release notes' }])
1829+
await keyword({ permitted: { kind: 'bounded', documents: large }, accessPlan })
1830+
expect(tinStatements()).toHaveLength(0)
1831+
expect(ginStatements()).toHaveLength(1)
1832+
expect(JSON.stringify(ginStatements()[0])).not.toContain('doc-4999')
1833+
})
1834+
1835+
it('keeps the bounded read for a set under the size', async () => {
1836+
mockResolveTinKeywordQuery.mockResolvedValue(null)
1837+
await keyword({ permitted: { kind: 'bounded', documents: large.slice(0, -1) }, accessPlan })
1838+
expect(mockResolveTinKeywordQuery).not.toHaveBeenCalled()
1839+
expect(JSON.stringify(ginStatements()[0])).toContain('doc-4998')
1840+
})
1841+
})
1842+
17971843
it('leaves the page to the GIN ranking when the widest window cannot fill it', async () => {
17981844
tinPages = [
17991845
{ ranked: 2000, candidates: [] },
@@ -2400,6 +2446,59 @@ describe('filters on a resolved scope', () => {
24002446
expect(JSON.stringify(walks[0])).toContain('release')
24012447
})
24022448

2449+
describe('a bounded set past the exact-ranking size', () => {
2450+
const large = Array.from({ length: PERMITTED_EXACT_DOCUMENT_LIMIT }, (_, index) => ({
2451+
id: `doc-${index}`,
2452+
connectorId: 'src-a',
2453+
}))
2454+
const walked = Array.from({ length: 200 }, (_, index) => hit(`w-${index}`, 'src-a'))
2455+
const search = (documents: typeof large) =>
2456+
handleVectorOnlySearch({
2457+
...params,
2458+
permitted: { kind: 'bounded', documents },
2459+
accessPlan: plan(),
2460+
})
2461+
beforeEach(() => {
2462+
const execute = dbChainMockFns.execute.getMockImplementation()!
2463+
dbChainMockFns.execute.mockImplementation(async (query) => {
2464+
/** The projection is filled, so a walk decides readability on the row. */
2465+
if (render(query).sql.includes('AS unfilled')) return [{ unfilled: false }]
2466+
return execute(query)
2467+
})
2468+
})
2469+
2470+
it('walks the graph on the row instead of ranking every chunk of the set', async () => {
2471+
traversedRows = walked
2472+
queueTableRows(schemaMock.embedding, [walked[0]])
2473+
expect((await search(large)).map((row) => row.id)).toEqual(['w-0'])
2474+
const walks = statements().filter((query) => isWalk(query.sql))
2475+
expect(walks).toHaveLength(1)
2476+
expect(statements().filter((query) => isExactRanking(query.sql))).toHaveLength(0)
2477+
/** Readability rides on the row through the plan; the set's identifiers never cross the wire. */
2478+
expect(JSON.stringify(walks[0])).not.toContain('doc-4999')
2479+
expect(JSON.stringify(walks[0])).toContain('src-a')
2480+
})
2481+
2482+
it('ranks the set exactly when the walk cannot fill its pool', async () => {
2483+
traversedRows = []
2484+
exactRows = [hit('a', 'src-a')]
2485+
queueTableRows(schemaMock.embedding, [hit('a', 'src-a')])
2486+
expect((await search(large)).map((row) => row.id)).toEqual(['a'])
2487+
expect(statements().filter((query) => isWalk(query.sql))).toHaveLength(1)
2488+
const exact = statements().filter((query) => isExactRanking(query.sql))
2489+
expect(exact).toHaveLength(1)
2490+
expect(JSON.stringify(exact[0])).toContain('doc-4999')
2491+
})
2492+
2493+
it('ranks a set under the size exactly, without a walk', async () => {
2494+
exactRows = [hit('a', 'src-a')]
2495+
queueTableRows(schemaMock.embedding, [hit('a', 'src-a')])
2496+
expect((await search(large.slice(0, -1))).map((row) => row.id)).toEqual(['a'])
2497+
expect(statements().filter((query) => isWalk(query.sql))).toHaveLength(0)
2498+
expect(statements().filter((query) => isExactRanking(query.sql))).toHaveLength(1)
2499+
})
2500+
})
2501+
24032502
it('ranks a date-bounded set exactly even when a member source has its own index', async () => {
24042503
indexedSourceRows = [{ name: 'idx', connectorId: 'member-src' }]
24052504
exactRows = [{ id: 'a' }]

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

Lines changed: 86 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,16 @@ export const VECTOR_PROBE_DOCUMENT_LIMIT = Math.round(
216216
(VECTOR_PROBE_BUDGET_MS * 1000) / VECTOR_PROBE_MICROSECONDS_PER_DOCUMENT
217217
)
218218

219+
/**
220+
* Documents a bounded permitted set may hold before ranking it exactly costs more than walking
221+
* the graph on the row. Exact ranking reads every chunk of the set, a few per document, where an
222+
* on-row walk reads at most {@link CANDIDATE_HNSW_MAX_SCAN_TUPLES} tuples; at this size the two
223+
* meet. A set past it is walked first and ranked exactly only if the walk cannot fill its pool, so
224+
* its recall is never below the exact ranking's and its usual cost is the walk's. The same size
225+
* turns the keyword leg from a read of the set's every chunk into a ranking decided on the row.
226+
*/
227+
export const PERMITTED_EXACT_DOCUMENT_LIMIT = 5_000
228+
219229
/** How long to stop trying the iterative-scan settings after the server rejected them. */
220230
const HNSW_SETTINGS_UNSUPPORTED_RETRY_MS = 10 * 60 * 1000
221231

@@ -1766,6 +1776,49 @@ async function selectVectorResults(params: SearchParams): Promise<SearchResult[]
17661776
)
17671777
}
17681778
let selected: SearchReadCandidate[]
1779+
/**
1780+
* The bounded ANN traversal is the whole candidate set. LIMIT keeps document
1781+
* authorization downstream of the traversal, with a primary-key lookup per candidate.
1782+
*/
1783+
const scopeOfWalk = and(
1784+
inArray(embeddingSearch.knowledgeBaseId, params.knowledgeBaseIds),
1785+
eq(embeddingSearch.enabled, true),
1786+
excludedOnRow
1787+
)
1788+
const walkGraph = () =>
1789+
withVectorScanSettings(
1790+
(executor) =>
1791+
executor.execute<SearchReadCandidate>(
1792+
plan
1793+
? sql`
1794+
SELECT ${PROJECTION_CANDIDATE_COLUMNS}
1795+
FROM ${embeddingSearch} /* on-row visibility */
1796+
WHERE ${and(
1797+
scopeOfWalk,
1798+
projectionCandidateAccessCondition(embeddingSearch, params.access, plan, { filled }),
1799+
documentCondition === undefined
1800+
? undefined
1801+
: sql`EXISTS (SELECT 1 FROM ${document} WHERE ${and(eq(document.id, embeddingSearch.documentId), documentCondition)})`
1802+
)}
1803+
ORDER BY ${candidateDistance} LIMIT ${candidateLimit}
1804+
`
1805+
: sql`
1806+
SELECT ${embeddingSearch.id} AS id, ${embeddingSearch.documentId} AS "documentId",
1807+
visible.connector_id AS "connectorId"
1808+
FROM ${embeddingSearch}
1809+
CROSS JOIN LATERAL (
1810+
SELECT ${document.connectorId} AS connector_id FROM ${document}
1811+
WHERE ${and(eq(document.id, embeddingSearch.documentId), ...candidateDocumentVisibility, candidateTagCondition)}
1812+
LIMIT 1
1813+
) AS visible
1814+
WHERE ${scopeOfWalk}
1815+
ORDER BY ${candidateDistance} LIMIT ${candidateLimit}
1816+
`
1817+
),
1818+
params.budget,
1819+
'vector.candidate_search',
1820+
plan ? onRowWalkScanTuples(documentCondition, filled) : undefined
1821+
)
17691822
/**
17701823
* A source the caller is a member of that has its own index is walked on its own, which
17711824
* beats ranking it exactly once it is large enough to have earned that index.
@@ -1774,6 +1827,25 @@ async function selectVectorResults(params: SearchParams): Promise<SearchResult[]
17741827
(id) => plannedIndexedSources?.has(id) ?? false
17751828
)
17761829
if (
1830+
params.permitted?.kind === 'bounded' &&
1831+
plan &&
1832+
filled &&
1833+
params.permitted.documents.length >= PERMITTED_EXACT_DOCUMENT_LIMIT
1834+
) {
1835+
/**
1836+
* A set this large costs more to rank exactly than to walk: exact ranking reads every
1837+
* chunk of every document in it, while the walk decides readability on the rows it
1838+
* visits and stops at its tuple cap. The walk answers whenever the set is a fair share
1839+
* of the graph; where it is not, the walk underfills and the exact ranking that was
1840+
* always complete takes over, so nothing is lost but the walk's bounded cost.
1841+
*/
1842+
selected = await walkGraph()
1843+
if (selected.length < candidateLimit) {
1844+
selected = await rankPermittedExactly(
1845+
params.permitted.documents.map((entry) => entry.id)
1846+
)
1847+
}
1848+
} else if (
17771849
params.permitted?.kind === 'bounded' &&
17781850
(!walksASource || dateFilterCondition(params.filters) || params.filters?.source)
17791851
) {
@@ -1806,48 +1878,7 @@ async function selectVectorResults(params: SearchParams): Promise<SearchResult[]
18061878
budget: params.budget,
18071879
})
18081880
} else {
1809-
/**
1810-
* The bounded ANN traversal is the whole candidate set. LIMIT keeps document
1811-
* authorization downstream of the traversal, with a primary-key lookup per candidate.
1812-
*/
1813-
const scopeOfWalk = and(
1814-
inArray(embeddingSearch.knowledgeBaseId, params.knowledgeBaseIds),
1815-
eq(embeddingSearch.enabled, true),
1816-
excludedOnRow
1817-
)
1818-
selected = await withVectorScanSettings(
1819-
(executor) =>
1820-
executor.execute<SearchReadCandidate>(
1821-
plan
1822-
? sql`
1823-
SELECT ${PROJECTION_CANDIDATE_COLUMNS}
1824-
FROM ${embeddingSearch} /* on-row visibility */
1825-
WHERE ${and(
1826-
scopeOfWalk,
1827-
projectionCandidateAccessCondition(embeddingSearch, params.access, plan, { filled }),
1828-
documentCondition === undefined
1829-
? undefined
1830-
: sql`EXISTS (SELECT 1 FROM ${document} WHERE ${and(eq(document.id, embeddingSearch.documentId), documentCondition)})`
1831-
)}
1832-
ORDER BY ${candidateDistance} LIMIT ${candidateLimit}
1833-
`
1834-
: sql`
1835-
SELECT ${embeddingSearch.id} AS id, ${embeddingSearch.documentId} AS "documentId",
1836-
visible.connector_id AS "connectorId"
1837-
FROM ${embeddingSearch}
1838-
CROSS JOIN LATERAL (
1839-
SELECT ${document.connectorId} AS connector_id FROM ${document}
1840-
WHERE ${and(eq(document.id, embeddingSearch.documentId), ...candidateDocumentVisibility, candidateTagCondition)}
1841-
LIMIT 1
1842-
) AS visible
1843-
WHERE ${scopeOfWalk}
1844-
ORDER BY ${candidateDistance} LIMIT ${candidateLimit}
1845-
`
1846-
),
1847-
params.budget,
1848-
'vector.candidate_search',
1849-
plan ? onRowWalkScanTuples(documentCondition, filled) : undefined
1850-
)
1881+
selected = await walkGraph()
18511882
/**
18521883
* A full traversal is already the nearest permitted chunks, so nothing else is worth
18531884
* running. An underfilled one is the signal that visibility removed neighbours the graph
@@ -2021,10 +2052,18 @@ export async function executeKeywordSearch(params: KeywordSearchParams): Promise
20212052
* A caller reaching past the permitted-set limit reads much of the index, so ranking every
20222053
* match before checking access is the leg's whole cost for a common term. Where the Tin
20232054
* projection is complete, BM25 ranks inside the bases first and access is checked only on the
2024-
* top of that ranking.
2055+
* top of that ranking. A bounded set past the exact-ranking size is read on the row like an
2056+
* unbounded one: the bounded read materializes every chunk of the set before it matches a
2057+
* term, where a ranking decided on the row costs what the term matches.
20252058
*/
2059+
const accessPlan = access.kind === 'user' ? params.accessPlan : undefined
2060+
const largePermittedSet =
2061+
accessPlan !== undefined &&
2062+
params.permitted?.kind === 'bounded' &&
2063+
params.permitted.documents.length >= PERMITTED_EXACT_DOCUMENT_LIMIT
2064+
const onRowReader = params.permitted?.kind === 'unbounded' || largePermittedSet
20262065
let tinQuery: Awaited<ReturnType<typeof resolveTinKeywordQuery>> = null
2027-
if (params.permitted?.kind === 'unbounded' && tagFilterConditions.length === 0) {
2066+
if (onRowReader && tagFilterConditions.length === 0) {
20282067
try {
20292068
tinQuery = await resolveTinKeywordQuery(
20302069
params.searchIndexOnly === true,
@@ -2038,9 +2077,7 @@ export async function executeKeywordSearch(params: KeywordSearchParams): Promise
20382077
return []
20392078
}
20402079
}
2041-
if (params.permitted?.kind === 'unbounded')
2042-
annotateSearchDiagnostics({ keywordRanking: tinQuery ? 'tin' : 'gin' })
2043-
const accessPlan = access.kind === 'user' ? params.accessPlan : undefined
2080+
if (onRowReader) annotateSearchDiagnostics({ keywordRanking: tinQuery ? 'tin' : 'gin' })
20442081
/** A filled projection decides readability on the ranked row alone; none of its rows needs the document. */
20452082
const tinFilled =
20462083
accessPlan && tinQuery
@@ -2098,8 +2135,7 @@ export async function executeKeywordSearch(params: KeywordSearchParams): Promise
20982135
*/
20992136
const narrow =
21002137
accessPlan !== undefined &&
2101-
params.permitted?.kind === 'unbounded' &&
2102-
!params.permitted.broad
2138+
((params.permitted?.kind === 'unbounded' && !params.permitted.broad) || largePermittedSet)
21032139
const windows: readonly number[] = narrow ? NARROW_KEYWORD_WINDOWS : TIN_KEYWORD_WINDOWS
21042140
/**
21052141
* A narrow reader's page is the readable remainder of a wide ranking, and that ranking is
@@ -2190,7 +2226,7 @@ export async function executeKeywordSearch(params: KeywordSearchParams): Promise
21902226
* still re-applies the candidate predicate, so the restriction can only narrow.
21912227
*/
21922228
const permittedIds =
2193-
params.permitted?.kind === 'bounded'
2229+
params.permitted?.kind === 'bounded' && !largePermittedSet
21942230
? params.permitted.documents.map((entry) => entry.id)
21952231
: undefined
21962232
if (permittedIds?.length === 0) return { candidates: [], nextOffset: offset }

0 commit comments

Comments
 (0)