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 |