One parentless commit with only the files that the open seed tasks touch and the modules they import. The full squashed import (SPEC 8.6) is a later step: this tree is not the cutoff tree. T2T-StandIn-Source-Commit: 43f95b7333efe73aeaebf806023f17bffea27aba
192 lines
9.8 KiB
JavaScript
192 lines
9.8 KiB
JavaScript
// 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; }
|