Spaces:
Running
Running
File size: 10,010 Bytes
51ec7b5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 | // 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; }
|