,

Rule-based NLP that still wins: regex, spaCy Matcher, and hybrid rule+ML pipelines

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:

  1. use regular expressions for character-level structures;

  2. use spaCy’s token-aware Matcher for contextual patterns;

  3. use EntityRuler for declarative entity rules;

  4. let statistical NER handle genuinely ambiguous language;

  5. 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 ABC123456 match?

  • should lowercase abc-123456 match?

  • should ABC-1234567 match?

  • 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.