LEARN · RULES, HEURISTICS & SYMBOLIC AI
The rule-based renaissance nobody needed to announce
Large language models have changed what is possible in natural-language processing, but they have not changed a more basic engineering fact: when a text pattern is narrow, stable, and precisely specified, deterministic rules are often the best tool for the job.
An email address does not need reasoning. A support ticket identifier such as INC-482917 does not need world knowledge. A phrase such as “order number 483921” does not require a generative model to speculate about what the user meant.
For these tasks, a rule can be:
-
faster,
-
cheaper,
-
deterministic,
-
straightforward to test,
-
straightforward to audit,
-
easier to debug when it fails,
-
and safer to deploy in systems where unexpected output is expensive.
That does not mean returning to a 1990s-style NLP stack made entirely of brittle regular expressions.
The more useful pattern is a hybrid:
-
use regular expressions for character-level structures;
-
use spaCy’s token-aware
Matcherfor contextual patterns; -
use
EntityRulerfor declarative entity rules; -
let statistical NER handle genuinely ambiguous language;
-
explicitly decide which layer wins when rules and models disagree.
That final point is the difference between “a pile of regexes” and an engineered NLP pipeline.
As of August 2026, spaCy’s current 3.8 release line remains actively maintained, with spaCy 3.8.15 listed as the latest GitHub release. The APIs used throughout this tutorial are current spaCy 3.8 APIs rather than legacy spaCy 2.x patterns.
Set up a reproducible environment
Create a virtual environment and install the current spaCy release:
python -m venv .venv
On macOS or Linux:
source .venv/bin/activate
On Windows PowerShell:
.venv\Scripts\Activate.ps1
Then install spaCy and its small English pipeline:
python -m pip install --upgrade pip python -m pip install "spacy==3.8.15" python -m spacy download en_core_web_sm
Confirm what you installed:
python -c "import spacy; print(spacy.__version__)"
The expected spaCy version is:
3.8.15
For production projects, pin both the library and your trained pipeline through your normal dependency-management process. Models and rules are application dependencies too.
Level 1: use regex when the characters are the specification
Regular expressions are ideal when the information you want is defined mostly by characters rather than linguistic context.
Good candidates include:
-
internal ticket identifiers;
-
SKU or product codes;
-
structured invoice numbers;
-
ISO-like timestamps;
-
version strings;
-
email-shaped values;
-
IP addresses;
-
log identifiers.
The important qualification is structured.
Regex becomes progressively less attractive when your specification starts sounding like this:
Match “Apple” when it refers to the company, but not when somebody is discussing fruit.
That is no longer primarily a character problem.
Consider a support-message extractor. We want to detect email addresses and case IDs from synthetic customer-support text.
import re EMAIL_RE = re.compile( r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b" ) CASE_ID_RE = re.compile( r"\b(?:INC|CASE)-\d{6}\b" ) text = ( "Customer alex@example.test reported CASE-482917. " "Escalations can be sent to ops@example.test." ) emails = [match.group(0) for match in EMAIL_RE.finditer(text)] case_ids = [match.group(0) for match in CASE_ID_RE.finditer(text)] print("Emails:", emails) print("Case IDs:", case_ids)
A run produces:
Emails: ['alex@example.test', 'ops@example.test'] Case IDs: ['CASE-482917']
Notice what this regex does not claim to be: a complete validator for every address permitted by every relevant email standard.
That distinction matters.
For extraction, you often want a practical pattern that recognizes the formats your application actually encounters. Trying to encode an entire protocol specification into one heroic regex frequently produces a pattern nobody can safely maintain.
Make regexes return spans, not just strings
An extraction system usually needs to preserve the position of each match. Positions let you:
-
redact the original document;
-
display the matched region;
-
merge regex output with model output;
-
detect overlapping predictions;
-
attach provenance.
Use start() and end() rather than throwing this information away:
import re CASE_ID_RE = re.compile(r"\b(?:INC|CASE)-\d{6}\b") text = "Please reopen INC-193842 before tomorrow." for match in CASE_ID_RE.finditer(text): result = { "text": match.group(0), "start": match.start(), "end": match.end(), "label": "CASE_ID", } print(result)
Output:
{'text': 'INC-193842', 'start': 14, 'end': 24, 'label': 'CASE_ID'}
Now your regex behaves like an information-extraction component instead of a boolean test.
Level 2: reach for spaCy Matcher when tokens matter
Raw regex sees characters.
spaCy’s Matcher sees tokens and token attributes.
That difference is enormous.
Current spaCy Matcher rules can target attributes such as exact text, lowercase text, punctuation status, numeric likeness, URLs, emails, part-of-speech tags, lemmas, dependency labels, entity annotations, and more.
Suppose a support system contains sentences such as:
-
“ticket INC-293817”
-
“Ticket: CASE-002341”
-
“case CASE-918244”
We do not merely want every identifier-shaped string. We specifically want identifiers appearing after a ticket-related cue.
import spacy from spacy.matcher import Matcher nlp = spacy.blank("en") matcher = Matcher(nlp.vocab, validate=True) ticket_pattern = [ { "LOWER": { "IN": ["ticket", "case"] } }, { "IS_PUNCT": True, "OP": "?" }, { "TEXT": { "REGEX": r"^(?:INC|CASE)-\d{6}$" } }, ] matcher.add("TICKET_REFERENCE", [ticket_pattern]) text = ( "Ticket: INC-293817 was escalated. " "An unrelated code ABC-999999 was ignored. " "Please review case CASE-918244." ) doc = nlp(text) for match_id, start, end in matcher(doc): print(nlp.vocab.strings[match_id], doc[start:end].text)
Output:
TICKET_REFERENCE Ticket: INC-293817 TICKET_REFERENCE case CASE-918244
This illustrates an important design principle:
Regex inside a token matcher is not the same thing as regex across raw text.
The regular expression now applies to one already-tokenized unit. spaCy handles the surrounding linguistic structure.
That lets you write rules such as:
-
a lemma followed by an optional adjective;
-
a currency symbol followed by a number-like token;
-
a product keyword followed by punctuation and an identifier;
-
one of several verbs followed by an organization entity;
-
a word only when it has a particular part of speech.
Quantifiers make Matcher rules expressive
Matcher token patterns support operators such as optional and repeated tokens through the OP field.
For example, we can capture expressions such as:
-
“order 392817”
-
“order number 392817”
-
“order no. 392817”
import spacy from spacy.matcher import Matcher nlp = spacy.blank("en") matcher = Matcher(nlp.vocab, validate=True) pattern = [ { "LOWER": "order" }, { "LOWER": { "IN": ["number", "no"] }, "OP": "?" }, { "IS_PUNCT": True, "OP": "?" }, { "LIKE_NUM": True }, ] matcher.add("ORDER_REFERENCE", [pattern]) doc = nlp( "Check order 392817, order number 884211, " "and order no. 120044." ) for match_id, start, end in matcher(doc): print(nlp.vocab.strings[match_id], "->", doc[start:end].text)
The point is not that every order-number format should be implemented this way. The point is that token-aware rules let you express bounded linguistic variation without immediately reaching for a model.
Level 3: use EntityRuler when the result should become an entity
A Matcher gives you matches.
An EntityRuler goes one step further: it writes rule matches into Doc.ents, the same entity interface used by spaCy’s statistical named-entity recognizer. spaCy supports both exact phrase rules and token-based patterns in the component.
That makes it one of the cleanest bridges between deterministic and statistical NLP.
Here is a purely rule-based example:
import spacy nlp = spacy.blank("en") ruler = nlp.add_pipe( "entity_ruler", config={ "validate": True }, ) patterns = [ { "label": "PRODUCT", "pattern": "CloudCart Pro", "id": "product-cloudcart-pro", }, { "label": "CASE_ID", "pattern": [ { "TEXT": { "REGEX": r"^(?:INC|CASE)-\d{6}$" } } ], }, ] ruler.add_patterns(patterns) doc = nlp( "CloudCart Pro is affected by incident CASE-482917." ) for ent in doc.ents: print(ent.text, ent.label_, ent.ent_id_)
Output:
CloudCart Pro PRODUCT product-cloudcart-pro CASE-482917 CASE_ID
The optional id field is particularly useful when many surface forms refer to one canonical concept. spaCy exposes that pattern ID through Span.ent_id_.
For example, an organization might want all of these to resolve to the same internal product:
patterns = [
{
"label": "PRODUCT",
"pattern": "CloudCart Pro",
"id": "product-cc-pro",
},
{
"label": "PRODUCT",
"pattern": "CC Pro",
"id": "product-cc-pro",
},
{
"label": "PRODUCT",
"pattern": "CloudCart Professional",
"id": "product-cc-pro",
},
]
That is already edging into entity normalization, not just recognition.
Build the hybrid: rules first, statistical NER second
This is where rule-based NLP becomes much more interesting.
Suppose your statistical model is good at extracting:
-
people,
-
organizations,
-
locations,
-
dates.
But your application also has domain-specific identifiers that the generic NER model was never trained to understand:
-
case IDs;
-
internal products;
-
customer account codes.
You could retrain the NER model.
Sometimes that is exactly the right answer.
But retraining a model because you need to recognize CASE-123456 is often unnecessary. The format is already a specification.
spaCy explicitly supports placing EntityRuler before the statistical ner component. In that configuration, the statistical recognizer respects the entity spans already present and adjusts its predictions around them.
Create the hybrid pipeline:
import spacy nlp = spacy.load("en_core_web_sm") ruler = nlp.add_pipe( "entity_ruler", before="ner", config={ "validate": True, "overwrite_ents": False, }, ) ruler.add_patterns( [ { "label": "PRODUCT", "pattern": "CloudCart Pro", "id": "product-cc-pro", }, { "label": "CASE_ID", "pattern": [ { "TEXT": { "REGEX": r"^(?:INC|CASE)-\d{6}$" } } ], }, ] ) text = ( "Maya Chen at Northwind Labs reported CASE-482917 " "while testing CloudCart Pro in Berlin." ) doc = nlp(text) for ent in doc.ents: print(ent.text, ent.label_)
A typical result contains your deterministic entities alongside the entities detected by statistical NER:
Maya Chen PERSON Northwind Labs ORG CASE-482917 CASE_ID CloudCart Pro PRODUCT Berlin GPE
The division of labor is clean:
-
the rule recognizes what the specification defines exactly;
-
the statistical model recognizes what depends on natural-language variation.
That is often a much more sensible architecture than forcing one model to solve both problems.
Regex as an actual spaCy pipeline component
Sometimes you need raw character-level regex, not token-level patterns.
You can still integrate it into a spaCy pipeline.
The following custom component turns regex matches into real spaCy entities before statistical NER runs.
import re import spacy from spacy.language import Language from spacy.util import filter_spans EMAIL_RE = re.compile( r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b" ) CASE_ID_RE = re.compile( r"\b(?:INC|CASE)-\d{6}\b" ) @Language.component("regex_entities") def regex_entities(doc): spans = list(doc.ents) for match in EMAIL_RE.finditer(doc.text): span = doc.char_span( match.start(), match.end(), label="EMAIL", alignment_mode="contract", ) if span is not None: spans.append(span) for match in CASE_ID_RE.finditer(doc.text): span = doc.char_span( match.start(), match.end(), label="CASE_ID", alignment_mode="contract", ) if span is not None: spans.append(span) doc.ents = filter_spans(spans) return doc nlp = spacy.load("en_core_web_sm") nlp.add_pipe( "regex_entities", before="ner", ) doc = nlp( "Priya Shah sent CASE-482917 to support@example.test " "from the Berlin office." ) for ent in doc.ents: print(ent.text, ent.label_)
This architecture gives you a very useful pipeline:
tokenizer ↓ tagger / parser components ↓ regex_entities ↓ statistical NER ↓ application logic
The rule-generated entities enter the same Doc.ents structure the model uses.
filter_spans is also important because Doc.ents cannot contain overlapping entity spans. spaCy’s entity annotations require valid, non-overlapping spans.
What if the model should run first?
Rules-first is not the only useful architecture.
Sometimes you trust the statistical model generally but know that it makes a few systematic mistakes in your domain.
Examples:
-
a product name is repeatedly labeled as an organization;
-
a company abbreviation is repeatedly tagged as a person;
-
a known internal location name gets classified incorrectly;
-
a phrase has a business-specific label that differs from the model’s training ontology.
In that case, run statistical NER first and use an EntityRuler afterward as a correction layer.
By default, an EntityRuler placed after NER only adds entities that do not conflict with existing entities. Setting overwrite_ents=True allows its matches to replace overlapping model entities.
import spacy nlp = spacy.load("en_core_web_sm") ruler = nlp.add_pipe( "entity_ruler", after="ner", config={ "validate": True, "overwrite_ents": True, }, ) ruler.add_patterns( [ { "label": "PRODUCT", "pattern": "Northwind Edge", "id": "product-northwind-edge", } ] ) doc = nlp( "The team deployed Northwind Edge yesterday." ) for ent in doc.ents: print(ent.text, ent.label_)
Think of the two arrangements differently.
Rules before NER:
“These spans are known facts. Model, work around them.”
Rules after NER with overwrite enabled:
“Model, make your best prediction. Then apply these business-specific corrections.”
That architectural decision should be explicit.
Do not scatter random post-processing fixes throughout application code. Centralize them in a recognizable correction layer.
A production-style combined extractor
Now we can assemble a pipeline that uses all three techniques.
The application will recognize:
-
email-shaped values with regex;
-
structured case IDs with regex;
-
contextual order references with
Matcher; -
canonical product names with
EntityRuler; -
open-ended people, organizations and locations with statistical NER.
Create hybrid_nlp.py:
import re import spacy from spacy.language import Language from spacy.matcher import Matcher from spacy.tokens import Span from spacy.util import filter_spans EMAIL_RE = re.compile( r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b" ) CASE_ID_RE = re.compile( r"\b(?:INC|CASE)-\d{6}\b" ) @Language.component("regex_entities") def regex_entities(doc): spans = list(doc.ents) patterns = [ (EMAIL_RE, "EMAIL"), (CASE_ID_RE, "CASE_ID"), ] for regex, label in patterns: for match in regex.finditer(doc.text): span = doc.char_span( match.start(), match.end(), label=label, alignment_mode="contract", ) if span is not None: spans.append(span) doc.ents = filter_spans(spans) return doc def build_pipeline(): nlp = spacy.load("en_core_web_sm") nlp.add_pipe( "regex_entities", before="ner", ) ruler = nlp.add_pipe( "entity_ruler", before="ner", config={ "validate": True, "overwrite_ents": False, }, ) ruler.add_patterns( [ { "label": "PRODUCT", "pattern": "CloudCart Pro", "id": "product-cloudcart-pro", }, { "label": "PRODUCT", "pattern": "SignalDesk", "id": "product-signaldesk", }, ] ) return nlp def extract_order_references(doc): matcher = Matcher(doc.vocab, validate=True) matcher.add( "ORDER_REFERENCE", [ [ { "LOWER": "order" }, { "LOWER": { "IN": ["number", "no"] }, "OP": "?", }, { "IS_PUNCT": True, "OP": "?", }, { "LIKE_NUM": True }, ] ], ) results = [] for _, start, end in matcher(doc): span = Span( doc, start, end, label="ORDER_REFERENCE", ) results.append( { "text": span.text, "start": span.start_char, "end": span.end_char, "label": span.label_, } ) return results def extract(text): nlp = build_pipeline() doc = nlp(text) entities = [ { "text": ent.text, "start": ent.start_char, "end": ent.end_char, "label": ent.label_, "rule_id": ent.ent_id_ or None, } for ent in doc.ents ] return { "entities": entities, "order_references": extract_order_references(doc), } if __name__ == "__main__": sample = ( "Elena Torres from Northwind Labs emailed " "elena@example.test about CASE-482917. " "The failure affects CloudCart Pro and order no. 392817 " "in the Berlin warehouse." ) result = extract(sample) for entity in result["entities"]: print(entity) for order_reference in result["order_references"]: print(order_reference)
Run it:
python hybrid_nlp.py
The exact statistical entities can depend on the installed English pipeline, but your rule-defined email, case ID and product entities should be deterministic.
That difference is worth preserving in the output schema.
A mature extraction API may even expose provenance explicitly:
{
"text": "CASE-482917",
"label": "CASE_ID",
"source": "regex",
"rule_id": "case-id-v2",
"confidence": 1.0
}
And for a statistical entity:
{
"text": "Berlin",
"label": "GPE",
"source": "statistical_ner",
"rule_id": null
}
Do not fake a probabilistic confidence score for a deterministic regex merely to make schemas symmetrical. “Matched rule X” is often more useful provenance than an invented confidence number.
Test rules like code, because they are code
One of the strongest arguments for deterministic extraction is testability.
If CASE-123456 is valid and CASE-12345 is not, encode that expectation.
Create a small regression test:
from hybrid_nlp import extract def labels_for(text): result = extract(text) return { (entity["text"], entity["label"]) for entity in result["entities"] } def test_case_id(): labels = labels_for( "Please investigate CASE-123456." ) assert ("CASE-123456", "CASE_ID") in labels def test_invalid_case_id(): labels = labels_for( "Please investigate CASE-12345." ) assert ("CASE-12345", "CASE_ID") not in labels def test_product(): labels = labels_for( "The failure only affects CloudCart Pro." ) assert ("CloudCart Pro", "PRODUCT") in labels if __name__ == "__main__": test_case_id() test_invalid_case_id() test_product() print("All tests passed.")
Run it:
python test_hybrid.py
Expected output:
All tests passed.
Real rule suites should contain negative examples as aggressively as positive ones.
If a pattern matches ABC-123456, ask:
-
should
ABC123456match? -
should lowercase
abc-123456match? -
should
ABC-1234567match? -
should
ABC-123456.include the period? -
what happens at the beginning of a line?
-
what happens next to parentheses?
-
what happens with Unicode punctuation?
-
what happens inside a URL?
Most rule failures live at boundaries.
Measure precision before celebrating recall
Rules encourage a dangerous illusion: “It matched my examples, therefore it works.”
Production evaluation needs a labeled test set.
For an extraction task, track at least:
-
true positives;
-
false positives;
-
false negatives;
-
exact-span precision;
-
exact-span recall;
-
F1;
-
latency per document;
-
throughput;
-
memory use if volume is high.
Also break metrics down by extraction source.
You might discover:
-
regex CASE_ID precision: 99.9%;
-
regex CASE_ID recall: 94%;
-
statistical organization precision: 91%;
-
statistical organization recall: 89%;
-
product-ruler precision: 100% on the current test set.
Those numbers suggest engineering decisions.
If the case-ID regex has excellent precision but misses legitimate new formats, expand the rule.
If a product dictionary has strong precision but constant maintenance churn, perhaps the source of truth should be generated automatically from your product catalog.
If statistical NER performs poorly on your organization names, then retraining becomes a stronger option.
The goal is not ideological purity about rules or machine learning.
The goal is to make each class of error belong to the component best equipped to fix it.
When rules really do beat ML
Rules tend to dominate when the target is:
Structurally rigid
Examples:
-
INC-123456; -
SKU-A7F21; -
semantic-version strings;
-
known command syntaxes.
High-volume
A tiny deterministic operation repeated millions of times can justify substantial optimization.
Stable
If the format has changed twice in ten years, a rule may be a fantastic investment.
High-precision
If the business requirement is “never classify arbitrary prose as an account ID,” a carefully constrained rule can be preferable to a model with fuzzy boundaries.
Auditable
A reviewer can inspect:
CASE- followed by exactly six digits
That is much easier to reason about than a latent representation with millions of learned parameters.
When rules lose badly
Rules are not magic.
Do not build a 4,000-line regex monster because you are afraid to train a model.
Rules become fragile when:
-
the number of linguistic variants grows rapidly;
-
meaning depends on broad context;
-
you are enumerating endless exceptions;
-
the labels themselves are subjective;
-
new vocabulary arrives continuously;
-
paraphrases matter more than surface form.
Consider extracting cancellation intent:
-
“Cancel my account.”
-
“I don’t think I want to keep using this.”
-
“Please make sure it doesn’t renew next month.”
-
“We’re moving to another vendor.”
-
“Close everything after the current billing period.”
You can build rules for these.
Then more examples arrive.
Then negation arrives:
“Do not cancel my subscription.”
Then reported speech arrives:
“The customer asked whether cancelling was possible but decided not to.”
At some point, you are implementing a worse statistical language model by hand.
That is your signal to change tools.
The strongest hybrid pattern: deterministic precision plus learned recall
A particularly effective design is a cascade.
Stage 1: high-precision rules
Extract things you can identify almost unquestionably:
-
exact catalog products;
-
strongly structured IDs;
-
known legal entity names;
-
URLs and email-shaped values.
Stage 2: statistical model
Use trained NER for ambiguous, open-ended categories.
Stage 3: correction rules
Repair known systematic model mistakes.
Stage 4: fallback model or LLM
For genuinely difficult cases, route only unresolved text to a more expensive model.
The architecture becomes:
input text
|
v
cheap deterministic extraction
|
v
statistical NLP
|
v
deterministic correction
|
v
uncertain cases only
|
v
expensive fallback
This is fundamentally different from sending every document to the largest available model.
It uses expensive inference where expensive inference creates marginal value.
Cherry on the cake: rules can be more than 1,000× cheaper than a frontier LLM agent
A striking 2026 result makes the economic argument unusually concrete.
In August 2026, researchers introduced Scout, a system for document extraction that creates and refines reusable extraction programs the authors describe as rules. On six real-world datasets, Scout matched the accuracy of the strongest baseline—a frontier LLM agent reading complete documents—while costing 61× to more than 1,000× less on collections of 1,000 documents. It was also reported as 61% more accurate than the strongest prior program-based method.
The interesting lesson is not “LLMs are bad.”
Scout itself is part of a new generation of systems that use intelligent automation to discover reusable computation rather than repeatedly paying a model to solve the same pattern from scratch.
That distinction is profound.
Imagine you have 10 million documents with nearly identical invoice layouts.
There are two conceptual strategies.
Strategy A:
document 1 -> frontier model document 2 -> frontier model document 3 -> frontier model ... document 10,000,000 -> frontier model
Strategy B:
sample documents
|
v
learn/discover extraction logic
|
v
run reusable rules at scale
The second strategy amortizes intelligence.
You pay for discovering the pattern, then execute the pattern cheaply.
That is exactly the instinct behind well-designed regex, token matchers, entity dictionaries, parsers and other deterministic infrastructure.
For narrow extraction, a general-purpose reasoning model may be capable of solving the problem while still being the wrong runtime.
Treat rule ownership as a software-engineering problem
Rule systems become painful when nobody owns their lifecycle.
Do not bury production patterns across random Python modules.
A better project layout might look like:
nlp_project/ ├── hybrid_nlp.py ├── rules/ │ ├── case_ids.jsonl │ ├── products.jsonl │ └── organizations.jsonl ├── evaluation/ │ ├── extraction_cases.json │ └── evaluate.py ├── tests/ │ ├── test_case_ids.py │ ├── test_products.py │ └── test_regressions.py └── requirements.txt
spaCy’s EntityRuler supports serialization of rule patterns to JSONL, which makes storing rules separately from application code a natural approach.
External rule files make it easier to:
-
review changes in version control;
-
generate dictionaries from upstream systems;
-
assign ownership;
-
diff releases;
-
roll back faulty rules;
-
evaluate rule changes before deployment.
Keep a provenance trail
When hybrid pipelines fail, debugging depends on knowing why an entity exists.
For each extraction, consider storing:
-
text;
-
label;
-
character offsets;
-
extraction source;
-
rule identifier;
-
pipeline version;
-
model version;
-
timestamp;
-
normalization result.
A normalized extraction record could look like:
{
"text": "CloudCart Pro",
"label": "PRODUCT",
"start": 47,
"end": 60,
"source": "entity_ruler",
"rule_id": "product-cloudcart-pro",
"canonical_id": "product-cloudcart-pro",
"pipeline_version": "2026.08.1"
}
Provenance turns a mysterious NLP prediction into something operations teams can investigate.
Be careful with PII pipelines
If you use these techniques to identify personally identifying information, detection is only half the problem.
Do not casually log the values you are trying to protect.
A redaction service that prints every detected email address to application logs has created a new data-leak path.
Prefer diagnostics such as:
{
"label": "EMAIL",
"start": 154,
"end": 177,
"source": "regex",
"matched": true
}
instead of duplicating the sensitive content unless you genuinely need it.
Also distinguish detection from validation.
A string matching an email pattern tells you that it looks like an email address. It does not prove:
-
the mailbox exists;
-
the user owns it;
-
it is safe to contact;
-
it belongs to the person mentioned nearby.
Your NLP layer should not silently promote syntax into business truth.
Avoid regex denial-of-service traps
Rules are cheap only when the rules themselves are well behaved.
Certain regular expressions with ambiguous nested repetition can exhibit catastrophic backtracking on adversarial strings. The exact danger depends on the expression and regex engine, but the engineering lesson is universal: treat patterns that process untrusted text as executable logic, not harmless configuration.
Prefer:
-
bounded repetition when the format is bounded;
-
explicit character classes;
-
anchors where appropriate;
-
simple alternations;
-
test cases containing very long malformed inputs;
-
performance regression tests.
Instead of an unnecessarily permissive pattern, encode the specification tightly.
For example, if your IDs always contain six digits, say six:
import re CASE_RE = re.compile( r"\bCASE-\d{6}\b" )
Do not say “one or more digits” because it feels flexible if the business rule is actually exact.
Specificity is both a correctness feature and often a performance feature.
A practical decision framework
Before choosing an LLM for an extraction problem, ask four questions.
Can I specify the answer syntactically?
If yes, start with regex.
Does the answer depend on nearby token structure?
If yes, try a token-aware matcher.
Is it a known entity dictionary or finite catalog?
If yes, consider an entity ruler or phrase matcher.
Does the answer depend on broad context, semantics or paraphrase?
Now a trained model or LLM is much more justified.
Many real applications need all four.
The mistake is treating them as competing religions.
They are layers with different cost and capability profiles.
What a mature production stack looks like
A solid rule-plus-ML extraction service usually ends up with several characteristics.
It has versioned rules, rather than anonymous regexes copied into functions.
It has gold evaluation data, rather than developers eyeballing three sample sentences.
It measures precision and recall by entity type and component.
It records provenance, so engineers know whether a prediction came from regex, a ruler, statistical NER or an expensive fallback.
It has negative regression cases, especially for historical false positives.
It has an explicit precedence policy for overlapping outputs.
It avoids asking expensive models questions whose answers are already fully encoded in stable business rules.
And crucially, it has a threshold for retiring rules.
If maintaining a rule family becomes more expensive than training or invoking a model, the architecture should change.
Rules are a tool, not a commitment.
The takeaway
Modern NLP engineering is not “rules versus AI.”
The more useful question is:
Which parts of the problem deserve intelligence at runtime?
If a six-digit case number always begins with CASE-, runtime reasoning adds little.
If a product catalog already defines exactly which names are valid, asking a generative model to rediscover that catalog on every request is wasteful.
If you need to understand whether an angry paragraph implies cancellation intent, deterministic pattern matching is probably not enough.
Use each technology where its economics and failure mode make sense:
-
regex for strict character structures;
-
spaCy Matcher for token-aware patterns;
-
EntityRuler for entities that can be declared deterministically;
-
statistical NER for open-ended linguistic categories;
-
LLMs for semantic ambiguity and difficult long-tail cases.
Start your next NLP extractor by building a 50-to-100-example evaluation set before calling any model. Implement the obvious deterministic baseline, measure it, then add statistical or generative machinery only where the errors prove that you need it.
The fastest way to discover that rule-based NLP still wins is simple: benchmark the rule first.