Skip to main content

Two-Lane Model

Every search query runs two parallel, independent lanes. Results are group-stacked by surface — not RRF-fused or score-blended.

User types query
       │
       ├─── Lane A (instant) ────────────────────────────────────►
       │    Prerendered shard fetched once on first palette open
       │    Matched in-browser via scoreRecords() (no network)
       │    Surfaces: page / showcase / section / doc titles
       │
       └─── Lane B (debounced 300 ms) ──────────────────────────►
            GET /api/search → searchContent()
            ├── searchDocs()   full-body lexical scan (in-memory corpus)
            └── searchBlog()   live Postgres tsvector @@ websearch_to_tsquery

Both lanes emit SearchResult[]. AppShell merges by destination key,
server result wins (richer snippet). Palette renders grouped by surface.

Lane A is immune to Neon cold-start. The shard is a prerendered CDN asset; after the one lazy fetch it stays in memory for the session.

Lane B failure is isolated. Each sub-lane (docs, blog) is wrapped in its own Promise.catch. A failing blog query never kills doc results; a failing docs scan never kills blog results.

Engine State

createSearchEngine() in $lib/state/search.svelte.ts — a Svelte 5 factory (.svelte.ts), not a singleton.

engine.instant    → SearchResult[]   (derived from in-memory shard match)
engine.async      → SearchResult[]   (latest server lane response)
engine.status     → 'idle' | 'loading' | 'done' | 'error'
engine.ensureLoaded()   prefetch shard (called on first palette open)
engine.setLocale(loc)   swap locale, reload shard
engine.setQuery(q)      drive both lanes; clears async if q is empty
engine.reset()          clear everything

The debounce timer is 300 ms. Stale server requests are aborted via AbortController.

AppShell.svelte owns the single engine instance and drives it via two $effect blocks — one for locale, one for query.

Dedup and Merge

Both lanes use the same SearchResult shape ($lib/search/types.ts). AppShell.svelte merges before passing to CommandPalette:

const key = (r) => `${r.surface}:${r.path}:${r.anchor ?? ''}`;
for (const r of search.instant) byDest.set(key(r), r);
for (const r of search.async)   byDest.set(key(r), r);  // server wins

The /search page server load applies the same logic (static match + searchContent, server result wins).

Result Grouping

Results are grouped by SearchSurface in a fixed display order defined in $lib/search/types.ts:

export const SURFACE_ORDER: SearchSurface[] = ['page', 'showcase', 'section', 'doc', 'blog'];

The palette caps per-group: pages → 6, showcases → 5, sections → 5, docs → 5, blog → 5, panels → 4, actions → 3.

Surfaces and Their Sources

Surface Source Lane
page Nav registry (public routes only) A
showcase Showcase card tree A
section showcaseSections (deep-link anchors) A
doc Markdown manifest (titles in A, full body in B) A + B
blog Postgres FTS on blog.revision B only
panel DESK_PANELS config (client, always) client
action Hardcoded: toggle-theme, shortcuts, ai-assistant client

API Endpoints

GET /api/search

Server lane. Public route, IP-rate-limited (40 req / 10 s), Valibot-validated.

Param Type Notes
q string Required. Max 200 chars.
locale en | de | ru Optional. Falls back to locals.locale then en.
limit 1–50 Optional. Defaults to 8.
scope all | docs | blog Optional. Defaults to all.

Response: { data: { items: SearchResult[] } }.

GET /api/search-index/[locale]

Lane-A shard. Prerendered at build time. Returns SearchRecord[] (titles only, no bodies). Unknown locales → 404. Supported: en, de, ru.

AI Reuse

buildSearchIndex(locale) and searchContent() are also consumed by the search_catalog AI tool (src/lib/server/ai/tools/search-catalog.ts). The tool composes both lanes in-process — same index, same merge logic, no separate embedding index. See blueprint/ai/layered-rag.md.

Shared Types

$lib/search/types.ts — client-safe, imported by both the browser engine and all server adapters.

  • SearchRecord — indexed item (what goes into the shard and the in-memory corpus)
  • SearchResult — ranked hit (what the palette, /search page, and /api/search all return)
  • SearchSurface, SearchLocale, SURFACE_ORDER

$lib/search/match.ts — pure lexical matcher used by Lane A (browser) and by the /search SSR load. Unicode-aware tokenization, AND semantics, phrase-bonus, locale-fallback rank penalty.

← Back to Blueprint

Think this pattern could be better? Tell us how.

Leave feedback