Spaces:
Running
Running
| // 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; } | |