Architektura wyszukiwania po stronie klienta — indeks odwrócony, trie i stan URL dla 350 stron symulacji

Chcieliśmy natychmiastowego wyszukiwania po wszystkich symulacjach na stronie — bez zapytań do serwera, bez klucza API, działającego offline. Oto techniczny rozkład: jak zbudowaliśmy 28 kB indeks wyszukiwania, zapytania poniżej 10 ms, autouzupełnianie prefiksów i adresy URL wyszukiwania, którymi można się dzielić.

Problem

Przy 350 stronach symulacji stare podejście — filtrowanie zakodowanej na sztywno tablicy JavaScript — stawało się nieporęczne. Użytkownicy szukający „refrakcji" powinni znaleźć prawo Snella, całkowite wewnętrzne odbicie i powstawanie tęczy, a nie tylko strony z dokładnym słowem „refrakcja" w tytule. Potrzebowaliśmy pełnotekstowego wyszukiwania z rankingiem trafności, działającego w całości w przeglądarce, bez backendu.

Struktura danych: indeks odwrócony

Indeks wyszukiwania to zwykły obiekt JavaScript budowany w czasie wdrożenia z tytułu, opisu, kategorii i tagów każdej symulacji. Każdy token odwzorowuje listę wystąpień: tablicę identyfikatorów dokumentów wraz z ich wynikami częstości termu.

// Simplified index structure const index = { "refraction": [ { id: "snells-law", tf: 0.82 }, { id: "total-internal-refl", tf: 0.71 }, { id: "rainbow", tf: 0.44 }, ], "lorenz": [ { id: "lorenz", tf: 1.00 }, { id: "bifurcation", tf: 0.18 }, ], // ... ~4 200 tokens, 28 kB minified + gzip → 8.4 kB };

Wynik trafności = TF-IDF z niewielkim wzmocnieniem tytułu: tokeny w tytule liczą się 3× bardziej niż te w opisie. Zapytania wielowyrazowe stosują semantykę AND dla przecięcia list wystąpień, a następnie sumują wyniki.

Autouzupełnianie prefiksów: trie

Rozwijana lista autouzupełniania — „wpisz »ref« i zobacz »refraction«, »reflection«, »refractive index«" — jest oparta na strukturze trie (drzewie prefiksowym) na tym samym zbiorze tokenów. Wyszukiwanie ma złożoność O(m), gdzie m to długość prefiksu, niezależnie od rozmiaru słownika.

class Trie { constructor() { this.root = {}; } insert(word) { let node = this.root; for (const ch of word) { node[ch] ??= {}; node = node[ch]; } node.$end = true; } suggest(prefix, limit = 5) { let node = this.root; for (const ch of prefix) { if (!node[ch]) return []; node = node[ch]; } // DFS from prefix node, collect up to `limit` words return this._collect(node, prefix, [], limit); } }

Tokenizacja i normalizacja

Surowy tekst przechodzi cztery kroki przed indeksowaniem:

Wydajność

8.4 kB
Rozmiar indeksu (gzip)
<6 ms
Opóźnienie zapytania (p99)
<2 ms
Autouzupełnianie p99

Indeks jest ładowany jednorazowo przy pierwszej interakcji z wyszukiwarką poprzez dynamiczny import() — zerowy koszt na stronach, które nigdy nie korzystają z wyszukiwania. Przy zimnym cache pobieranie zajmuje ~40 ms na połączeniu 3G; kolejne zapytania to czyste wyszukiwania w pamięci.

Stan URL i głębokie linki

Zapytania wyszukiwania są przechowywane w adresie URL jako ?q=lorenz+attractor — zarówno dla możliwości udostępniania, jak i po to, by przycisk wstecz przeglądarki działał zgodnie z oczekiwaniami. API historii jest aktualizowane przez replaceState przy każdym naciśnięciu klawisza (z debounce 150 ms) oraz przez pushState tylko wtedy, gdy użytkownik przechodzi do wyniku.

Historia wyszukiwania przez localStorage

Ostatnie wyszukiwania są zapisywane w localStorage pod kluczem sim_search_history — do 10 wpisów, przechowywanych jako tablica JSON. Historia jest wyświetlana jako chipy pod polem wyszukiwania po jego zaznaczeniu. Nigdy nie są przechowywane żadne dane osobowe — tylko surowy ciąg zapytania. Użytkownicy mogą wyczyścić historię jednym przyciskiem.

Dlaczego nie użyć hostowanej usługi wyszukiwania? Koszt, prywatność i wsparcie offline. Algolia/Typesense dodają zależność infrastrukturalną 50–200 USD/mies. i całkowicie psują tryb offline. Nasz 8,4 kB indeks pobiera się raz i mieszka w pamięci podręcznej service workera — wyszukiwanie działa w samolocie bez WiFi, a nie ma żadnego klucza API wyszukiwania do rotowania czy wycieku.