fix(search): skip message prefix search below three characters (#8947)

## 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 <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Luke Tornquist
2026-11-03 11:08:00 -05:00
committed by GitHub
parent 58eb36dd57
commit d005c7bb9c
2 changed files with 234 additions and 9 deletions

View File

@@ -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<sqlx::Postgres>, mode: SearchMode, search_text: &str) {
/// `min_prefix_chars` applies to [`SearchMode::Prefix`] only; 0 disables it.
fn push_tsquery(
qb: &mut QueryBuilder<sqlx::Postgres>,
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<sqlx::Postgres>, 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<String> {
/// 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<SearchResult, SearchError> {
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<SearchResult,
// first for this one narrow caller shape.
let prioritize_exact_profile_lexeme = query.sort == SearchSort::Relevance
&& query.mode == SearchMode::Prefix
&& query.kinds.as_deref() == Some(&[0][..])
&& profile_only
&& search_text.chars().count() <= 2;
let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new(
@@ -259,7 +286,8 @@ pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result<SearchResult,
ts_rank_cd(search_tsv, search_query.query) AS rank \
FROM events CROSS JOIN LATERAL (SELECT ",
);
push_tsquery(&mut qb, query.mode, &search_text);
let min_prefix_chars = if profile_only { 0 } else { PREFIX_MIN_CHARS };
push_tsquery(&mut qb, query.mode, &search_text, min_prefix_chars);
qb.push(" AS query) AS search_query WHERE community_id = ");
qb.push_bind(*query.community.as_uuid());
qb.push(" AND deleted_at IS NULL AND search_tsv @@ search_query.query");
@@ -423,7 +451,7 @@ pub async fn search_profile_names(
") AS tier \
FROM events e CROSS JOIN LATERAL (SELECT ",
);
push_tsquery(&mut qb, SearchMode::Prefix, &search_text);
push_tsquery(&mut qb, SearchMode::Prefix, &search_text, 0);
qb.push(" AS query, ");
qb.push(TIER_TEXT[0]);
qb.push_bind(search_text.clone());

View File

@@ -349,6 +349,44 @@ async fn prefix_mode_matches_final_token_prefix_without_changing_full_text() {
1_700_000_002,
)
.await;
// Rows whose own parser output contains a short lexeme, so a short
// trailing `:*` from punctuation in the query would match them.
let contraction = rand_bytes32();
let hyphenated = rand_bytes32();
let profile = rand_bytes32();
insert_event(
&pool,
c,
contraction,
rand_bytes32(),
9,
"don't panic",
None,
1_700_000_003,
)
.await;
insert_event(
&pool,
c,
hyphenated,
rand_bytes32(),
9,
"pre-processing done",
None,
1_700_000_004,
)
.await;
insert_event(
&pool,
c,
profile,
rand_bytes32(),
0,
r#"{"display_name":"prof"}"#,
None,
1_700_000_005,
)
.await;
let svc = SearchService::new(pool.clone());
let full_text = svc
@@ -397,6 +435,165 @@ async fn prefix_mode_matches_final_token_prefix_without_changing_full_text() {
assert!(prefix_ids.contains(&project_plan));
assert!(prefix_ids.contains(&project_archive));
// Non-profile prefix search returns no hits, in either sort, whatever the
// kinds and whatever completed words precede it, when the trailing token's
// `simple` parser output has a lexeme below three characters. Punctuation
// must not pad the count: `pr.`, `@pr` and `#pr` still yield `pr`, `don't`
// yields `t` and `pre-p` yields `p`. Each row would match without the
// minimum. kind:0-only stays one-character typeahead.
for (q, kinds, sort, expected) in [
(
"pr",
Some(vec![9]),
buzz_search::SearchSort::Relevance,
vec![],
),
(
" p ",
Some(vec![9]),
buzz_search::SearchSort::Relevance,
vec![],
),
(
"project pr",
Some(vec![9]),
buzz_search::SearchSort::Relevance,
vec![],
),
(
"project p ",
Some(vec![9]),
buzz_search::SearchSort::Relevance,
vec![],
),
("pr", None, buzz_search::SearchSort::Relevance, vec![]),
(
"pr",
Some(vec![0, 9]),
buzz_search::SearchSort::Relevance,
vec![],
),
("pr", Some(vec![9]), buzz_search::SearchSort::Newest, vec![]),
(
"pr.",
Some(vec![9]),
buzz_search::SearchSort::Relevance,
vec![],
),
(
"@pr",
Some(vec![9]),
buzz_search::SearchSort::Relevance,
vec![],
),
(
"#pr",
Some(vec![9]),
buzz_search::SearchSort::Relevance,
vec![],
),
(
"...p",
Some(vec![9]),
buzz_search::SearchSort::Relevance,
vec![],
),
(
"don't",
Some(vec![9]),
buzz_search::SearchSort::Relevance,
vec![],
),
(
"pre-p",
Some(vec![9]),
buzz_search::SearchSort::Relevance,
vec![],
),
// Controls: padded but long enough, punctuation-only trailing token
// (no `:*` lexeme, so the completed word matches exactly), long
// hyphenated parts, a short completed word (exact, so not limited),
// and a one-character profile query.
(
"pro.",
Some(vec![9]),
buzz_search::SearchSort::Relevance,
vec![project_plan, project_archive, projectile_plan, hyphenated],
),
(
"project ?",
Some(vec![9]),
buzz_search::SearchSort::Relevance,
vec![project_plan, project_archive],
),
(
"pre-pro",
Some(vec![9]),
buzz_search::SearchSort::Relevance,
vec![hyphenated],
),
(
"t pan",
Some(vec![9]),
buzz_search::SearchSort::Relevance,
vec![contraction],
),
(
"p",
Some(vec![0]),
buzz_search::SearchSort::Relevance,
vec![profile],
),
] {
let result = svc
.search(&SearchQuery {
community: c,
q: q.into(),
channel_scope: ChannelScope::Any,
kinds: kinds.clone(),
authors: None,
since: None,
until: None,
page: 1,
per_page: 10,
mode: buzz_search::SearchMode::Prefix,
sort,
})
.await
.expect("prefix search ok");
let mut got: Vec<[u8; 32]> = 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<_>>(),
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,