, teníamos SpaCy, que era la biblioteca de PNL de facto tanto para principiantes como para usuarios avanzados. Facilitó la inmersión en la PNL, incluso si no eras un experto en aprendizaje profundo. Sin embargo, con el auge de ChatGPT y otros LLM, parece haber quedado a un lado.
Si bien los LLM como Claude o Gemini pueden hacer todo tipo de cosas de PNL de forma automática, no siempre es necesario llevar un lanzacohetes a una pelea a puñetazos. GliNER encabeza el regreso de modelos más pequeños y enfocados para técnicas clásicas de PNL como la extracción de entidades y relaciones. Es lo suficientemente liviano como para ejecutarse en una CPU, pero lo suficientemente potente como para haber construido una comunidad próspera a su alrededor.
Lanzado a principios de este año, GliNER2 es un importante paso adelante. Mientras que el GliNER original se centraba en el reconocimiento de entidades (generando varios productos derivados como GLiREL para relaciones y GLiClass para clasificación), GliNER2 unifica el reconocimiento de entidades con nombre, la clasificación de texto, la extracción de relaciones y la extracción de datos estructurados en un único marco.
El cambio principal en GliNER2 es su enfoque basado en esquemas, que le permite definir los requisitos de extracción de forma declarativa y ejecutar múltiples tareas en una única llamada de inferencia. A pesar de estas capacidades ampliadas, el modelo sigue siendo eficiente en cuanto a CPU, lo que lo convierte en una solución ideal para transformar texto desordenado y no estructurado en datos limpios sin la sobrecarga de un modelo de lenguaje grande.
Como entusiasta de los gráficos de conocimiento en Neo4j, me ha atraído particularmente la extracción de datos estructurados recientemente agregada a través del método extract_json. Si bien la extracción de entidades y relaciones es valiosa por sí sola, lo que realmente me entusiasma es la capacidad de definir un esquema y extraer JSON estructurado directamente del texto. Es una opción natural para la ingesta de gráficos de conocimiento, donde la salida estructurada y consistente es esencial.
En esta publicación de blog, evaluaremos las capacidades de GliNER2, específicamente el modelo fastino/gliner2-large-v1, centrándonos en qué tan bien puede ayudarnos a crear gráficos de conocimiento limpios y estructurados.
El código está disponible en GitHub.
Selección de conjunto de datos
No estamos ejecutando puntos de referencia formales aquí, solo una revisión rápida del ambiente para ver qué puede hacer GliNER2. Aquí está nuestro texto de prueba, extraído de la página de Wikipedia de Ada Lovelace:
Augusta Ada King, condesa de Lovelace (10 de diciembre de 1815 – 27 de noviembre de 1852), también conocida como Ada Lovelace, fue una matemática y escritora inglesa conocida principalmente por su trabajo en la computadora mecánica de propósito general propuesta por Charles Babbage, la máquina analítica. Ella fue la primera en reconocer que la máquina tenía aplicaciones más allá del puro cálculo. Lovelace es a menudo considerado el primer programador informático. Lovelace era el único hijo legítimo del poeta Lord Byron y la reformadora Anne Isabella Milbanke. Todos sus medios hermanos, los otros hijos de Lord Byron, nacieron fuera del matrimonio de otras mujeres. Lord Byron se separó de su esposa un mes después del nacimiento de Ada y abandonó Inglaterra para siempre. Murió en Grecia durante la Guerra de Independencia griega, cuando ella tenía ocho años. Lady Byron estaba ansiosa por la educación de su hija y promovió el interés de Lovelace por las matemáticas y la lógica, para evitar que desarrollara la locura percibida por su padre. A pesar de esto, Lovelace siguió interesada en su padre, nombrando a un hijo Byron y al otro, por el segundo nombre de su padre, Gordon. Lovelace fue enterrada junto a su padre a petición suya. Aunque a menudo enfermaba en la infancia, Lovelace prosiguió sus estudios asiduamente. Se casó con William King en 1835. King era barón y fue creado vizconde de Ockham y primer conde de Lovelace en 1838. Se eligió el nombre Lovelace porque Ada descendía del extinto barón Lovelaces. El título otorgado a su marido convirtió así a Ada en condesa de Lovelace.
Con 322 tokens, es una gran cantidad de texto con el que trabajar. Vamos a sumergirnos.
Extracción de entidades
Comencemos con la extracción de entidades. En esencia, la extracción de entidades es el proceso de identificar y categorizar automáticamente entidades clave dentro del texto, como personas, ubicaciones, organizaciones o conceptos técnicos. GliNER1 ya manejó esto bien, pero GliNER2 va más allá al permitirle agregar descripciones a los tipos de entidades, lo que le brinda un control más preciso sobre lo que se extrae.
entidades = extractor.extract_entities( text, { "Persona": "Nombres de personas, incluidos títulos nobiliarios.", "Ubicación": "Países, ciudades o lugares geográficos.", "Invención": "Máquinas, dispositivos o creaciones tecnológicas.", "Evento": "Eventos históricos, guerras o conflictos." } )
Los resultados son los siguientes:
Proporcionar descripciones personalizadas para cada tipo de entidad ayuda a resolver la ambigüedad y mejora la precisión de la extracción. Esto es especialmente útil para categorías amplias como Eventos, donde por sí solo el modelo podría no saber si incluir guerras, ceremonias o hitos personales. Agregar eventos históricos, guerras o conflictos aclara el alcance previsto.
Extracción de relaciones
La extracción de relaciones identifica relaciones entre pares de entidades en el texto. Por ejemplo, en la frase "Steve Jobs fundó Apple", un modelo de extracción de relaciones identificaría la relación fundada entre las entidades Steve Jobs y Apple.
Con GLiNER2, usted define solo los tipos de relación que desea extraer, ya que no puede restringir qué tipos de entidad están permitidos como principio o final de cada relación. Esto simplifica la interfaz, pero puede requerir un posprocesamiento para filtrar emparejamientos no deseados.
Aquí, agregué un experimento simple agregando las definiciones de alias y de relación igual_como.
relaciones = extractor.extract_relations( text, { "parent_of": "Una persona es el padre de otra persona", "married_to": "Una persona está casada con otra persona", "worked_on": "Una persona contribuyó o trabajó en una invención", "invented": "Una persona creó o propuso una invención", "alias": "La entidad es un alias, apodo, título o referencia alternativa para otra entidad", "same_as": "La entidad es un alias, apodo, título o alternativa referencia para otra entidad" } )
Los resultados son los siguientes:
La extracción identificó correctamente relaciones clave: Lord Byron y Anne Isabella Milbanke como los padres de Ada, su matrimonio con William King, Babbage como inventor del motor analítico y el trabajo de Ada en él. En particular, la modelo detectó a Augusta Ada King como un alias de Ada Lovelace, pero Same_as no fue capturada a pesar de tener una descripción idéntica. La selección no parece aleatoria ya que el modelo siempre completa el alias pero nunca la relación igual que. Esto resalta cuán sensible es la extracción de relaciones para nombrar etiquetas, no solo descripciones.
Convenientemente, GLiNER2 permite combinar múltiples tipos de extracción en una sola llamada para que pueda obtener tipos de entidades junto con tipos de relaciones en una sola pasada. Sin embargo, las operaciones son independientes: la extracción de entidades no filtra ni restringe qué entidades aparecen en la extracción de relaciones, y viceversa. Piense en ello como si se ejecutaran ambas extracciones en paralelo en lugar de como una canalización.
esquema = (extractor.create_schema() .entities({ "Persona": "Nombres de personas, incluidos títulos nobiliarios.", "Ubicación": "Países, ciudades o lugares geográficos.", "Invención": "Máquinas, dispositivos o creaciones tecnológicas.", "Evento": "Eventos históricos, guerras o conflictos". }) .relations({ "parent_of": "Una persona es padre de otra persona", "casado_con": "Una persona está casada con otra persona", "worked_on": "Una persona contribuyó o trabajó en una invención", "invented": "Una persona creó o propuso una invención", "alias": "La entidad es un alias, apodo, título o referencia alternativa para otra entidad" }) ) resultados = extractor.extract(texto, esquema)
Los resultados son los siguientes:
La extracción combinada ahora nos proporciona tipos de entidades, que se distinguen por el color. Sin embargo, varios nodos parecen aislados (Grecia, Inglaterra, Guerra de Independencia griega) ya que no todas las entidades extraídas participan en una relación detectada.
Extracción JSON estructurada
Quizás la característica más poderosa sea la extracción de datos estructurados mediante extract_json. Esto imita la funcionalidad de salida estructurada de LLM como ChatGPT o Gemini, pero se ejecuta completamente en la CPU. A diferencia de la extracción de entidades y relaciones, esto le permite definir campos arbitrarios y colocarlos en registros estructurados. La sintaxis sigue un patrón nombre_campo::tipo::descripción, donde tipo es cadena o lista.
resultados = extractor.extract_json( text, { "persona": [ "nombre::str", "género::str::masculino o femenino", "alias::str::breve resumen de la información incluida sobre la persona", "descripción::str", "fecha_nacimiento::str", "fecha_muerte::str", "padre_de::str", "casado_con::str" ] } )
Aquí estamos experimentando con cierta superposición: alias, padre_de y casado_con también podrían modelarse como relaciones. Vale la pena explorar qué enfoque funciona mejor para su caso de uso. Una adición interesante es el campo de descripción, que traspasa un poco los límites: está más cerca de la generación de resumen que de la extracción pura.
Los resultados son los siguientes:
{ "persona": [ { "nombre": "Augusta Ada King", "género": null, "alias": "Ada Lovelace", "descripción": "matemática y escritora inglesa", "fecha_nacimiento": "10 de diciembre de 1815", "fecha_muerte": "27 de noviembre de 1852", "parent_of": "Ada Lovelace", "married_to": "William King" }, { "nombre": "Charles Babbage", "género": nulo, "alias": nulo, "descripción": nulo, "fecha_nacimiento": nulo, "fecha_muerte": nulo, "padre_de": "Ada Lovelace", "casado_con": nulo }, { "nombre": "Lord Byron", "género": nulo, "alias": nulo, "descripción": "reformador", "fecha_nacimiento": nulo, "fecha_muerte": nulo, "padre_de": "Ada Lovelace", "casado_con": nulo }, { "nombre": "Anne Isabella Milbanke", "género": nulo, "alias": nulo, "descripción": "reformador", "fecha_nacimiento": nulo, "fecha_muerte": nulo, "padre_de": "Ada Lovelace", "casado_con": nulo }, { "nombre": "William King", "género": nulo, "alias": nulo, "descripción": nulo, "fecha_nacimiento": nulo, "fecha_muerte": nulo, "parent_of": "Ada Lovelace", "casado_con": nulo } ] }
Los resultados revelan algunas limitaciones. Todos los campos de género son nulos, aunque a Ada se la llama explícitamente hija, el modelo no infiere que sea mujer. El campo de descripción captura solo frases superficiales (“matemático y escritor inglés”, “reformador”) en lugar de generar resúmenes significativos, lo que no es útil para flujos de trabajo como GraphRAG de Microsoft que se basan en descripciones de entidades más ricas. También hay errores claros: Charles Babbage y William King están marcados incorrectamente como padres de Ada, y Lord Byron está etiquetado como reformador (esa es Anne Isabella). Estos errores con parent_of no aparecieron durante la extracción de la relación, por lo que quizás ese sea el mejor método aquí. En general, los resultados sugieren que el modelo sobresale en la extracción, pero tiene dificultades con el razonamiento o la inferencia, probablemente una compensación por su tamaño compacto.
Además, todos los atributos son opcionales, lo que tiene sentido y simplifica las cosas. Sin embargo, debe tener cuidado ya que a veces el atributo de nombre será nulo, lo que invalidará el registro. Por último, podríamos usar algo como PyDantic para validar resultados y convertirlos a tipos apropiados como flotantes o fechas y manejar resultados no válidos.
Construyendo gráficos de conocimiento
Dado que GLiNER2 permite múltiples tipos de extracción en una sola pasada, podemos combinar todos los métodos anteriores para construir un gráfico de conocimiento. En lugar de ejecutar canales separados para la extracción de entidades, relaciones y datos estructurados, una única definición de esquema maneja los tres. Esto hace que sea sencillo pasar de un texto sin formato a una representación rica e interconectada.
esquema = (extractor.create_schema() .entities({ "Persona": "Nombres de personas, incluidos títulos nobiliarios.", "Ubicación": "Países, ciudades o lugares geográficos.", "Invención": "Máquinas, dispositivos o creaciones tecnológicas.", "Evento": "Eventos históricos, guerras o conflictos". }) .relations({ "parent_of": "Una persona es padre de otra persona", "casado_con": "Una persona está casada con otra persona", "worked_on": "Una persona contribuyó o trabajó en una invención", "invented": "Una persona creó o propuso una invención", }) .structure("persona") .field("name", dtype="str") .field("alias", dtype="str") .field("description", dtype="str") .field("birth_date", dtype="str") ) results = extractor.extract(text, esquema)
La forma en que asigna estas salidas a su gráfico (nodos, relaciones, propiedades) depende de su modelo de datos. En este ejemplo, utilizamos el siguiente modelo de datos:
Lo que puede notar es que también incluimos el fragmento de texto original en el gráfico, lo que nos permite recuperar y hacer referencia al material fuente al consultar el gráfico, lo que permite resultados más precisos y rastreables. El cifrado de importación se parece al siguiente:
import_cypher_query = """ // Crear nodo Chunk a partir de texto CREATE (c:Chunk {texto: $text}) // Crear nodos de persona con propiedades CON c CALL (c) { UNWIND $data.person AS p CON p WHERE p.name IS NOT NULL MERGE (n:__Entity__ {name: p.name}) SET n.description = p.description, n.birth_date = p.birth_date MERGE (c)-[:MENCIONES]->(n) CON p, n DONDE p.alias NO ES NULL MERGE (m:__Entity__ {nombre: p.alias}) MERGE (n)-[:ALIAS_OF]->(m) } // Crea nodos de entidad dinámicamente con __Entity__ etiqueta base + etiqueta dinámica CALL (c) { UNWIND claves($data.entities) AS etiqueta UNWIND $data.entities[label] ASentityName MERGE (n:__Entity__ {name:entityName}) SET n:$(label) MERGE (c)-[:MENTIONS]->(n) } // Crea relaciones dinámicamente CALL (c) { UNWIND claves($data.relation_extraction) AS relType UNWIND $data.relation_extraction[relType] AS rel MATCH (a:__Entity__ {nombre: rel[0]}) PARTIDO (b:__Entidad__ {nombre: rel[1]}) MERGE (a)-[:$(toUpper(relType))]->(b) } RETURN resultado distinto 'importación completada' AS """
La consulta Cypher toma los resultados de la salida de GliNER2 y los almacena en Neo4j. También podríamos incluir incrustaciones para fragmentos de texto, entidades, etc.
Resumen
GliNER2 es un paso en la dirección correcta para la extracción de datos estructurados. Con el auge de los LLM, es fácil recurrir a ChatGPT o Claude siempre que necesites extraer información de un texto, pero eso suele ser excesivo. Ejecutar un modelo de miles de millones de parámetros para extraer algunas entidades y relaciones parece un desperdicio cuando herramientas más pequeñas y especializadas pueden hacer el trabajo en una CPU.
GliNER2 unifica el reconocimiento de entidades con nombre, la extracción de relaciones y la salida JSON estructurada en un único marco. Es muy adecuado para tareas como la construcción de gráficos de conocimiento, donde se necesita una extracción coherente basada en esquemas en lugar de una generación abierta.
Si bien el modelo tiene sus limitaciones. Funciona mejor para la extracción directa que para la inferencia o el razonamiento, y los resultados pueden ser inconsistentes. Pero el progreso del GliNER1 original al GliNER2 es alentador y, con suerte, veremos un desarrollo continuo en este espacio. Para muchos casos de uso, un modelo de extracción enfocado supera a un LLM que hace mucho más de lo que necesita.
El código está disponible en GitHub.