rkllm_server braucht 6,5 GB von 7,74 GB. Blieb daneben ein anderer Dauerhelfer resident, wurde rkllm beim Laden OOM-getoetet -- bei #101 in allen 5 Startversuchen, weil das Retry nichts freigab. Ausloeser war der warme CT2-Whisper-Daemon, den die 2-Sekunden-Aufnahme davor gestartet hatte. Zweiter Fall: rkllm blieb nach der Analyse fuer immer resident, und der Suchindex startete e5-small daneben. gate.js bekommt eine Freigabe-Registry (registerReleaser/freeRam/ killAndWait, verdrahtet in server.js wie setProbe). Vor jedem rkllm-Versuch und vor dem NPU-Whisper-Lauf werden die anderen Helfer beendet und ihr Prozessende abgewartet; analyzeRecording gibt rkllm vor dem Suchindex frei. Alle Helfer starten faul neu. Verifiziert mit der Abfolge, die scheiterte: 12-s-Clip (CT2-Daemon resident) -> Analyse #101 laeuft durch (221 s, done), rkllm lud in 22 s, kein OOM, danach 5,3 GB frei statt 100 MB.
102 lines
4.2 KiB
JavaScript
102 lines
4.2 KiB
JavaScript
// Text embeddings for semantic search (multilingual-e5-small via onnxruntime).
|
|
//
|
|
// Entirely optional: if the model directory is missing, available() is false,
|
|
// every call resolves to null and search.js silently falls back to full-text.
|
|
// That keeps the search working on a fresh install before anything is
|
|
// downloaded, instead of 500-ing.
|
|
//
|
|
// Same daemon pattern as spk.js: one long-lived python process on the A55 cores
|
|
// (4-7 are reserved for whisper/sherpa), line-delimited JSON over stdio, so the
|
|
// ~2 s model load is paid once instead of per query.
|
|
import { spawn } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import readline from 'node:readline';
|
|
import path from 'node:path';
|
|
import * as gate from './gate.js';
|
|
|
|
const PY = process.env.AIME_PY || '/opt/aime-venv/bin/python';
|
|
const DIR = process.env.AIME_EMB_DIR || '/opt/aime-models/e5-small';
|
|
const SCRIPT = '/opt/callrec/inference/embed_text.py';
|
|
// e5 models are trained with these prefixes and lose a lot of accuracy without
|
|
// them — the asymmetric pair is what makes a short question match a long passage.
|
|
const Q_PREFIX = 'query: ';
|
|
const P_PREFIX = 'passage: ';
|
|
const BATCH = +(process.env.AIME_EMB_BATCH || 16);
|
|
|
|
let proc = null, ready = null, rl = null, pending = null, chain = Promise.resolve();
|
|
|
|
export function available() {
|
|
return fs.existsSync(path.join(DIR, 'model.onnx')) && fs.existsSync(path.join(DIR, 'tokenizer.json'));
|
|
}
|
|
|
|
export function status() {
|
|
return { available: available(), dir: DIR, running: !!proc };
|
|
}
|
|
|
|
function start() {
|
|
if (proc) return ready;
|
|
if (!available()) return Promise.reject(new Error('Embedding-Modell fehlt: ' + DIR));
|
|
// Cores 0-3 (A55): the big cores stay free for whisper and sherpa, which are
|
|
// the actual bottleneck. Embedding a query is a few ms either way.
|
|
proc = spawn('taskset', ['-c', '0-3', PY, SCRIPT, DIR], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
proc.stderr.on('data', d => process.stderr.write('[emb] ' + d));
|
|
proc.on('exit', () => { proc = null; ready = null; rl = null; if (pending) { pending.reject(new Error('emb-Daemon beendet')); pending = null; } });
|
|
ready = new Promise((res, rej) => {
|
|
const to = setTimeout(() => rej(new Error('emb READY timeout')), 120000);
|
|
rl = readline.createInterface({ input: proc.stdout });
|
|
rl.on('line', line => {
|
|
if (line.startsWith('READY')) { clearTimeout(to); res(); return; }
|
|
if (!pending) return;
|
|
const p = pending; pending = null;
|
|
try {
|
|
const msg = JSON.parse(line);
|
|
if (msg.error) p.reject(new Error(msg.error)); else p.resolve(msg.vecs);
|
|
} catch (e) { p.reject(new Error('emb: unlesbare Antwort — ' + line.slice(0, 120))); }
|
|
});
|
|
});
|
|
return ready;
|
|
}
|
|
|
|
// Serialised through `chain`: the daemon handles one request at a time, and a
|
|
// concurrent write would interleave two JSON lines on the same stdin.
|
|
function request(texts) {
|
|
const run = async () => {
|
|
await start();
|
|
return new Promise((resolve, reject) => {
|
|
pending = { resolve, reject };
|
|
const to = setTimeout(() => { if (pending) { pending = null; reject(new Error('emb timeout')); } }, 120000);
|
|
const done = fn => v => { clearTimeout(to); fn(v); };
|
|
pending = { resolve: done(resolve), reject: done(reject) };
|
|
proc.stdin.write(JSON.stringify({ texts }) + '\n');
|
|
});
|
|
};
|
|
chain = chain.then(run, run);
|
|
return chain;
|
|
}
|
|
|
|
export async function embedQuery(text) {
|
|
if (!available()) return null;
|
|
const v = await request([Q_PREFIX + String(text || '').slice(0, 2000)]);
|
|
return v && v[0];
|
|
}
|
|
|
|
// Returns null (not a throw) when the model is absent, so indexing just stores
|
|
// passages without vectors.
|
|
export async function embedMany(texts) {
|
|
if (!available() || !texts || !texts.length) return null;
|
|
const out = [];
|
|
for (let i = 0; i < texts.length; i += BATCH) {
|
|
const batch = texts.slice(i, i + BATCH).map(t => P_PREFIX + String(t || '').slice(0, 2000));
|
|
out.push(...await request(batch));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Beendet den Daemon und wartet auf sein Ende (siehe gate.freeRam: das e5-Modell
|
|
// hält in der Spitze 1,15 GB und muss weg, bevor rkllm lädt). Startet faul neu.
|
|
export function stop() {
|
|
const p = proc;
|
|
proc = null; ready = null; rl = null;
|
|
return gate.killAndWait(p);
|
|
}
|