Files
graph_explorer/issues/0042-gliner2-unified-extractor.md
T
egutierrez 5e6023f639 docs: issues 0041 (split thresholds) + 0042 (GLiNER2), supersedes mREBEL
Cierra el ciclo del analysis gliner_glirel_tuning: documenta en app.md el
pipeline NER+RE disponible en el registry y abre los dos issues que faltan
para cablearlo en extract_graph_hybrid + panel paste_extract. Archiva el
0042 original (mREBEL) tras la decision a favor de GLiNER2 (Apache 2.0,
joint NER+RE, 20-30x mas rapido en CPU).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 10:07:43 +02:00

7.2 KiB
Raw Blame History

id, title, status, priority, created, parent, depends_on, supersedes
id title status priority created parent depends_on supersedes
0042 GLiNER2 — extractor unificado NER+RE (Apache 2.0) sustituye GLiREL en extract_graph_hybrid pending high 2026-05-04 0013
0041
0042-mrebel-relation-extractor (archivado, ver decisions/2026-05-04-gliner2-over-mrebel.md)

Contexto

El issue 0042 inicial (sustituir GLiREL por mREBEL) queda superado por el descubrimiento de GLiNER2 (fastino/gliner2-large-v1):

  • Apache 2.0 — sin restriccion comercial.
  • NER + RE en un solo modelo — un solo forward pass.
  • 20-30× mas rapido que mREBEL en CPU.
  • Funciona en OSINT castellano — captura IPs, dominios defanged, CVEs, malware.

Datos en vaults/osint_nlp_models/models/gliner2.md, decision en vaults/osint_nlp_models/decisions/2026-05-04-gliner2-over-mrebel.md, validacion empirica en analysis/gliner_glirel_tuning/notebooks/04_gliner2_winner.ipynb.

Objetivo

Anadir GLiNER2 como motor principal de NER+RE en el pipeline extract_graph_hybrid. GLiREL queda como engine secundario en el panel paste_extract. mREBEL queda disponible via las funciones del registry pero no por defecto.

Diseño

1. Funciones nuevas en el registry

python/functions/datascience/
├── gliner2_load_model.py            # carga + cache GLiNER2 (Apache 2.0)
└── extract_graph_gliner2.py         # ejecuta schema entity+relation, normaliza a EntityCandidate/RelationCandidate

Plantilla gliner2_load_model.py:

"""Carga (y cachea) un modelo GLiNER2 que hace NER+RE conjunto.

LICENCIA: Apache 2.0 — uso libre incluido comercial.
"""
from typing import Any
_MODEL_CACHE: dict[tuple[str, str], Any] = {}

def gliner2_load_model(
    model_name: str = "fastino/gliner2-large-v1",
    device: str = "auto",
) -> Any:
    """Returns GLiNER2 instance ready for .extract() with schemas."""
    resolved = _resolve_device(device)
    key = (model_name, resolved)
    if key in _MODEL_CACHE: return _MODEL_CACHE[key]
    from gliner2 import GLiNER2
    m = GLiNER2.from_pretrained(model_name)
    if hasattr(m, "to"): m.to(resolved)
    _MODEL_CACHE[key] = m
    return m

Plantilla extract_graph_gliner2.py:

def extract_graph_gliner2(
    text: str,
    entity_labels: list[str],
    relation_labels: list[str],
    model,
    chunk_by_sentences: bool = False,
) -> tuple[list, list]:
    """Returns (entities, relations) as list[EntityCandidate], list[RelationCandidate].
    
    If chunk_by_sentences=True, splits text by sentence and aggregates.
    Recommended for texts > 20 sentences (recall improves).
    """

2. Cambio en extract_graph_hybrid.py

Anadir parametro relation_engine con cuatro valores:

def extract_graph_hybrid(
    chunks, entity_schema, relation_types,
    relation_engine: str = "gliner2",   # 'gliner2' | 'glirel' | 'mrebel' | 'none'
    gliner_model=None,                  # capa 1 NER (legacy stack)
    glirel_model=None,                  # capa 4 RE (legacy)
    mrebel_models=None,                 # tuple (tok, mdl) si engine=mrebel
    gliner2_model=None,                 # NUEVO — sustituye gliner+glirel
    llm_chat_json=None,
    entity_threshold=0.5,
    relation_threshold=0.15,
    chunk_by_sentences=False,
):
    if relation_engine == "gliner2":
        ents, rels = extract_graph_gliner2(text=..., entity_labels=..., relation_labels=...,
                                            model=gliner2_model, chunk_by_sentences=chunk_by_sentences)
        # NO se llama a GLiNER ni GLiREL, GLiNER2 hace ambos.
    elif relation_engine == "glirel":
        # legacy: GLiNER + GLiREL como antes
        ...
    elif relation_engine == "mrebel":
        # GLiNER + mREBEL via las funciones del registry (extract_relations_mrebel)
        ...
    elif relation_engine == "none":
        rels = []

3. enrichers/paste_extract/run.py

Manifest:

params:
  text: { type: string, required: true }
  use_hybrid: { type: bool, default: true }
  relation_engine: { type: enum, values: [gliner2, glirel, mrebel, none], default: gliner2 }
  entity_threshold: { type: float, default: 0.5 }
  relation_threshold: { type: float, default: 0.15 }
  chunk_by_sentences: { type: bool, default: false }

4. Panel C++ (extract_panel.cpp)

  • Combo: Engine: [GLiNER2 (recomendado) | GLiNER+GLiREL (rapido EN) | GLiNER+mREBEL (no comercial) | None]
  • Banner amarillo solo si mREBEL seleccionado (recordatorio de licencia CC BY-NC-SA).
  • Checkbox chunk_by_sentences (recomendado para texto largo).
  • Sliders del issue 0041 (entity_threshold, relation_threshold).

5. app.md

notes: |
  Motor de extraccion por defecto: GLiNER2 (Apache 2.0, NER+RE conjunto, ES/EN/FR).
  Engines alternativos: GLiNER+GLiREL (rapido EN), GLiNER+mREBEL (no comercial,
  18 idiomas). Ver vaults/osint_nlp_models/ para benchmarks.

dependencies:
  - "gliner2 >= 1.3.0 (Apache 2.0)"
optional_dependencies:
  - "gliner + glirel (Apache 2.0, legacy)"
  - "transformers + sentencepiece (mREBEL/REBEL — non-commercial mREBEL)"

6. Tests pytest

tests/test_extract_graph_gliner2.py:

  • Test smoke con stub de modelo (mock que retorna shape oficial).
  • Test que normaliza output a EntityCandidate / RelationCandidate correctamente.
  • Test que chunk_by_sentences=True agrega correctamente.

tests/test_paste_extract.py: anadir test del path relation_engine=gliner2 (default).

Definicion de hecho

  • Pego el corpus es_corporate_short, dejo engine=GLiNER2, pulso Extract.
  • Tras ~1.5s veo 14 entidades y 8 relaciones, mayoria correctas.
  • Cambio engine a GLiREL → resultado pobre conocido (validacion contraria).
  • Cambio engine a mREBEL → veo banner de licencia + 5 relaciones lentas pero limpias.
  • Pego corpus es_osint con label set OSINT → veo IPs, CVEs, malware, hashes correctamente.
  • Apply solo guarda las marcadas; dedupe sigue funcionando.
  • Tests verdes en WSL.

Latencia / UX

  • GLiNER2: ~1-5s para texto medio (4-30 frases). UX fluida, no necesita progress bar.
  • GLiNER2 chunked: ~150ms/frase. Para 30 frases = 4.5s, sigue fluido.
  • mREBEL queda con barra de progreso obligatoria (issue separado dentro de este).

Out of scope (issues separados)

  • Threshold tuning interno de GLiNER2 (si la API lo expone).
  • Mapeo de tipos de entidad GLiNER2 a vocabulario controlado custom (ej. personaPerson).
  • Extension a Frances (declarado pero sin probar).
  • Comparativa con gliner2-base-v1 (mas pequeño) — issue de optimizacion.

Riesgos

  • Recall bajo en texto largo sin chunking. Mitigacion: chunk_by_sentences=True por defecto si texto > 20 frases.
  • Errores semanticos puntuales (e.g. Inditex acquired Pablo Isla). El panel los muestra con checkbox — el humano filtra antes de Apply.
  • Spans sucios ocasionales en OSINT. Mitigacion: aplicar align_relations_to_entities_py_datascience (funcion ya en el registry) post-extract para descartar relaciones cuyas puntas no son entidades reales.

Relacionado

  • vaults/osint_nlp_models/models/gliner2.md
  • vaults/osint_nlp_models/decisions/2026-05-04-gliner2-over-mrebel.md
  • analysis/gliner_glirel_tuning/notebooks/04_gliner2_winner.ipynb
  • Issue 0041 (split confidence_threshold) — sigue valido.
  • Issue 0042-mrebel-relation-extractor.md.superseded (archivado).