Regex vs NER for PII detection: when patterns aren't enough
Use validated patterns (regex plus checksums like Luhn and mod-97) for structured PII such as emails, cards, and IBANs. Use label-anchored matching for "name: Sarah" style data. Add NER only when prompts carry names in free prose, and run it locally, because a cloud detection API sees the PII it detects.
- Structured PII has grammar and checksums. Regex plus validation (Luhn for cards, mod-97 for IBANs) catches it with near-zero false positives, at near-zero cost.
- Label-anchored matching catches semi-structured data like "name: Sarah" or "DOB: 12/04/1991", across Latin, CJK, Cyrillic, and Arabic scripts.
- Only NER catches free-text names: "tell Chidi I'll send the money on Friday". No pattern matches that.
- NER should run locally. Calling a cloud API to detect PII means sending the PII to another cloud.
- Ladder: start with patterns, add labels, add NER only when your prompts carry free prose. Keep NER as an opt-in package so the core stays small.
What do patterns catch perfectly?
Some PII announces itself by shape. An email address has a grammar. A credit card number is a digit sequence that must pass a Luhn checksum. An IBAN carries a mod-97 check built into the standard. A phone number parses against real country metadata.
This is the class where regex is not a hack, it is the right tool. The trick is that the pattern alone is not enough; validation is what kills false positives. Sixteen digits in a log line could be an order ID. Sixteen digits that pass Luhn are almost certainly a card.
Sether's basic pack works this way: email, phone (via libphonenumber-js, the one runtime dependency), card with Luhn, SSN, IPv4/IPv6, IBAN with mod-97. The secrets pack does the same for API keys: AWS, OpenAI, Anthropic, GitHub, Slack, Stripe, JWTs, plus a Shannon-entropy catch-all for high-entropy strings.
Validated patterns are effectively free. No model, no download, no meaningful latency. If your PII is structured, you are done, and adding a neural model would be pure overhead.
What does label anchoring add?
A name has no checksum. "Sarah Chen" is just two capitalized words, and so is "New York" and "Prime Minister". A bare name-regex would flag half your text.
But a lot of real data labels itself. Forms, tickets, CRM exports, and copied records read like "name: Sarah Chen", "DOB: 12/04/1991", "passport: A1234567". The label is the anchor: match the label, capture the value that follows.
Sether's identity pack is built on this, and the labels are multilingual across Latin, CJK, Cyrillic, and Arabic scripts. Label anchoring keeps the false-positive rate low because it only fires where the data declared what it is. Its limit is equally clear: it needs the label to exist.
What can only NER catch?
Nobody types "name: Chidi" into a chat box. They type "tell Chidi I'll send the money on Friday". No pattern matches that, and no label anchors it. The only way to catch it is to read the sentence.
That is named-entity recognition: a model that understands enough context to say "Chidi is a person" in one sentence and "Friday is not" in the same one. If your prompts carry support tickets, chat transcripts, emails, or documents, free-text names are exactly what flows through them.
This is what @raeven-co/sether-ner exists for. It detects names, organisations, and locations in running prose, and emits the same token format as the core library, so a <NAME_...> token restores through the core Sether vault with no extra wiring.
import { Sether } from '@raeven-co/sether';
import { createNerRedactor } from '@raeven-co/sether-ner';
const sether = new Sether();
const ner = createNerRedactor(); // default model: Xenova/bert-base-NER
// Batched pass over the outbound prompt, locally, before it leaves
const safe = await ner.redact("tell Chidi I'll send the money on Friday");
// -> "tell <NAME_1a2b> I'll send the money on Friday"
// The model reply restores through the core Sether vault,
// same as pattern-based tokens, no extra wiring:
modelReply.pipe(sether.restore());What do the trade-offs actually look like?
| Technique | Catches | False positives | Cost and latency | Footprint |
|---|---|---|---|---|
| Validated patterns | Structured PII: emails, phones, cards, SSNs, IPs, IBANs, API keys | Near zero (checksums do the filtering) | Effectively free, microseconds | Tiny (Sether core is ~35 KB) |
| Label anchoring | Semi-structured: "name: Sarah", "DOB: ...", multilingual labels | Low (only fires on labeled data) | Effectively free, microseconds | Included in the same core |
| Local NER (ONNX) | Free-text names, organisations, locations in prose | Model-dependent, tunable via threshold | First call downloads a 30 MB+ model; inference ~50% slower than PyTorch | Separate opt-in package, lazy-loaded |
| Cloud detection API | Same class as NER | Model-dependent | Network round trip per call, plus billing | Zero local, but the PII leaves your boundary to be detected |
The last row is the one people miss. A cloud PII-detection API has good accuracy and no local footprint, and it defeats the purpose: to find out whether a text contains personal data, you send the personal data to another company's servers.
Why should NER run locally?
Redaction exists to keep PII inside your boundary. If the detection step itself makes a network call, you have not shrunk your data exposure, you have added a second processor to it. Your compliance story now includes the detection vendor too.
Local inference closes that loop. sether-ner runs an ONNX model in-process via transformers.js, default Xenova/bert-base-NER. The text is read, classified, and tokenized on your machine. Nothing leaves. The same reasoning applies to the GDPR question: every extra party that sees the data is a party you have to account for.
How do you keep the core small if NER is heavy?
By not putting it in the core. Sether's core stays at ~35 KB with exactly one runtime dependency. NER lives in a separate package, @raeven-co/sether-ner, with @huggingface/transformers as an optional peer dependency and the model lazy-loaded on first use.
The two packages share the token format, so nothing else changes. Pattern tokens and NER tokens go into the same vault, and one restore call brings both back. Teams that never need NER never pay for it, in bundle size or in startup time.
What are the honest limits of NER here?
- The first call downloads the model. 30 MB+ for bert-base-NER. Warm it at deploy time, not on the first user request.
- ONNX is slower than PyTorch. Roughly 50% slower inference in this setup. The trade is portability: it runs in Node without a Python sidecar.
- It is not on the streaming hot path. sether-ner runs as a batched pass on the outbound prompt. The per-chunk streaming path stays pattern-based, for the reasons in the chunk-boundary post.
- Accuracy is model-bound, and bert-base-NER skews Western. It is strong on names and organisations like the ones in its training data, weaker elsewhere. I see this directly: Nigerian names in my own test prompts get lower confidence than Western ones.
- You can bring your own model. The
inferoption accepts a custom inference function (GLiNER is a popular choice), andthresholdandlabelsare configurable. If your users are not well served by the default, swap it.
So which should you use?
It is a ladder, not a choice. Climb only as far as your data requires.
- Start with validated patterns. Emails, phones, cards, keys. Near-zero false positives, near-zero cost. Every LLM app should have this floor.
- Add label anchoring if your prompts carry forms, tickets, records, or anything with "field: value" structure, especially in multiple languages.
- Add local NER only when prompts carry free prose about people: support conversations, emails, documents. That is when "tell Chidi..." starts flowing through your API calls.
- Never add a cloud detection call. If you need NER-class detection, run it locally or do not run it.
For where detection fits in the full redact-then-restore pipeline, the complete guide to PII redaction for LLM apps covers the whole picture. If you are comparing this approach to Presidio's analyzer stack, see lightweight Presidio alternatives for TypeScript.
Frequently asked questions
- Can I skip patterns and just use NER for everything?
- You could, but you would trade near-perfect structured detection for model-dependent detection, and pay inference cost on every call. A Luhn-validated card match is more reliable than any NER model on card numbers. Use NER for the class only it can catch.
- How big is the NER model download?
- The default, Xenova/bert-base-NER, is a 30 MB+ download on first use. It is cached after that. Warm it at startup in production so no user request pays the download.
- Can I use a different model than bert-base-NER?
- Yes. createNerRedactor accepts an infer option for bring-your-own-model inference (GLiNER works well), plus threshold and labels settings. If the default skews away from your users' names, swapping the model is the intended fix.
- Does NER run on streaming responses?
- No. It runs as a batched pass on the outbound prompt before it leaves. The per-chunk streaming path stays pattern-based, because transformer inference inside a hold-back window would add latency streaming exists to avoid.
Sether is free and MIT-licensed: npm i @raeven-co/sether · pip install sether. Or paste some text into the live sandbox and watch the redaction happen.
Keep reading
- PII redaction for LLM applications: the complete guide (2026)How to keep names, emails, cards, and secrets out of OpenAI, Anthropic, and other LLM providers: detection methods, reversible tokenization, streaming pitfalls, and tool comparison.
- The chunk-boundary bug: why streaming PII redaction is hardHow a property-based test caught emails slipping through Sether's streaming redactor when values split across chunks, and the hold-back window that fixed it.
- Microsoft Presidio alternatives for Node.js and TypeScript (2026)An honest comparison of Presidio, LLM Guard, Sether, and DIY regex for PII redaction in Node/TypeScript apps, including when Presidio is still the right choice.
- Is it GDPR-compliant to send customer data to OpenAI?It can be, if you sign the DPA, list OpenAI as a sub-processor, handle the US transfer, and apply technical measures like redaction. Here is the honest checklist.