JARVIS/nucleo/saber/enriquece/experimento_embedder.py
sito 8e4bc8ad94 JARVIS: asistente de voz local para Linux
Nucleo propio: oye con whisper.cpp, piensa con un modelo de Ollama, habla
con Piper, y hace RAG sobre los apuntes del usuario. 100% local, sin
cuentas ni claves.

Escrito bajo una restriccion dura, 4 GB de VRAM: el cerebro y whisper
comparten tarjeta y solo caben porque estan dimensionados para ello. El
RAG usa embeddings estaticos con busqueda hibrida; la voz clonada se sirve
de una cache de frases.

Incluye instalador (install.sh), requisitos, y documentacion del stack,
del manejo de root y de las acciones. Los apuntes indexados y el diario NO
se incluyen: son privados y el .gitignore los bloquea.
2026-08-16 15:34:37 +02:00

142 lines
5.6 KiB
Python

#!/usr/bin/env python3
"""Experimento: mide si un embedder transformer supera al model2vec estatico.
./experimento_embedder.py prueba intfloat/multilingual-e5-base
./experimento_embedder.py BAAI/bge-m3 prueba otro modelo
## Por que este experimento
model2vec (potion-multilingual-128M) es un embedder ESTATICO: rapidisimo (un
vector por token, precalculado, sin red neuronal en la consulta) pero flojo en
lo semantico. Su punto ciego se ve en las dos preguntas que el RAG no resuelve:
"detectar sql injection a ciegas basada en tiempo" -> no encuentra SLEEP
"descubrir directorios ocultos en una web" -> no encuentra gobuster
Las dos son cross-lingual: la pregunta va en castellano coloquial y el comando
en ingles tecnico ("time-based", "content discovery"). Un transformer de verdad
—e5, bge— entiende eso; el estatico no.
Este experimento NO toca nada: coge una MUESTRA del indice vivo (para que quepa
en memoria y sea rapido), la re-embebe con el transformer, y corre la misma
evaluacion. Si gana claro, merece la pena cambiar busca.py; si no, no.
## El coste que habria que pagar si se adopta
El estatico encodea la consulta en microsegundos. Un transformer en CPU tarda
~50-150 ms por consulta. Para un asistente de voz local es asumible, pero no es
gratis, y por eso se mide antes de decidir.
"""
import json
import os
import re
import sys
import unicodedata
AQUI = os.path.dirname(os.path.abspath(__file__))
RAIZ = os.path.dirname(os.path.dirname(AQUI))
VIVO = os.path.join(RAIZ, "datos", "saber.jsonl")
EVAL = os.path.join(AQUI, "eval_set.jsonl")
MODELO_TF = sys.argv[1] if len(sys.argv) > 1 else "intfloat/multilingual-e5-base"
# Muestra: todo lo que un caso de eval podria querer + relleno, para no cargar
# 14.000 vectores de transformer en RAM. Se cogen todas las entradas de
# metodologia y pregunta (el oro) mas una parte del resto.
MAX_ENTRADAS = 6000
def _norm(t):
t = unicodedata.normalize("NFD", (t or "").lower())
t = "".join(c for c in t if unicodedata.category(c) != "Mn")
return re.sub(r"[^a-z0-9 ]", " ", t)
def _muestra():
oro, resto = [], []
with open(VIVO, encoding="utf-8") as f:
for l in f:
try:
d = json.loads(l)
except json.JSONDecodeError:
continue
(oro if d.get("tipo") in ("metodologia", "pregunta") else resto).append(d)
ent = oro + resto[: max(0, MAX_ENTRADAS - len(oro))]
return ent
def _eval(nombre, encode_corpus, encode_query, entradas):
import numpy as np
textos = [(e.get("indexar") or e["texto"]) for e in entradas]
M = encode_corpus(textos)
M = M / np.clip(np.linalg.norm(M, axis=1, keepdims=True), 1e-9, None)
palabras_e = [set(_norm(e["texto"]).split()) for e in entradas]
nom = [_norm(e.get("herramienta", "")) for e in entradas]
casos = [json.loads(l) for l in open(EVAL, encoding="utf-8") if l.strip()]
hit1 = hit5 = 0
fallos = []
for c in casos:
q = encode_query([c["pregunta"]])[0]
q = q / max(float(np.linalg.norm(q)), 1e-9)
sim = M @ q
pal = {w for w in _norm(c["pregunta"]).split() if len(w) > 3}
if pal:
bono = np.array([0.08 * len(pal & palabras_e[i]) +
(0.5 if nom[i] and nom[i] in pal else 0.0)
for i in range(len(entradas))], dtype="float32")
sim = sim + bono
orden = np.argsort(-sim)[:5]
esperado = [_norm(k).strip() for k in c["espera"]]
tops = [_norm(entradas[i]["texto"]) for i in orden]
ok = lambda t: any(k and k in t for k in esperado)
if tops and ok(tops[0]):
hit1 += 1
if any(ok(t) for t in tops):
hit5 += 1
else:
fallos.append(c["pregunta"])
n = len(casos)
print(f"\n {nombre}")
print(f" hit@1 {hit1:3d}/{n} ({100*hit1//n}%)")
print(f" hit@5 {hit5:3d}/{n} ({100*hit5//n}%)")
if fallos:
print(f" no resuelve {len(fallos)}: " + "; ".join(fallos[:6]))
return hit1, hit5
def main():
entradas = _muestra()
print(f"Muestra: {len(entradas)} entradas del indice vivo")
# 1) model2vec estatico, la referencia
from model2vec import StaticModel
est = StaticModel.from_pretrained("minishlab/potion-multilingual-128M")
enc_est = lambda xs: est.encode(xs)
h1e, h5e = _eval("model2vec (estatico, el actual)", enc_est, enc_est, entradas)
# 2) el transformer a prueba
print(f"\n cargando {MODELO_TF} (puede descargar ~1-2 GB la 1a vez)...", flush=True)
from sentence_transformers import SentenceTransformer
tf = SentenceTransformer(MODELO_TF)
# los modelos e5 piden prefijos "query:" y "passage:"
es_e5 = "e5" in MODELO_TF.lower()
enc_doc = lambda xs: tf.encode([("passage: " + x) if es_e5 else x for x in xs],
show_progress_bar=False, batch_size=32)
enc_q = lambda xs: tf.encode([("query: " + x) if es_e5 else x for x in xs],
show_progress_bar=False, batch_size=32)
h1t, h5t = _eval(f"{MODELO_TF} (transformer)", enc_doc, enc_q, entradas)
print("\n" + "=" * 44)
print(f" hit@1 estatico {h1e} -> transformer {h1t} ({h1t-h1e:+d})")
print(f" hit@5 estatico {h5e} -> transformer {h5t} ({h5t-h5e:+d})")
print("=" * 44)
if h5t > h5e or (h5t == h5e and h1t > h1e):
print(" El transformer gana. Merece plantear el cambio en busca.py")
print(" (coste: ~50-150 ms por consulta en CPU, hoy es instantaneo).")
else:
print(" El transformer NO compensa aqui. El estatico se queda.")
return 0
if __name__ == "__main__":
sys.exit(main())