Skip to content

Commit eaa9ff0

Browse files
authored
improvement(knowledge): rank organization keyword search with Tin (#8007)
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 embedding and knowledge-base triggers, 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 6892dd7 commit eaa9ff0

24 files changed

Lines changed: 29319 additions & 18 deletions

‎.github/workflows/test-build.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ jobs:
241241
bunx vitest run
242242
script-migrations/0016_backfill_search_vectors.postgres.test.ts
243243
script-migrations/0018_repair_workspace_file_content_revision.postgres.test.ts
244+
script-migrations/0019_tin_keyword_projection.postgres.test.ts
244245
245246
- name: Verify Search progress, pagination, and outbox scheduling in PostgreSQL
246247
working-directory: apps/sim

‎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: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ export type SearchStage =
5151
| 'vector.exact_candidates'
5252
| 'vector.exact'
5353
| 'vector.candidate_search'
54+
| 'keyword.tin'
55+
| 'keyword.tin_readiness'
56+
| 'keyword.tin_query'
5457
| 'source_overview'
5558
| 'source_overview.availability'
5659
| 'source_overview.providers'
@@ -98,6 +101,13 @@ export interface SearchDiagnosticMetadata {
98101
permittedDocuments?: 'bounded' | 'unbounded'
99102
/** Documents in a bounded permitted set. */
100103
permittedDocumentCount?: number
104+
/**
105+
* Which index ranked an unbounded keyword leg: `tin` ranks by BM25 and checks access on the top
106+
* of that ranking; `gin` ranks every match. Absent when the leg ranked inside a bounded set.
107+
*/
108+
keywordRanking?: 'tin' | 'gin'
109+
/** Candidates Tin ranked before access was checked on the last keyword page. */
110+
keywordTinWindow?: number
101111
vectorCandidateCount?: number
102112
vectorCandidateDimensions?: number
103113
resultCount?: number

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

Lines changed: 109 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,106 @@ 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(
1535+
['org-index'],
1536+
'release',
1537+
'english',
1538+
params.budget
1539+
)
1540+
expect(ginStatements()).toHaveLength(0)
1541+
expect(JSON.stringify(tinStatements()[0])).toContain('2000')
1542+
/** `==>` binds tighter than `||`, so the concatenated query must be parenthesized. */
1543+
expect(tinStatements()[0].sql).toContain('==> (?)')
1544+
})
1545+
1546+
it('widens the ranked window while too few ranked chunks are readable', async () => {
1547+
tinPages = [
1548+
{ ranked: 2000, candidates: [] },
1549+
{ ranked: 4000, candidates: [hit('b', null)] },
1550+
]
1551+
queueTableRows(schemaMock.embedding, [{ ...hit('b', null), content: 'release notes' }])
1552+
expect((await keyword()).map((row) => row.id)).toEqual(['b'])
1553+
const windows = tinStatements().map((query) => JSON.stringify(query))
1554+
expect(windows[0]).toContain('2000')
1555+
expect(windows[1]).toContain('10000')
1556+
expect(ginStatements()).toHaveLength(0)
1557+
})
1558+
1559+
it('stops widening once Tin ranked every match', async () => {
1560+
tinPages = [{ ranked: 12, candidates: [] }]
1561+
expect(await keyword()).toEqual([])
1562+
expect(tinStatements()).toHaveLength(1)
1563+
expect(ginStatements()).toHaveLength(0)
1564+
})
1565+
1566+
it('leaves the page to the GIN ranking when the widest window cannot fill it', async () => {
1567+
tinPages = [
1568+
{ ranked: 2000, candidates: [] },
1569+
{ ranked: 10_000, candidates: [] },
1570+
{ ranked: 50_000, candidates: [] },
1571+
]
1572+
await keyword()
1573+
expect(tinStatements()).toHaveLength(3)
1574+
expect(ginStatements()).toHaveLength(1)
1575+
})
1576+
1577+
it.each([
1578+
['a bounded permitted set', { permitted: bounded({ id: 'doc-a', connectorId: null }) }],
1579+
[
1580+
'structured tag filters',
1581+
{
1582+
structuredFilters: [
1583+
{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'x' },
1584+
] as StructuredFilter[],
1585+
},
1586+
],
1587+
])('keeps GIN ranking for %s', async (_case, overrides) => {
1588+
await keyword(overrides)
1589+
expect(mockResolveTinKeywordQuery).not.toHaveBeenCalled()
1590+
expect(tinStatements()).toHaveLength(0)
1591+
})
1592+
1593+
it('keeps GIN ranking when Tin is not ready or cannot express the query', async () => {
1594+
mockResolveTinKeywordQuery.mockResolvedValue(null)
1595+
await keyword()
1596+
expect(tinStatements()).toHaveLength(0)
1597+
expect(ginStatements()).toHaveLength(1)
1598+
})
1599+
})
1600+
14921601
it('skips keyword SQL entirely when nothing is permitted', async () => {
14931602
expect(
14941603
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'
@@ -35,6 +36,7 @@ import {
3536
import { workspaceSearchFilterConditions } from '@/lib/knowledge/search/filter-conditions'
3637
import type { WorkspaceSearchFilters } from '@/lib/knowledge/search/filters'
3738
import { applyRecencyBoost, RRF_K } from '@/lib/knowledge/search/recency'
39+
import { resolveTinKeywordQuery } from '@/lib/knowledge/search/tin-keyword'
3840
import {
3941
coerceTagFilterValue,
4042
escapeLikePattern,
@@ -491,6 +493,13 @@ export function getStructuredTagFilters(filters: StructuredFilter[], embeddingTa
491493
*/
492494
const FTS_CONFIG = 'english'
493495

496+
/**
497+
* Chunks Tin ranks before access is checked, widening while too few are readable to fill a page.
498+
* A caller past the permitted-set limit reads a large share of the index, so the first window
499+
* almost always fills; the widest bounds the work before the GIN ranking takes over.
500+
*/
501+
const TIN_KEYWORD_WINDOWS = [2000, 10_000, 50_000] as const
502+
494503
/**
495504
* Row visibility predicates shared by every search leg: a chunk is only
496505
* retrievable when both it and its document are enabled, the document finished
@@ -1348,6 +1357,96 @@ export async function executeKeywordSearch(params: KeywordSearchParams): Promise
13481357
...tagFilterConditions,
13491358
]
13501359
const candidateRank = sql<number>`ts_rank_cd(${embeddingKeywordSearch.contentTsv}, ${tsQuery})`
1360+
/**
1361+
* A caller reaching past the permitted-set limit reads much of the index, so ranking every
1362+
* match before checking access is the leg's whole cost for a common term. Where the Tin
1363+
* projection is complete, BM25 ranks inside the bases first and access is checked only on the
1364+
* top of that ranking.
1365+
*/
1366+
const tinQuery =
1367+
params.permitted?.kind === 'unbounded' && tagFilterConditions.length === 0
1368+
? await resolveTinKeywordQuery(knowledgeBaseIds, query, FTS_CONFIG, params.budget)
1369+
: null
1370+
annotateSearchDiagnostics({
1371+
...(params.permitted?.kind === 'unbounded'
1372+
? { keywordRanking: tinQuery ? 'tin' : 'gin' }
1373+
: {}),
1374+
})
1375+
const documentConditions = (excludedSources: readonly string[]) =>
1376+
and(
1377+
...candidateDocumentConditions(
1378+
knowledgeBaseIds,
1379+
access,
1380+
params.filters,
1381+
knowledgeMetadataCandidateAccessCondition(access)
1382+
),
1383+
excludeSearchSources(excludedSources)
1384+
)
1385+
/**
1386+
* One page from the top of Tin's ranking. The window of ranked chunks widens while too few of
1387+
* them are readable to fill the page; if the widest window still cannot, the page is left to
1388+
* the GIN ranking, which covers every match.
1389+
*/
1390+
const selectTinPage = async (
1391+
scopedQuery: SQL,
1392+
limit: number,
1393+
offset: number,
1394+
excludedSources: readonly string[]
1395+
): Promise<SearchReadCandidatePage | null> => {
1396+
for (const window of TIN_KEYWORD_WINDOWS) {
1397+
if (window < offset + limit) continue
1398+
const [page] = await runSearchQuery(params.budget, 'keyword.tin', (executor) =>
1399+
executor.execute<{ ranked: number; candidates: SearchReadCandidate[] }>(sql`
1400+
WITH ranked_tin_chunks AS MATERIALIZED (
1401+
SELECT ${embeddingKeywordTin.id} AS id, ${embeddingKeywordTin.documentId} AS document_id,
1402+
${embeddingKeywordTin.enabled} AS enabled,
1403+
tin.full_score(${embeddingKeywordTin}.ctid) AS keyword_rank
1404+
FROM ${embeddingKeywordTin}
1405+
WHERE ${embeddingKeywordTin.content} ==> (${scopedQuery})
1406+
ORDER BY keyword_rank DESC
1407+
LIMIT ${window}
1408+
), visible_keyword_documents AS MATERIALIZED (
1409+
SELECT ${document.id} AS id FROM ${document}
1410+
WHERE ${and(
1411+
sql`${document.id} = ANY (ARRAY(SELECT document_id FROM ranked_tin_chunks))`,
1412+
documentConditions(excludedSources)
1413+
)}
1414+
), page AS (
1415+
SELECT ranked_tin_chunks.id, ${document.id} AS "documentId",
1416+
${document.connectorId} AS "connectorId",
1417+
${SEARCH_READ_CANDIDATE_FIELDS.liveAuthorizationSource} AS "liveAuthorizationSource",
1418+
ranked_tin_chunks.keyword_rank
1419+
FROM ranked_tin_chunks INNER JOIN ${document}
1420+
ON ${document.id} = ranked_tin_chunks.document_id
1421+
WHERE ranked_tin_chunks.enabled
1422+
AND ranked_tin_chunks.document_id IN (SELECT id FROM visible_keyword_documents)
1423+
ORDER BY ranked_tin_chunks.keyword_rank DESC, ranked_tin_chunks.id
1424+
LIMIT ${limit} OFFSET ${offset}
1425+
)
1426+
SELECT (SELECT count(*)::int FROM ranked_tin_chunks) AS ranked,
1427+
coalesce((
1428+
SELECT json_agg(json_build_object(
1429+
'id', page.id, 'documentId', page."documentId", 'connectorId', page."connectorId",
1430+
'liveAuthorizationSource', page."liveAuthorizationSource"
1431+
) ORDER BY page.keyword_rank DESC, page.id)
1432+
FROM page
1433+
), '[]'::json) AS candidates
1434+
`)
1435+
)
1436+
annotateSearchDiagnostics({ keywordTinWindow: window })
1437+
if (page.candidates.length === limit || page.ranked < window) {
1438+
return { candidates: page.candidates, nextOffset: offset + page.candidates.length }
1439+
}
1440+
}
1441+
return null
1442+
}
1443+
/** Parenthesized where used: `==>` binds tighter than `||`. */
1444+
const tinScope = tinQuery
1445+
? sql`'(' || ${sql.join(
1446+
knowledgeBaseIds.map((id) => sql`knowledge_tin_base_token(${id}) || '^0'`),
1447+
sql` || ' OR ' || `
1448+
)} || ') AND (' || ${tinQuery} || ')'`
1449+
: undefined
13511450
/** Keep readable identities and rank scalars separate so sorts never carry full text-search vectors. */
13521451
return selectAuthorizedSearchResults({
13531452
leg: 'keyword',
@@ -1368,6 +1467,11 @@ export async function executeKeywordSearch(params: KeywordSearchParams): Promise
13681467
? permittedDocumentIds(params.permitted.documents, excludedSources)
13691468
: undefined
13701469
if (permittedIds?.length === 0) return { candidates: [], nextOffset: offset }
1470+
if (tinScope && !permittedIds) {
1471+
const tinPage = await selectTinPage(tinScope, limit, offset, excludedSources)
1472+
if (tinPage) return tinPage
1473+
annotateSearchDiagnostics({ keywordRanking: 'gin' })
1474+
}
13711475
const baseScope = and(
13721476
inArray(embeddingKeywordSearch.knowledgeBaseId, knowledgeBaseIds),
13731477
eq(embeddingKeywordSearch.enabled, true)
@@ -1411,13 +1515,7 @@ export async function executeKeywordSearch(params: KeywordSearchParams): Promise
14111515
SELECT ${document.id} AS id FROM ${document}
14121516
WHERE ${and(
14131517
sql`${document.id} = ANY (ARRAY(SELECT document_id FROM matched_keyword_chunks))`,
1414-
...candidateDocumentConditions(
1415-
knowledgeBaseIds,
1416-
access,
1417-
params.filters,
1418-
knowledgeMetadataCandidateAccessCondition(access)
1419-
),
1420-
excludeSearchSources(excludedSources)
1518+
documentConditions(excludedSources)
14211519
)}
14221520
), ranked_keyword_candidates AS MATERIALIZED (
14231521
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', undefined)).toBeNull()
25+
indexValid = true
26+
expect(await resolveTinKeywordQuery(['kb-index'], 'release', 'english', undefined)).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)