Web Worker — Полный код¶
Уровень: L3 (deep-dive) | ← Назад к Embeddings
Файл: frontend/src/embeddings/search.worker.ts¶
Поисковый worker, выполняющий similarity search в фоновом потоке.
import { topKSimilar } from './vectorMath';
/**
* Кешированный набор кандидатов.
* Позволяет не передавать все embeddings при каждом поиске.
* Обновляется только при invalidateCache().
*/
let cachedCandidates: Map<string, Float32Array> = new Map();
// ─── MESSAGE HANDLER ─────────────────────────────────────────────────────────
self.onmessage = (e: MessageEvent) => {
const { type } = e.data;
// ── Обновление кеша кандидатов ──
if (type === 'UPDATE_CANDIDATES') {
const { candidates } = e.data as {
candidates: Array<{ id: string; vec: Float32Array | number[] }>;
};
// Перестроить кеш
cachedCandidates = new Map();
for (const c of candidates) {
// Конвертировать number[] → Float32Array если нужно (serialization boundary)
cachedCandidates.set(
c.id,
c.vec instanceof Float32Array ? c.vec : new Float32Array(c.vec)
);
}
self.postMessage({ type: 'CANDIDATES_UPDATED' });
return;
}
// ── Поиск похожих ──
if (type === 'SEARCH') {
const { requestId, query, candidates, topK } = e.data;
const start = performance.now();
// Конвертировать query (может быть number[] после structured clone)
const queryVec = query instanceof Float32Array ? query : new Float32Array(query);
// Определить источник кандидатов
let candidateVecs: Array<{ id: string; vec: Float32Array }>;
if (candidates && candidates.length > 0) {
// Новые кандидаты переданы — использовать их и обновить кеш
candidateVecs = candidates.map((c: { id: string; vec: Float32Array | number[] }) => ({
id: c.id,
vec: c.vec instanceof Float32Array ? c.vec : new Float32Array(c.vec),
}));
} else {
// Кандидаты не переданы — использовать кеш
candidateVecs = Array.from(cachedCandidates.entries()).map(([id, vec]) => ({ id, vec }));
}
// Выполнить поиск TopK
const results = topKSimilar(queryVec, candidateVecs, topK);
const timeMs = performance.now() - start;
// Отправить результат обратно
self.postMessage({ type: 'SEARCH_RESULT', requestId, results, timeMs });
}
};
Протокол сообщений¶
Main Thread → Worker¶
UPDATE_CANDIDATES¶
Обновить кешированный набор кандидатов.
worker.postMessage({
type: 'UPDATE_CANDIDATES',
candidates: [
{ id: 'item-uuid-1', vec: Float32Array(1024) },
{ id: 'item-uuid-2', vec: Float32Array(1024) },
// ...
]
});
SEARCH¶
Выполнить поиск TopK.
worker.postMessage({
type: 'SEARCH',
requestId: '42', // Для сопоставления ответа
query: Float32Array(1024), // Вектор запроса
candidates: [...] | [], // Новые кандидаты или [] для использования кеша
topK: 8 // Количество результатов
});
Worker → Main Thread¶
CANDIDATES_UPDATED¶
Подтверждение обновления кеша.
SEARCH_RESULT¶
Результат поиска.
{
type: 'SEARCH_RESULT',
requestId: '42',
results: [
{ id: 'item-uuid-5', score: 0.923 },
{ id: 'item-uuid-12', score: 0.891 },
// ... topK items
],
timeMs: 3.2 // Время вычислений в мс
}
Почему Web Worker?¶
Проблема¶
Cosine similarity для 500 вещей × 1024 dim = ~500K операций с плавающей точкой. На main thread это заблокирует рендер на 3-5ms.
Решение¶
Web Worker выполняет вычисления в отдельном потоке: - UI остаётся отзывчивым (60 FPS) - Длительные поиски не вызывают jank - Worker может быть terminated при unmount
Structured Clone Limitation¶
При передаче через postMessage Float32Array проходит через structured clone:
- Float32Array → serialized → Float32Array (сохраняется тип)
- Но если данные пришли из JSON → number[] → нужна конвертация
Поэтому в worker проверка:
Стратегия кеширования в Worker¶
stateDiagram-v2
[*] --> Empty: Worker создан
Empty --> Cached: UPDATE_CANDIDATES
Cached --> Cached: SEARCH (candidates=[])
Cached --> Cached: UPDATE_CANDIDATES (обновлён)
note right of Cached
Worker хранит Map<id, Float32Array>
При SEARCH без candidates — использует кеш
При SEARCH с candidates — использует переданные
end note

Когда отправляются кандидаты:¶
- Первый поиск → все embeddings из IndexedDB → worker
- Последующие поиски (cache hit) →
candidates: []→ worker использует кеш - После invalidateCache() → следующий поиск перешлёт все embeddings
Это экономит ~200KB данных на каждый поиск (500 × 1024 × 4 bytes).
Lifecycle Management¶
// Создание Worker (lazy)
worker = new Worker(new URL('./search.worker.ts', import.meta.url), { type: 'module' });
// Terminate (при logout или unmount)
similarityService.terminate() → worker.terminate()
Worker создаётся лениво при первом поиске и живёт до logout.