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:
- Zamiana na małe litery + normalizacja Unicode (NFC) — „Lorenz" i „lorenz" trafiają w ten sam token.
- Usuwanie słów pomocniczych — usunięto 63 angielskie słowa pomocnicze, aby zmniejszyć rozmiar indeksu.
- Lekki stemmer Portera — „refracting", „refracted", „refraction" → „refract". Utrzymuje indeks zwarty bez utraty trafności.
- Mapa synonimów — ręcznie stworzona mapa 40 wpisów: „fourier" → indeksuje też „fft", „spectrum"; „lorenz" → „chaos", „attractor".
Wydajność
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.