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 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}')
" Para profundizar: la referencia de Ollama (en inglés) →
¿Esto en producción, con tus datos? Hablemos 15 minutos →