Files
Sascha ff285011d2 fix(ram): nur ein grosses Modell resident (Analyse starb im OOM-Killer)
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.
2026-08-07 15:55:31 +02:00

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);
}