Архітектура пошуку на клієнті — інвертований індекс, trie і стан URL для 350 сторінок симуляцій

Ми хотіли миттєвий пошук по всіх симуляціях сайту — без запиту до сервера, без API-ключа, з роботою офлайн. Ось технічний розбір: як ми побудували пошуковий індекс розміром 28 кБ, запити з затримкою менше 10 мс, автозавершення за префіксом і URL-адреси пошуку, якими можна ділитися.

Проблема

З 350 сторінками симуляцій старий підхід — фільтрація жорстко заданого масиву JavaScript — ставав некерованим. Користувачі, які шукають «заломлення», мають знаходити закон Снелля, повне внутрішнє відбиття та формування веселки, а не лише сторінки з точним словом «заломлення» в заголовку. Нам був потрібен повнотекстовий пошук з ранжуванням релевантності, що працює повністю в браузері без бекенду.

Структура даних: інвертований індекс

Пошуковий індекс — це звичайний об'єкт JavaScript, побудований під час деплою на основі заголовка, опису, категорії та тегів кожної симуляції. Кожен токен зіставляється зі списком входжень: масивом ідентифікаторів документів з їхніми показниками частоти терміна.

// Спрощена структура індексу 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 токенів, 28 кБ мінімізовано + gzip → 8.4 кБ };

Оцінка релевантності — це TF-IDF з невеликим підвищенням для заголовка: токени в заголовку зважуються втричі більше, ніж в описі. Багатослівні запити використовують семантику AND для перетину списків входжень, після чого підсумовуються бали.

Автозавершення за префіксом: Trie

Випадаючий список автозавершення — «набери 'заломл' і побач 'заломлення', 'відбиття', 'показник заломлення'» — реалізований на основі trie (префіксного дерева) над тим самим набором токенів. Пошук виконується за O(m), де m — довжина префікса, незалежно від розміру словника.

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 від вузла префікса, зібрати до `limit` слів return this._collect(node, prefix, [], limit); } }

Токенізація та нормалізація

Перед індексацією необроблений текст проходить чотири етапи:

Продуктивність

8.4 кБ
Розмір індексу (gzip)
<6 мс
Затримка запиту (p99)
<2 мс
Автозавершення p99

Індекс завантажується один раз при першій взаємодії з пошуком через динамічний import() — нульова вартість на сторінках, де пошук ніколи не використовується. На холодному кеші він завантажується приблизно за 40 мс на з'єднанні 3G; наступні запити — це чисті пошуки в пам'яті.

Стан URL і глибокі посилання

Пошукові запити зберігаються в URL-адресі у вигляді ?q=lorenz+attractor — і для можливості ділитися, і щоб кнопка «назад» у браузері працювала очікувано. History API оновлюється через replaceState при кожному натисканні клавіші (з затримкою 150 мс) і через pushState лише коли користувач переходить до результату.

Історія пошуку через localStorage

Останні запити зберігаються в localStorage під ключем sim_search_history — до 10 записів у вигляді масиву JSON. Історія показується у вигляді чипів під полем вводу при фокусі. Жодні персональні дані ніколи не зберігаються — лише сам текст запиту. Користувачі можуть очистити історію однією кнопкою.

Чому не використати хостований сервіс пошуку? Вартість, приватність і підтримка офлайн-режиму. Algolia/Typesense додають залежність від інфраструктури вартістю 50–200 $/міс і повністю ламають офлайн-режим. Наш індекс на 8.4 кБ завантажується один раз і живе в кеші сервіс-воркера — пошук працює в літаку без Wi-Fi, і немає жодного API-ключа пошуку, який треба ротувати чи який може витекти.