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,/searchpage, and/api/searchall 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.