# Search waterfall How /api/search decides, tier by tier, in the order do_search runs them — with a simulator that runs your query against the real API and lights the tier that answered. - Page: - Updated: 2026-09-25 - Verified against: `7a343c6` on 2026-09-25 - From the Sri Guru Granth Sahib Ji — Knowledge Base wiki. Sri Guru Granth Sahib Ji is quoted verbatim, cited by Ang, and never altered. --- `/api/search?q=&mode=` runs a deliberate **waterfall**: cheap exact tiers first, progressively fuzzier tiers only when the earlier ones found nothing, results bm25-ranked with weighted columns. The poster shows the cascade as `do_search` codes it; the simulator below runs a real query and lights the tier that answered. The tiers in detail, the fold, the verification engine and the harnesses are in [Search & verification](https://docs.gurbanisoul.com/search/). ![Poster 05 — The search waterfall: normalise, mixed script, explicit modes, then the auto-mode cascade of twelve tiers and the ranking](https://docs.gurbanisoul.com/posters/05-search-waterfall.svg) ## Try it On the rendered wiki this is a simulator: type a query, pick a mode, and the `mode` the API reports names the tier that answered while the matching step of the poster lights up; results are shown verbatim with their Ang. On GitHub, try the same queries on [gurbanisoul.com](https://gurbanisoul.com). ## The tiers, in code order Every `mode` string below is a literal in `webapp/sggs/search.py`; `tools/docs_check.py` fails the build if the code gains a tier this page does not name. | # | Runs when | What it queries | `mode` reported | |---|---|---|---| | 1 | always | length ≤ 300; dandas and punctuation → spaces; script detected | — | | 2 | auto · Gurmukhi **and** Latin present | `mixed_search` (one AND over text, variants, lexicon, fold); else the Latin tokens alone, re-run | `mixed-script`, `mixed-script (latin part: …)` | | 3 | an explicit `mode` | `gurmukhi` → text FTS then skeleton; `roman` → translit then the fold; `first` → `fl_g`/`fl_r` phrase; `theme` → concept index; `english` → `fts_en` | `gurmukhi`, `gurmukhi-skeleton`, `roman`, `roman-spelling-tolerant`, `first-letters`, `theme`, `english` | | 4 | auto · Gurmukhi | all single-letter tokens → `fl_g` phrase; else text FTS; else vowel signs stripped → `skeleton LIKE` | `first-letters`, `gurmukhi`, `gurmukhi-skeleton` | | 5 | auto · Roman | exactly a concept name → theme; ≥ 2 tokens of ≤ 3 letters → `fl_r` phrase (then translit); else translit FTS | `theme`, `first-letters`, `roman` | | 6 | nothing yet | the seeker lexicon: curated words → a transliteration term or a theme | `seeker-lexicon (term)` | | 7 | nothing yet | the precomputed romanisation-variant index, hybrid per-token resolution | `variant-match` | | 8 | nothing yet | the English layer, `fts_en` — before the fold, so "mercy" reaches translations | `english-translation` | | 9 | nothing yet, honorifics present | drop `ji`, `sahib`, `maharaj`, `guru`, `dev`, `baba`, … and re-run the waterfall | `… (honorifics dropped)` | | 10 | nothing yet, ≥ 3 tokens | folded tokens AND at composition level in `fts_shabad`; the composition's lines in order | `passage-match (quote spans lines)` | | 11 | nothing yet | the phonetic fold: `roman_norm(query)` against `translit_norm`, tokens ≥ 2 chars, headings excluded | `roman-spelling-tolerant` | | 12 | nothing yet, ≤ 14 tokens | `blob_search`: the query collapsed to a fold-skeleton blob, prefiltered and fuzzy-ranked; a single token is tried as a theme | `skeleton-blob`, `theme` | | — | after any tier, auto, < 3 results | the honorific-dropped retry tops up the list | `… + honorific-dropped` | Then, whatever tier answered: `bm25` ranking with column weights **10 · 5 · 4 · 3 · 3 · 1** over `text`, `translit`, `translit_norm`, `fl_g`, `fl_r`, `skeleton`; translations attached to every result; related themes for a Gurmukhi query. Nothing is guessed: when every tier is exhausted the list is empty and the last tier reached is the `mode`. ```python def do_search(q, mode, limit, offset): if len(q) > 300: raise ValueError('query too long (max 300 chars)') q = PUNCT_RE.sub(' ', q).strip() # dandas & punctuation are separators q = re.sub(r'\s+', ' ', q) if not q: return {'mode': mode, 'results': []} if core.HAVE_FTS is None: core.HAVE_FTS = have_fts() toks = q.split() is_gurmukhi = bool(GURMUKHI.search(q)) def run(col, phrase=False): if core.HAVE_FTS: return search_fts(col, q, phrase, limit, offset) return search_like('text' if col == 'text' else col, q, limit, offset) latin_present = bool(re.search('[a-zA-Z]', q)) if mode == 'auto' and is_gurmukhi and latin_present: ms = mixed_search(q, limit, offset) if ms: return {'mode': 'mixed-script', 'results': ms} q_lat = ' '.join(t for t in toks if not GURMUKHI.search(t)) if q_lat: sub = do_search(q_lat, 'auto', limit, offset) # drop Gurmukhi tokens, retry if sub.get('results'): sub['mode'] = 'mixed-script (latin part: ' + sub['mode'] + ')' return sub # explicit modes if mode == 'gurmukhi': res = run('text'); used = 'gurmukhi' if not res and core.HAVE_FTS: res = search_like('skeleton', re.sub('[ਾਿੀੁੂੇੈੋੌੰਂ੍]', '', q), limit, offset); used = 'gurmukhi-skeleton' elif mode == 'roman': res = run('translit'); used = 'roman' if not res and core.HAVE_FTS: strong = ' '.join(t for t in roman_norm(q).split() if len(t) >= 2) if strong: res = search_fts('translit_norm', strong, False, limit, offset, no_headers=True) used = 'roman-spelling-tolerant' elif mode == 'first': col = 'fl_g' if is_gurmukhi else 'fl_r' res = run(col, phrase=True); used = 'first-letters' elif mode == 'theme': return theme_search(q, limit, offset) elif mode == 'english': res = search_en(q, limit, offset); used = 'english' else: # auto if is_gurmukhi: single_letters = all(len(t) == 1 for t in toks) and len(toks) >= 2 if single_letters: res = run('fl_g', phrase=True); used = 'first-letters' else: res = run('text'); used = 'gurmukhi' if not res: res = search_like('skeleton', re.sub('[ਾਿੀੁੂੇੈੋੌੰਂ੍]', '', q), limit, offset); used = 'gurmukhi-skeleton' else: ql = q.lower() cpt = db().execute('SELECT concept FROM concepts WHERE concept = ?', (ql,)).fetchone() if cpt and ql not in LEXICON_OVER_SLUG: return theme_search(ql, limit, offset) if all(len(t) <= 3 for t in toks) and len(toks) >= 2: res = run('fl_r', phrase=True); used = 'first-letters' if not res: res = run('translit'); used = 'roman' else: res = run('translit'); used = 'roman' if not res: # curated seeker words lx = lexicon_search(q, limit, offset) if lx: return lx if not res: # precomputed variant index vr = variant_search(q, limit, offset) if vr: return {'mode': 'variant-match', 'results': vr} if not res: # English layer before fold: res = search_en(q, limit, offset) # 'mercy' must hit translations, used = 'english-translation' # not fold-collide with ਮੋਰਚਾ if not res: # hallucinated honorifics: retry early kept = [t for t in toks if t.lower() not in {'ji','jee','jeo','jio','sahib','maharaj','maharaaj','shri','shree','sri','baba','guru','dev','waale','wale'}] if 2 <= len(kept) < len(toks): sub = do_search(' '.join(kept), 'auto', limit, offset) if sub.get('results'): sub['mode'] = sub['mode'] + ' (honorifics dropped)' return sub if not res and len(toks) >= 3: # quote spans ॥ lines — ps = passage_search(q, limit, offset) # shabad-level AND is more if ps: return {'mode': 'passage-match (quote spans lines)', 'results': ps} # precise than per-line fold if not res and core.HAVE_FTS: strong = ' '.join(t for t in roman_norm(q).split() if len(t) >= 2) if strong: res = search_fts('translit_norm', strong, False, limit, offset, no_headers=True) used = 'roman-spelling-tolerant' if not res and len(toks) <= 14: # blob targets short/spaceless smash; only an bl = blob_search(q, limit, offset) # absurd 15+ token paste can't collapse to one if bl: return {'mode': 'skeleton-blob', 'results': attach_translations(bl)} # blob line — skip just those if not res and len(toks) == 1: t = theme_search(q, limit, offset) if t['results']: return t HONORIFICS = {'ji', 'jee', 'jeo', 'sahib', 'maharaj', 'maharaaj', 'shri', 'shree', 'sri', 'baba', 'guru', 'dev', 'waale', 'wale', 'jio'} if len(res) < 3 and mode == 'auto': kept = [t for t in toks if t.lower() not in HONORIFICS] if len(kept) >= 2 and len(kept) < len(toks): sub = do_search(' '.join(kept), 'auto', limit, offset) if sub.get('results'): seen = {r['id'] for r in res} merged = [r for r in sub['results'] if r['id'] not in seen] if not res: sub['mode'] = sub['mode'] + ' (honorifics dropped)' return sub res = (res + merged)[:limit] used = used + ' + honorific-dropped' out = {'mode': used, 'results': res} if is_gurmukhi: rel = term_concepts(toks) if rel: out['related_themes'] = rel[:3] return out ``` *`webapp/sggs/search.py`, lines 240–351, read at `c3d9360`: * Source: [`webapp/sggs/search.py` · `do_search`](https://github.com/Algorythmos-AI/sggs-platform/blob/integration/webapp/sggs/search.py) — the excerpt is read from the file at build time. ## The Roman fold — the highest-risk invariant `roman_norm` folds spelling variants (`waheguru`, `vaahiguroo` → `vhgr`). It exists in **three** places that must stay byte-identical: `webapp/romannorm.py` here (query time; re-exported by `serve.py`, imported by `verify.py`), `pipeline/sggs_pipeline.py:roman_norm` in sggs-data (built the `translit_norm` column) and `RomanNorm.swift` in the iOS app. `contract/golden_roman_norm.ndjson` (24,719 vectors) pins all three. The whole story is [The Roman fold](https://docs.gurbanisoul.com/search/the-roman-fold/). ## Verifying a search change ```bash python3 tools/roundtrip_harness.py python3 tools/casual_quote_harness.py python3 tools/chaos_harness.py "qa/chaos/attacks_*.jsonl" qa/results/chaos.jsonl # needs a running server make contract # golden vectors must not drift unexpectedly ``` Do not reorder tiers or change weights without re-running these: small changes shift ranking corpus-wide. What each harness measures is on [Harnesses and golden vectors](https://docs.gurbanisoul.com/search/harnesses-and-golden-vectors/).