Перейти к содержанию

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) },
    // ...
  ]
});

Выполнить поиск TopK.

worker.postMessage({
  type: 'SEARCH',
  requestId: '42',                    // Для сопоставления ответа
  query: Float32Array(1024),          // Вектор запроса
  candidates: [...] | [],             // Новые кандидаты или [] для использования кеша
  topK: 8                             // Количество результатов
});

Worker → Main Thread

CANDIDATES_UPDATED

Подтверждение обновления кеша.

{ type: '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 проверка:

c.vec instanceof Float32Array ? c.vec : new Float32Array(c.vec)

Стратегия кеширования в 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

Диаграмма

Когда отправляются кандидаты:

  1. Первый поиск → все embeddings из IndexedDB → worker
  2. Последующие поиски (cache hit)candidates: [] → worker использует кеш
  3. После 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.