tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1572Shell 203Rust 109Markdown 993728 Definiciones

⎇ 2907e74a8f41b07296102c14adc378cea53f57f9 recipes/ia-modelo-embeddings.toml TOML · 157 líneas · 10.4 KB Historia crudo

el guardián del modelo no falla por lento: falla por MEMORIA, y decía otra cosa f53913d7e · Sergio ·

1# ia-modelo-embeddings — el modelo con el que se le pregunta al archivo personal en lenguaje natural
2# (SDD 26 §6.3, mitad semántica).
3#
4# ══ QUÉ ES ═════════════════════════════════════════════════════════════════════════════════════
5# **Qwen3-Embedding-0.6B, Q8_0 oficial**: 1024 dimensiones, 639.160.320 bytes. Es el GGUF que publica
6# Qwen, no una conversión de terceros — y eso, acá, resultó ser lo que decide si el modelo SIRVE.
7#
8# ══ ⚠ LA HISTORIA IMPORTA, PORQUE EL FALLO NO SE PARECÍA A LA CAUSA ════════════════════════════
9# La primera elección fue **multilingual-e5-small** (MIT, 118 M, el tamaño ideal). Sellaba, cargaba,
10# contestaba 384 dimensiones y el endpoint funcionaba. Y el ranking devolvía **siempre la misma
11# página**, para cualquier pregunta.
12#
13# Lo que se midió, en este orden:
14#   1. ¿es el batching? No: los puntajes son idénticos pidiendo los pasajes juntos o de a uno.
15#   2. ¿es la posición? No: cambiando el orden de los pasajes, los puntajes no se mueven.
16#   3. ¿es nuestro código? No: los mismos textos a mano contra el servidor dan lo mismo.
17#   4. ¿es la cuantización? No: el fp32 sin tocar falla igual.
18#   5. ¿es la conversión? No: dos conversos independientes fallan **idéntico**.
19#   6. **Es el TOKENIZADOR**, y `/tokenize` lo dijo en una línea: «cortafuegos», «minino», «duerme»
20#      y «tejado» van todos al **id 100 = `<unk>`**. Un vocabulario XLM-RoBERTa por esta ruta deja
21#      casi todo en desconocido, y un texto que es todo `<unk>` embebe igual que cualquier otro.
22#      La huella era visible desde el principio: `gato~perro` y `gato~cortafuegos` daban **el mismo
23#      número a cuatro decimales**.
24#
25# ⇒ **REGLA QUE SALE DE ESTO Y VIVE EN EL GUARDIÁN DE ABAJO: antes de creerle a un modelo de
26#   embeddings, preguntarle al tokenizador por una frase del idioma que va a leer.** Un modelo que
27#   tokeniza mal no falla: contesta vectores plausibles y ordena al azar.
28#
29# ══ POR QUÉ ÉSTE, Y QUÉ CUESTA ═════════════════════════════════════════════════════════════════
30# · **Apache-2.0** y GGUF **oficial** de Qwen (108 k descargas), no una conversión sin dueño;
31# · **tokeniza español de verdad** — `llama-tokenize` parte «cortafuegos» en `cort|af|uegos`, no en
32#   `<unk>`;
33# · **acierta 3/3** con los tres pasajes y las tres preguntas de prueba, con márgenes anchos
34#   (0,649 contra 0,237 el segundo). Con la consulta pelada, 2/3: la instrucción no es decorado;
35# · misma familia que el modelo de chat, que ya estaba medido contestando en español;
36# · **cuesta 610 MiB** en la imagen, cinco veces el e5 que no servía. Es el precio de que funcione.
37#
38# `--pooling last` (lo pone el consumidor): es el que este modelo usa. Medido: con `mean` también
39# acierta 3/3 pero pegado (0,574 contra 0,557), y sin pooling declarado el endpoint contesta un 400
40# que parece del cliente.
41#
42# ══ EL TARBALL Y LA PROCEDENCIA ════════════════════════════════════════════════════════════════
43# takana extrae toda fuente con `tar` y un `.gguf` pelado no es un tar ⇒ el objeto pineado es un tar
44# que lo envuelve, publicado en el mirror y servido por sha256 (ADR 0013: la URL no entra en
45# `hash_inputs`). Igual que `ia-modelo-chat` y `firefox-pgo-profile`.
46#
47#   upstream  https://huggingface.co/Qwen/Qwen3-Embedding-0.6B-GGUF
48#   fichero   Qwen3-Embedding-0.6B-Q8_0.gguf
49#   sha256    06507c7b42688469c4e7298b0a1e16deff06caf291cf0a5b278c308249c3e439   ← del GGUF, no del tar
50name    = "ia-modelo-embeddings"
51version = "qwen3-embedding-0.6b-q8_0"
52license = "Apache-2.0"
53
54[source]
55tarball = "https://no-hay-upstream.invalid/takana/ia-modelo-embeddings.tar"
56sha256  = "b7f46f7519c66c65c8ec18c61920d6e6ec318107152b748141993aef4afa2978"
57strip_components = 0
58
59[build]
60compiler = "zig-cc"
61
62[build.phases]
63configure = "true"
64compile   = "true"
65install = '''
66set -e
67mkdir -p /out/usr/share/takana/ia
68# ⚠ El fichero DENTRO del tar se llama `qwen3-emb-q8.gguf` y no como en upstream: el tar lo
69# armamos nosotros y quedó con el nombre corto con el que se bajó. Se deja así —renombrarlo
70# significaría reempaquetar y volver a subir 610 MB— pero queda dicho acá, porque el bloque de
71# procedencia de arriba sí nombra el fichero de upstream y las dos cosas tienen que cuadrar.
72cp qwen3-emb-q8.gguf /out/usr/share/takana/ia/embeddings.gguf
73M=/out/usr/share/takana/ia/embeddings.gguf
74
75# ── GUARDIÁN 1: QUE SEA UN GGUF ──────────────────────────────────────────────────────────────
76head -c 4 "$M" | grep -q GGUF || { echo "!! lo instalado no empieza con el mágico GGUF" >&2; exit 1; }
77n=$(stat -c%s "$M")
78test "$n" -gt 500000000 || { echo "!! el GGUF pesa $n bytes: está truncado" >&2; exit 1; }
79
80# ── GUARDIÁN 2: QUE TOKENICE EL IDIOMA QUE VA A LEER ─────────────────────────────────────────
81# Ésta es la prueba que le faltaba a la receta anterior y que habría ahorrado la caza entera.
82# Un modelo que manda cada palabra a `<unk>` sella, carga y contesta vectores plausibles: el fallo
83# aparece semanas después como «la búsqueda devuelve siempre lo mismo». Dos frases SIN palabras en
84# común tienen que dar conjuntos de tokens distintos; si todo cae en el mismo id de desconocido, se
85# parecen demasiado y esto falla.
86tok() { llama-tokenize --model "$M" --prompt "$1" 2>/dev/null | sed 's/ *\([0-9]*\) ->.*/\1/' | sort -u; }
87tok "el cortafuegos bloquea la salida de una aplicación" > /tmp/t1
88tok "un minino atigrado dormía sobre las tejas del tejado" > /tmp/t2
89c1=$(wc -l < /tmp/t1); c2=$(wc -l < /tmp/t2); comunes=$(comm -12 /tmp/t1 /tmp/t2 | wc -l)
90echo "guardián: tokens distintos $c1 y $c2, comunes $comunes"
91test "$c1" -ge 8 || { echo "!! la primera frase da sólo $c1 tokens distintos: el tokenizador no la entiende" >&2; exit 1; }
92test "$c2" -ge 8 || { echo "!! la segunda frase da sólo $c2 tokens distintos" >&2; exit 1; }
93# Con el e5 roto, las dos frases compartían la mitad de sus ids (todos `<unk>`). Con un tokenizador
94# sano comparten sólo artículos y preposiciones.
95test "$comunes" -le 5 || {
96  echo "!! las dos frases comparten $comunes tokens: huele a que casi todo cae en <unk>" >&2
97  head -20 /tmp/t1 >&2; exit 1
98}
99
100# ── GUARDIÁN 3: QUE EL ESPACIO VECTORIAL DISTINGA ────────────────────────────────────────────
101# «Tokeniza» todavía no es «ordena». Se levanta el servidor de verdad y se le pide que diga que un
102# gato se parece más a un perro que a un cortafuegos. Sin esto, el guardián 2 podría pasar y el
103# modelo seguir siendo inútil para buscar.
104#
105# ⚠⚠ ESTE GUARDIÁN NECESITA ~3,2 GB DE RAM, Y CUANDO NO LOS HAY MIENTE (medido 2026-09-22).
106# `llama-server` con este modelo llega a `anon-rss:3246920kB` / `total-vm:4432388kB`. En una caja
107# apretada el OOM killer se lo lleva, `/health` no contesta nunca, el bucle agota sus 60 vueltas y
108# la receta muere diciendo **«el servidor no levantó el modelo»** — que suena a modelo roto y es
109# falta de memoria. Costó un diagnóstico equivocado («el timeout es corto») que sólo se deshizo
110# mirando `dmesg`:
111#
112#     oom-kill: … task=llama-server,pid=19786,uid=1001
113#     Out of memory: Killed process 19786 (llama-server) total-vm:4432388kB, anon-rss:3246920kB
114#
115# ⇒ **Construir esta receta en una máquina con ≥6 GB libres.** El worker (16 GB + 7 de swap) va
116# sobrado; el hub de la jaula, con 7 GB y 5 en uso, no. Subir el bucle de espera NO arregla nada:
117# esperaría más tiempo a un proceso que ya está muerto. Si algún día hace falta que corra en poco
118# espacio, lo que hay que tocar es el servidor (`--ctx-size`, `--no-mmap`), no el reloj.
119S=/tmp/emb-guardian.sock
120rm -f "$S"
121llama-server --model "$M" --host "$S" --embedding --pooling last --no-warmup > /tmp/emb.log 2>&1 &
122SRV=$!
123listo=0
124i=0
125while [ $i -lt 60 ]; do
126  curl -s --unix-socket "$S" http://localhost/health 2>/dev/null | grep -q '"ok"' && { listo=1; break; }
127  i=$((i+1)); sleep 2
128done
129test "$listo" = "1" || { echo "!! el servidor no levantó el modelo" >&2; tail -20 /tmp/emb.log >&2; kill $SRV 2>/dev/null; exit 1; }
130curl -s --unix-socket "$S" -H 'Content-Type: application/json' \
131  -d '{"input":["gato","perro","cortafuegos"]}' http://localhost/v1/embeddings > /tmp/emb.json
132kill $SRV 2>/dev/null
133# Los tres vectores, uno por línea, y el coseno en awk: el lab no trae python y para esto alcanza.
134# ⚠ OJO CON LOS BACKSLASHES: esto es un literal TOML de comilla triple, así que NO se escapan — lo
135# que se escribe es lo que ve la shell. Un backslash DOBLE manda dos caracteres, y entonces `tr` se
136# pone a borrar las letras «n». (Y ojo también con teclear una comilla triple en un comentario de
137# acá: cierra la cadena y el TOML deja de parsear. Pasó, y el error señala a la línea del comentario.)
138# ⚠ Las dos cosas de abajo se probaron EN EL HUB antes de gastar un turno de build, y las dos
139# estaban mal en el primer intento: un `sed` por corchete de apertura se comía el último vector
140# (salían 2 de 3), y el arreglo no puede llamarse `cos` — **`cos` es una función interna de awk** y
141# el script muere con un `syntax error` que no menciona el nombre.
142tr -d ' \n' < /tmp/emb.json | grep -o '\[[-0-9.,e]*\]' | tr -d '[]' > /tmp/vs
143test "$(wc -l < /tmp/vs)" -ge 3 || { echo "!! no salieron tres vectores" >&2; head -c 300 /tmp/emb.json >&2; exit 1; }
144awk -F, 'NR<=3{s=0;for(i=1;i<=NF;i++){v[NR,i]=$i;s+=$i*$i};len[NR]=sqrt(s);cols=NF}
145END{
146  for(p=2;p<=3;p++){d=0;for(i=1;i<=cols;i++)d+=v[1,i]*v[p,i];cs[p]=d/(len[1]*len[p])}
147  printf "guardián: gato~perro=%.4f  gato~cortafuegos=%.4f\n", cs[2], cs[3]
148  if (cs[2] <= cs[3] + 0.02) {
149    print "!! el espacio no distingue: un gato se parece a un cortafuegos tanto como a un perro" > "/dev/stderr"
150    exit 1
151  }
152}' /tmp/vs
153echo "guardián: GGUF válido, tokeniza español y el espacio distingue — $n bytes"
154'''
155
156[deps]
157build = ["llama-cpp", "curl"]