Skip to content

Commit 185650f

Browse files
authored
improvement(knowledge): maintain keyword projections only for search indexes (#8333)
- Since #8314, workspace knowledge search ranks on `embedding`/`document`. The only live reader of `embedding_keyword_search` and `embedding_keyword_tin` is dormant indexed search, and only for bases that are search indexes. Yet every chunk write still upserted a keyword row, ran a Tin `DELETE` on insert, and every document insert fanned an ACL/connector sync out to the projections - Script migration `0025_scope_keyword_projections`, all `CREATE OR REPLACE` in one transaction with the 0024 lock-timeout retry: - `sync_embedding_keyword_search` takes the shared membership lock and writes keyword rows only for `is_search_index` bases. On an update that moves a chunk out of a search index, it deletes the chunk's row - A new flip trigger backfills a base's keyword rows when it becomes a search index and deletes them when it stops being one, under the exclusive membership lock. It is separate from the Tin flip trigger, which only exists where Tin is installed. Its upsert skips unchanged rows - The Tin chunk trigger skips its `DELETE` on insert, since a new chunk can't have a Tin row - `projection_source_acl_sync` becomes `AFTER UPDATE OF connector_id, acl ... WHEN` the value actually changed. A new document has no chunks yet (FK), so the insert arm never matched anything - The projector's keyword page writes only for search-index bases, keyed on the base flag - Fresh installs, `db:push`, and late Tin adoption end with the same trigger definitions: 0019 installs the final guarded, key-share-locked Tin triggers, and 0025 re-runs after 0016. A `db:push` re-run of the legacy 0016 backfill may refill keyword rows for non-search bases; they're unread and left in place like the existing ones - Both adoption backfills read their chunks `FOR KEY SHARE`, so a chunk delete racing an adoption is waited out instead of failing it - Rollback floor: v0.9.1 and earlier ranked workspace keyword search through `embedding_keyword_search`. Once 0025 has run, don't roll back below v0.9.2, and self-hosters should upgrade through v0.9.2 or later - Existing keyword rows for non-search bases are left in place (unread) and removed separately
1 parent 5a69768 commit 185650f

12 files changed

Lines changed: 645 additions & 56 deletions

‎apps/sim/lib/knowledge/__integration__/embedding-insert-batches.integration.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ describe('bounded embedding insert transactions', () => {
4949
beforeAll(async () => {
5050
fixtures.root = mkdtempSync(path.join(tmpdir(), 'sim-embedding-batches-'))
5151
await seedKnowledgeAclFixture(ids, { connectorType: 'google_drive' })
52+
/** A search index, the only kind of base whose chunks the keyword projection holds. */
53+
await db
54+
.update(knowledgeBase)
55+
.set({ isSearchIndex: true })
56+
.where(eq(knowledgeBase.id, ids.knowledgeBaseId))
5257
vi.spyOn(embeddingClient, 'assertKnowledgeEmbeddingCapacity').mockResolvedValue(undefined)
5358
})
5459

‎apps/sim/lib/knowledge/__integration__/knowledge-projection.integration.ts‎

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
credentialGroupEnrollment,
2525
document,
2626
embedding,
27+
embeddingKeywordSearch,
2728
embeddingKeywordTin,
2829
embeddingSearch,
2930
knowledgeBase,
@@ -383,7 +384,6 @@ beforeAll(async () => {
383384
sql.raw(`CREATE SCHEMA tin;
384385
CREATE FUNCTION tin.full_score(tid) RETURNS double precision LANGUAGE sql IMMUTABLE AS 'SELECT 1.0::float8';
385386
CREATE FUNCTION knowledge_tin_base_token(text) RETURNS text LANGUAGE sql IMMUTABLE AS $$SELECT 'kb'$$;
386-
CREATE FUNCTION knowledge_tin_membership_key(text) RETURNS bigint LANGUAGE sql IMMUTABLE AS $$SELECT hashtextextended('embedding_keyword_tin:' || $1, 0)$$;
387387
CREATE FUNCTION knowledge_tin_stream(vector tsvector) RETURNS text LANGUAGE sql IMMUTABLE AS $$
388388
SELECT coalesce(string_agg(entry.lexeme, ' ' ORDER BY position), '')
389389
FROM unnest(vector) AS entry(lexeme, positions, weights), unnest(entry.positions) AS position
@@ -400,7 +400,6 @@ afterAll(async () => {
400400
sql.raw(`DROP OPERATOR IF EXISTS ==> (text, text);
401401
DROP FUNCTION IF EXISTS tin_fixture_match(text, text);
402402
DROP FUNCTION IF EXISTS knowledge_tin_base_token(text);
403-
DROP FUNCTION IF EXISTS knowledge_tin_membership_key(text);
404403
DROP FUNCTION IF EXISTS knowledge_tin_stream(tsvector);
405404
DROP SCHEMA IF EXISTS tin CASCADE;`)
406405
)
@@ -730,6 +729,46 @@ describe('the projector', () => {
730729
expect(await markOf()).toBeUndefined()
731730
})
732731

732+
it('writes keyword rows for search-index knowledge bases only', async () => {
733+
const workspaceBaseId = generateId()
734+
const workspaceDocument = generateId()
735+
const workspaceChunk = generateId()
736+
await db.insert(knowledgeBase).values({
737+
id: workspaceBaseId,
738+
userId: ids.aliceId,
739+
workspaceId: ids.workspaceId,
740+
name: 'Workspace keyword fixture',
741+
chunkingConfig: { maxSize: 1024, minSize: 1, overlap: 20 },
742+
})
743+
try {
744+
await db.insert(document).values({
745+
id: workspaceDocument,
746+
knowledgeBaseId: workspaceBaseId,
747+
filename: 'keyword.md',
748+
fileUrl: 'https://fixture.test/keyword',
749+
fileSize: 12,
750+
mimeType: 'text/plain',
751+
processingStatus: 'completed',
752+
})
753+
await write('async', (tx) =>
754+
tx.insert(embedding).values({
755+
...chunkRow(workspaceChunk, 0),
756+
documentId: workspaceDocument,
757+
knowledgeBaseId: workspaceBaseId,
758+
})
759+
)
760+
await project()
761+
const keywordRows = await db
762+
.select({ id: embeddingKeywordSearch.id })
763+
.from(embeddingKeywordSearch)
764+
.where(inArray(embeddingKeywordSearch.id, [chunkId, workspaceChunk]))
765+
expect(keywordRows).toEqual([{ id: chunkId }])
766+
expect(await rowAcl(embeddingSearch, workspaceChunk)).toBeDefined()
767+
} finally {
768+
await db.delete(knowledgeBase).where(eq(knowledgeBase.id, workspaceBaseId))
769+
}
770+
})
771+
733772
it.each(['sync', 'async'] as const)(
734773
'writes %s projection rows from a chunk commit only when the writer did not defer them',
735774
async (mode) => {

‎apps/sim/lib/knowledge/__integration__/processing-lock-scope.integration.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ describe('document processing commit lock scope', () => {
5454
beforeAll(async () => {
5555
fixtures.root = mkdtempSync(path.join(tmpdir(), 'sim-processing-lock-scope-'))
5656
await seedKnowledgeAclFixture(ids, { connectorType: 'google_drive' })
57+
/** A search index, the only kind of base whose chunks the keyword projection holds. */
58+
await db
59+
.update(knowledgeBase)
60+
.set({ isSearchIndex: true })
61+
.where(eq(knowledgeBase.id, ids.knowledgeBaseId))
5762
vi.spyOn(embeddingClient, 'assertKnowledgeEmbeddingCapacity').mockResolvedValue(undefined)
5863
fixtures.process.mockResolvedValue({
5964
chunks,
@@ -152,6 +157,14 @@ describe('document processing commit lock scope', () => {
152157
expect(await db.select().from(document).where(eq(document.id, file.documentId))).toMatchObject([
153158
{ processingStatus: 'completed', chunkCount: 3 },
154159
])
160+
for (const projection of ['embedding_search', 'embedding_keyword_search']) {
161+
expect(
162+
await db.$client.unsafe(
163+
`SELECT count(*)::int AS count FROM ${projection} WHERE document_id = $1`,
164+
[file.documentId]
165+
)
166+
).toEqual([{ count: 3 }])
167+
}
155168
})
156169

157170
it.each([

‎apps/sim/lib/sim-search/indexed/README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ While the gate is off:
1414
- Indexed-only surfaces refuse with `SearchIndexDormantError` (a `409`): the Stats report and connecting a source that crawls into a search index. The indexed document page is not found.
1515
- A knowledge search that names a search-index knowledge base (the Knowledge block, v1, v2, Sim's knowledge tool) still answers from the documents it already holds, decided on each document exactly as a workspace knowledge base is.
1616
- Nothing crawls into search indexes: content syncs, member syncs, and processing recovery skip them (`lib/knowledge/connectors/indexing-policy.ts`).
17-
- The projector owes search-index documents nothing: their marks are released with the rest, and it writes no Tin keyword rows.
17+
- The projector owes search-index documents nothing: their marks are released with the rest, and it writes no Tin keyword rows. The GIN keyword projection follows `is_search_index` alone, so it keeps its search-index rows either way.
1818

1919
## Layout
2020

@@ -28,7 +28,7 @@ The dormant UI sits in `indexed/` folders next to the component that picks it fr
2828
## Re-enabling
2929

3030
1. Set `SIM_SEARCH_LIVE=false` in both the app and the Trigger.dev environment, and deploy. The container entrypoint (`apps/sim/bootstrap.ts`) mirrors it to `NEXT_PUBLIC_SIM_SEARCH_LIVE` for the client; crawling, processing, and projection read it in whichever process runs them.
31-
2. Confirm the Tin objects exist (`0019_tin_keyword_projection`, `0024_knowledge_projection_async`), backfill `embedding_keyword_tin` for every search-index knowledge base, and build its index.
31+
2. Confirm the keyword projection objects exist (`0019_tin_keyword_projection`, `0024_knowledge_projection_async`, `0025_scope_keyword_projections`). Both keyword projections, `embedding_keyword_search` and `embedding_keyword_tin`, hold only search-index rows, written by the chunk triggers and by the trigger on `knowledge_base.is_search_index`. Backfill both for every search-index knowledge base whose rows were removed while dormant, and build the Tin index.
3232
3. Resume and fully resync the connectors of search-index knowledge bases, so content that went stale while dormant is indexed again.
3333

3434
Projection rows written before projections carried their document's source and ACL are decided on their document until they are rewritten.

‎packages/db/knowledge-projection.ts‎

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,10 @@ export type SourceAclProjection = (typeof SOURCE_ACL_PROJECTIONS)[number]
106106
const mirrorsSourceAcl = (projection: KnowledgeProjection): projection is SourceAclProjection =>
107107
(SOURCE_ACL_PROJECTIONS as readonly string[]).includes(projection)
108108

109+
/** The keyword projections, which hold only the rows of search-index knowledge bases. */
110+
const holdsSearchIndexesOnly = (projection: KnowledgeProjection) =>
111+
projection === 'embedding_keyword_search' || projection === 'embedding_keyword_tin'
112+
109113
/**
110114
* The chunks one page covers: the next {@link PROJECTION_ROW_BATCH_SIZE} of the document in
111115
* chunk order, read off `emb_doc_chunk_idx`. Every projection pages the same way, so a page is
@@ -163,10 +167,16 @@ function contentPageStatement(projection: KnowledgeProjection): string {
163167
}
164168
if (projection === 'embedding_keyword_search') {
165169
const compared = ['knowledge_base_id', 'document_id', 'enabled', 'content_tsv']
166-
return `WITH ${PAGE}, written AS (
167-
INSERT INTO embedding_keyword_search AS s (${['id', ...compared].join(', ')})
168-
SELECT e.id, e.knowledge_base_id, e.document_id, e.enabled, e.content_tsv
170+
return `WITH ${PAGE}, source AS MATERIALIZED (
171+
SELECT e.id, ${compared.map((column) => `e.${column}`).join(', ')}, k.is_search_index
169172
FROM page p JOIN embedding e ON e.id = p.id
173+
JOIN knowledge_base k ON k.id = e.knowledge_base_id
174+
), removed AS (
175+
DELETE FROM embedding_keyword_search s USING source
176+
WHERE s.id = source.id AND NOT source.is_search_index
177+
), written AS (
178+
INSERT INTO embedding_keyword_search AS s (${['id', ...compared].join(', ')})
179+
SELECT id, ${compared.join(', ')} FROM source WHERE is_search_index
170180
ON CONFLICT (id) DO UPDATE SET
171181
${compared.map((column) => `${column} = EXCLUDED.${column}`).join(', ')}
172182
WHERE (${compared.map((column) => `s.${column}`).join(', ')})
@@ -249,8 +259,9 @@ export interface KnowledgeProjectionOptions {
249259
/**
250260
* Whether search-index rows are read, that is whether indexed organization search is on (see
251261
* {@link MarkScope}). Only then is the Tin keyword projection written, since it holds only
252-
* search-index rows; the other projections are written either way, and Tin is still skipped
253-
* where it is not installed.
262+
* search-index rows; the other projections are written either way, the GIN keyword projection
263+
* for search-index bases alone whatever this says, and Tin is still skipped where it is not
264+
* installed.
254265
*/
255266
searchIndexes: boolean
256267
/** Called after each page commits, for tests that interleave writes with a run. */
@@ -375,8 +386,8 @@ async function projectDocumentRows(
375386
if (Date.now() >= deadline) return { pages, written, finished: false }
376387
const page = await sql.begin(async (tx) => {
377388
await enterProjectorTransaction(tx, PROJECTION_PAGE_LOCK_TIMEOUT_MS)
378-
/** Shares the base's Tin membership lock, as the embedding trigger does, so a flip of its marker waits. */
379-
if (projection === 'embedding_keyword_tin')
389+
/** Shares the base's membership lock, as the embedding triggers do, so a flip of its marker waits. */
390+
if (holdsSearchIndexesOnly(projection))
380391
await tx`SELECT pg_advisory_xact_lock_shared(knowledge_tin_membership_key(${mark.knowledgeBaseId}))`
381392
const [row] = await tx.unsafe<
382393
Array<{ scanned: number; written: number; last_chunk: number | null }>

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,7 @@ describe('script migration registry', () => {
377377
'0022_projection_source_acl_backfill',
378378
'0023_projection_acl_skip_unfilled',
379379
'0024_knowledge_projection_async',
380+
'0025_scope_keyword_projections',
380381
])
381382
})
382383
})

‎packages/db/script-migrations/0016_backfill_search_vectors.integration.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,7 @@ describe('search projection upgrade in PostgreSQL', () => {
395395
{ name: '0022_projection_source_acl_backfill' },
396396
{ name: '0023_projection_acl_skip_unfilled' },
397397
{ name: '0024_knowledge_projection_async' },
398+
{ name: '0025_scope_keyword_projections' },
398399
])
399400
const [{ complete }] = await sql`SELECT count(*)::int AS complete FROM embedding e
400401
JOIN embedding_search s ON s.id = e.id JOIN embedding_keyword_search k ON k.id = e.id

‎packages/db/script-migrations/0019_tin_keyword_projection.ts‎

Lines changed: 78 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
import { SYNCHRONOUS_PROJECTION_WHEN } from '@sim/db/knowledge-projection'
12
import { EMBEDDING_KEYWORD_TIN_INDEX } from '@sim/db/schema'
23
import { resolveMigrationDatabaseUrl } from '@sim/db/script-migrations/database-url'
34
import { type ScriptMigration, ScriptMigrationDeferred } from '@sim/db/script-migrations/types'
45
import { createLogger } from '@sim/logger'
5-
import postgres, { type Sql } from 'postgres'
6+
import postgres, { type Sql, type TransactionSql } from 'postgres'
67

78
const logger = createLogger('TinKeywordProjection')
89
const BATCH_SIZE = 500
@@ -39,6 +40,75 @@ async function tinAvailable(sql: Sql): Promise<boolean> {
3940
return rows.length > 0
4041
}
4142

43+
/**
44+
* The advisory lock key that serializes a knowledge base's keyword projection writers with a change
45+
* of its search-index marker (see {@link installProjection}).
46+
*/
47+
export async function installMembershipKey(tx: Sql | TransactionSql): Promise<void> {
48+
await tx.unsafe(`CREATE OR REPLACE FUNCTION knowledge_tin_membership_key(knowledge_base_id text)
49+
RETURNS bigint LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
50+
SELECT hashtextextended('embedding_keyword_tin:' || knowledge_base_id, 0)
51+
$$`)
52+
}
53+
54+
/**
55+
* The chunk trigger's body. A chunk outside a search index writes nothing; an update removes the
56+
* row a chunk left behind by moving out of one. An insert has nothing to remove, since a new chunk
57+
* id has no row and a base adopted meanwhile waits on the membership lock the insert holds.
58+
*/
59+
export async function installTinChunkSync(tx: Sql | TransactionSql): Promise<void> {
60+
await tx.unsafe(`CREATE OR REPLACE FUNCTION sync_embedding_keyword_tin()
61+
RETURNS trigger LANGUAGE plpgsql AS $$
62+
BEGIN
63+
PERFORM pg_advisory_xact_lock_shared(knowledge_tin_membership_key(NEW.knowledge_base_id));
64+
IF NOT EXISTS (
65+
SELECT 1 FROM knowledge_base WHERE id = NEW.knowledge_base_id AND is_search_index
66+
) THEN
67+
IF TG_OP = 'UPDATE' THEN
68+
DELETE FROM embedding_keyword_tin WHERE id = NEW.id;
69+
END IF;
70+
RETURN NEW;
71+
END IF;
72+
INSERT INTO embedding_keyword_tin (id, knowledge_base_id, document_id, enabled, content)
73+
VALUES (NEW.id, NEW.knowledge_base_id, NEW.document_id, NEW.enabled,
74+
knowledge_tin_base_token(NEW.knowledge_base_id) || ' ' || knowledge_tin_stream(NEW.content_tsv))
75+
ON CONFLICT (id) DO UPDATE SET
76+
knowledge_base_id = EXCLUDED.knowledge_base_id, document_id = EXCLUDED.document_id,
77+
enabled = EXCLUDED.enabled, content = EXCLUDED.content;
78+
RETURN NEW;
79+
END;
80+
$$`)
81+
}
82+
83+
/**
84+
* The knowledge base trigger's body: projects or removes a whole base when its search-index marker
85+
* changes. The chunks are key-share locked as they are read, as the backfill locks its own, so a
86+
* chunk delete in flight is waited for and its chunk skipped rather than failing the adoption on
87+
* the row's foreign key.
88+
*/
89+
export async function installTinMembershipSync(tx: Sql | TransactionSql): Promise<void> {
90+
await tx.unsafe(`CREATE OR REPLACE FUNCTION sync_knowledge_base_keyword_tin()
91+
RETURNS trigger LANGUAGE plpgsql AS $$
92+
BEGIN
93+
PERFORM pg_advisory_xact_lock(knowledge_tin_membership_key(NEW.id));
94+
IF NOT NEW.is_search_index THEN
95+
DELETE FROM embedding_keyword_tin t USING embedding e
96+
WHERE e.knowledge_base_id = NEW.id AND t.id = e.id;
97+
RETURN NEW;
98+
END IF;
99+
INSERT INTO embedding_keyword_tin (id, knowledge_base_id, document_id, enabled, content)
100+
SELECT id, knowledge_base_id, document_id, enabled,
101+
knowledge_tin_base_token(knowledge_base_id) || ' ' || knowledge_tin_stream(content_tsv)
102+
FROM embedding WHERE knowledge_base_id = NEW.id
103+
ORDER BY id FOR KEY SHARE
104+
ON CONFLICT (id) DO UPDATE SET
105+
knowledge_base_id = EXCLUDED.knowledge_base_id, document_id = EXCLUDED.document_id,
106+
enabled = EXCLUDED.enabled, content = EXCLUDED.content;
107+
RETURN NEW;
108+
END;
109+
$$`)
110+
}
111+
42112
/**
43113
* Installs the stream and base-token functions shared by the triggers and the query path, and the
44114
* embedding and knowledge base triggers, atomically with respect to embedding writers.
@@ -60,7 +130,9 @@ async function tinAvailable(sql: Sql): Promise<boolean> {
60130
* other's rows. A row lock cannot do this: a key-share lock on the marker row is compatible with
61131
* the uncommitted marker update, so a writer would read the old marker without waiting. A whole
62132
* base is reached through `embedding`'s knowledge base index, since a projected row always carries
63-
* its chunk's base and the projection keeps no index but Tin's.
133+
* its chunk's base and the projection keeps no index but Tin's. The embedding trigger carries the
134+
* guard `0024_knowledge_projection_async` added, so a database adopting Tin later ends where a full
135+
* migration run does.
64136
*/
65137
export async function installProjection(sql: Sql): Promise<void> {
66138
await sql.begin(async (tx) => {
@@ -75,51 +147,12 @@ export async function installProjection(sql: Sql): Promise<void> {
75147
RETURNS text LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
76148
SELECT 'zkb' || md5(knowledge_base_id)
77149
$$`)
78-
await tx.unsafe(`CREATE OR REPLACE FUNCTION knowledge_tin_membership_key(knowledge_base_id text)
79-
RETURNS bigint LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
80-
SELECT hashtextextended('embedding_keyword_tin:' || knowledge_base_id, 0)
81-
$$`)
82-
await tx.unsafe(`CREATE OR REPLACE FUNCTION sync_embedding_keyword_tin()
83-
RETURNS trigger LANGUAGE plpgsql AS $$
84-
BEGIN
85-
PERFORM pg_advisory_xact_lock_shared(knowledge_tin_membership_key(NEW.knowledge_base_id));
86-
IF NOT EXISTS (
87-
SELECT 1 FROM knowledge_base WHERE id = NEW.knowledge_base_id AND is_search_index
88-
) THEN
89-
DELETE FROM embedding_keyword_tin WHERE id = NEW.id;
90-
RETURN NEW;
91-
END IF;
92-
INSERT INTO embedding_keyword_tin (id, knowledge_base_id, document_id, enabled, content)
93-
VALUES (NEW.id, NEW.knowledge_base_id, NEW.document_id, NEW.enabled,
94-
knowledge_tin_base_token(NEW.knowledge_base_id) || ' ' || knowledge_tin_stream(NEW.content_tsv))
95-
ON CONFLICT (id) DO UPDATE SET
96-
knowledge_base_id = EXCLUDED.knowledge_base_id, document_id = EXCLUDED.document_id,
97-
enabled = EXCLUDED.enabled, content = EXCLUDED.content;
98-
RETURN NEW;
99-
END;
100-
$$`)
150+
await installMembershipKey(tx)
151+
await installTinChunkSync(tx)
101152
await tx.unsafe(`CREATE OR REPLACE TRIGGER embedding_keyword_tin_sync
102153
AFTER INSERT OR UPDATE OF knowledge_base_id, document_id, enabled, content ON embedding
103-
FOR EACH ROW EXECUTE FUNCTION sync_embedding_keyword_tin()`)
104-
await tx.unsafe(`CREATE OR REPLACE FUNCTION sync_knowledge_base_keyword_tin()
105-
RETURNS trigger LANGUAGE plpgsql AS $$
106-
BEGIN
107-
PERFORM pg_advisory_xact_lock(knowledge_tin_membership_key(NEW.id));
108-
IF NOT NEW.is_search_index THEN
109-
DELETE FROM embedding_keyword_tin t USING embedding e
110-
WHERE e.knowledge_base_id = NEW.id AND t.id = e.id;
111-
RETURN NEW;
112-
END IF;
113-
INSERT INTO embedding_keyword_tin (id, knowledge_base_id, document_id, enabled, content)
114-
SELECT id, knowledge_base_id, document_id, enabled,
115-
knowledge_tin_base_token(knowledge_base_id) || ' ' || knowledge_tin_stream(content_tsv)
116-
FROM embedding WHERE knowledge_base_id = NEW.id
117-
ON CONFLICT (id) DO UPDATE SET
118-
knowledge_base_id = EXCLUDED.knowledge_base_id, document_id = EXCLUDED.document_id,
119-
enabled = EXCLUDED.enabled, content = EXCLUDED.content;
120-
RETURN NEW;
121-
END;
122-
$$`)
154+
FOR EACH ROW WHEN (${SYNCHRONOUS_PROJECTION_WHEN}) EXECUTE FUNCTION sync_embedding_keyword_tin()`)
155+
await installTinMembershipSync(tx)
123156
await tx.unsafe(`CREATE OR REPLACE TRIGGER knowledge_base_keyword_tin_sync
124157
AFTER UPDATE OF is_search_index ON knowledge_base
125158
FOR EACH ROW WHEN (OLD.is_search_index IS DISTINCT FROM NEW.is_search_index)

0 commit comments

Comments
 (0)