Agentes y percepción · Media · 25 min

Entidades: regex contra LLM

Saca nombres, empresas, fechas, importes y NIF de un contrato con expresiones regulares, y mide con F1 si un modelo local lo hace mejor.

CONTRACT es un contrato sintético y GOLD las entidades que marcó una persona: { text, type } con type en TYPES. Escribe cuatro funciones:

regexNER(text): entidades por reglas, en orden de aparición y sin repetir (misma text y type): DATE para dd/mm/aaaa y «3 de septiembre de 2026»; MONEY para importes como 1.250,00 EUR; ID para CIF (letra, 7 dígitos, control) y NIF (8 dígitos y letra); ORG para nombres con mayúsculas que acaban en S.L., S.L.U. o S.A.; PER para el nombre que sigue a D. o Dña. (dos o más palabras con mayúscula, sin el tratamiento).

parseEntities(json): de un texto JSON {"entities": [...]} devuelve la lista de { text, type } con type en mayúsculas, ignorando lo que no tenga ambos campos. Si no es JSON válido, [].

scoreEntities(pred, gold): { tp, fp, fn, precision, recall, f1 } comparando pares (norm(text), type) como conjuntos; 0 cuando un denominador es 0.

keySentences(text, n): las n frases (separadas por punto y espacio, salvo tras D., Dña., S.L., S.A. o S.L.U., o por salto de línea) con más entidades según regexNER, en su orden original: un resumen extractivo. La vista previa compara tus reglas con el modelo (LIVE), y el último reto puntúa la respuesta real de tu Ollama.

Retos 0/6

  • Reglas sobre una frase: fecha, importe, NIF, empresa y persona
  • Las reglas sobre el contrato: todo menos el lugar, sin repetir
  • Lee el JSON de un modelo y aguanta uno roto
  • Precisión, exhaustividad y F1 como conjuntos
  • Las dos frases con más entidades, en su orden
  • Puntúa la respuesta real de tu modelo (Mi Ollama) o la muestra

function regexNER(text) {
  // una regex por tipo (DATE, MONEY, ID, ORG, PER); en PER se guarda el grupo 1, el nombre sin D. / Dña.
  // recoge { text, type, index } con matchAll, ordena por index y quita repetidos de text + type
  return [];
}

function parseEntities(json) {
  // JSON.parse dentro de un try; conserva los elementos con text y type de texto; type en mayúsculas; [] si falla
  return [];
}

function scoreEntities(pred, gold) {
  // conjuntos de norm(text) + "|" + type; tp, fp, fn; precision, recall, f1 (0 cuando un denominador es 0)
  return { tp: 0, fp: 0, fn: 0, precision: 0, recall: 0, f1: 0 };
}

function keySentences(text, n) {
  // parte por ". " (no tras D., Dña., S.L., S.A., S.L.U.) o saltos de línea, cuenta regexNER(frase).length y conserva las n mejores en su orden original
  return [];
}

console.log('regex:', scoreEntities(regexNER(CONTRACT), GOLD), 'modelo:', scoreEntities(parseEntities(LIVE), GOLD));
La consola aparece aquí (console.log).

La idea

Extraer entidades es la tarea más vendida de la «IA documental», y la pregunta correcta no es si un modelo puede hacerlo, sino si lo hace mejor que diez expresiones regulares en tu tipo de documento, y cómo lo mides. La medida es F1 sobre pares (texto, tipo): precisión (de lo que marcó, cuánto era correcto) y exhaustividad (de lo que había, cuánto encontró).

Lo que medimos, en este contrato y el 18-10-2026 en el DGX Spark: las reglas que escribes aquí alcanzan F1 0,96 (encuentran todo salvo «Valencia», que no tiene forma fija). qwen3.6:35b llegó a F1 0.87 (precisión 77 %, exhaustividad 100 %) en 42.1 s; llama3.1:8b, a F1 0.78 en 3.7 s. Tres intentos por modelo, salida en modo JSON, temperatura 0,2.

Mira por qué pierde el modelo grande: marca «Dña. Marta Ferrer Soler» con el tratamiento, y las direcciones como lugar. Son respuestas razonables que la comparación exacta castiga. Eso es una lección sobre la métrica tanto como sobre el modelo: define el criterio de anotación antes de puntuar a nadie.

Las reglas ganan en documentos con plantilla; el modelo gana cuando el formato cambia cada vez. Lo honesto es medir con tus documentos y tu criterio, y quedarte con la opción barata que supere tu umbral. Todo esto corre en tu máquina: ni el contrato ni los NIF salen de ella.

Hazlo en tu máquina

Con Python, sobre el contrato y sus entidades (CC BY 4.0). Las mismas reglas, puntuadas con F1:

curl -O https://vorluxai.com/data/labs/contrato-entidades.json
python3 -c "
import json, re
c = json.load(open('contrato-entidades.json'))
rules = {'DATE': r'\d{2}/\d{2}/\d{4}|\d{1,2} de [a-záéíóú]+ de \d{4}', 'MONEY': r'\d[\d.]*,\d{2} EUR', 'ID': r'\b[A-Z]\d{7}[0-9A-J]\b|\b\d{8}[A-Z]\b',
         'ORG': r'[A-ZÁÉÍÓÚ][\wáéíóúñ]*(?: [A-ZÁÉÍÓÚ][\wáéíóúñ]*)* S\.(?:L\.U\.|L\.|A\.)', 'PER': r'(?:D|Dña)\. ((?:[A-ZÁÉÍÓÚÑ][a-záéíóúñ]+)(?: [A-ZÁÉÍÓÚÑ][a-záéíóúñ]+)+)'}
pred = {(m.group(1) if t == 'PER' else m.group(0), t) for t, r in rules.items() for m in re.finditer(r, c['text'])}
gold = {(e['text'], e['type']) for e in c['gold']}
tp = len(pred & gold); p, r = tp / len(pred), tp / len(gold)
print(f'precision {p:.2f} recall {r:.2f} f1 {2*p*r/(p+r):.2f}')
"

Comprueba lo que has entendido

1. ¿Qué mide la exhaustividad (recall)?
2. El modelo marcó «Dña. Marta Ferrer Soler» y el oro dice «Marta Ferrer Soler». ¿Qué pasa con la comparación exacta?
3. ¿Cuándo conviene el modelo y cuándo las reglas?
4. ¿Qué entidad del oro no encuentra ninguna regla de este ejercicio?

Para profundizar: la referencia de Ollama (en inglés) →

¿Esto en producción, con tus datos? Hablemos 15 minutos →