--- id: 0042 title: GLiNER2 — extractor unificado NER+RE (Apache 2.0) sustituye GLiREL en extract_graph_hybrid status: pending priority: high created: 2026-05-04 parent: 0013 depends_on: [0041] supersedes: 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`: ```python """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`: ```python 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: ```python 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: ```yaml 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` ```yaml 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. `persona` → `Person`). - 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).