fix(search): surface exact short profile names (#5480)

## Summary

- prioritize exact whole-lexeme matches within short kind-0 prefix
searches
- preserve the existing prefix result set, pagination, community/channel
scope, hydration, and authorization path
- add a Postgres regression where newer noisy `jm…` profiles saturate
the bounded page

## Why

Desktop mention autocomplete starts searching after one character. The
`jm` profile is indexed and matches both `jm:*` prefix search and
standard full-text search, but production prefix search returns a full
50-result page without it. Raw profile JSON supplies enough unrelated
`jm…` lexemes that newer equal-rank matches fill the bounded page before
the exact short display name.

Changing clients would leave deployed Desktop 0.5.8 installations
broken. This shared search-layer compatibility fix changes ordering only
for `Prefix + kinds:[0] + query length <= 2`; message search, longer
profile typeahead, and agent eligibility are untouched.

## Validation

At commit `ff88761135d5045139aeb3da14d08cbfba203169` with a clean
worktree:

- `BUZZ_TEST_DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz
cargo test -p buzz-search --tests -- --include-ignored` — 22 passed (3
unit + 19 Postgres integration)
- `cargo clippy -p buzz-search --tests -- -D warnings`
- `cargo fmt --all -- --check`
- mutation check: disabling exact-lexeme priority makes
`short_kind0_prefix_prioritizes_exact_lexeme_on_a_noisy_page` fail
- mandatory pre-push hooks: branch-skew, Rust tests, and Desktop/Tauri
checks passed

## Risk

Low. The extra ordering predicate applies only to one- or two-character
prefix searches restricted exactly to kind 0. It does not add
candidates, bypass filters, or alter access control. Exact matches move
ahead of broader prefix matches; all remaining ordering stays relevance,
recency, then event ID.

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
This commit is contained in:
Wes
2026-08-10 08:58:45 -07:00
committed by GitHub
co-authored by Carl
parent 563e4346da
commit 3c76f682c3
2 changed files with 76 additions and 1 deletions
+15 -1
View File
@@ -229,6 +229,14 @@ pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result<SearchResult,
};
let page = query.page.clamp(1, PAGE_MAX);
let offset = ((page - 1) as i64) * (per_page_actual as i64);
// Profile typeahead uses broad prefix matching. For one- and two-character
// queries, a busy community can have enough newer prefix matches to push a
// short exact display name off the bounded first page. Keep the same result
// set and pagination contract, but put rows containing the whole lexeme
// first for this one narrow caller shape.
let prioritize_exact_profile_lexeme = query.mode == SearchMode::Prefix
&& query.kinds.as_deref() == Some(&[0][..])
&& search_text.chars().count() <= 2;
let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new(
"SELECT id, kind, pubkey, channel_id, \
@@ -292,7 +300,13 @@ pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result<SearchResult,
qb.push(")");
}
qb.push(" ORDER BY rank DESC, created_at DESC, id LIMIT ");
if prioritize_exact_profile_lexeme {
qb.push(" ORDER BY search_tsv @@ websearch_to_tsquery('simple', ");
qb.push_bind(&search_text);
qb.push(") DESC, rank DESC, created_at DESC, id LIMIT ");
} else {
qb.push(" ORDER BY rank DESC, created_at DESC, id LIMIT ");
}
qb.push_bind(per_page_actual as i64);
qb.push(" OFFSET ");
qb.push_bind(offset);
@@ -303,6 +303,67 @@ async fn kind0_search_by_display_name_works_without_flattening() {
teardown(pool, &schema).await;
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn short_kind0_prefix_prioritizes_exact_lexeme_on_a_noisy_page() {
let (pool, schema) = setup().await;
let c = mk_community(&pool, "short-profile-prefix.example").await;
let exact_id = rand_bytes32();
insert_event(
&pool,
c,
exact_id,
rand_bytes32(),
0,
r#"{"display_name":"jm"}"#,
None,
1_700_000_000,
)
.await;
// These profiles all match jm:* and are newer than the exact name. Without
// exact-lexeme priority they consume the entire bounded first page.
for (i, display_name) in ["jma", "jmbravo", "jmcharlie", "jmdelta"]
.iter()
.enumerate()
{
insert_event(
&pool,
c,
rand_bytes32(),
rand_bytes32(),
0,
&format!(r#"{{"display_name":"{display_name}"}}"#),
None,
1_700_000_100 + i as i64,
)
.await;
}
let svc = SearchService::new(pool.clone());
let first_page = svc
.search(&SearchQuery {
community: c,
q: "jm".into(),
channel_scope: ChannelScope::Any,
kinds: Some(vec![0]),
authors: None,
since: None,
until: None,
page: 1,
per_page: 3,
mode: buzz_search::SearchMode::Prefix,
})
.await
.expect("short profile prefix search ok");
assert_eq!(first_page.hits.len(), 3);
assert_eq!(first_page.hits[0].event_id, exact_id);
teardown(pool, &schema).await;
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn prefix_mode_matches_final_token_prefix_without_changing_full_text() {