#!/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())