Files
OmniRoute/src/lib/memory/embedding/transformersLocal.ts
diegosouzapw 02bc079ef9 feat(memory): add embedding layer — remote/static/transformers/cache (plan 21 F3)
Implements the multi-source embedding layer for the Memory Engine Redesign (plan 21).
Adds 5 production modules under src/lib/memory/embedding/:
- cache.ts: LRU+TTL in-memory cache (max=1000, TTL=5min, sha256 keyed)
- remote.ts: delegates to createEmbeddingResponse(), maps HTTP 401/403→no_key, 429→rate_limited, AbortError→timeout; all errors via sanitizeErrorMessage()
- staticPotion.ts: download-once potion-base-8M (JS-only WordPiece tokenizer + mean pooling, no WASM)
- transformersLocal.ts: lazy await import('@huggingface/transformers') singleton pipeline (Xenova/all-MiniLM-L6-v2, q8)
- index.ts: resolveEmbeddingSource (pure, sync), embed (cached dispatch), listEmbeddingProviders, invalidateEmbeddingCache

Also adds @huggingface/transformers and sqlite-vec to dependencies, and registers
@huggingface/transformers in next.config.mjs serverExternalPackages (D8/D25).

6 unit test files: cache (9), resolve (14), remote (10), static-potion (13), transformers (6), list-providers (8) — all 60 tests green.
2026-05-28 00:40:56 -03:00

154 lines
5.0 KiB
TypeScript

/**
* Transformers.js local embedding (D8) — Xenova/all-MiniLM-L6-v2.
*
* IMPORTANT: @huggingface/transformers is imported lazily (await import())
* ONLY when this function is called. Never imported at module level.
* This satisfies D8 + D25 (serverExternalPackages + no bundle impact).
*/
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
import type { EmbeddingResult, EmbeddingError } from "./types";
const TRANSFORMERS_MODEL =
process.env.MEMORY_TRANSFORMERS_MODEL || "Xenova/all-MiniLM-L6-v2";
// Singleton pipeline, initialized once
type PipelineFn = (text: string | string[], options?: Record<string, unknown>) => Promise<unknown>;
let _pipeline: PipelineFn | null = null;
let _pipelineLoading: Promise<PipelineFn> | null = null;
/** For testing: inject a mock pipeline factory. */
export function _injectPipeline(fn: PipelineFn | null): void {
_pipeline = fn;
_pipelineLoading = null;
}
async function getOrLoadPipeline(): Promise<PipelineFn> {
if (_pipeline) return _pipeline;
if (_pipelineLoading) return _pipelineLoading;
_pipelineLoading = (async (): Promise<PipelineFn> => {
// Lazy import — never at module level (D8, D25)
const transformers = await import("@huggingface/transformers");
const { pipeline } = transformers as { pipeline: (task: string, model: string, opts?: Record<string, unknown>) => Promise<PipelineFn> };
const pipe = await pipeline("feature-extraction", TRANSFORMERS_MODEL, { dtype: "q8" });
_pipeline = pipe;
_pipelineLoading = null;
return pipe;
})();
return _pipelineLoading;
}
/**
* Convert Tensor-like output from transformers pipeline to Float32Array.
* Transformers.js pipelines return a Tensor with `.data` (Float32Array or similar)
* and `.dims` [batch, seq, hidden_size]. We flatten to hidden_size via mean pooling.
*/
function tensorToFloat32Array(output: unknown): Float32Array {
// Handle Tensor objects from @huggingface/transformers
const tensor = output as {
data?: Float32Array | number[];
dims?: number[];
tolist?: () => number[][][];
};
if (tensor && tensor.data && tensor.dims) {
const data = tensor.data instanceof Float32Array ? tensor.data : new Float32Array(tensor.data);
const dims = tensor.dims;
// Typical dims: [1, seq_len, hidden_size] or [seq_len, hidden_size]
let seqLen: number;
let hiddenSize: number;
if (dims.length === 3) {
// [batch=1, seq_len, hidden_size]
seqLen = dims[1];
hiddenSize = dims[2];
} else if (dims.length === 2) {
// [seq_len, hidden_size]
seqLen = dims[0];
hiddenSize = dims[1];
} else {
// Already flat — return as-is
return data instanceof Float32Array ? data : new Float32Array(data);
}
// Mean pool over sequence dimension
const result = new Float32Array(hiddenSize);
for (let s = 0; s < seqLen; s++) {
for (let h = 0; h < hiddenSize; h++) {
result[h] += data[s * hiddenSize + h];
}
}
for (let h = 0; h < hiddenSize; h++) {
result[h] /= seqLen;
}
return result;
}
// Fallback: try tolist()
if (tensor && typeof tensor.tolist === "function") {
const list = tensor.tolist();
if (Array.isArray(list) && Array.isArray(list[0])) {
// [batch=1][seq_len][hidden]
const inner = list[0];
const hiddenSize2 = (inner[0] as number[]).length;
const result2 = new Float32Array(hiddenSize2);
for (const row of inner) {
for (let h = 0; h < hiddenSize2; h++) {
result2[h] += (row as number[])[h];
}
}
for (let h = 0; h < hiddenSize2; h++) {
result2[h] /= inner.length;
}
return result2;
}
}
throw new Error("Cannot convert transformers output to Float32Array");
}
export async function embedTransformers(text: string): Promise<EmbeddingResult | EmbeddingError> {
const t0 = Date.now();
let pipe: PipelineFn;
try {
pipe = await getOrLoadPipeline();
} catch (err: unknown) {
const isTimeout =
err instanceof Error &&
(err.name === "AbortError" || err.message.toLowerCase().includes("timeout"));
return {
source: "transformers",
model: TRANSFORMERS_MODEL,
reason: isTimeout ? "timeout" : "model_load_failed",
message: sanitizeErrorMessage(err instanceof Error ? err.message : String(err)),
};
}
try {
const output = await pipe(text, { pooling: "mean", normalize: true });
const vector = tensorToFloat32Array(output);
return {
vector,
source: "transformers",
model: TRANSFORMERS_MODEL,
dimensions: vector.length,
latencyMs: Date.now() - t0,
cached: false,
};
} catch (err: unknown) {
const isTimeout =
err instanceof Error &&
(err.name === "AbortError" || err.message.toLowerCase().includes("timeout"));
return {
source: "transformers",
model: TRANSFORMERS_MODEL,
reason: isTimeout ? "timeout" : "request_failed",
message: sanitizeErrorMessage(err instanceof Error ? err.message : String(err)),
};
}
}