feat: motor de correlacion semantica

Reemplaza el solape de palabras por similitud coseno sobre embeddings.
La comparacion vieja cruzaba todos los documentos contra todos contando
palabras comunes, stopwords incluidas: O(n*m) y mucho falso positivo.

El motor nuevo compara noticia contra corpus de leaks en el espacio
vectorial, reduce de fragmento a documento por maximo, y penaliza los
documentos hub: score = cos - lambda*ln(1+grado). Sin esa penalizacion, los
documentos catch-all (cables largos, buzones de correo enteros) salen
emparejados con casi cualquier noticia.

Escribe en una coleccion aparte, asi que se puede regenerar sin dejar el
grafo a oscuras. Acepta --dry-run para calibrar umbrales antes de escribir.
This commit is contained in:
hacklab 2026-08-23 17:11:31 +02:00
parent 6de1b6de02
commit 91e6c11a08
4 changed files with 335 additions and 17 deletions

View file

@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Muestrea pares noticia↔leak en bandas de coseno y vuelca su texto a JSON,
para evaluar la calidad del matching y calibrar el umbral. Solo lectura."""
import json, os, sys
import numpy as np
from pymongo import MongoClient
db = MongoClient("mongodb://localhost:27017", serverSelectionTimeoutMS=4000)["FLUJOS_DATOS"]
# --- noticias con embedding (query) ---
news = list(db.noticias.find(
{"embedding": {"$exists": True}, "apto_embedding": {"$ne": False}},
{"archivo": 1, "embedding": 1, "titulo_original": 1, "texto": 1, "fecha": 1}))
# muestra de noticias para no cargar las 21k (determinista: primeras 3000)
news = news[:3000]
Qarch = [n["archivo"] for n in news]
Q = np.asarray([n["embedding"] for n in news], dtype=np.float32)
Q /= np.clip(np.linalg.norm(Q, axis=1, keepdims=True), 1e-9, None)
# --- chunks de leaks (corpus) ---
ch = list(db.embeddings_chunks.find({"coleccion": "torrents"}, {"archivo": 1, "embedding": 1}))
C = np.asarray([c["embedding"] for c in ch], dtype=np.float32)
C /= np.clip(np.linalg.norm(C, axis=1, keepdims=True), 1e-9, None)
leak_of = [c["archivo"] for c in ch]
uniq, idx = {}, np.empty(len(ch), dtype=np.int64)
names = []
for i, a in enumerate(leak_of):
j = uniq.setdefault(a, len(names))
if j == len(names): names.append(a)
idx[i] = j
nL = len(names)
print(f"news={len(Q)} corpus_chunks={len(C)} leaks={nL}", file=sys.stderr)
# top-1 leak por noticia (doc-level max) → recolectar (cos, news_i, leak_j)
pares = []
Ct = np.ascontiguousarray(C.T)
for b in range(0, len(Q), 256):
S = Q[b:b+256] @ Ct
for r in range(S.shape[0]):
dm = np.full(nL, -1.0, np.float32)
np.maximum.at(dm, idx, S[r])
j = int(dm.argmax())
pares.append((float(dm[j]), b+r, j))
# bandas de coseno; muestrear hasta 10 por banda
bandas = [(0.40,0.45),(0.45,0.50),(0.50,0.55),(0.55,0.60),(0.60,0.65),(0.65,0.70),(0.70,1.01)]
muestras = []
for lo, hi in bandas:
enb = [p for p in pares if lo <= p[0] < hi]
enb.sort(key=lambda x: -x[0])
paso = max(1, len(enb)//10)
for cos, ni, lj in enb[::paso][:10]:
n = news[ni]
leak = db.torrents.find_one({"archivo": names[lj]}, {"texto": 1, "archivo": 1, "tema": 1})
muestras.append({
"banda": f"{lo:.2f}-{hi:.2f}", "cosine": round(cos, 3),
"noticia_titulo": (n.get("titulo_original") or "")[:200],
"noticia_texto": (n.get("texto") or "")[:600],
"leak_archivo": names[lj],
"leak_texto": ((leak or {}).get("texto") or "")[:600],
})
json.dump(muestras, open("/tmp/flujos_pares.json", "w"), ensure_ascii=False, indent=1)
print(f"{len(muestras)} pares volcados a /tmp/flujos_pares.json", file=sys.stderr)
# resumen por banda
from collections import Counter
print("por banda:", dict(Counter(m["banda"] for m in muestras)), file=sys.stderr)

View file

@ -0,0 +1,237 @@
#!/usr/bin/env python3
"""
motor_comparacion.py motor de correlación semántica
=======================================================
Reemplaza el solape-de-palabras (pipeline_completo.py) por SIMILITUD SEMÁNTICA
sobre embeddings MiniLM ya calculados (ver backfill_embeddings.py).
Cómo funciona:
- QUERY = noticias con `embedding` (coconews/rss; se preservan los existentes).
- CORPUS = chunks de leaks/wiki desde `embeddings_chunks` (recall por fragmento).
Si no hay chunks para una colección, cae a `embedding` a nivel doc.
- Para cada noticia: coseno contra todo el corpus, reducido a nivel-documento
por MAX de chunks.
- PENALIZACIÓN DE HUB: los documentos "catch-all" (viewer
de cablegate, emails genéricos...) caen cerca del centroide y matchean con todo
el 39% de los falsos positivos. Se mitiga con:
· drop duro de hubs extremos (grado > --max-grado-frac · nº_noticias)
· ranking por score = cos λ·ln(1+grado) (los hubs moderados bajan en el top-k)
El GATE de creación de arista sigue siendo el coseno crudo (--umbral 0.55,
calibrado con panel de jueces), para no descalibrar las aristas legítimas.
NO DESTRUCTIVO: nunca escribe en `comparaciones`. Salida = colección nueva.
Calibración (no escribe):
/usr/bin/python3 motor_comparacion.py --dry-run
Generación real:
/usr/bin/python3 motor_comparacion.py --umbral 0.55 --salida comparaciones_v2
"""
import argparse
import logging
import math
import os
import sys
import time
from collections import Counter, defaultdict
MONGO_URL = os.getenv("MONGO_URL", "mongodb://localhost:27017")
DB_NAME = os.getenv("DB_NAME", "FLUJOS_DATOS")
CHUNKS_COL = os.getenv("CHUNKS_COL", "embeddings_chunks")
MODEL_NAME = os.getenv("EMBEDDER_MODEL", "paraphrase-multilingual-MiniLM-L12-v2")
logging.basicConfig(level=logging.INFO, format="%(asctime)s [motor] %(message)s")
log = logging.getLogger(__name__)
def conectar():
from pymongo import MongoClient
from pymongo.errors import ServerSelectionTimeoutError
cli = MongoClient(MONGO_URL, serverSelectionTimeoutMS=4000)
try:
cli.admin.command("ping")
except ServerSelectionTimeoutError:
log.error("MongoDB no responde en %s — ¿está mongod arriba? Aborto sin tocar nada.", MONGO_URL)
sys.exit(2)
return cli[DB_NAME]
def cargar_matriz(db, colecciones, query_side=False, limit=0):
"""Devuelve (vectores np.float32 [N,384] L2-normalizados, archivos[N])."""
import numpy as np
vecs, archivos = [], []
if query_side:
cur = db.noticias.find(
{"embedding": {"$exists": True}, "apto_embedding": {"$ne": False}},
{"archivo": 1, "embedding": 1})
if limit:
cur = cur.limit(limit)
for d in cur:
e = d.get("embedding")
if e and d.get("archivo"):
vecs.append(e); archivos.append(d["archivo"].strip())
else:
ch = db[CHUNKS_COL]
for d in ch.find({"coleccion": {"$in": colecciones}}, {"archivo": 1, "embedding": 1}):
e = d.get("embedding")
if e and d.get("archivo"):
vecs.append(e); archivos.append(d["archivo"].strip())
if not vecs: # fallback: sin chunks → embedding a nivel doc
log.warning("Sin chunks en '%s' para %s; usando embedding a nivel documento.",
CHUNKS_COL, colecciones)
for c in colecciones:
for d in db[c].find({"embedding": {"$exists": True}, "apto_embedding": {"$ne": False}},
{"archivo": 1, "embedding": 1}):
e = d.get("embedding")
if e and d.get("archivo"):
vecs.append(e); archivos.append(d["archivo"].strip())
if not vecs:
return np.zeros((0, 384), dtype=np.float32), []
M = np.asarray(vecs, dtype=np.float32)
norms = np.linalg.norm(M, axis=1, keepdims=True)
norms[norms == 0] = 1.0
return M / norms, archivos
def main():
ap = argparse.ArgumentParser(description="Motor de comparación semántica (embeddings + anti-hub)")
ap.add_argument("--query", default="noticias")
ap.add_argument("--corpus", default="torrents",
help="colecciones corpus separadas por coma, p.ej. torrents,wikipedia")
ap.add_argument("--topk", type=int, default=15, help="leaks por noticia (el front capa a 8)")
ap.add_argument("--umbral", type=float, default=0.52,
help="GATE principal: score penalizado mínimo (score = cos λ·ln(1+grado))")
ap.add_argument("--min-cos", type=float, default=0.50, dest="min_cos",
help="suelo duro de coseno crudo (descarta la banda tóxica 0.45-0.50)")
ap.add_argument("--prefiltro", type=float, default=0.50,
help="coseno mínimo para contar como candidato y computar el grado de hub")
ap.add_argument("--lambda-hub", type=float, default=0.02, dest="lambda_hub",
help="penalización de hub: score = cos λ·ln(1+grado)")
ap.add_argument("--max-grado-frac", type=float, default=0.10, dest="max_grado_frac",
help="drop duro de seguridad: descartar leaks que tocan > esta fracción de noticias")
ap.add_argument("--marca-evento", type=float, default=0.68, dest="marca_evento",
help="coseno a partir del cual se marca posible_evento=true")
ap.add_argument("--salida", default="comparaciones_v2")
ap.add_argument("--limit-query", type=int, default=0)
ap.add_argument("--block", type=int, default=256)
ap.add_argument("--dry-run", action="store_true", help="histograma + hubs, NO escribe")
args = ap.parse_args()
corpus = [c.strip() for c in args.corpus.split(",") if c.strip()]
if args.salida == "comparaciones":
log.error("Negado: --salida no puede ser 'comparaciones' (se preserva intacta). Usa comparaciones_v2.")
sys.exit(1)
import numpy as np
db = conectar()
log.info("Cargando QUERY (%s)…", args.query)
Q, q_arch = cargar_matriz(db, [args.query], query_side=True, limit=args.limit_query)
log.info("Cargando CORPUS (%s)…", corpus)
C, c_arch = cargar_matriz(db, corpus, query_side=False)
log.info("QUERY=%d vectores · CORPUS=%d vectores (dim=%d)", len(Q), len(C), Q.shape[1] if len(Q) else 0)
if len(Q) == 0 or len(C) == 0:
log.error("Faltan embeddings. Corre antes backfill_embeddings.py. Aborto.")
sys.exit(3)
# chunk → documento-leak
uniq, leak_names = {}, []
c_leak_idx = np.empty(len(c_arch), dtype=np.int64)
for i, a in enumerate(c_arch):
j = uniq.get(a)
if j is None:
j = len(leak_names); uniq[a] = j; leak_names.append(a)
c_leak_idx[i] = j
n_leaks, n_news = len(leak_names), len(Q)
log.info("Corpus colapsa a %d documentos-leak distintos.", n_leaks)
Ct = np.ascontiguousarray(C.T)
t0 = time.time()
# ---- PASE 1: candidatos (noticia, leak, coseno) ≥ prefiltro ----
cand_news, cand_leak, cand_cos = [], [], []
for b in range(0, n_news, args.block):
scores = Q[b:b + args.block] @ Ct
for r in range(scores.shape[0]):
doc_max = np.full(n_leaks, -1.0, dtype=np.float32)
np.maximum.at(doc_max, c_leak_idx, scores[r]) # reducción chunk→doc por MAX
for j in np.where(doc_max >= args.prefiltro)[0]:
cand_news.append(b + r); cand_leak.append(int(j)); cand_cos.append(float(doc_max[j]))
if (b // args.block) % 20 == 0:
log.info("… pase1 %d/%d noticias (%d candidatos) — %.0fs",
min(b + args.block, n_news), n_news, len(cand_cos), time.time() - t0)
log.info("Pase 1: %d candidatos ≥ %.2f.", len(cand_cos), args.prefiltro)
# ---- grado de hub de cada leak ----
grado = Counter(cand_leak)
max_grado = args.max_grado_frac * n_news
hubs_duros = {j for j, g in grado.items() if g > max_grado}
if hubs_duros:
ej = ", ".join(leak_names[j][:45] for j in sorted(hubs_duros, key=lambda j: -grado[j])[:3])
log.info("Hubs duros descartados (grado > %.0f = %.0f%% noticias): %d leaks. Top: %s",
max_grado, args.max_grado_frac * 100, len(hubs_duros), ej)
# ---- PASE 2: ranking penalizado por noticia, GATE por coseno crudo ----
por_news = defaultdict(list) # ni -> [(score_pen, lj, cos)]
for ni, lj, cos in zip(cand_news, cand_leak, cand_cos):
if lj in hubs_duros:
continue
score = cos - args.lambda_hub * math.log1p(grado[lj])
por_news[ni].append((score, lj, cos))
edges, todos_cos, todos_score = [], [], []
for ni, lst in por_news.items():
lst.sort(reverse=True) # ranking por score penalizado
for score, lj, cos in lst[:args.topk]:
todos_cos.append(cos); todos_score.append(score)
if score >= args.umbral and cos >= args.min_cos: # gate hub-aware + suelo de coseno
edges.append((q_arch[ni], leak_names[lj], score, cos, grado[lj]))
log.info("Generadas %d aristas (gate score≥%.2f, cos≥%.2f, λ_hub=%.3f) en %.0fs.",
len(edges), args.umbral, args.min_cos, args.lambda_hub, time.time() - t0)
if args.dry_run:
cos = np.asarray(todos_cos) if todos_cos else np.zeros(0)
sco = np.asarray(todos_score) if todos_score else np.zeros(0)
log.info("=== DRY-RUN (top-%d/noticia, %d valores) ===", args.topk, len(cos))
log.info(" -- coseno crudo --")
for lo in [0.50, 0.55, 0.60, 0.65, 0.68, 0.70]:
log.info(" cos ≥ %.2f : %d", lo, int((cos >= lo).sum()) if len(cos) else 0)
log.info(" -- score penalizado (gate real) --")
for lo in [0.50, 0.52, 0.55, 0.60, 0.65]:
log.info(" score ≥ %.2f : %d", lo, int((sco >= lo).sum()) if len(sco) else 0)
if len(cos):
log.info(" cos p50=%.3f p90=%.3f | score p50=%.3f p90=%.3f",
float(np.percentile(cos, 50)), float(np.percentile(cos, 90)),
float(np.percentile(sco, 50)), float(np.percentile(sco, 90)))
top_hubs = sorted(grado.items(), key=lambda kv: -kv[1])[:10]
log.info(" leaks-hub por grado (sobre %d noticias):", n_news)
for j, g in top_hubs:
log.info(" grado=%-5d %s", g, leak_names[j][:70])
log.info("No se escribió nada (--dry-run). Elige --umbral/--max-grado-frac y relanza sin --dry-run.")
return
# ---- escritura NO destructiva ----
from pymongo import UpdateOne
out = db[args.salida]
ops = []
for a, bk, score, cos, g in edges:
n1, n2 = sorted((a, bk)) # orden canónico min/max
ops.append(UpdateOne(
{"noticia1": n1, "noticia2": n2},
{"$set": {"noticia1": n1, "noticia2": n2,
"porcentaje_similitud": round(score * 100, 2), # ranking de-hubbeado para el front
"cosine": round(cos, 4), "score": round(score, 4),
"grado_leak": g, "posible_evento": bool(cos >= args.marca_evento),
"metodo": "embedding", "modelo": MODEL_NAME}},
upsert=True))
for i in range(0, len(ops), 2000):
out.bulk_write(ops[i:i + 2000], ordered=False)
out.create_index([("noticia1", 1), ("noticia2", 1)], unique=True, name="par_unico")
out.create_index([("porcentaje_similitud", -1)], name="sim_desc")
n_ev = sum(1 for e in edges if e[3] >= args.marca_evento)
log.info("✅ Escritas %d aristas en '%s' (%d marcadas posible_evento ≥%.2f). 'comparaciones' intacta.",
len(ops), args.salida, n_ev, args.marca_evento)
log.info("Activar en front: COMPARACIONES_COL=%s + repuntar la línea de FLUJOS_APP.js.", args.salida)
if __name__ == "__main__":
main()

View file

@ -12,6 +12,12 @@ import string
import nltk
from nltk.corpus import stopwords
# Raiz de FLUJOS_DATOS. Por defecto se deduce de la ubicacion de este fichero,
# asi el repo funciona en cualquier maquina sin editar nada.
DATOS_DIR = os.getenv('FLUJOS_DATOS_DIR',
os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
# Descargar stopwords la primera vez
nltk.download('stopwords')
stop_words = set(stopwords.words('spanish'))
@ -222,23 +228,26 @@ def main():
try:
# Rutas absolutas de los archivos en formato txt
carpeta_noticias_txt = '/var/www/theflows.net/flujos/FLUJOS_DATOS/NOTICIAS/articulos'
carpeta_wikipedia_txt = '/var/www/theflows.net/flujos/FLUJOS_DATOS/WIKIPEDIA/articulos_wikipedia'
carpeta_torrents_txt = '/var/www/theflows.net/flujos/FLUJOS_DATOS/TORRENTS/TORRENTS_WIKILEAKS_COMPLETO/txt'
carpeta_noticias_txt = os.path.join(DATOS_DIR, 'NOTICIAS', 'articulos')
carpeta_wikipedia_txt = os.path.join(DATOS_DIR, 'WIKIPEDIA', 'articulos_wikipedia')
carpeta_torrents_txt = os.path.join(DATOS_DIR, 'TORRENTS', 'TORRENTS_WIKILEAKS_COMPLETO', 'txt')
# Carpetas con los archivos tokenizados para hacer las comparaciones
carpeta_noticias_tokenized = '/var/www/theflows.net/flujos/FLUJOS_DATOS/NOTICIAS/tokenized'
carpeta_wikipedia_tokenized = '/var/www/theflows.net/flujos/FLUJOS_DATOS/WIKIPEDIA/articulos_tokenizados'
carpeta_torrents_tokenized = '/var/www/theflows.net/flujos/FLUJOS_DATOS/TORRENTS/TORRENTS_WIKILEAKS_COMPLETO/tokenized'
carpeta_noticias_tokenized = os.path.join(DATOS_DIR, 'NOTICIAS', 'tokenized')
carpeta_wikipedia_tokenized = os.path.join(DATOS_DIR, 'WIKIPEDIA', 'articulos_tokenizados')
carpeta_torrents_tokenized = os.path.join(DATOS_DIR, 'TORRENTS', 'TORRENTS_WIKILEAKS_COMPLETO', 'tokenized')
# Iniciar cliente MongoDB
client = init_mongo_client()
# Omitir la subida de documentos
print("Subiendo archivos originales de Noticias a MongoDB...")
logging.info("Iniciando subida de noticias a MongoDB")
procesar_documentos(carpeta_noticias_txt, client['FLUJOS_DATOS']['noticias'])
logging.info("Finalizada subida de noticias a MongoDB")
# [DESACTIVADO 2026-06-06] Dual-write legacy de noticias a Mongo.
# Escribía docs incompatibles (sin source_type/embedding) en la colección
# canónica 'noticias', desincronizándola de coconews. Backup de los 41.467
# docs legacy ya existentes en colección 'noticias_legacy_backup'.
# El sistema canónico de noticias es ahora coconews (source_type:'rss').
# logging.info("Iniciando subida de noticias a MongoDB")
# procesar_documentos(carpeta_noticias_txt, client['FLUJOS_DATOS']['noticias'])
# logging.info("Finalizada subida de noticias a MongoDB")
# print("Subiendo archivos originales de Wikipedia a MongoDB...")
logging.info("Iniciando subida de Wikipedia a MongoDB")

View file

@ -11,6 +11,12 @@ import string
import nltk
from nltk.corpus import stopwords
# Raiz de FLUJOS_DATOS. Por defecto se deduce de la ubicacion de este fichero,
# asi el repo funciona en cualquier maquina sin editar nada.
DATOS_DIR = os.getenv('FLUJOS_DATOS_DIR',
os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
# Descargar stopwords la primera vez
nltk.download('stopwords')
stop_words = set(stopwords.words('spanish'))
@ -226,14 +232,14 @@ def manejar_comparaciones_multiproceso(directorios_tokenized):
def main():
try:
# Rutas absolutas de los archivos en formato txt
carpeta_noticias_txt = '/var/www/theflows.net/flujos/FLUJOS_DATOS/NOTICIAS/articulos'
carpeta_wikipedia_txt = '/var/www/theflows.net/flujos/FLUJOS_DATOS/WIKIPEDIA/articulos_wikipedia'
carpeta_torrents_txt = '/var/www/theflows.net/flujos/FLUJOS_DATOS/TORRENTS/TORRENTS_WIKILEAKS_COMPLETO/txt'
carpeta_noticias_txt = os.path.join(DATOS_DIR, 'NOTICIAS', 'articulos')
carpeta_wikipedia_txt = os.path.join(DATOS_DIR, 'WIKIPEDIA', 'articulos_wikipedia')
carpeta_torrents_txt = os.path.join(DATOS_DIR, 'TORRENTS', 'TORRENTS_WIKILEAKS_COMPLETO', 'txt')
# Carpetas con los archivos tokenizados para hacer las comparaciones
carpeta_noticias_tokenized = '/var/www/theflows.net/flujos/FLUJOS_DATOS/NOTICIAS/tokenized'
carpeta_wikipedia_tokenized = '/var/www/theflows.net/flujos/FLUJOS_DATOS/WIKIPEDIA/articulos_tokenizados'
carpeta_torrents_tokenized = '/var/www/theflows.net/flujos/FLUJOS_DATOS/TORRENTS/TORRENTS_WIKILEAKS_COMPLETO/tokenized'
carpeta_noticias_tokenized = os.path.join(DATOS_DIR, 'NOTICIAS', 'tokenized')
carpeta_wikipedia_tokenized = os.path.join(DATOS_DIR, 'WIKIPEDIA', 'articulos_tokenizados')
carpeta_torrents_tokenized = os.path.join(DATOS_DIR, 'TORRENTS', 'TORRENTS_WIKILEAKS_COMPLETO', 'tokenized')
# Iniciar cliente MongoDB
client = init_mongo_client()