Skip to content

Commit e91801c

Browse files
committed
improvement(knowledge): rank organization keyword search with Tin
A member who reaches more of an organization search index than an exact ranking can afford searched keywords through the GIN projection, which scores every chunk matching the term before access is checked, so a common word cost seconds. Where the database provides the tin extension, a BM25 projection of search-index chunks is kept by an embedding trigger, and the keyword leg ranks with Tin first and checks access only on the top of that ranking, widening the window while too few ranked chunks are readable and leaving a page to GIN if the widest window cannot fill it. The query is analyzed by the same websearch_to_tsquery as the GIN path and translated to TINQL; shapes TINQL cannot express keep GIN. The path is gated by the knowledge-tin-keyword flag, a valid Tin index (built only after the backfill completes), and every base being a search index. Script migration 0019 installs the projection only where tin is available and creatable, so self-hosted databases keep an empty table and the GIN path.
1 parent f76aca1 commit e91801c

18 files changed

Lines changed: 28941 additions & 7 deletions

‎apps/sim/lib/core/config/env.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,7 @@ export const env = createEnv({
629629
TABLE_ROW_TTL: z.boolean().optional(),
630630
CREDENTIAL_GROUPS: z.boolean().optional(), // Enable enterprise Credential Groups globally
631631
KNOWLEDGE_MEMBER_ACCESS: z.boolean().optional(), // Enable per-member knowledge connectors and hybrid-by-default retrieval globally
632+
KNOWLEDGE_TIN_KEYWORD: z.boolean().optional(), // Rank large-scope keyword retrieval through the Tin text index where it exists
632633

633634
// Organizations - for self-hosted deployments
634635
ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements)

‎apps/sim/lib/core/config/feature-flags.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const { mockFetch, mockIsPlatformAdmin, envRef } = vi.hoisted(() => ({
1515
TABLE_ROW_TTL: undefined as boolean | undefined,
1616
CREDENTIAL_GROUPS: undefined as boolean | undefined,
1717
KNOWLEDGE_MEMBER_ACCESS: undefined as boolean | undefined,
18+
KNOWLEDGE_TIN_KEYWORD: undefined as boolean | undefined,
1819
SLACK_SEARCH_SHARED_APP: undefined as boolean | undefined,
1920
},
2021
}))
@@ -126,6 +127,7 @@ describe('isFeatureEnabled', () => {
126127
setEnvFlags({ isAppConfigEnabled: false })
127128
envRef.CREDENTIAL_GROUPS = undefined
128129
envRef.KNOWLEDGE_MEMBER_ACCESS = undefined
130+
envRef.KNOWLEDGE_TIN_KEYWORD = undefined
129131
envRef.SLACK_SEARCH_SHARED_APP = undefined
130132
})
131133

@@ -162,6 +164,19 @@ describe('isFeatureEnabled', () => {
162164
})
163165
})
164166

167+
describe('knowledge-tin-keyword flag', () => {
168+
it('is a global switch', async () => {
169+
expect(await isFeatureEnabled('knowledge-tin-keyword')).toBe(false)
170+
envRef.KNOWLEDGE_TIN_KEYWORD = true
171+
expect(await isFeatureEnabled('knowledge-tin-keyword')).toBe(true)
172+
})
173+
174+
it('follows an AppConfig global rule', async () => {
175+
withAppConfig({ 'knowledge-tin-keyword': { enabled: true } })
176+
expect(await isFeatureEnabled('knowledge-tin-keyword')).toBe(true)
177+
})
178+
})
179+
165180
describe('knowledge-member-access flag', () => {
166181
it('uses a global fallback switch off AppConfig', async () => {
167182
expect(await isFeatureEnabled('knowledge-member-access')).toBe(false)

‎apps/sim/lib/core/config/feature-flags.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,13 @@ const FEATURE_FLAGS = {
9393
'KNOWLEDGE_MEMBER_ACCESS.',
9494
fallback: 'KNOWLEDGE_MEMBER_ACCESS',
9595
},
96+
'knowledge-tin-keyword': {
97+
description:
98+
'Rank keyword retrieval for members whose permitted set is too large to enumerate through ' +
99+
'the Tin text index instead of GIN. Has no effect where the Tin keyword index is absent or ' +
100+
'invalid. Off-AppConfig falls back to KNOWLEDGE_TIN_KEYWORD.',
101+
fallback: 'KNOWLEDGE_TIN_KEYWORD',
102+
},
96103
} satisfies Record<string, FeatureFlagDefinition>
97104

98105
/**

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export type SearchStage =
5151
| 'vector.exact_candidates'
5252
| 'vector.exact'
5353
| 'vector.candidate_search'
54+
| 'keyword.tin'
5455
| 'source_overview'
5556
| 'source_overview.availability'
5657
| 'source_overview.providers'
@@ -98,6 +99,13 @@ export interface SearchDiagnosticMetadata {
9899
permittedDocuments?: 'bounded' | 'unbounded'
99100
/** Documents in a bounded permitted set. */
100101
permittedDocumentCount?: number
102+
/**
103+
* Which index ranked an unbounded keyword leg: `tin` ranks by BM25 and checks access on the top
104+
* of that ranking; `gin` ranks every match. Absent when the leg ranked inside a bounded set.
105+
*/
106+
keywordRanking?: 'tin' | 'gin'
107+
/** Candidates Tin ranked before access was checked on the last keyword page. */
108+
keywordTinWindow?: number
101109
vectorCandidateCount?: number
102110
vectorCandidateDimensions?: number
103111
resultCount?: number

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

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@ import {
1010
schemaMock,
1111
} from '@sim/testing'
1212
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
13+
14+
const { mockResolveTinKeywordQuery } = vi.hoisted(() => ({
15+
mockResolveTinKeywordQuery: vi.fn<() => Promise<string | null>>(async () => null),
16+
}))
17+
18+
vi.mock('@/lib/knowledge/search/tin-keyword', () => ({
19+
resolveTinKeywordQuery: mockResolveTinKeywordQuery,
20+
}))
21+
1322
import {
1423
type KnowledgeAccessProvider,
1524
type UserAccessScope,
@@ -1489,6 +1498,101 @@ describe('permitted-document planner', () => {
14891498
expect(JSON.stringify(keyword)).toContain('doc-a')
14901499
})
14911500

1501+
describe('Tin keyword ranking for an unbounded caller', () => {
1502+
const unbounded: PermittedDocuments = { kind: 'unbounded' }
1503+
const keyword = (overrides: Partial<Parameters<typeof executeKeywordSearch>[0]> = {}) =>
1504+
executeKeywordSearch({
1505+
...params,
1506+
topK: 1,
1507+
query: 'release',
1508+
queryVector: params.queryVector!,
1509+
permitted: unbounded,
1510+
...overrides,
1511+
})
1512+
const tinStatements = () =>
1513+
statements().filter((query) => query.sql.includes('ranked_tin_chunks'))
1514+
const ginStatements = () =>
1515+
statements().filter((query) => query.sql.includes('WITH matched_keyword_chunks'))
1516+
let tinPages: Array<{ ranked: number; candidates: ReturnType<typeof hit>[] }>
1517+
1518+
beforeEach(() => {
1519+
mockResolveTinKeywordQuery.mockReset()
1520+
mockResolveTinKeywordQuery.mockResolvedValue('"releas"')
1521+
tinPages = []
1522+
dbChainMockFns.execute.mockImplementation(async (query) =>
1523+
render(query).sql.includes('ranked_tin_chunks')
1524+
? [tinPages.shift() ?? { ranked: 0, candidates: [] }]
1525+
: []
1526+
)
1527+
})
1528+
1529+
it('ranks with Tin and checks access only on the top of that ranking', async () => {
1530+
tinPages = [{ ranked: 1500, candidates: [hit('a', null)] }]
1531+
queueTableRows(schemaMock.embedding, [{ ...hit('a', null), content: 'release notes' }])
1532+
const results = await keyword()
1533+
expect(results.map((row) => row.id)).toEqual(['a'])
1534+
expect(mockResolveTinKeywordQuery).toHaveBeenCalledWith(['org-index'], 'release', 'english')
1535+
expect(ginStatements()).toHaveLength(0)
1536+
expect(JSON.stringify(tinStatements()[0])).toContain('2000')
1537+
/** `==>` binds tighter than `||`, so the concatenated query must be parenthesized. */
1538+
expect(tinStatements()[0].sql).toContain('==> (?)')
1539+
})
1540+
1541+
it('widens the ranked window while too few ranked chunks are readable', async () => {
1542+
tinPages = [
1543+
{ ranked: 2000, candidates: [] },
1544+
{ ranked: 4000, candidates: [hit('b', null)] },
1545+
]
1546+
queueTableRows(schemaMock.embedding, [{ ...hit('b', null), content: 'release notes' }])
1547+
expect((await keyword()).map((row) => row.id)).toEqual(['b'])
1548+
const windows = tinStatements().map((query) => JSON.stringify(query))
1549+
expect(windows[0]).toContain('2000')
1550+
expect(windows[1]).toContain('10000')
1551+
expect(ginStatements()).toHaveLength(0)
1552+
})
1553+
1554+
it('stops widening once Tin ranked every match', async () => {
1555+
tinPages = [{ ranked: 12, candidates: [] }]
1556+
expect(await keyword()).toEqual([])
1557+
expect(tinStatements()).toHaveLength(1)
1558+
expect(ginStatements()).toHaveLength(0)
1559+
})
1560+
1561+
it('leaves the page to the GIN ranking when the widest window cannot fill it', async () => {
1562+
tinPages = [
1563+
{ ranked: 2000, candidates: [] },
1564+
{ ranked: 10_000, candidates: [] },
1565+
{ ranked: 50_000, candidates: [] },
1566+
]
1567+
await keyword()
1568+
expect(tinStatements()).toHaveLength(3)
1569+
expect(ginStatements()).toHaveLength(1)
1570+
})
1571+
1572+
it.each([
1573+
['a bounded permitted set', { permitted: bounded({ id: 'doc-a', connectorId: null }) }],
1574+
[
1575+
'structured tag filters',
1576+
{
1577+
structuredFilters: [
1578+
{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'x' },
1579+
] as StructuredFilter[],
1580+
},
1581+
],
1582+
])('keeps GIN ranking for %s', async (_case, overrides) => {
1583+
await keyword(overrides)
1584+
expect(mockResolveTinKeywordQuery).not.toHaveBeenCalled()
1585+
expect(tinStatements()).toHaveLength(0)
1586+
})
1587+
1588+
it('keeps GIN ranking when Tin is not ready or cannot express the query', async () => {
1589+
mockResolveTinKeywordQuery.mockResolvedValue(null)
1590+
await keyword()
1591+
expect(tinStatements()).toHaveLength(0)
1592+
expect(ginStatements()).toHaveLength(1)
1593+
})
1594+
})
1595+
14921596
it('skips keyword SQL entirely when nothing is permitted', async () => {
14931597
expect(
14941598
await executeKeywordSearch({

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

Lines changed: 105 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
document,
44
embedding,
55
embeddingKeywordSearch,
6+
embeddingKeywordTin,
67
embeddingSearch,
78
knowledgeConnector,
89
} from '@sim/db/schema'
@@ -33,6 +34,7 @@ import {
3334
import { workspaceSearchFilterConditions } from '@/lib/knowledge/search/filter-conditions'
3435
import type { WorkspaceSearchFilters } from '@/lib/knowledge/search/filters'
3536
import { applyRecencyBoost, RRF_K } from '@/lib/knowledge/search/recency'
37+
import { resolveTinKeywordQuery } from '@/lib/knowledge/search/tin-keyword'
3638
import {
3739
coerceTagFilterValue,
3840
escapeLikePattern,
@@ -489,6 +491,13 @@ export function getStructuredTagFilters(filters: StructuredFilter[], embeddingTa
489491
*/
490492
const FTS_CONFIG = 'english'
491493

494+
/**
495+
* Chunks Tin ranks before access is checked, widening while too few are readable to fill a page.
496+
* A caller past the permitted-set limit reads a large share of the index, so the first window
497+
* almost always fills; the widest bounds the work before the GIN ranking takes over.
498+
*/
499+
const TIN_KEYWORD_WINDOWS = [2000, 10_000, 50_000] as const
500+
492501
/**
493502
* Row visibility predicates shared by every search leg: a chunk is only
494503
* retrievable when both it and its document are enabled, the document finished
@@ -1310,6 +1319,96 @@ export async function executeKeywordSearch(params: KeywordSearchParams): Promise
13101319
...tagFilterConditions,
13111320
]
13121321
const candidateRank = sql<number>`ts_rank_cd(${embeddingKeywordSearch.contentTsv}, ${tsQuery})`
1322+
/**
1323+
* A caller reaching past the permitted-set limit reads much of the index, so ranking every
1324+
* match before checking access is the leg's whole cost for a common term. Where the Tin
1325+
* projection is complete, BM25 ranks inside the bases first and access is checked only on the
1326+
* top of that ranking.
1327+
*/
1328+
const tinQuery =
1329+
params.permitted?.kind === 'unbounded' && tagFilterConditions.length === 0
1330+
? await resolveTinKeywordQuery(knowledgeBaseIds, query, FTS_CONFIG)
1331+
: null
1332+
annotateSearchDiagnostics({
1333+
...(params.permitted?.kind === 'unbounded'
1334+
? { keywordRanking: tinQuery ? 'tin' : 'gin' }
1335+
: {}),
1336+
})
1337+
const documentConditions = (excludedSources: readonly string[]) =>
1338+
and(
1339+
...candidateDocumentConditions(
1340+
knowledgeBaseIds,
1341+
access,
1342+
params.filters,
1343+
knowledgeMetadataCandidateAccessCondition(access)
1344+
),
1345+
excludeSearchSources(excludedSources)
1346+
)
1347+
/**
1348+
* One page from the top of Tin's ranking. The window of ranked chunks widens while too few of
1349+
* them are readable to fill the page; if the widest window still cannot, the page is left to
1350+
* the GIN ranking, which covers every match.
1351+
*/
1352+
const selectTinPage = async (
1353+
scopedQuery: SQL,
1354+
limit: number,
1355+
offset: number,
1356+
excludedSources: readonly string[]
1357+
): Promise<SearchReadCandidatePage | null> => {
1358+
for (const window of TIN_KEYWORD_WINDOWS) {
1359+
if (window < offset + limit) continue
1360+
const [page] = await runSearchQuery(params.budget, 'keyword.tin', (executor) =>
1361+
executor.execute<{ ranked: number; candidates: SearchReadCandidate[] }>(sql`
1362+
WITH ranked_tin_chunks AS MATERIALIZED (
1363+
SELECT ${embeddingKeywordTin.id} AS id, ${embeddingKeywordTin.documentId} AS document_id,
1364+
${embeddingKeywordTin.enabled} AS enabled,
1365+
tin.full_score(${embeddingKeywordTin}.ctid) AS keyword_rank
1366+
FROM ${embeddingKeywordTin}
1367+
WHERE ${embeddingKeywordTin.content} ==> (${scopedQuery})
1368+
ORDER BY keyword_rank DESC
1369+
LIMIT ${window}
1370+
), visible_keyword_documents AS MATERIALIZED (
1371+
SELECT ${document.id} AS id FROM ${document}
1372+
WHERE ${and(
1373+
sql`${document.id} = ANY (ARRAY(SELECT document_id FROM ranked_tin_chunks))`,
1374+
documentConditions(excludedSources)
1375+
)}
1376+
), page AS (
1377+
SELECT ranked_tin_chunks.id, ${document.id} AS "documentId",
1378+
${document.connectorId} AS "connectorId",
1379+
${SEARCH_READ_CANDIDATE_FIELDS.liveAuthorizationSource} AS "liveAuthorizationSource",
1380+
ranked_tin_chunks.keyword_rank
1381+
FROM ranked_tin_chunks INNER JOIN ${document}
1382+
ON ${document.id} = ranked_tin_chunks.document_id
1383+
WHERE ranked_tin_chunks.enabled
1384+
AND ranked_tin_chunks.document_id IN (SELECT id FROM visible_keyword_documents)
1385+
ORDER BY ranked_tin_chunks.keyword_rank DESC, ranked_tin_chunks.id
1386+
LIMIT ${limit} OFFSET ${offset}
1387+
)
1388+
SELECT (SELECT count(*)::int FROM ranked_tin_chunks) AS ranked,
1389+
coalesce((
1390+
SELECT json_agg(json_build_object(
1391+
'id', page.id, 'documentId', page."documentId", 'connectorId', page."connectorId",
1392+
'liveAuthorizationSource', page."liveAuthorizationSource"
1393+
) ORDER BY page.keyword_rank DESC, page.id)
1394+
FROM page
1395+
), '[]'::json) AS candidates
1396+
`)
1397+
)
1398+
annotateSearchDiagnostics({ keywordTinWindow: window })
1399+
if (page.candidates.length === limit || page.ranked < window) {
1400+
return { candidates: page.candidates, nextOffset: offset + page.candidates.length }
1401+
}
1402+
}
1403+
return null
1404+
}
1405+
/** Parenthesized where used: `==>` binds tighter than `||`. */
1406+
const tinScope = tinQuery
1407+
? sql`'(' || ${sql.join(
1408+
knowledgeBaseIds.map((id) => sql`knowledge_tin_base_token(${id}) || '^0'`),
1409+
sql` || ' OR ' || `
1410+
)} || ') AND (' || ${tinQuery} || ')'`
1411+
: undefined
13131412
/** Keep readable identities and rank scalars separate so sorts never carry full text-search vectors. */
13141413
return selectAuthorizedSearchResults({
13151414
leg: 'keyword',
@@ -1330,6 +1429,11 @@ export async function executeKeywordSearch(params: KeywordSearchParams): Promise
13301429
? permittedDocumentIds(params.permitted.documents, excludedSources)
13311430
: undefined
13321431
if (permittedIds?.length === 0) return { candidates: [], nextOffset: offset }
1432+
if (tinScope && !permittedIds) {
1433+
const tinPage = await selectTinPage(tinScope, limit, offset, excludedSources)
1434+
if (tinPage) return tinPage
1435+
annotateSearchDiagnostics({ keywordRanking: 'gin' })
1436+
}
13331437
const baseScope = and(
13341438
inArray(embeddingKeywordSearch.knowledgeBaseId, knowledgeBaseIds),
13351439
eq(embeddingKeywordSearch.enabled, true)
@@ -1373,13 +1477,7 @@ export async function executeKeywordSearch(params: KeywordSearchParams): Promise
13731477
SELECT ${document.id} AS id FROM ${document}
13741478
WHERE ${and(
13751479
sql`${document.id} = ANY (ARRAY(SELECT document_id FROM matched_keyword_chunks))`,
1376-
...candidateDocumentConditions(
1377-
knowledgeBaseIds,
1378-
access,
1379-
params.filters,
1380-
knowledgeMetadataCandidateAccessCondition(access)
1381-
),
1382-
excludeSearchSources(excludedSources)
1480+
documentConditions(excludedSources)
13831481
)}
13841482
), ranked_keyword_candidates AS MATERIALIZED (
13851483
SELECT matched_keyword_chunks.id, matched_keyword_chunks.document_id,
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing'
5+
import { expect, it, vi } from 'vitest'
6+
7+
vi.mock('@/lib/core/config/feature-flags', () => ({
8+
isFeatureEnabled: vi.fn(async () => true),
9+
}))
10+
11+
import { resolveTinKeywordQuery } from '@/lib/knowledge/search/tin-keyword'
12+
13+
/** Its own file, so the process-wide readiness cache starts empty. */
14+
it('stays on the GIN projection while the Tin index is incomplete, and remembers that', async () => {
15+
resetDbChainMock()
16+
let indexValid = false
17+
dbChainMockFns.execute.mockImplementation(async (query) => {
18+
const text = JSON.stringify(query)
19+
if (text.includes('indisvalid')) return [{ valid: indexValid }]
20+
if (text.includes('websearch_to_tsquery')) return [{ rendered: "'releas'" }]
21+
return []
22+
})
23+
queueTableRows(schemaMock.knowledgeBase, [{ id: 'kb-index', isSearchIndex: true }])
24+
expect(await resolveTinKeywordQuery(['kb-index'], 'release', 'english')).toBeNull()
25+
indexValid = true
26+
expect(await resolveTinKeywordQuery(['kb-index'], 'release', 'english')).toBeNull()
27+
const readinessReads = dbChainMockFns.execute.mock.calls.filter(([query]) =>
28+
JSON.stringify(query).includes('indisvalid')
29+
)
30+
expect(readinessReads).toHaveLength(1)
31+
})

0 commit comments

Comments
 (0)