// The local text-embedding model behind semantic search: bge-small-en-v1.5 // (quantized, ~34 MB) run with transformers.js. Nothing is sent anywhere; the model // is downloaded once into MODELS_DIR (the Docker image ships with it). const path = require('path'); const MODEL = 'Xenova/bge-small-en-v1.5'; const DIMENSIONS = 384; const CACHE_DIR = process.env.MODELS_DIR || path.join(__dirname, '..', 'models'); // bge models match short queries against passages best with this instruction. const QUERY_PREFIX = 'Represent this sentence for searching relevant passages: '; const IDLE_MS = 10 * 60 * 1000; let pipelinePromise = null; let idleTimer = null; // Loaded on first use and released after a while unused (it holds ~150 MB). function load() { if (!pipelinePromise) { pipelinePromise = (async () => { const { pipeline, env } = await import('@huggingface/transformers'); env.cacheDir = CACHE_DIR; return pipeline('feature-extraction', MODEL, { dtype: 'q8' }); })(); pipelinePromise.catch(() => { pipelinePromise = null; }); } clearTimeout(idleTimer); idleTimer = setTimeout(release, IDLE_MS); idleTimer.unref(); return pipelinePromise; } async function release() { const loaded = pipelinePromise; pipelinePromise = null; try { await (await loaded).dispose(); } catch { /* already gone */ } } // texts -> unit-length vectors (arrays of numbers). async function embed(texts) { if (!texts.length) return []; const extractor = await load(); const output = await extractor(texts, { pooling: 'cls', normalize: true }); return output.tolist(); } module.exports = { MODEL, DIMENSIONS, QUERY_PREFIX, load, embed };