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

Embedding Storage — Полный код

Уровень: L3 (deep-dive) | ← Назад к Embeddings

1. Embedding Repository

Файл: frontend/src/embeddings/embeddingRepository.ts

Персистентное хранение embedding-векторов в IndexedDB.

import { db } from '../offline/db/dexie';
import type { LocalEmbedding } from '../offline/sync/syncTypes';
import type { EmbeddingDto } from '../types/api';

export const embeddingRepository = {
  /** Получить embedding по типу и ID сущности */
  async get(
    entityType: 'item' | 'outfit',
    entityId: string
  ): Promise<LocalEmbedding | undefined> {
    // Составной ключ [entity_type, entity_id]
    return db.embeddings.get([entityType, entityId]);
  },

  /** Получить все embeddings по типу (для передачи в Worker) */
  async getAllByType(entityType: 'item' | 'outfit'): Promise<LocalEmbedding[]> {
    return db.embeddings.where('entity_type').equals(entityType).toArray();
  },

  /** Сохранить embedding с сервера (EmbeddingDto → LocalEmbedding) */
  async upsertFromServer(
    entityType: 'item' | 'outfit',
    entityId: string,
    dto: EmbeddingDto
  ): Promise<void> {
    const record: LocalEmbedding = {
      entity_type: entityType,
      entity_id: entityId,
      vector: new Float32Array(dto.vector),  // number[] → Float32Array
      dim: dto.dim,
      model_version: dto.model_version,
      computed_at: dto.computed_at,
      source: 'server',
      is_stale: false,
    };
    await db.embeddings.put(record);
  },

  /** Сохранить производный embedding (outfit = mean of items) */
  async putDerived(
    entityId: string,
    vector: Float32Array,
    dim: number,
    modelVersion: string
  ): Promise<void> {
    const record: LocalEmbedding = {
      entity_type: 'outfit',
      entity_id: entityId,
      vector,
      dim,
      model_version: modelVersion,
      computed_at: new Date().toISOString(),
      source: 'derived',  // Вычислен на клиенте, не с сервера
      is_stale: false,
    };
    await db.embeddings.put(record);
  },

  /** Пометить embedding как устаревший (после update вещи) */
  async markStale(entityType: 'item' | 'outfit', entityId: string): Promise<void> {
    await db.embeddings
      .where('[entity_type+entity_id]')
      .equals([entityType, entityId])
      .modify({ is_stale: true });
  },

  /** Удалить embedding (при delete вещи/образа) */
  async remove(entityType: 'item' | 'outfit', entityId: string): Promise<void> {
    await db.embeddings.delete([entityType, entityId]);
  },

  /** Массовое сохранение (при initial sync) */
  async bulkUpsertFromServer(
    entityType: 'item' | 'outfit',
    items: Array<{ entityId: string; embedding: EmbeddingDto }>
  ): Promise<void> {
    const records: LocalEmbedding[] = items.map((item) => ({
      entity_type: entityType,
      entity_id: item.entityId,
      vector: new Float32Array(item.embedding.vector),
      dim: item.embedding.dim,
      model_version: item.embedding.model_version,
      computed_at: item.embedding.computed_at,
      source: 'server' as const,
      is_stale: false,
    }));
    await db.embeddings.bulkPut(records);  // Upsert: перезаписать если существует
  },

  /** Количество embeddings (для debug/stats) */
  async count(entityType?: 'item' | 'outfit'): Promise<number> {
    if (entityType) {
      return db.embeddings.where('entity_type').equals(entityType).count();
    }
    return db.embeddings.count();
  },

  /** Очистить все embeddings (при logout) */
  async clear(): Promise<void> {
    await db.embeddings.clear();
  },
};

2. Outfit Embedding Service

Файл: frontend/src/embeddings/outfitEmbeddingService.ts

Вычисление производных embedding-ов для образов из embedding-ов вещей.

import { db } from '../offline/db/dexie';
import { embeddingRepository } from './embeddingRepository';
import { meanVector } from './vectorMath';

export const outfitEmbeddingService = {
  /**
   * Вычислить и сохранить embedding для одного образа.
   * 
   * Алгоритм:
   * 1. Загрузить все вещи образа из outfit_items
   * 2. Для каждой вещи получить embedding
   * 3. Вычислить mean vector
   * 4. Сохранить как derived embedding
   */
  async computeAndStore(outfitId: string): Promise<void> {
    // Получить вещи образа
    const outfitItems = await db.outfit_items
      .where('outfit_id')
      .equals(outfitId)
      .toArray();

    if (outfitItems.length === 0) return;

    // Собрать embeddings вещей
    const itemEmbeddings: Float32Array[] = [];
    let dim = 1024;                    // Default dimension
    let modelVersion = 'resnet50-v1';  // Default model

    for (const oi of outfitItems) {
      const emb = await embeddingRepository.get('item', oi.item_id);
      if (emb && !emb.is_stale) {
        itemEmbeddings.push(emb.vector);
        dim = emb.dim;                 // Взять из реальных данных
        modelVersion = emb.model_version;
      }
    }

    if (itemEmbeddings.length === 0) return;  // Нет embeddings — нечего вычислять

    // Вычислить средний вектор
    const mean = meanVector(itemEmbeddings);

    // Сохранить как derived embedding
    await embeddingRepository.putDerived(outfitId, mean, dim, modelVersion);
  },

  /**
   * Пересчитать embeddings для ВСЕХ образов.
   * Вызывается после initial sync (когда все item embeddings загружены).
   */
  async recomputeAll(): Promise<void> {
    const allOutfits = await db.outfits.toArray();
    for (const outfit of allOutfits) {
      if (outfit.deleted_locally) continue;
      await this.computeAndStore(outfit.id);
    }
  },
};

3. Similarity Service

Файл: frontend/src/embeddings/similarityService.ts

Оркестрация: управление Worker lifecycle, кеширование, timeout.

import { embeddingRepository } from './embeddingRepository';
import type { SimilarityResult, WorkerSearchResponse } from './embeddingTypes';

let worker: Worker | null = null;
let requestCounter = 0;

// Pending requests: ожидаем ответа от Worker
const pendingRequests = new Map<string, {
  resolve: (value: { results: SimilarityResult[]; timeMs: number }) => void;
  reject: (reason: Error) => void;
  timer: ReturnType<typeof setTimeout>;
}>();

/** Версии кеша — инкрементируются при invalidateCache() */
let itemCacheVersion = 0;
let outfitCacheVersion = 0;
let workerItemVersion = -1;   // Версия, отправленная в Worker
let workerOutfitVersion = -1;

/** Lazy-создание Worker */
function getWorker(): Worker {
  if (!worker) {
    workerItemVersion = -1;
    workerOutfitVersion = -1;
    worker = new Worker(new URL('./search.worker.ts', import.meta.url), { type: 'module' });

    // Обработчик ответов от Worker
    worker.onmessage = (e: MessageEvent<WorkerSearchResponse>) => {
      const { requestId, results, timeMs } = e.data;
      const pending = pendingRequests.get(requestId);
      if (pending) {
        clearTimeout(pending.timer);
        pendingRequests.delete(requestId);
        pending.resolve({ results, timeMs });
      }
    };

    // Обработчик ошибок Worker
    worker.onerror = (e) => {
      console.error('[SimilarityService] Worker error:', e.message);
      for (const [, pending] of pendingRequests) {
        clearTimeout(pending.timer);
        pending.reject(new Error(`Worker error: ${e.message}`));
      }
      pendingRequests.clear();
      worker = null;  // Пересоздать при следующем поиске
    };
  }
  return worker;
}

/** Отправить поисковый запрос в Worker */
function sendSearchRequest(
  query: Float32Array,
  candidates: Array<{ id: string; vec: Float32Array }> | null,
  topK: number,
  excludeId: string
): Promise<{ results: SimilarityResult[]; timeMs: number }> {
  return new Promise((resolve, reject) => {
    const requestId = String(++requestCounter);

    // Timeout 5 секунд
    const timer = setTimeout(() => {
      pendingRequests.delete(requestId);
      reject(new Error('Worker search timeout'));
    }, 5000);

    pendingRequests.set(requestId, { resolve, reject, timer });

    getWorker().postMessage({
      type: 'SEARCH',
      requestId,
      query,
      // Отправить кандидатов только при обновлении кеша
      candidates: candidates
        ?.filter((c) => c.id !== excludeId)  // Исключить сам элемент
        .map((c) => ({ id: c.id, vec: c.vec })) ?? [],
      topK,
    });
  });
}

export const similarityService = {
  /** Инвалидация кеша (после sync, update, delete) */
  invalidateCache(type: 'item' | 'outfit') {
    if (type === 'item') itemCacheVersion++;
    else outfitCacheVersion++;
  },

  /** Найти похожие вещи */
  async findSimilarItems(
    itemId: string,
    topK = 8
  ): Promise<{ results: SimilarityResult[]; timeMs: number }> {
    // Загрузить query embedding
    const queryEmb = await embeddingRepository.get('item', itemId);
    if (!queryEmb || queryEmb.is_stale) {
      return { results: [], timeMs: 0 };
    }

    // Загрузить кандидатов только при stale cache
    let candidates: Array<{ id: string; vec: Float32Array }> | null = null;
    if (workerItemVersion !== itemCacheVersion) {
      const allEmbeddings = await embeddingRepository.getAllByType('item');
      candidates = allEmbeddings
        .filter((e) => !e.is_stale)
        .map((e) => ({ id: e.entity_id, vec: e.vector }));
      workerItemVersion = itemCacheVersion;

      if (candidates.filter((c) => c.id !== itemId).length === 0) {
        return { results: [], timeMs: 0 };
      }
    }

    return sendSearchRequest(queryEmb.vector, candidates, topK, itemId);
  },

  /** Найти похожие образы */
  async findSimilarOutfits(
    outfitId: string,
    topK = 6
  ): Promise<{ results: SimilarityResult[]; timeMs: number }> {
    const queryEmb = await embeddingRepository.get('outfit', outfitId);
    if (!queryEmb || queryEmb.is_stale) {
      return { results: [], timeMs: 0 };
    }

    let candidates: Array<{ id: string; vec: Float32Array }> | null = null;
    if (workerOutfitVersion !== outfitCacheVersion) {
      const allEmbeddings = await embeddingRepository.getAllByType('outfit');
      candidates = allEmbeddings
        .filter((e) => !e.is_stale)
        .map((e) => ({ id: e.entity_id, vec: e.vector }));
      workerOutfitVersion = outfitCacheVersion;

      if (candidates.filter((c) => c.id !== outfitId).length === 0) {
        return { results: [], timeMs: 0 };
      }
    }

    return sendSearchRequest(queryEmb.vector, candidates, topK, outfitId);
  },

  /** Остановить Worker (при logout) */
  terminate() {
    if (worker) {
      worker.terminate();
      worker = null;
    }
    for (const [, pending] of pendingRequests) {
      clearTimeout(pending.timer);
    }
    pendingRequests.clear();
    workerItemVersion = -1;
    workerOutfitVersion = -1;
  },
};

4. Типы (Embedding Types)

Файл: frontend/src/embeddings/embeddingTypes.ts

/** Запрос к Worker для similarity search */
export interface WorkerSearchRequest {
  type: 'SEARCH';
  requestId: string;
  query: Float32Array;
  candidates: WorkerCandidate[];
  topK: number;
}

export interface WorkerCandidate {
  id: string;
  vec: Float32Array;
}

/** Ответ Worker */
export interface WorkerSearchResponse {
  type: 'SEARCH_RESULT';
  requestId: string;
  results: SimilarityResult[];
  timeMs: number;  // Время вычислений
}

export interface SimilarityResult {
  id: string;      // ID вещи/образа
  score: number;   // Cosine similarity [0..1]
}

/** Состояние хука similarity search */
export interface SimilaritySearchState {
  results: SimilarityResult[];
  isLoading: boolean;
  error: string | null;
}

Инвалидация кеша: когда и кем

Событие Кто вызывает invalidateCache
Initial sync (embeddings loaded) performInitialSync ('item') + ('outfit')
Refresh sync performRefreshSync ('item') + ('outfit')
Item created (embedding returned) syncEngine._execItemCreate ('item')
Item deleted syncEngine._execItemDelete ('item') + ('outfit')
Outfit created syncEngine._execOutfitCreate ('outfit')
Item updated (markStale) offlineItemsService.update Не invalidate, но stale=true