@@ -68,11 +68,16 @@ const SEARCH_VECTOR_COLUMNS = SEARCH_VECTOR_WIDTHS.map((width) => widthColumn('v
6868/** The bit columns of `embedding_search`, whose width check requires exactly one. */
6969const SEARCH_BINARY_COLUMNS = [ '"binary"' , 'binary_384' , 'binary_768' , 'binary_1024' , 'binary_3072' ]
7070
71+ /** The `ON CONFLICT DO UPDATE` assignments that take each column from the proposed row. */
72+ function excludedAssignments ( columns : readonly string [ ] ) : string {
73+ return columns . map ( ( column ) => `${ column } = EXCLUDED.${ column } ` ) . join ( ', ' )
74+ }
75+
7176/**
7277 * The halfvec projections of an embedding row, in {@link SEARCH_VECTOR_COLUMNS} order. Shortening
7378 * is valid only for the two OpenAI models trained for prefix retrieval. These and the bit
74- * projections match what `sync_embedding_search()` from `0016_backfill_search_vectors` writes, so
75- * a pass over rows a synchronous writer wrote finds nothing to rewrite.
79+ * projections are what { @link SYNC_EMBEDDING_SEARCH_FUNCTION} writes, so a pass over rows a
80+ * synchronous writer wrote finds nothing to rewrite.
7681 */
7782function searchVectorProjections ( prefix : string , shortened : string ) : string {
7883 return SEARCH_VECTOR_WIDTHS . map ( ( width ) =>
@@ -94,6 +99,39 @@ function searchVectorShortened(model: string, prefix: string): string {
9499 return `${ model } IN ${ SHORTENED_EMBEDDING_MODELS } AND ${ prefix } .embedding_384 IS NULL`
95100}
96101
102+ /**
103+ * The `embedding_search_sync` trigger function: projects an ordinary-KB chunk and deletes the row
104+ * of a Search-KB chunk. Migration 0448 installs this exact body; fresh databases and pushes install
105+ * it through `0016_backfill_search_vectors`.
106+ *
107+ * contract-pending(after Search KB candidate rows are purged and Search KBs are unwritable): drop
108+ * the `search_index` delete branch — no Search-KB chunk write can reach the trigger then.
109+ */
110+ export const SYNC_EMBEDDING_SEARCH_FUNCTION = `CREATE OR REPLACE FUNCTION sync_embedding_search()
111+ RETURNS trigger LANGUAGE plpgsql AS $$
112+ DECLARE
113+ search_index boolean;
114+ shortened boolean;
115+ BEGIN
116+ SELECT is_search_index, ${ searchVectorShortened ( 'embedding_model' , 'NEW' ) }
117+ INTO STRICT search_index, shortened
118+ FROM knowledge_base WHERE id = NEW.knowledge_base_id;
119+ IF search_index THEN
120+ DELETE FROM embedding_search WHERE id = NEW.id;
121+ RETURN NEW;
122+ END IF;
123+ INSERT INTO embedding_search
124+ (id, knowledge_base_id, document_id, enabled, ${ SEARCH_BINARY_COLUMNS . join ( ', ' ) } ,
125+ ${ SEARCH_VECTOR_COLUMNS . join ( ', ' ) } )
126+ VALUES (NEW.id, NEW.knowledge_base_id, NEW.document_id, NEW.enabled,
127+ ${ searchBinaryProjections ( 'NEW' ) } ,
128+ ${ searchVectorProjections ( 'NEW' , 'shortened' ) } )
129+ ON CONFLICT (id) DO UPDATE SET
130+ ${ excludedAssignments ( [ 'knowledge_base_id' , 'document_id' , 'enabled' , ...SEARCH_BINARY_COLUMNS , ...SEARCH_VECTOR_COLUMNS ] ) } ;
131+ RETURN NEW;
132+ END;
133+ $$`
134+
97135export type KnowledgeProjection = 'embedding_search'
98136
99137/** Active vector projections that mirror document access for legacy synchronous writers. */
@@ -138,7 +176,7 @@ function contentPageStatement(): string {
138176 ${ searchVectorProjections ( 'source' , 'shortened' ) }
139177 FROM source
140178 ON CONFLICT (id) DO UPDATE SET
141- ${ [ ...compared , ...SEARCH_BINARY_COLUMNS ] . map ( ( column ) => ` ${ column } = EXCLUDED. ${ column } ` ) . join ( ', ' ) }
179+ ${ excludedAssignments ( [ ...compared , ...SEARCH_BINARY_COLUMNS ] ) }
142180 WHERE (${ compared . map ( ( column ) => `s.${ column } ` ) . join ( ', ' ) } )
143181 IS DISTINCT FROM (${ compared . map ( ( column ) => `EXCLUDED.${ column } ` ) . join ( ', ' ) } )
144182 RETURNING s.id
0 commit comments