diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 1188bd917..b6cf923ee 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -164,12 +164,15 @@ mod tests { assert_eq!(migrations[3].version, 4); assert_eq!(&*migrations[3].description, "search fts"); assert!( - migrations[3].sql.as_str().contains("content_tsv tsvector") + migrations[3] + .sql + .as_str() + .contains("idx_events_content_fts") && migrations[3] .sql .as_str() - .contains("idx_events_content_tsv"), - "fourth migration should add the generated tsvector column and GIN index" + .contains("to_tsvector('simple', content)"), + "fourth migration should add the expression GIN index for FTS" ); } @@ -204,23 +207,21 @@ mod tests { } /// Returns `schema/schema.sql` with the NIP-ER reminder DDL and the - /// search-FTS DDL removed, so it models a pre-stack deployment whose - /// `events` table lacks the reminder columns and the generated tsvector - /// column. The strip is asserted: if the snapshot text drifts so these + /// search-FTS index removed, so it models a pre-stack deployment whose + /// `events` table lacks the reminder columns and the FTS expression + /// index. The strip is asserted: if the snapshot text drifts so these /// fragments no longer match, the test fails loudly rather than silently - /// loading a snapshot that already carries the columns (which would make + /// loading a snapshot that already carries them (which would make /// migration 0003 or 0004 collide on re-add). fn pre_reminder_schema_snapshot() -> String { const REMINDER_COLUMNS: &str = " not_before BIGINT,\n delivered_at BIGINT,\n"; const REMINDER_INDEX: &str = "CREATE INDEX idx_events_not_before ON events (not_before)\n WHERE not_before IS NOT NULL AND deleted_at IS NULL AND delivered_at IS NULL;\n"; - const FTS_COLUMN: &str = " content_tsv tsvector\n GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED,\n"; const FTS_INDEX: &str = - "CREATE INDEX idx_events_content_tsv ON events USING GIN (content_tsv);\n"; + "CREATE INDEX idx_events_content_fts ON events USING GIN (to_tsvector('simple', content));\n"; assert!( SCHEMA_SQL.contains(REMINDER_COLUMNS) && SCHEMA_SQL.contains(REMINDER_INDEX) - && SCHEMA_SQL.contains(FTS_COLUMN) && SCHEMA_SQL.contains(FTS_INDEX), "schema.sql reminder/FTS DDL drifted; update pre_reminder_schema_snapshot to match" ); @@ -228,7 +229,6 @@ mod tests { SCHEMA_SQL .replace(REMINDER_COLUMNS, "") .replace(REMINDER_INDEX, "") - .replace(FTS_COLUMN, "") .replace(FTS_INDEX, "") } diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 5638fc4a5..278a321a3 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -208,7 +208,7 @@ async fn main() -> anyhow::Result<()> { service } SearchBackend::Postgres => { - info!("Search backend: postgres (content_tsv generated column)"); + info!("Search backend: postgres (expression GIN index idx_events_content_fts)"); SearchService::with_postgres(db.pool()) } SearchBackend::Disabled => { diff --git a/crates/buzz-search/src/lib.rs b/crates/buzz-search/src/lib.rs index 22d31647b..a02b778bd 100644 --- a/crates/buzz-search/src/lib.rs +++ b/crates/buzz-search/src/lib.rs @@ -9,8 +9,9 @@ //! `search()` path returns event IDs that the relay then refetches from //! Postgres. //! - [`SearchService::with_postgres`] (Postgres FTS): runs `plainto_tsquery` -//! against a generated `content_tsv` column on `events`. No write-path -//! indexing needed — the generated stored column auto-populates on INSERT. +//! against the `idx_events_content_fts` expression GIN index on `events`. No +//! write-path indexing needed — Postgres maintains the expression index on +//! every INSERT/UPDATE, exactly like a column index. //! - [`SearchService::disabled`]: returns empty results for every query and //! accepts indexing calls as no-ops. Used when NIP-50 search is intentionally //! off (e.g. for tenants who opted out). @@ -39,7 +40,7 @@ use sqlx::PgPool; pub enum SearchBackend { /// Typesense (current production default). Typesense, - /// Postgres full-text search via the `content_tsv` generated column. + /// Postgres full-text search via the `idx_events_content_fts` expression GIN index. Postgres, /// NIP-50 search is disabled; every query returns empty. Disabled, @@ -138,8 +139,8 @@ impl SearchService { } /// Creates a Postgres FTS `SearchService` backed by the supplied pool. - /// No indexing setup is required — the `content_tsv` generated column - /// populates on every INSERT. + /// No indexing setup is required — the `idx_events_content_fts` expression + /// index is maintained by Postgres on every INSERT/UPDATE. pub fn with_postgres(pool: PgPool) -> Self { Self::Postgres(pool) } @@ -174,8 +175,8 @@ impl SearchService { /// Indexes a single event (upsert semantics). /// /// - **Typesense**: writes a document to the collection. - /// - **Postgres**: no-op — the `content_tsv` generated stored column is - /// populated automatically on the original INSERT. + /// - **Postgres**: no-op — the `idx_events_content_fts` expression index is + /// maintained automatically on the original INSERT. /// - **Disabled**: no-op. pub async fn index_event(&self, event: &StoredEvent) -> Result<(), SearchError> { match self { @@ -236,9 +237,9 @@ impl SearchService { /// Removes an event from the search index by its event ID hex string. /// /// - **Typesense**: deletes the document. - /// - **Postgres**: no-op — `content_tsv` is tied to the event row; - /// removing the row removes the index entry, and the relay's event - /// deletion path already handles that. + /// - **Postgres**: no-op — the `idx_events_content_fts` entry is tied to the + /// event row; removing the row removes the index entry, and the relay's + /// event deletion path already handles that. /// - **Disabled**: no-op. pub async fn delete_event(&self, event_id: &str) -> Result<(), SearchError> { match self { diff --git a/crates/buzz-search/src/postgres.rs b/crates/buzz-search/src/postgres.rs index 5fd73b2e9..e4e376407 100644 --- a/crates/buzz-search/src/postgres.rs +++ b/crates/buzz-search/src/postgres.rs @@ -6,8 +6,9 @@ //! `db.get_events_by_ids` → `filters_match` → auth post-filter chain in //! `crates/buzz-relay/src/handlers/req.rs` is unchanged. //! -//! Matching uses `plainto_tsquery('simple', $q)` against the `content_tsv` -//! generated column added in migration `0004_search_fts.sql`. Pushdowns: +//! Matching uses `plainto_tsquery('simple', $q)` against the +//! `idx_events_content_fts` expression GIN index added in migration +//! `0004_search_fts.sql`. Pushdowns: //! //! - `kinds` → `kind = ANY($kinds)` //! - `authors` → `pubkey = ANY($authors)` (hex-decoded) @@ -69,8 +70,9 @@ pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result Result "run") and mangle handles. Chat content is heterogeneous; +-- `simple` is the safer default for v1. The query path in +-- `buzz-search/src/postgres.rs` renders the identical `to_tsvector('simple', content)` +-- expression so the planner matches this index. -- --- kind:0 metadata flattening: the existing Typesense pipeline appends parsed --- display_name/name/nip05 to event content before indexing --- (`buzz-search/src/index.rs::flatten_kind0_for_indexing`). With FTS on raw +-- kind:0 metadata: the old Typesense pipeline appended parsed +-- display_name/name/nip05 to event content before indexing. With FTS over raw -- `content`, those strings still tokenize because they live in the kind:0 JSON --- body — `to_tsvector('simple', '{"name":"alice"}')` matches `q=alice` after --- json-aware tokenization. Validated by the NIP-50 e2e suite. +-- body — `to_tsvector('simple', '{"name":"alice"}')` matches `q=alice`. +-- Validated by the NIP-50 e2e suite. -- --- `events` is partitioned by RANGE (created_at); ADD COLUMN on the parent --- cascades the generated column to every partition, and CREATE INDEX on the --- parent builds a partitioned GIN index that propagates to each partition. --- Partition pruning on since/until queries narrows the GIN scan further than --- Typesense's full-collection scan does today. +-- `events` is partitioned by RANGE (created_at). `CREATE INDEX ... ON events` +-- builds a partitioned GIN index whose per-partition child indexes propagate to +-- existing and future partitions. Partition pruning on since/until narrows the +-- GIN scan further than Typesense's full-collection scan does today. +-- +-- IF NOT EXISTS makes this migration idempotent against the operator runbook: +-- on a large relay the index is built per-child with CREATE INDEX CONCURRENTLY +-- and ATTACHed to a parent named `idx_events_content_fts` BEFORE this code +-- deploys, so this statement is a no-op. See +-- GUIDES/BUZZ_POSTGRES_FTS_LIVE_RELAY_RUNBOOK.md. -- -- Managed by sqlx migrations. -ALTER TABLE events - ADD COLUMN content_tsv tsvector - GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED; - -CREATE INDEX idx_events_content_tsv ON events USING GIN (content_tsv); +CREATE INDEX IF NOT EXISTS idx_events_content_fts + ON events USING GIN (to_tsvector('simple', content)); diff --git a/schema/schema.sql b/schema/schema.sql index 6fea78b66..a351683eb 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -107,8 +107,6 @@ CREATE TABLE events ( d_tag TEXT, not_before BIGINT, delivered_at BIGINT, - content_tsv tsvector - GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED, PRIMARY KEY (created_at, id) ) PARTITION BY RANGE (created_at); @@ -138,7 +136,7 @@ CREATE INDEX idx_events_addressable ON events (kind, pubkey, channel_id, deleted CREATE INDEX idx_events_parameterized ON events (kind, pubkey, d_tag, deleted_at) WHERE d_tag IS NOT NULL; CREATE INDEX idx_events_not_before ON events (not_before) WHERE not_before IS NOT NULL AND deleted_at IS NULL AND delivered_at IS NULL; -CREATE INDEX idx_events_content_tsv ON events USING GIN (content_tsv); +CREATE INDEX idx_events_content_fts ON events USING GIN (to_tsvector('simple', content)); -- ── Event mentions ────────────────────────────────────────────────────────────