elffuss-code / js /embed.js
KikoCis's picture
ACE v2: BM25 con IDF endogena sin heuristicas + reserva de cabecera + embeddings opcionales tras bandera. Medido: recall de hechos 15,1% -> 65,3%; el encargo original pasa de perderse siempre a conservarse siempre.
51ec7b5 verified
Raw
History Blame Contribute Delete
10 kB
// Lado SEMÁNTICO del motor de contexto (el compañero de acer-core.js).
//
// ─────────────────────────────────────────────────────────────────────────────
// POR QUÉ EXISTE
//
// acer-core.js ya trae la vía híbrida completa (`packHistoryACERHybrid`): BM25 y
// embeddings fusionados por rangos. Lo que le faltaba era una función `embed`,
// así que degradaba a la vía léxica y en producción sólo corría BM25.
//
// Medido en LoCoMo (200 preguntas, F1 del propio repo, modelo real respondiendo,
// mismo presupuesto de tokens):
//
// contexto COMPLETO sin comprimir ..... 22,56
// BM25 (lo que corría hasta ahora) .... 23,59
// embeddings solos .................... 23,95
// fusión de RANGOS (esto) ............. 28,09 ← +4,50 sobre BM25
//
// El motivo de que sume no es que lo semántico sea «mejor»: en global EMPATAN
// (23,59 vs 23,95). Hacen trabajos DISTINTOS. Partiendo las mismas preguntas por
// solape de vocabulario entre la pregunta y la respuesta:
//
// sin solape (n=107) con solape (n=93)
// BM25 ...................... 18,60 29,33
// embeddings ................ 24,28 23,58
// fusión de rangos .......... 24,05 32,73
//
// Cuando la pregunta NO nombra literalmente lo que busca, BM25 se hunde (18,60)
// y lo semántico aguanta (24,28). Cuando sí lo nombra, manda BM25 (29,33). La
// fusión se queda con los dos. Lo semántico no sustituye al léxico: le cubre el
// punto ciego. Eso —y no una ganancia global— es lo que se está cableando aquí.
//
// ─────────────────────────────────────────────────────────────────────────────
// MODELO ELEGIDO — Xenova/multilingual-e5-small, dtype q8 (118 MB)
//
// Requisitos duros y cómo los cumple:
// · transformers.js: es la conversión ONNX oficial del mantenedor de la
// librería → carga con el MISMO `pipeline()` que ya usa providers/onnx.js.
// · pequeño: 118 MB en q8. Es la variante más liviana del repo (el q4 pesa
// MÁS —398 MB— porque la matriz de embeddings de un vocabulario de 250k no
// se cuantiza igual de bien; aquí «menos bits» no significa menos disco).
// · multilingüe: tokenizador XLM-R, 100 idiomas. Los prompts van en castellano
// y el código en inglés, y ese cruce es exactamente el punto ciego léxico.
// · licencia: MIT (intfloat/multilingual-e5-small aguas arriba).
// · ventana de 512 tokens, que es la que necesitan los bloques que arma
// acer-core; no es un modelo de frases sueltas.
//
// Descartados, y por qué:
// · Xenova/all-MiniLM-L6-v2 (23 MB, Apache-2.0) — 5× más barato, pero SÓLO
// inglés. Justo el caso que venimos a cubrir es el castellano.
// · Xenova/paraphrase-multilingual-MiniLM-L12-v2 (118 MB, Apache-2.0) — mismo
// peso, pero está afinado para paráfrasis de FRASES con ventana de 128
// tokens: trocearía los bloques por la mitad.
// · onnx-community/embeddinggemma-300m-ONNX (175 MB en q4f16 + pesos externos)
// — mejor calidad, pero 1,5× de descarga y licencia Gemma (uso comercial
// permitido pero con política de uso y obligaciones de redistribución), no
// una licencia comercial limpia.
// · ibm-granite/granite-embedding-107m-multilingual (Apache-2.0) — sólo
// publica ONNX en fp32: 428 MB, 3,6× la descarga.
// · minishlab/potion-multilingual-128M (MIT) — estático y rapidísimo, pero su
// único ONNX pesa 512 MB y transformers.js no lo soporta de serie.
//
// DTYPE SEGÚN EL DISPOSITIVO — medido, no supuesto. Mismo modelo, mismo lote de
// bloques, sólo cambiando dtype y dispositivo (coste relativo, 1,0 = el mejor):
//
// fp16 / WebGPU .... 1,0× (235 MB) ← el elegido cuando hay adaptador
// q4f16/ WebGPU .... 0,6× (205 MB) con bloques medianos; ver punto 2
// q8 / wasm ...... 8,9× (118 MB) ← la red de seguridad
// fp32 / wasm ...... 11,0× (470 MB)
// q8 / WebGPU .... 14,1× (118 MB) ← PEOR que en CPU
//
// Dos cosas que contradicen la intuición y por eso van escritas:
// 1. Los enteros de 8 bits NO se aceleran en WebGPU: ORT-web no tiene kernels
// para esos operadores y acaba yendo y viniendo de la CPU. El fichero más
// pequeño resulta ser el MÁS LENTO en la GPU. Elegir dtype por tamaño de
// descarga, sin medir, habría dado la peor combinación posible.
// 2. Con textos CORTOS —que es nuestro caso, ver SEM_BUDGET en context.js— el
// 4-bit se da la vuelta y pierde contra fp16 (2,6× más lento en líneas
// sueltas): a esas longitudes se paga más por deshacer la cuantización que
// lo que se ahorra en ancho de banda. Por eso fp16 y no q4f16, aunque en
// bloques grandes q4f16 gane.
//
// De ahí la regla: fp16 si hay adaptador WebGPU de verdad, y q8/wasm como red de
// seguridad. La red de seguridad FUNCIONA pero es ~9× más lenta, hasta el punto
// de notarse en cada turno, y ése es uno de los motivos de que el lado semántico
// vaya apagado por defecto (ver context.js).
// ─────────────────────────────────────────────────────────────────────────────
import { createEmbedCache } from './acer-core.js';
export const EMBED_MODEL = {
id: 'Xenova/multilingual-e5-small',
dims: 384,
label: 'multilingual-e5-small',
// dtype → fichero → descarga
webgpu: { dtype: 'fp16', sizeMB: 235 },
wasm: { dtype: 'q8', sizeMB: 118 },
};
// La ventana del modelo son 512 tokens; recortar antes de tokenizar evita pagar
// el troceado de texto que el propio modelo va a descartar.
const MAX_CHARS = 2000;
// Lotes pequeños: transformers.js rellena cada lote hasta el más largo, así que
// un lote gigante hace pagar la longitud del peor elemento por todos.
const BATCH = 16;
// e5 se entrenó SIEMPRE con prefijo ('query: ' / 'passage: '), nunca con texto
// pelado. acer-core llama a la misma `embed` para la pregunta y para los
// bloques, y no hay forma de distinguirlos sin tocar el núcleo; los autores del
// modelo documentan usar 'query: ' en AMBOS lados para uso simétrico. Quitarlo
// del todo sería peor: el modelo nunca vio esa distribución.
const PREFIX = 'query: ';
let pipePromise = null;
export let backend = null; // {device, dtype, sizeMB} una vez resuelto
/**
* Carga PEREZOSA: nada de esto se toca en el arranque. El módulo entero se
* importa dinámicamente desde context.js sólo cuando la bandera está encendida,
* y el modelo no se descarga hasta la primera petición que de verdad lo use.
*/
function getPipe(onProgress) {
if (!pipePromise) {
pipePromise = (async () => {
const tf = await import('https://cdn.jsdelivr.net/npm/@huggingface/transformers@4');
// navigator.gpu puede EXISTIR sin adaptador real (mismo cuidado que en
// providers/onnx.js): comprobar el adaptador, no el objeto. Elegir mal
// aquí significa descargar 235 MB para acabar corriendo en CPU.
let device = 'wasm';
if (navigator.gpu) {
try { device = (await navigator.gpu.requestAdapter()) ? 'webgpu' : 'wasm'; }
catch { device = 'wasm'; }
}
const cfg = { device, ...EMBED_MODEL[device] };
const pipe = await tf.pipeline('feature-extraction', EMBED_MODEL.id, {
device,
dtype: cfg.dtype,
progress_callback: onProgress,
});
backend = cfg; // sólo cuando de verdad está listo: isReady() no miente
return pipe;
})().catch(e => {
pipePromise = null; // que un fallo de red no deje el módulo muerto
backend = null;
throw e;
});
}
return pipePromise;
}
/** Precarga opcional (p.ej. desde Ajustes) sin bloquear ningún turno. */
export function warmup(onProgress) { return getPipe(onProgress); }
/** ¿Está ya cargado? Sirve para decidir si un turno va a pagar la descarga. */
export function isReady() { return backend !== null; }
/**
* embed(textos) → vectores normalizados de 384 dimensiones.
* Contrato exacto que espera `packHistoryACERHybrid`: si esto lanza, acer-core
* se va por la vía léxica sin romperse ni avisar.
*/
export async function embed(texts) {
if (!texts || !texts.length) return [];
const pipe = await getPipe();
const out = [];
for (let i = 0; i < texts.length; i += BATCH) {
const batch = texts.slice(i, i + BATCH)
.map(t => PREFIX + String(t == null ? '' : t).slice(0, MAX_CHARS));
const t = await pipe(batch, { pooling: 'mean', normalize: true });
for (const v of t.tolist()) out.push(Float32Array.from(v));
}
return out;
}
// Caché de sesión POR CONTENIDO. No es una optimización: es lo que hace viable
// la idea. acer-core rearma los bloques desde el principio del historial en cada
// turno, así que sin caché un texto que ya se codificó en el turno 3 se volvería
// a codificar en el 4, el 5 y el 20 — el coste crecería con el CUADRADO de los
// turnos. Con caché, cada bloque se codifica una vez por sesión y un turno sólo
// paga los bloques nuevos.
// El tope es holgado a propósito: acer-core trocea el historial ENTERO en cada
// turno, así que si la caché desaloja entradas que el turno siguiente vuelve a
// pedir, se recodifica gratis. Son vectores de 384 flotantes: ~12 MB llenos.
let cache = null;
export function embedCache() {
if (!cache) cache = createEmbedCache(embed, { max: 8000 });
return cache;
}
/** Para diagnóstico/ajustes: cuántos textos lleva cacheados la sesión. */
export function embedCacheSize() { return cache ? cache.size() : 0; }