Files
silo-server/internal/catalog/search_query.go
b96e359b4e feat(catalog): typo-tolerant Postgres search fallback (did-you-mean) (#386)
* feat(catalog): typo-tolerant search for the Postgres (non-Meilisearch) path

## What this does (plain language)

When someone searches the library and misspells a title — "intersteller",
"godfathr", "jurasic" — the Postgres-backed search used to return nothing,
because it only did exact full-text matching. This adds a "did you mean"
fallback: when the normal search finds little or nothing, we run a second,
typo-tolerant lookup and surface the closest titles.

This only affects deployments that search via Postgres (the fallback path).
Meilisearch already does its own typo tolerance and is left untouched.

## Why not just make the main query fuzzy

The obvious approach — OR a trigram similarity match into the main search — is a
performance trap. The trigram operator is "lossy", so Postgres re-checks every
near-miss candidate by rebuilding three title search-vectors per row. On a real
library that turned routine searches into multi-second queries.

Measured on a 175k-title dev database:
  - exact full-text only:               ~60 ms
  - fuzzy OR'd into the main query:      ~217 ms (and far worse on prod-sized data)

## How it works

The fuzzy arm is a completely separate query (buildFuzzySearchSQL). It matches
only on the trigram-indexed title_normalized column and ranks only by
similarity() on that same column — it never touches the title search-vectors, so
it pays no per-row rebuild. It runs only when the exact search is "sparse" (fewer
than 5 hits) and the query is long enough for the trigram index to help (>= 4
characters), so the common case stays on the fast exact path. It is wired into
SearchPage (not just the thin Search wrapper) so the catalog search provider
benefits too.

## Measured on the live 183k-title catalog (read-only EXPLAIN ANALYZE)

  - exact query for a typo:   ~0.8 ms (0 hits -> triggers the fallback)
  - fuzzy fallback query:     ~5-27 ms, always via the trigram index, with no
                              search-vector rebuild
  - "intersteller" -> Interstellar (similarity 0.63)
  - "godfathr"     -> GodFather (0.58), The Godfather
  - "breakin" (134 exact hits) -> fuzzy correctly does NOT fire

Shared scope predicates (type / library / access / manga-exclusion) are extracted
into appendSearchScopeFilters so the exact and fuzzy queries filter identically.

Adapted from the earlier feat/search-fuzzy-fallback prototype onto main's current
SearchPage / includeTotal architecture.

* refactor(catalog): correct fuzzy-search pagination and parse the query once

Follow-up to the fuzzy fallback, from an adversarial code review. Two things: a
pagination correctness fix and a small performance/readability cleanup. Both were
validated against the live 183k-title catalog.

## The pagination bug (plain language)

Fuzzy results are shown after the exact results, as one combined list. The first
version stitched that list together with page-offset math, and got the math wrong
past the first page:

  - the reported result count grew as you paged (page 1 said "31 results",
    page 2 said "33");
  - titles shown on page 1 could reappear on page 2;
  - paging far past the end still ran the (pointless) fuzzy query every time;
  - a tiny page size (e.g. an autocomplete asking for 3) could hide the fuzzy
    results behind a page the client was told did not exist.

## The fix

Because the fuzzy fallback only runs when exact results are sparse (< 5) and the
fuzzy part is capped at 50, the whole combined list is tiny. So instead of
fragile per-page offset math, we now fetch that small combined list once and take
the requested slice in memory. Every page is then correct by construction: stable
total, no repeats, no wasted work past the end.

Before -> after, typo search "intersteller" (21 results, page size 5):
  - total reported on page 2:        31 then 33 (drifting)  ->  21 (stable)
  - repeated titles across pages:    yes                    ->  none
  - request past the end (offset 500): 2-3 DB queries       ->  0 extra queries
  - autocomplete (page size 1):      fuzzy hidden           ->  paginates correctly

Cursor-style callers (that don't ask for a total) can't locate the boundary
between the two blocks on a later page, so they now get the fuzzy results as a
single terminal first page — no misleading "more results" flag.

## The cleanup

The raw query string was being parsed three times per search (once for the
eligibility check, once in each SQL builder). It is now parsed once in SearchPage
and passed down; the shared search-text derivation is extracted so the two
builders can't disagree; and the normalized form the eligibility gate needs is
precomputed at parse time. ("Performance first", per the repo guidelines.)

Also considered and rejected: excluding exact hits from the fuzzy query with a
NOT(full-text) clause instead of by id. It reintroduced the search-vector rebuild
the whole design avoids — measured ~51 ms vs ~20 ms on the worst case — so
id-based exclusion stayed.

Known limitation: the fuzzy path re-reads the small exact block in a second
query, so a title written in the sub-millisecond gap between the two reads could
be missed until the next search. Harmless and inherent to a multi-query design.

* fix(catalog): close fuzzy-search library-scope leak and restore small-limit cursor recall

Addresses two findings from the PR #386 review bots.

## Library-scope leak (Codex P1)

The search scope helper shared by the FTS query and the trigram fuzzy fallback
filtered libraries with `JOIN media_item_libraries mil` +
`NOT (mil.media_folder_id = ANY($disabled))`. An item linked to BOTH a disabled
and a non-disabled library fans out to two joined rows; the non-disabled row
satisfies the deny check, GROUP BY collapses the item back, and it surfaces in
search results despite the disabled library. Because the new fuzzy fallback
reuses this helper, typo searches could leak disabled-library items too.

appendSearchScopeFilters now delegates to the leak-safe
appendLibraryAccessConditions (access_filter.go), which emits item-scoped
EXISTS/NOT EXISTS subqueries — the same form GetByIDs/EnsureAccessible already
use — and needs no membership JOIN. The disabled-only path keeps its
argument-free positive-membership EXISTS so orphan items don't slip through a
vacuous NOT EXISTS. The scored CTEs keep GROUP BY (now required only for the
MAX() ranking aggregates). New regression test pins the EXISTS/NOT EXISTS shape
and the absence of a JOIN for both the FTS and fuzzy builders.

## Small-limit cursor recall (Codex P2)

In cursor mode (include_total=false) the FTS probe fetched only limit+1 rows.
For a tiny caller limit (e.g. an autocomplete asking for 2) with a few incidental
exact hits, that made ftsHasMore true, so the block never looked "sparse" and the
typo fallback never fired — and subsequent offsets are barred from triggering it,
so the fuzzy results were unreachable entirely.

SearchPage now floors the cursor-mode probe at fuzzyFallbackThreshold rows, and
execSearchBlock returns the pre-trim row count so sparsity is judged as
`fetched < threshold` independent of the caller's page size. The returned page is
still trimmed to limit with correct hasMore. Exact mode is unchanged (it judges
sparsity by the page-independent window count).

* fix(catalog): harden fuzzy-search fallback per adversarial review

Addresses the confirmed findings from a deep review of the fuzzy-search
fallback:

- Cursor mode now enters the fallback only when the whole sparse FTS
  block fits the caller's page, so the terminal fuzzy page can never
  hide exact matches the plain hasMore path would have surfaced
  (jellycompat clients with EnableTotalRecordCount=false lost matches).
- execSearchBlock takes a querier and returns its untrimmed rows;
  SearchPage hands the already-fetched block to the fallback instead of
  re-running an identical FTS query on every sparse search.
- Fuzzy truncation is detected with LIMIT cap+1 instead of a
  COUNT(*) OVER () window count that only fed a debug log; truncated
  exact-mode responses now report total_exact=false rather than
  presenting the cap as an exact count.
- The fuzzy query runs in a transaction pinning
  pg_trgm.similarity_threshold via SET LOCAL, so match quality cannot
  drift with cluster configuration.
- When the FTS block has real hits, fuzzy augmentation demands
  similarity >= 0.45 so correctly-spelled sparse queries only gain
  near-identical titles instead of base-threshold trigram noise.
- filterCatalogSearchItems no longer erases fuzzy matches on the
  filtered/sorted/prefix resolver path: a typo token is never a
  substring of the titles it matched, which left typo search returning
  zero results there while the plain search box showed matches.
- The cursor probe floor applies only when the fallback can fire;
  cursor fuzzy fetches no more rows than the terminal page can serve.
- slog.Debug -> slog.DebugContext (sloglint); reuse
  contentIDsFromMediaItems instead of a duplicate helper; document the
  title-only fuzzy scope.

Verified against the dev deployment: stable totals across pages, no
duplicates, small-limit cursor recall restored, filtered-path typo
search working, ~160ms typo-path latency.

Part of PR #386.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(catalog): reach long titles via strict word similarity in fuzzy search

Full-string trigram similarity is diluted by every extra trigram a long
title contributes, so a typo of one word could never reach titles like
"Avengers: Endgame" ("avegners" scores ~0.38 against "avengers" but far
below threshold against the full title). Swap the fuzzy predicate from %
to <<% (strict_word_similarity), which scores the query against the best
word-boundary extent of the title. At equal thresholds <<% is a strict
superset of %, and the existing gin_trgm_ops index serves both — no
migration needed.

The SET LOCAL pin moves to pg_trgm.strict_word_similarity_threshold and
is load-bearing: the 0.6 server default would reject ordinary one-edit
typos outright.

Ranking is strict word similarity first with whole-title similarity()
as tie-break, so near-identical short titles ("The Avengers") sort above
long titles that merely contain the matched word.

The 0.45 augmentation floor deliberately stays on whole-title
similarity(): word similarity rates embedded prefix words far too high
("coral" scores 0.5 against "coraline"), which dev testing showed would
flood a correctly-spelled sparse query with 27 noise rows. Zero-hit
(true typo) queries skip the floor, so the new long-title recall applies
where it matters.

Dev-verified: "avegners" now returns The Avengers first, then Avengers
Grimm / Avengers: Endgame; "coraline" still returns exactly its 4 real
titles; cursor small-limit recall, filtered-path typo search, pagination
stability, and ~160ms typo-path latency all unchanged.

Part of PR #386.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 20:56:07 -04:00

278 lines
6.7 KiB
Go

package catalog
import (
"strconv"
"strings"
"unicode"
)
type parsedSearchQuery struct {
Raw string
Text string
Phrase string
ExactTitleHint string
// NormalizedText is normalizeTitleForComparison(Text) computed once at parse
// time. Text already folds phrase + remainder together, so this is the full
// normalized query used by eligibleForFuzzy's token gate (which would
// otherwise re-normalize on every sparse search).
NormalizedText string
Year *int
}
func parseSearchQuery(raw string) parsedSearchQuery {
trimmed := collapseSearchWhitespace(strings.TrimSpace(raw))
phrase, remainder := extractBalancedPhrase(trimmed)
year, remainder := extractYearHint(remainder, phrase != "")
parts := make([]string, 0, 2)
if phrase != "" {
parts = append(parts, phrase)
}
if remainder != "" {
parts = append(parts, remainder)
}
text := collapseSearchWhitespace(strings.Join(parts, " "))
if text == "" {
text = collapseSearchWhitespace(strings.ReplaceAll(trimmed, "\"", " "))
}
return parsedSearchQuery{
Raw: raw,
Text: text,
Phrase: phrase,
ExactTitleHint: normalizeTitleForComparison(firstNonEmptySearchValue(phrase, text)),
NormalizedText: normalizeTitleForComparison(text),
Year: year,
}
}
// fuzzyMinTokenLen is the shortest normalized token that may enable the trigram
// fuzzy title fallback. A token shorter than this forms too few trigrams to use
// the gin_trgm_ops index selectively (and a 1-2 char token can't use it at
// all), so for short queries the fuzzy fallback is skipped and search stays on
// the exact FTS/prefix path. The gate is applied to the longest token so a stray
// short token ("a vengers") is judged on "vengers", not "a".
const fuzzyMinTokenLen = 4
// eligibleForFuzzy reports whether a parsed query clears the min-token gate for
// the trigram fuzzy title fallback.
func eligibleForFuzzy(parsed parsedSearchQuery) bool {
longest := 0
for _, tok := range strings.Fields(parsed.NormalizedText) {
if n := len([]rune(tok)); n > longest {
longest = n
}
}
return longest >= fuzzyMinTokenLen
}
func extractBalancedPhrase(input string) (string, string) {
start := strings.Index(input, "\"")
if start == -1 {
return "", input
}
end := strings.Index(input[start+1:], "\"")
if end == -1 {
return "", collapseSearchWhitespace(strings.ReplaceAll(input, "\"", " "))
}
end += start + 1
phrase := collapseSearchWhitespace(input[start+1 : end])
remainder := collapseSearchWhitespace(strings.Join([]string{
input[:start],
input[end+1:],
}, " "))
return phrase, remainder
}
func extractYearHint(input string, hasPhrase bool) (*int, string) {
fields := strings.Fields(input)
if len(fields) == 0 {
return nil, ""
}
for i := len(fields) - 1; i >= 0; i-- {
year, ok := parseYearToken(fields[i])
if !ok {
continue
}
if len(fields) == 1 && !hasPhrase {
return nil, collapseSearchWhitespace(input)
}
remaining := append([]string{}, fields[:i]...)
remaining = append(remaining, fields[i+1:]...)
return &year, collapseSearchWhitespace(strings.Join(remaining, " "))
}
return nil, collapseSearchWhitespace(input)
}
func parseYearToken(token string) (int, bool) {
if len(token) != 4 {
return 0, false
}
year, err := strconv.Atoi(token)
if err != nil {
return 0, false
}
if year < 1900 || year > 2100 {
return 0, false
}
return year, true
}
func buildTitlePrefixTsQuery(input string) string {
normalized := normalizeTitleForComparison(input)
if normalized == "" {
return ""
}
fields := strings.Fields(normalized)
if len(fields) == 0 {
return ""
}
parts := make([]string, 0, len(fields))
for i, field := range fields {
if field == "" {
continue
}
if i == len(fields)-1 {
parts = append(parts, field+":*")
continue
}
parts = append(parts, field)
}
return strings.Join(parts, " & ")
}
// normalizeTitleForComparison must stay in lockstep with the SQL function
// public.normalize_search_text (migrations 127 / 138) and the title_normalized
// generated column. Mismatches between Go and SQL normalization produce
// asymmetric search results (Go-computed ExactTitleHint failing to match a
// row whose title_normalized has the same logical content).
func normalizeTitleForComparison(input string) string {
var b strings.Builder
b.Grow(len(input))
for _, r := range input {
switch {
case unicode.IsLetter(r), unicode.IsDigit(r):
b.WriteRune(unicode.ToLower(r))
default:
b.WriteByte(' ')
}
}
return normalizeSearchTokens(collapseSearchWhitespace(b.String()))
}
// normalizeSearchTokens drops the standalone token "and" from a
// whitespace-separated lowercase string and maps common number words /
// ordinals to digit tokens. Together with the alphanumeric pass above, this
// mirrors public.normalize_search_text().
func normalizeSearchTokens(input string) string {
if input == "" {
return ""
}
fields := strings.Fields(input)
filtered := fields[:0]
for _, f := range fields {
if f == "and" {
continue
}
filtered = append(filtered, normalizeSearchNumberToken(f))
}
return strings.Join(filtered, " ")
}
func normalizeSearchNumberToken(token string) string {
switch token {
case "zero", "zeroth":
return "0"
case "one", "first":
return "1"
case "two", "second":
return "2"
case "three", "third":
return "3"
case "four", "fourth":
return "4"
case "five", "fifth":
return "5"
case "six", "sixth":
return "6"
case "seven", "seventh":
return "7"
case "eight", "eighth":
return "8"
case "nine", "ninth":
return "9"
case "ten", "tenth":
return "10"
case "eleven", "eleventh":
return "11"
case "twelve", "twelfth":
return "12"
case "thirteen", "thirteenth":
return "13"
case "fourteen", "fourteenth":
return "14"
case "fifteen", "fifteenth":
return "15"
case "sixteen", "sixteenth":
return "16"
case "seventeen", "seventeenth":
return "17"
case "eighteen", "eighteenth":
return "18"
case "nineteen", "nineteenth":
return "19"
case "twenty", "twentieth":
return "20"
}
if stripped, ok := stripDigitOrdinalSuffix(token); ok {
return stripped
}
return token
}
func stripDigitOrdinalSuffix(token string) (string, bool) {
for _, suffix := range []string{"st", "nd", "rd", "th"} {
stem := strings.TrimSuffix(token, suffix)
if stem != token && hasOnlyDigits(stem) {
return stem, true
}
}
return "", false
}
func hasOnlyDigits(input string) bool {
if input == "" {
return false
}
for _, r := range input {
if !unicode.IsDigit(r) {
return false
}
}
return true
}
func collapseSearchWhitespace(input string) string {
return strings.Join(strings.Fields(input), " ")
}
func firstNonEmptySearchValue(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}