"""Buscar en el saber de COFRE lo que hace falta para contestar una pregunta. Esto es la mitad "recuperar" de RAG: dada una pregunta, devuelve los fragmentos de los apuntes que mas se le parecen, para pasarselos al cerebro. El cerebro construye la respuesta desde ESE texto, no desde lo que recuerde, que es lo que evita que se invente flags. ## Por que se carga una vez y se guarda El indice son 2.866 vectores. Cargarlo y encodear la pregunta cuesta, pero solo la primera vez: el modelo y los vectores se quedan en memoria. Una consulta despues es un producto escalar contra 2.866 filas, milisegundos. ## Por que no una base de datos vectorial FAISS, Chroma y compañia son para millones de vectores. Con 2.866, numpy y un producto escalar es mas rapido de arrancar, no añade una dependencia pesada, y el indice es un jsonl que se puede abrir y leer con los ojos. """ import json import os AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) INDICE = os.path.join(AQUI, "datos", "saber.jsonl") _modelo = None _entradas = None _matriz = None def _carga(): global _modelo, _entradas, _matriz if _entradas is not None: return _entradas is not False if not os.path.exists(INDICE): _entradas = False return False import numpy as np from model2vec import StaticModel entradas, vectores = [], [] with open(INDICE, encoding="utf-8") as f: for l in f: try: d = json.loads(l) except json.JSONDecodeError: continue v = d.pop("v", None) if v is None: continue entradas.append(d) vectores.append(v) _entradas = entradas _matriz = np.array(vectores, dtype="float32") # normalizar una vez: asi la similitud es un simple producto escalar normas = np.linalg.norm(_matriz, axis=1, keepdims=True) _matriz = _matriz / np.clip(normas, 1e-9, None) _modelo = StaticModel.from_pretrained("minishlab/potion-multilingual-128M") return True def _norm(t: str) -> str: import re import unicodedata 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) # El texto normalizado de cada entrada y su nombre, cacheados para el bono. _texto_norm = None _nombre_norm = None def _bono_palabras(palabras): """Un vector de bonos, uno por entrada, segun cuantas palabras compartan.""" import numpy as np global _texto_norm, _nombre_norm if _texto_norm is None: _texto_norm = [set(_norm(e["texto"]).split()) for e in _entradas] _nombre_norm = [_norm(e.get("herramienta", "")) for e in _entradas] bono = np.zeros(len(_entradas), dtype="float32") for i, (palabras_e, nombre) in enumerate(zip(_texto_norm, _nombre_norm)): comunes = len(palabras & palabras_e) b = 0.08 * comunes # cada palabra compartida suma if nombre and nombre in palabras: # y nombrar la herramienta, mucho b += 0.5 bono[i] = b return bono def busca(pregunta: str, cuantos: int = 5, perfil: str = None) -> list: """Los fragmentos mas parecidos a la pregunta, mejor primero. Cada uno lleva su similitud (0-1), de que herramienta y fichero viene, y el texto. perfil filtra por carpeta de COFRE si se quiere acotar. """ if not _carga() or not pregunta.strip(): return [] import numpy as np q = _modelo.encode([pregunta])[0] q = q / max(float(np.linalg.norm(q)), 1e-9) sim = _matriz @ q # Impulso por palabras exactas (busqueda hibrida). # # Los embeddings estaticos fallan cuando la pregunta va en castellano y el # texto en ingles —"cambiar permisos" no se parece a "change file mode # bits"—. Pero si la pregunta NOMBRA la herramienta ("como uso chmod") o # comparte palabras con el texto, eso es una señal fuerte que la similitud # semantica sola no aprovecha. Se suma un bono por cada palabra de la # pregunta que aparezca, y uno grande si coincide el nombre de la # herramienta: asi "usa nmap para..." lleva nmap al primer puesto. palabras = {w for w in _norm(pregunta).split() if len(w) > 3} if palabras: bono = _bono_palabras(palabras) sim = sim + bono orden = np.argsort(-sim)[: cuantos * 6] salida, vistos = [], set() for i in orden: e = _entradas[i] if perfil and e.get("perfil") != perfil: continue # sin duplicados: OSCP_APUNTES es copia de los apuntes de primer nivel. # Se comparan los primeros 120 caracteres, que basta para reconocerlos. firma = e["texto"][:120] if firma in vistos: continue vistos.add(firma) salida.append({**e, "sim": round(float(sim[i]), 3)}) if len(salida) >= cuantos: break return salida def contexto(pregunta: str, cuantos: int = 5, minimo: float = 0.35) -> str: """Lo mismo, pero ya montado como texto para meterle al cerebro. Se corta por debajo de `minimo` de similitud: pasarle al modelo fragmentos que no vienen a cuento es lo que le hace mezclar herramientas que no tienen nada que ver. Mejor darle poco y bueno que mucho y ruidoso. """ trozos = [t for t in busca(pregunta, cuantos) if t["sim"] >= minimo] if not trozos: return "" lineas = ["De los apuntes de COFRE del usuario:\n"] for t in trozos: fuente = t.get("fichero") or f"{t['perfil']}/{t['herramienta']}" lineas.append(f"--- {fuente} ---\n{t['texto']}\n") return "\n".join(lineas) def resumen(tope_temas: int = 10) -> str: """Un resumen de QUE hay indexado, para que el modelo sepa de que sabe. Se deriva del indice de VERDAD, no de una lista fija en el prompt. Asi es honesto en cualquier maquina: en la del usuario dice sus areas reales; en un clon sin apuntes propios dira lo que haya (comandos de Linux) o nada. Lo usa el cerebro para el prompt de sistema; evita que presuma de un conocimiento que en esa maquina no tiene.""" if not _carga() or not _entradas: return "" from collections import Counter perfiles, temas = Counter(), Counter() n_comandos = 0 for e in _entradas: t = e.get("tipo") if t == "comando": n_comandos += 1 elif t in ("apunte", "metodologia", "ficha", "pregunta") and e.get("perfil"): perfiles[e["perfil"]] += 1 if e.get("tema"): temas[e["tema"]] += 1 partes = [] if perfiles: partes.append("apuntes de " + ", ".join(p for p, _ in perfiles.most_common(6))) if temas: partes.append("metodologia en " + ", ".join(t for t, _ in temas.most_common(tope_temas))) if n_comandos: partes.append(f"{n_comandos} comandos de Linux") if not partes: return "" return ("En los apuntes que tienes indexados (buscalos con buscar_en_apuntes) " "hay: " + "; ".join(partes) + ".") def disponible() -> bool: return os.path.exists(INDICE)