From d005c7bb9cff17bc7fd44587f5586e08f67e21cf Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Tue, 3 Nov 2026 11:08:00 -0500 Subject: [PATCH] fix(search): skip message prefix search below three characters (#8947) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Typeahead surfaces (the buzz-app and desktop topbars) send a NIP-50 search with `search_mode: "prefix"` whenever typing pauses. A one- or two-character trailing `:*` prefix matches a large share of every indexed message. The GIN index on `search_tsv` can't stop early or return rows in rank order, so Postgres fetches, rechecks and ranks every match before it applies `LIMIT`. In a large community, each of these queries costs seconds of database CPU. With this change, prefix-mode `search()` returns no hits when the trailing whitespace token yields a lexeme shorter than three characters (`PREFIX_MIN_CHARS`). The check runs inside the prefix tsquery SQL, on the output of the same `to_tsvector('simple', …)` call that decides which lexemes get `:*`. Punctuation therefore can't pad the count: `pr.`, `@pr`, `#pr`, `c++`, `don't` and `pre-p` are all held back, and `project ?` still runs, because `?` yields no `:*` lexeme. When the rule applies, the tsquery is NULL, so it matches nothing and the GIN index lookup returns no rows. Completed words in front of the trailing token don't matter: `"the p"` runs the same broad `p:*` lookup as `"p"`. Kind:0-only queries (profile typeahead) are exempt because there are few profiles, and the short-name exact-lexeme ordering relies on one- and two-character queries. `search_profile_names` is unchanged. Full-text mode is unchanged. Only the HTTP bridge (`POST /query` with `search_mode: "prefix"`) reaches prefix mode. WebSocket REQ search always uses full-text mode, so this change doesn't affect it. ## What got simpler The kind:0-only predicate is now computed once (`profile_only`) and shared by the new minimum and the existing exact-lexeme ordering. The minimum lives in the one SQL fragment that already tokenizes the query, so there is no second tokenizer in Rust to keep in sync with Postgres. No new concepts beyond one constant. ## Known limits - This bounds the worst case only. A common three-character prefix can still match enough rows to be expensive. Bounding that needs a product decision, such as a recency window for typeahead or a different index. - Two-character CJK words (for example `東京`) get no message typeahead until a third character. A trailing contraction or hyphenated word with a short part (`don't`, `e-m`) gets none until the next word is started. Full-text mode is unaffected. - Clients should not show "no results" for one- and two-character input. A client-side minimum is a companion change. That includes the legacy desktop topbar in this repo (`desktop/src/features/search/hooks.ts`, which searches at 2 characters globally and 1 in a channel); it is left out here to keep this PR relay-only. ## Testing - Extended `prefix_mode_matches_final_token_prefix_without_changing_full_text` with a table of prefix queries and the exact hit sets they must return. It pins no hits for a trailing token that yields a lexeme below three characters: alone, after a completed word (`project pr`), padded with punctuation (`pr.`, `@pr`, `#pr`, `...p`), as a contraction (`don't`), and hyphenated (`pre-p`). It covers `kinds = [9]`, no kinds, mixed `[0, 9]`, and `Newest` sort. Each of those rows has data it would match without the minimum. The same table has controls that must still match: `pro.`, `project ?` (punctuation-only trailing token), `pre-pro`, `t pan` (a short completed word stays exact and isn't limited), and a one-character kind:0 `p`. A full-text `don't` control shows that full-text mode is unaffected. `short_kind0_prefix_prioritizes_exact_lexeme_on_a_noisy_page` pins the kind:0 exemption. - Mutation-checked on Blox. Each of these fails a test: removing the minimum, removing the kind:0 exemption, and applying the minimum to every lexeme instead of only the trailing ones. - Synthetic plan check on Blox Postgres (300k rows, GIN on a generated `simple` tsvector, same `CROSS JOIN LATERAL` query shape). `'pr':*` took 3.6 s and read 10,411 buffers. The NULL tsquery took 0.01 ms: the bitmap scan returned 0 rows and read no buffers. - Two existing prefix tests used a two-character trailing token (`project pl`, `alpha be`). They now use three or more characters (`project plan`, `alpha bet`) and assert the same behavior. - Blox: `cargo fmt --check`, `cargo clippy -p buzz-search --all-targets -D warnings`, `buzz-search` unit tests, and the `buzz-search` Postgres lane (`scripts/postgres-test-run.sh`). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Luke Tornquist Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 --- crates/buzz-search/src/query.rs | 42 +++- .../tests/postgres_fts_integration.rs | 201 +++++++++++++++++- 2 files changed, 234 insertions(+), 9 deletions(-) diff --git a/crates/buzz-search/src/query.rs b/crates/buzz-search/src/query.rs index 6f55d2300d..facd9a7f37 100644 --- a/crates/buzz-search/src/query.rs +++ b/crates/buzz-search/src/query.rs @@ -63,7 +63,9 @@ pub enum SearchMode { /// /// Intended for bounded typeahead surfaces such as the desktop topbar. The /// relay still refetches and re-authorizes every hit; this mode changes only - /// the candidate tsquery, not the access boundary. + /// the candidate tsquery, not the access boundary. A trailing token that + /// yields a lexeme shorter than three characters returns no hits unless the + /// query is kind:0-only (see `PREFIX_MIN_CHARS`). Prefix, } @@ -149,8 +151,26 @@ const SEARCH_TEXT_MAX_CHARS: usize = 4096; /// bridge uses page 1), but clamp here too so a future caller cannot accidentally /// wire untrusted input into a multi-trillion-row OFFSET. const PAGE_MAX: u32 = 1000; +/// Shortest lexeme [`SearchMode::Prefix`] gives `:*` for anything but a +/// kind:0-only (profile) query. If the trailing token yields a shorter one, +/// the tsquery is NULL and the search returns no hits, whatever completed +/// words precede it. The check runs on Postgres' own `simple` parser output, +/// so punctuation such as `c++`, `@al` or `don't` cannot slip a short `:*` +/// past it. +/// The GIN index cannot stop early or return rows in rank order, so a one- or +/// two-character `:*` prefix makes Postgres fetch, recheck and rank a large +/// share of every indexed message before it can apply `LIMIT`, and typeahead +/// sends one on every typing pause. Profiles are few enough to stay +/// one-character typeahead. +const PREFIX_MIN_CHARS: i32 = 3; -fn push_tsquery(qb: &mut QueryBuilder, mode: SearchMode, search_text: &str) { +/// `min_prefix_chars` applies to [`SearchMode::Prefix`] only; 0 disables it. +fn push_tsquery( + qb: &mut QueryBuilder, + mode: SearchMode, + search_text: &str, + min_prefix_chars: i32, +) { match mode { SearchMode::FullText => { qb.push("websearch_to_tsquery('simple', "); @@ -164,14 +184,20 @@ fn push_tsquery(qb: &mut QueryBuilder, mode: SearchMode, search_ // tsquery construction so query-side normalization matches the // `search_tsv` generated column. `quote_literal` prevents tsquery // syntax injection from punctuation/operators in the raw topbar input. + // A trailing lexeme shorter than `min_prefix_chars` makes the whole + // tsquery NULL, which matches nothing (see `PREFIX_MIN_CHARS`). qb.push( - "(SELECT COALESCE(\ + "(SELECT CASE WHEN bool_or(token_ord = max_token_ord AND char_length(lexeme) < ", + ); + qb.push_bind(min_prefix_chars); + qb.push( + ") THEN NULL::tsquery ELSE COALESCE(\ string_agg(\ quote_literal(lexeme) || CASE WHEN token_ord = max_token_ord THEN ':*' ELSE '' END, \ ' & ' ORDER BY token_ord, lex_ord\ ), \ ''\ - )::tsquery \ + )::tsquery END \ FROM (\ SELECT raw_token.token_ord, normalized.lexeme, normalized.lex_ord, raw_token.max_token_ord \ FROM (\ @@ -228,6 +254,7 @@ fn normalized_search_text(q: &str) -> Option { /// is no code path through this function that omits it. #[datastore_span(name = "search", system = "postgresql")] pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result { + let profile_only = query.kinds.as_deref() == Some(&[0][..]); let Some(search_text) = normalized_search_text(&query.q) else { return Ok(SearchResult { hits: Vec::new(), @@ -250,7 +277,7 @@ pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result = QueryBuilder::new( @@ -259,7 +286,8 @@ pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result = result.hits.iter().map(|h| h.event_id).collect(); + let mut want = expected.clone(); + got.sort(); + want.sort(); + assert_eq!(got, want, "{q:?} {kinds:?} {sort:?}"); + } + // A short trailing lexeme is a prefix-mode rule only; full text still + // matches the contraction exactly. + let full_text_contraction = svc + .search(&SearchQuery { + community: c, + q: "don't".into(), + channel_scope: ChannelScope::Any, + kinds: Some(vec![9]), + authors: None, + since: None, + until: None, + page: 1, + per_page: 10, + mode: buzz_search::SearchMode::FullText, + sort: buzz_search::SearchSort::Relevance, + }) + .await + .expect("full text search ok"); + assert_eq!( + full_text_contraction + .hits + .iter() + .map(|h| h.event_id) + .collect::>(), + vec![contraction] + ); + let multi_token_prefix = svc .search(&SearchQuery { community: c, @@ -419,7 +616,7 @@ async fn prefix_mode_matches_final_token_prefix_without_changing_full_text() { let completed_token_is_exact = svc .search(&SearchQuery { community: c, - q: "project pl".into(), + q: "project plan".into(), channel_scope: ChannelScope::Any, kinds: Some(vec![9]), authors: None, @@ -469,7 +666,7 @@ async fn prefix_mode_handles_tsquery_boundary_punctuation() { let result = svc .search(&SearchQuery { community: c, - q: "operators ' : & | ( ) ! alpha be".into(), + q: "operators ' : & | ( ) ! alpha bet".into(), channel_scope: ChannelScope::Any, kinds: Some(vec![9]), authors: None,