Files
takana/recipes/ia-modelo-embeddings.toml
T
Sergio c78c5ff91f ia: el modelo de embeddings elegido NO servía — Qwen3-Embedding-0.6B en su lugar, y un guardián que lo habría cazado
multilingual-e5-small sellaba, cargaba, contestaba 384 dimensiones y 45 tests en verde. Y con el
modelo de verdad, de punta a punta, el ranking devolvía SIEMPRE la misma página.

Seis pasos descartando hipótesis —batching, posición, nuestro código, la cuantización, la conversión—
hasta que `/tokenize` lo dijo en una línea: «cortafuegos», «minino», «duerme» y «tejado» van todos al
id 100 = `<unk>`. Un vocabulario XLM-RoBERTa por esta ruta deja casi todo en desconocido, y un texto
que es todo `<unk>` embebe igual que cualquier otro. La pista estaba a la vista desde el principio:
`gato~perro` y `gato~cortafuegos` daban el mismo número a CUATRO DECIMALES.

En su lugar, Qwen3-Embedding-0.6B Q8_0, GGUF oficial de Qwen (Apache-2.0): tokeniza español de verdad
(`cort|af|uegos`), acierta 3/3 con márgenes anchos (0,649 contra 0,237), y es de la misma familia que
el modelo de chat. Cuesta 610 MiB en vez de 126: es el precio de que funcione, y sube la cuenta de la
imagen a ~1,85 GiB si se declara.

⚠ Y LA PARTE QUE IMPORTA PARA LA PRÓXIMA VEZ: el guardián del `install` ya no mira sólo el mágico y
el tamaño —«existe» no es «sirve»—. Ahora tokeniza dos frases en español sin palabras en común y
exige que no compartan tokens (con el e5 roto compartían la mitad, todos `<unk>`), y después levanta
el servidor de verdad y exige que un gato se parezca más a un perro que a un cortafuegos. Con esos
dos chequeos el e5 no habría sellado nunca.

El tar del modelo roto se borró del mirror (476 MB) y del disco.

⚠ El build está en la cola del flock detrás de otro agente (pixi, 22 min y contando), así que el
artefacto todavía no está sellado y los guardianes del navegador no corrieron. Lo medido hasta acá:
el camino completo host→motor→índice con el modelo nuevo, 3/3 (`archive.ask` de punta a punta, fuera
de la jaula), más 45 tests en tawasuyu.
2026-09-12 02:17:06 +00:00

131 lines
8.3 KiB
TOML

# ia-modelo-embeddings — el modelo con el que se le pregunta al archivo personal en lenguaje natural
# (SDD 26 §6.3, mitad semántica).
#
# ══ QUÉ ES ═════════════════════════════════════════════════════════════════════════════════════
# **Qwen3-Embedding-0.6B, Q8_0 oficial**: 1024 dimensiones, 639.160.320 bytes. Es el GGUF que publica
# Qwen, no una conversión de terceros — y eso, acá, resultó ser lo que decide si el modelo SIRVE.
#
# ══ ⚠ LA HISTORIA IMPORTA, PORQUE EL FALLO NO SE PARECÍA A LA CAUSA ════════════════════════════
# La primera elección fue **multilingual-e5-small** (MIT, 118 M, el tamaño ideal). Sellaba, cargaba,
# contestaba 384 dimensiones y el endpoint funcionaba. Y el ranking devolvía **siempre la misma
# página**, para cualquier pregunta.
#
# Lo que se midió, en este orden:
# 1. ¿es el batching? No: los puntajes son idénticos pidiendo los pasajes juntos o de a uno.
# 2. ¿es la posición? No: cambiando el orden de los pasajes, los puntajes no se mueven.
# 3. ¿es nuestro código? No: los mismos textos a mano contra el servidor dan lo mismo.
# 4. ¿es la cuantización? No: el fp32 sin tocar falla igual.
# 5. ¿es la conversión? No: dos conversos independientes fallan **idéntico**.
# 6. **Es el TOKENIZADOR**, y `/tokenize` lo dijo en una línea: «cortafuegos», «minino», «duerme»
# y «tejado» van todos al **id 100 = `<unk>`**. Un vocabulario XLM-RoBERTa por esta ruta deja
# casi todo en desconocido, y un texto que es todo `<unk>` embebe igual que cualquier otro.
# La huella era visible desde el principio: `gato~perro` y `gato~cortafuegos` daban **el mismo
# número a cuatro decimales**.
#
# ⇒ **REGLA QUE SALE DE ESTO Y VIVE EN EL GUARDIÁN DE ABAJO: antes de creerle a un modelo de
# embeddings, preguntarle al tokenizador por una frase del idioma que va a leer.** Un modelo que
# tokeniza mal no falla: contesta vectores plausibles y ordena al azar.
#
# ══ POR QUÉ ÉSTE, Y QUÉ CUESTA ═════════════════════════════════════════════════════════════════
# · **Apache-2.0** y GGUF **oficial** de Qwen (108 k descargas), no una conversión sin dueño;
# · **tokeniza español de verdad** — `llama-tokenize` parte «cortafuegos» en `cort|af|uegos`, no en
# `<unk>`;
# · **acierta 3/3** con los tres pasajes y las tres preguntas de prueba, con márgenes anchos
# (0,649 contra 0,237 el segundo). Con la consulta pelada, 2/3: la instrucción no es decorado;
# · misma familia que el modelo de chat, que ya estaba medido contestando en español;
# · **cuesta 610 MiB** en la imagen, cinco veces el e5 que no servía. Es el precio de que funcione.
#
# `--pooling last` (lo pone el consumidor): es el que este modelo usa. Medido: con `mean` también
# acierta 3/3 pero pegado (0,574 contra 0,557), y sin pooling declarado el endpoint contesta un 400
# que parece del cliente.
#
# ══ EL TARBALL Y LA PROCEDENCIA ════════════════════════════════════════════════════════════════
# takana extrae toda fuente con `tar` y un `.gguf` pelado no es un tar ⇒ el objeto pineado es un tar
# que lo envuelve, publicado en el mirror y servido por sha256 (ADR 0013: la URL no entra en
# `hash_inputs`). Igual que `ia-modelo-chat` y `firefox-pgo-profile`.
#
# upstream https://huggingface.co/Qwen/Qwen3-Embedding-0.6B-GGUF
# fichero Qwen3-Embedding-0.6B-Q8_0.gguf
# sha256 06507c7b42688469c4e7298b0a1e16deff06caf291cf0a5b278c308249c3e439 ← del GGUF, no del tar
name = "ia-modelo-embeddings"
version = "qwen3-embedding-0.6b-q8_0"
license = "Apache-2.0"
[source]
tarball = "https://no-hay-upstream.invalid/takana/ia-modelo-embeddings.tar"
sha256 = "b7f46f7519c66c65c8ec18c61920d6e6ec318107152b748141993aef4afa2978"
strip_components = 0
[build]
compiler = "zig-cc"
[build.phases]
configure = "true"
compile = "true"
install = '''
set -e
mkdir -p /out/usr/share/takana/ia
cp Qwen3-Embedding-0.6B-Q8_0.gguf /out/usr/share/takana/ia/embeddings.gguf
M=/out/usr/share/takana/ia/embeddings.gguf
# ── GUARDIÁN 1: QUE SEA UN GGUF ──────────────────────────────────────────────────────────────
head -c 4 "$M" | grep -q GGUF || { echo "!! lo instalado no empieza con el mágico GGUF" >&2; exit 1; }
n=$(stat -c%s "$M")
test "$n" -gt 500000000 || { echo "!! el GGUF pesa $n bytes: está truncado" >&2; exit 1; }
# ── GUARDIÁN 2: QUE TOKENICE EL IDIOMA QUE VA A LEER ─────────────────────────────────────────
# Ésta es la prueba que le faltaba a la receta anterior y que habría ahorrado la caza entera.
# Un modelo que manda cada palabra a `<unk>` sella, carga y contesta vectores plausibles: el fallo
# aparece semanas después como «la búsqueda devuelve siempre lo mismo». Dos frases SIN palabras en
# común tienen que dar conjuntos de tokens distintos; si todo cae en el mismo id de desconocido, se
# parecen demasiado y esto falla.
tok() { llama-tokenize --model "$M" --prompt "$1" 2>/dev/null | sed 's/ *\\([0-9]*\\) ->.*/\\1/' | sort -u; }
tok "el cortafuegos bloquea la salida de una aplicación" > /tmp/t1
tok "un minino atigrado dormía sobre las tejas del tejado" > /tmp/t2
c1=$(wc -l < /tmp/t1); c2=$(wc -l < /tmp/t2); comunes=$(comm -12 /tmp/t1 /tmp/t2 | wc -l)
echo "guardián: tokens distintos $c1 y $c2, comunes $comunes"
test "$c1" -ge 8 || { echo "!! la primera frase da sólo $c1 tokens distintos: el tokenizador no la entiende" >&2; exit 1; }
test "$c2" -ge 8 || { echo "!! la segunda frase da sólo $c2 tokens distintos" >&2; exit 1; }
# Con el e5 roto, las dos frases compartían la mitad de sus ids (todos `<unk>`). Con un tokenizador
# sano comparten sólo artículos y preposiciones.
test "$comunes" -le 5 || {
echo "!! las dos frases comparten $comunes tokens: huele a que casi todo cae en <unk>" >&2
head -20 /tmp/t1 >&2; exit 1
}
# ── GUARDIÁN 3: QUE EL ESPACIO VECTORIAL DISTINGA ────────────────────────────────────────────
# «Tokeniza» todavía no es «ordena». Se levanta el servidor de verdad y se le pide que diga que un
# gato se parece más a un perro que a un cortafuegos. Sin esto, el guardián 2 podría pasar y el
# modelo seguir siendo inútil para buscar.
S=/tmp/emb-guardian.sock
rm -f "$S"
llama-server --model "$M" --host "$S" --embedding --pooling last --no-warmup > /tmp/emb.log 2>&1 &
SRV=$!
listo=0
i=0
while [ $i -lt 60 ]; do
curl -s --unix-socket "$S" http://localhost/health 2>/dev/null | grep -q '"ok"' && { listo=1; break; }
i=$((i+1)); sleep 2
done
test "$listo" = "1" || { echo "!! el servidor no levantó el modelo" >&2; tail -20 /tmp/emb.log >&2; kill $SRV 2>/dev/null; exit 1; }
curl -s --unix-socket "$S" -H 'Content-Type: application/json' \
-d '{"input":["gato","perro","cortafuegos"]}' http://localhost/v1/embeddings > /tmp/emb.json
kill $SRV 2>/dev/null
# Los tres vectores, uno por línea, y el coseno en awk: el lab no trae python y para esto alcanza.
tr -d ' \\n' < /tmp/emb.json | sed 's/.*"data"://' | tr '[' '\\n' | sed -n 's/^\\([-0-9.,e]*\\)\\].*/\\1/p' > /tmp/vs
test "$(wc -l < /tmp/vs)" -ge 3 || { echo "!! no salieron tres vectores" >&2; head -c 300 /tmp/emb.json >&2; exit 1; }
awk -F, 'NR<=3{n=0;s=0;for(i=1;i<=NF;i++){v[NR,i]=$i;s+=$i*$i;n++};len[NR]=sqrt(s);cols=n}
END{
for(p=2;p<=3;p++){d=0;for(i=1;i<=cols;i++)d+=v[1,i]*v[p,i];cos[p]=d/(len[1]*len[p])}
printf "guardián: gato~perro=%.4f gato~cortafuegos=%.4f\\n", cos[2], cos[3]
if (cos[2] <= cos[3] + 0.02) {
print "!! el espacio no distingue: un gato se parece a un cortafuegos tanto como a un perro" > "/dev/stderr"
exit 1
}
}' /tmp/vs
echo "guardián: GGUF válido, tokeniza español y el espacio distingue — $n bytes"
'''
[deps]
build = ["llama-cpp", "curl", "busybox"]