pg-fts: swap generated content_tsv column for expression GIN index

Replace the GENERATED ALWAYS ... STORED tsvector column + column-backed
GIN index with a single expression index on to_tsvector('simple', content).

Why: the expression index is maintained by Postgres on every INSERT/UPDATE
exactly like a column index (no write-path work), but avoids the stored
column's ALTER TABLE row rewrite / ACCESS EXCLUSIVE backfill — so the
migration build is online-safe on a fresh/small relay and the same-named
index (idx_events_content_fts) is pre-buildable out of band on a large
populated relay (CREATE INDEX CONCURRENTLY + ATTACH per the live-relay
runbook). IF NOT EXISTS makes the migration idempotent against that path.

The query path renders the identical to_tsvector('simple', content)
expression so the planner uses the index. Rank SQL (ts_rank_cd) is
unchanged in behavior.

- migrations/0004_search_fts.sql: single CREATE INDEX IF NOT EXISTS expr index
- schema/schema.sql: drop generated column; expr index for fresh installs
- crates/buzz-search/src/postgres.rs: query refs -> to_tsvector(...) expr
- crates/buzz-db/src/migration.rs: assertions match new shape
- crates/buzz-search/src/lib.rs, crates/buzz-relay/src/main.rs: doc wording

Tests: cargo test -p buzz-db -p buzz-search (incl. ignored PG tests):
118 passed, 0 failed.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
2026-06-24 19:27:35 -04:00
co-authored by Tyler Longwell
parent a4943e53bc
commit bbf125dab2
6 changed files with 62 additions and 56 deletions
+11 -11
View File
@@ -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, "")
}
+1 -1
View File
@@ -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 => {
+11 -10
View File
@@ -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 {
+8 -6
View File
@@ -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<SearchResult,
// hand-rolling a query with a stable parameter ordering is more readable
// and easier to audit.
//
// `simple` matches the tokenizer used by the `content_tsv` generated
// column in migration 0004. plainto_tsquery treats the input as a plain
// `simple` matches the tokenizer used by the `idx_events_content_fts`
// expression index in migration 0004 (`to_tsvector('simple', content)`),
// so this query is index-served. plainto_tsquery treats the input as a plain
// string (handles spaces, ignores punctuation) — closest analogue to
// Typesense's default `query_by=content` behavior.
let mut binds = Binds::new();
@@ -91,13 +93,13 @@ pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result<SearchResult,
// terms cluster together — closer match to Typesense's text relevance
// than plain `ts_rank`. Returned as `rank REAL`.
sql.push_str(&format!(
", ts_rank_cd(content_tsv, plainto_tsquery('simple', ${idx})) AS rank"
", ts_rank_cd(to_tsvector('simple', content), plainto_tsquery('simple', ${idx})) AS rank"
));
}
sql.push_str(", COUNT(*) OVER () AS total FROM events WHERE deleted_at IS NULL");
if let Some(idx) = q_idx {
sql.push_str(&format!(
" AND content_tsv @@ plainto_tsquery('simple', ${idx})"
" AND to_tsvector('simple', content) @@ plainto_tsquery('simple', ${idx})"
));
}
+30 -25
View File
@@ -1,35 +1,40 @@
-- Add a generated full-text-search column + GIN index to the events table so
-- the relay can serve NIP-50 search directly from Postgres, eliminating the
-- Typesense dependency.
-- Add a full-text-search GIN index to the events table so the relay can serve
-- NIP-50 search directly from Postgres, eliminating the Typesense dependency.
--
-- `content_tsv` is `GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED`
-- so every INSERT/UPDATE populates it automatically — no application-level
-- index maintenance needed (matches the pattern Typesense filled today via the
-- worker pipeline in `buzz-relay/src/state.rs`).
-- Shape: an EXPRESSION index on `to_tsvector('simple', content)` rather than a
-- `GENERATED ... STORED` column. Postgres maintains an expression index on every
-- INSERT/UPDATE exactly like a column index, so the write path needs no
-- application-level index maintenance (this replaces the Typesense worker
-- pipeline) — and there is no stored column, so no `ALTER TABLE ... STORED` row
-- rewrite and no `ACCESS EXCLUSIVE` backfill. The index build is therefore
-- online-safe on a fresh/small relay (this file) and pre-buildable out of band
-- on a large populated relay (operator runbook, below).
--
-- Tokenizer choice: `simple` does no stemming and preserves identifiers like
-- agent handles, nip05 strings, and slugs. The `english` config would stem
-- ("running" → "run") but mangle handles ("alice42" tokenizes fine, but
-- something like "agents" → "agent" would break exact-handle search). Chat
-- content is heterogeneous; `simple` is the safer default for v1.
-- ("running" -> "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));
+1 -3
View File
@@ -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 ────────────────────────────────────────────────────────────