mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
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.
154 lines
5.0 KiB
TypeScript
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)),
|
|
};
|
|
}
|
|
}
|