Skip to content

Commit 48d9d83

Browse files
committed
fix(knowledge): keep the Tin projection in step with search-index adoption
- Project or remove a whole base when is_search_index changes, serialized with embedding writers on a per-base advisory lock so neither misses the other's rows - Run Tin readiness and query analysis under the keyword leg's budget - Rebuild the Tin projection after db:push, which drops the schema-external index - Register 0019 in the script-migration registry tests
1 parent e91801c commit 48d9d83

14 files changed

Lines changed: 325 additions & 45 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/knowledge/search/diagnostics.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ export type SearchStage =
5252
| 'vector.exact'
5353
| 'vector.candidate_search'
5454
| 'keyword.tin'
55+
| 'keyword.tin_readiness'
56+
| 'keyword.tin_query'
5557
| 'source_overview'
5658
| 'source_overview.availability'
5759
| 'source_overview.providers'

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1531,7 +1531,12 @@ describe('permitted-document planner', () => {
15311531
queueTableRows(schemaMock.embedding, [{ ...hit('a', null), content: 'release notes' }])
15321532
const results = await keyword()
15331533
expect(results.map((row) => row.id)).toEqual(['a'])
1534-
expect(mockResolveTinKeywordQuery).toHaveBeenCalledWith(['org-index'], 'release', 'english')
1534+
expect(mockResolveTinKeywordQuery).toHaveBeenCalledWith(
1535+
['org-index'],
1536+
'release',
1537+
'english',
1538+
params.budget
1539+
)
15351540
expect(ginStatements()).toHaveLength(0)
15361541
expect(JSON.stringify(tinStatements()[0])).toContain('2000')
15371542
/** `==>` binds tighter than `||`, so the concatenated query must be parenthesized. */

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1327,7 +1327,7 @@ export async function executeKeywordSearch(params: KeywordSearchParams): Promise
13271327
*/
13281328
const tinQuery =
13291329
params.permitted?.kind === 'unbounded' && tagFilterConditions.length === 0
1330-
? await resolveTinKeywordQuery(knowledgeBaseIds, query, FTS_CONFIG)
1330+
? await resolveTinKeywordQuery(knowledgeBaseIds, query, FTS_CONFIG, params.budget)
13311331
: null
13321332
annotateSearchDiagnostics({
13331333
...(params.permitted?.kind === 'unbounded'

‎apps/sim/lib/knowledge/search/tin-keyword-readiness.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ it('stays on the GIN projection while the Tin index is incomplete, and remembers
2121
return []
2222
})
2323
queueTableRows(schemaMock.knowledgeBase, [{ id: 'kb-index', isSearchIndex: true }])
24-
expect(await resolveTinKeywordQuery(['kb-index'], 'release', 'english')).toBeNull()
24+
expect(await resolveTinKeywordQuery(['kb-index'], 'release', 'english', undefined)).toBeNull()
2525
indexValid = true
26-
expect(await resolveTinKeywordQuery(['kb-index'], 'release', 'english')).toBeNull()
26+
expect(await resolveTinKeywordQuery(['kb-index'], 'release', 'english', undefined)).toBeNull()
2727
const readinessReads = dbChainMockFns.execute.mock.calls.filter(([query]) =>
2828
JSON.stringify(query).includes('indisvalid')
2929
)

‎apps/sim/lib/knowledge/search/tin-keyword.test.ts‎

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ vi.mock('@/lib/core/config/feature-flags', () => ({
1212
isFeatureEnabled: mockIsFeatureEnabled,
1313
}))
1414

15+
import { SearchBudget, SearchDeadlineError } from '@/lib/knowledge/search/budget'
1516
import { resolveTinKeywordQuery } from '@/lib/knowledge/search/tin-keyword'
1617

1718
/**
@@ -37,13 +38,15 @@ describe('resolveTinKeywordQuery', () => {
3738

3839
it('is off while the rollout flag is off, without touching the database', async () => {
3940
mockIsFeatureEnabled.mockResolvedValue(false)
40-
expect(await resolveTinKeywordQuery(['kb-flag-off'], 'release notes', 'english')).toBeNull()
41+
expect(
42+
await resolveTinKeywordQuery(['kb-flag-off'], 'release notes', 'english', undefined)
43+
).toBeNull()
4144
expect(dbChainMockFns.execute).not.toHaveBeenCalled()
4245
})
4346

4447
it('translates the analyzed query once every base is a search index', async () => {
4548
queueTableRows(schemaMock.knowledgeBase, [{ id: 'kb-index', isSearchIndex: true }])
46-
expect(await resolveTinKeywordQuery(['kb-index'], 'release notes', 'english')).toBe(
49+
expect(await resolveTinKeywordQuery(['kb-index'], 'release notes', 'english', undefined)).toBe(
4750
'("releas" AND "note")'
4851
)
4952
})
@@ -54,18 +57,34 @@ describe('resolveTinKeywordQuery', () => {
5457
{ id: 'kb-plain', isSearchIndex: false },
5558
])
5659
expect(
57-
await resolveTinKeywordQuery(['kb-index-2', 'kb-plain'], 'release notes', 'english')
60+
await resolveTinKeywordQuery(
61+
['kb-index-2', 'kb-plain'],
62+
'release notes',
63+
'english',
64+
undefined
65+
)
5866
).toBeNull()
5967
})
6068

6169
it('is off for a query Tin cannot express', async () => {
6270
queueTableRows(schemaMock.knowledgeBase, [{ id: 'kb-negation', isSearchIndex: true }])
6371
rendered = "!'draft'"
64-
expect(await resolveTinKeywordQuery(['kb-negation'], '-draft', 'english')).toBeNull()
72+
expect(await resolveTinKeywordQuery(['kb-negation'], '-draft', 'english', undefined)).toBeNull()
73+
})
74+
75+
it('reads under the keyword budget, so an expired deadline ends the leg instead of querying', async () => {
76+
const expired = new SearchBudget('keyword', performance.now() - 1)
77+
await expect(
78+
resolveTinKeywordQuery(['kb-expired'], 'release notes', 'english', expired)
79+
).rejects.toBeInstanceOf(SearchDeadlineError)
80+
expect(dbChainMockFns.select).not.toHaveBeenCalled()
81+
expect(dbChainMockFns.execute).not.toHaveBeenCalled()
6582
})
6683

6784
it('falls back to GIN instead of failing the search when readiness cannot be read', async () => {
6885
mockIsFeatureEnabled.mockRejectedValue(new Error('config unavailable'))
69-
expect(await resolveTinKeywordQuery(['kb-error'], 'release notes', 'english')).toBeNull()
86+
expect(
87+
await resolveTinKeywordQuery(['kb-error'], 'release notes', 'english', undefined)
88+
).toBeNull()
7089
})
7190
})

‎apps/sim/lib/knowledge/search/tin-keyword.ts‎

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import { db } from '@sim/db'
21
import { EMBEDDING_KEYWORD_TIN_INDEX, knowledgeBase } from '@sim/db/schema'
32
import { createLogger } from '@sim/logger'
43
import { getErrorMessage } from '@sim/utils/errors'
54
import { inArray, sql } from 'drizzle-orm'
65
import { LRUCache } from 'lru-cache'
76
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
7+
import { runSearchQuery, type SearchBudget } from '@/lib/knowledge/search/budget'
88
import { tinQueryFromTsquery } from '@/lib/knowledge/search/tin-query'
99

1010
const logger = createLogger('TinKeywordSearch')
@@ -16,14 +16,19 @@ const logger = createLogger('TinKeywordSearch')
1616
const READINESS_TTL_MS = 60 * 1000
1717
const SEARCH_INDEX_TTL_MS = 10 * 60 * 1000
1818

19-
/** Whether the Tin index exists and finished building, i.e. the projection is complete. */
20-
const indexReadiness = new LRUCache<'index', boolean>({
19+
/**
20+
* Whether the Tin index exists and finished building, i.e. the projection is complete. The read
21+
* spends the budget of the search that missed the cache.
22+
*/
23+
const indexReadiness = new LRUCache<'index', boolean, SearchBudget | undefined>({
2124
max: 1,
2225
ttl: READINESS_TTL_MS,
23-
fetchMethod: async () => {
24-
const [row] = await db.execute<{ valid: boolean }>(sql`
25-
SELECT i.indisvalid AS valid FROM pg_index i
26-
WHERE i.indexrelid = to_regclass(${EMBEDDING_KEYWORD_TIN_INDEX})`)
26+
fetchMethod: async (_key, _stale, { context }) => {
27+
const [row] = await runSearchQuery(context, 'keyword.tin_readiness', (executor) =>
28+
executor.execute<{ valid: boolean }>(sql`
29+
SELECT i.indisvalid AS valid FROM pg_index i
30+
WHERE i.indexrelid = to_regclass(${EMBEDDING_KEYWORD_TIN_INDEX})`)
31+
)
2732
return row?.valid === true
2833
},
2934
})
@@ -34,13 +39,18 @@ const searchIndexBases = new LRUCache<string, boolean>({
3439
ttl: SEARCH_INDEX_TTL_MS,
3540
})
3641

37-
async function allSearchIndexes(knowledgeBaseIds: readonly string[]): Promise<boolean> {
42+
async function allSearchIndexes(
43+
knowledgeBaseIds: readonly string[],
44+
budget: SearchBudget | undefined
45+
): Promise<boolean> {
3846
const unknown = knowledgeBaseIds.filter((id) => searchIndexBases.get(id) === undefined)
3947
if (unknown.length > 0) {
40-
const rows = await db
41-
.select({ id: knowledgeBase.id, isSearchIndex: knowledgeBase.isSearchIndex })
42-
.from(knowledgeBase)
43-
.where(inArray(knowledgeBase.id, unknown))
48+
const rows = await runSearchQuery(budget, 'keyword.tin_readiness', (executor) =>
49+
executor
50+
.select({ id: knowledgeBase.id, isSearchIndex: knowledgeBase.isSearchIndex })
51+
.from(knowledgeBase)
52+
.where(inArray(knowledgeBase.id, unknown))
53+
)
4454
for (const row of rows) searchIndexBases.set(row.id, row.isSearchIndex)
4555
}
4656
return knowledgeBaseIds.every((id) => searchIndexBases.get(id) === true)
@@ -52,22 +62,31 @@ async function allSearchIndexes(knowledgeBaseIds: readonly string[]): Promise<bo
5262
* base is not an organization search index (only those are projected), or the query uses a shape
5363
* TINQL cannot express. The text is analyzed by the same `websearch_to_tsquery` the GIN path
5464
* uses, so both engines match the same stemmed terms.
65+
*
66+
* Every read runs under the keyword leg's `budget`, so deciding the engine cannot outlast the leg's
67+
* deadline. A read that fails for another reason, including a shared cache read cut short by
68+
* another search's deadline, keeps the GIN projection; this search's own expired deadline or
69+
* cancellation propagates like any other keyword query's.
5570
*/
5671
export async function resolveTinKeywordQuery(
5772
knowledgeBaseIds: readonly string[],
5873
query: string,
59-
ftsConfig: string
74+
ftsConfig: string,
75+
budget: SearchBudget | undefined
6076
): Promise<string | null> {
6177
if (knowledgeBaseIds.length === 0) return null
6278
try {
6379
if (!(await isFeatureEnabled('knowledge-tin-keyword'))) return null
64-
if (!(await indexReadiness.fetch('index'))) return null
65-
if (!(await allSearchIndexes(knowledgeBaseIds))) return null
66-
const [{ rendered }] = await db.execute<{ rendered: string }>(
67-
sql`SELECT websearch_to_tsquery(${ftsConfig}::regconfig, ${query})::text AS rendered`
80+
if (!(await indexReadiness.fetch('index', { context: budget }))) return null
81+
if (!(await allSearchIndexes(knowledgeBaseIds, budget))) return null
82+
const [{ rendered }] = await runSearchQuery(budget, 'keyword.tin_query', (executor) =>
83+
executor.execute<{ rendered: string }>(
84+
sql`SELECT websearch_to_tsquery(${ftsConfig}::regconfig, ${query})::text AS rendered`
85+
)
6886
)
6987
return tinQueryFromTsquery(rendered)
7088
} catch (error) {
89+
budget?.remaining()
7190
logger.warn('Tin keyword readiness check failed; using the GIN projection', {
7291
error: getErrorMessage(error),
7392
})

‎packages/db/schema.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3478,18 +3478,18 @@ export const embeddingKeywordSearch = pgTable(
34783478
})
34793479
)
34803480

3481+
/** The Tin index over {@link embeddingKeywordTin}; valid only once the projection is backfilled. */
3482+
export const EMBEDDING_KEYWORD_TIN_INDEX = 'embedding_keyword_tin_content_idx'
3483+
34813484
/**
34823485
* BM25 keyword ranking for organization search indexes, served by the Tin text index where the
34833486
* database provides the `tin` extension. `content` is the chunk's `english` lexemes in position
34843487
* order, prefixed with a token naming its knowledge base, so ranking is scoped to one base inside
34853488
* the index and stems exactly as the GIN projection does. Access never enters the row, so ACL
34863489
* changes never rewrite it. Script migration `0019_tin_keyword_projection` installs the extension,
3487-
* the index, and the embedding write trigger that owns these rows, and only where `tin` exists;
3488-
* elsewhere the table stays empty and keyword search keeps the GIN projection.
3490+
* the index, and the embedding and knowledge base triggers that own these rows, and only where
3491+
* `tin` exists; elsewhere the table stays empty and keyword search keeps the GIN projection.
34893492
*/
3490-
/** The Tin index over {@link embeddingKeywordTin}; valid only once the projection is backfilled. */
3491-
export const EMBEDDING_KEYWORD_TIN_INDEX = 'embedding_keyword_tin_content_idx'
3492-
34933493
export const embeddingKeywordTin = pgTable('embedding_keyword_tin', {
34943494
id: text('id')
34953495
.primaryKey()

‎packages/db/script-migrations-paused-billing-attribution.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,7 @@ describe('script migration registry', () => {
452452
'0016_backfill_search_vectors',
453453
'0017_index_search_documents',
454454
'0018_repair_workspace_file_content_revision',
455+
'0019_tin_keyword_projection',
455456
])
456457
})
457458
})

‎packages/db/script-migrations/0016_backfill_search_vectors.postgres.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,7 @@ describe.runIf(Boolean(databaseUrl))('search projection upgrade in PostgreSQL',
390390
{ name: '0016_backfill_search_vectors' },
391391
{ name: '0017_index_search_documents' },
392392
{ name: '0018_repair_workspace_file_content_revision' },
393+
{ name: '0019_tin_keyword_projection' },
393394
])
394395
const [{ complete }] = await sql`SELECT count(*)::int AS complete FROM embedding e
395396
JOIN embedding_search s ON s.id = e.id JOIN embedding_keyword_search k ON k.id = e.id

0 commit comments

Comments
 (0)