Free-Text Payment Memos → Structured References¶
What
Pull the structured tokens a downstream ledger needs — an invoice
number, an order reference, a has-any-reference flag — out of free-text
payment memos, where each token sits at a variable position inside an
otherwise human-written sentence. Done with REGEX_EXTRACT / REGEX_MATCH,
which match by shape, anywhere in the string.
Synthetic / teaching example
The data is constructed, not
sourced — sample.csv is hand-written memos that pack a moving-target token
into a sentence. The problem class — bank / PSP / ERP exports whose only
machine-readable handle is a free-text "reference" or "memo" field — is
universal; the rows are not.
Why interesting¶
This is the one extraction job the cheaper string tools
cannot express. SPLIT_PART needs a stable delimiter at a fixed position;
CONTAINS only tests presence; IN / REMAP only match whole values. When the
invoice number can appear as "Payment for INV-2024-0042 thank you" in one row
and "Refund INV-2023-0911 order #88 processed" in the next, only a pattern
match pinned to the token's shape reaches it. The example also teaches the
flip side: the Tags column is cleanly a|b|c-delimited, so its first tag is a
plain SPLIT_PART — reaching for regex there would be paying the engine for a
job a delimiter split already does.
The trick¶
(see sample.json)
- Capture group —
REGEX_EXTRACT([Memo], 'INV-([0-9]{4}-[0-9]{4})')returns just the innerYYYY-NNNN(the group), dropping theINV-literal; no match →"". - Different anchor —
REGEX_EXTRACT([Memo], '#([0-9]+)')pulls the order number that follows a#, wherever it lands. - Alternation gate —
REGEX_MATCH([Memo], 'INV-[0-9]{4}|#[0-9]+')answers "does this memo carry any structured reference" in a single pass;CONTAINSwould need two calls and would still accept a bareINV-with no digits. - Cost-hierarchy contrast —
SPLIT_PART([Tags], '|', 1)for the already-delimited tag. No regex on purpose.
The cost ladder is deliberate: IN/REMAP (hash) < CONTAINS/REPLACE
(literal scan) < regex (pattern engine). Pick the cheapest tool that does the
job; regex earns its keep only on a real pattern the others cannot phrase.
Final result¶
A pile of free-text memos —
T001,Payment for INV-2024-0042 thank you,priority|cleared|eu
T005,Refund INV-2023-0911 order #88 processed,low|cleared|us
T003,Card settlement no reference here,normal|pending|us
— becomes a clean, joinable reference table, each token lifted out by shape:
At full scale¶
Regex is the most expensive rung of the
ladder, so it is worth knowing the price. On this same workload — but scaled to
1,000,000 synthetic memo rows (~62 MB) — the regex template (two
REGEX_EXTRACT + one REGEX_MATCH per row) was measured against a
literal-only template that produces byte-identical output by leaning on the
stable INV- / # anchors (CONTAINS + nested SPLIT_PART):
| Template (1M rows) | Wall | Peak RSS | Throughput |
|---|---|---|---|
regex (2× REGEX_EXTRACT + REGEX_MATCH) |
~2.4 s | ~23 MB | ~415k rows/s |
cheap (CONTAINS + SPLIT_PART) |
~1.3 s | ~23 MB | ~790k rows/s |
So the regex path costs roughly 1.9× the wall time here — about +1 µs per
row for the three pattern ops — while peak RSS is flat (the Pike-VM engine
is window/arena-bounded, with no per-row growth). The takeaway matches the cost
ladder above: regex is cheap enough to use freely when you need shape
matching, and still worth skipping when a delimiter split or a literal
CONTAINS already answers the question.
Reproduce it — the 1M-row file is generated rather than committed, so this directory ships only the six-row teaching slice:
bash make-full.sh # writes ./full/memos.csv (~62 MB)
bxp-cli --config full.json # the regex template
bxp-cli --config full-cheap.json # the literal-only template
diff full/memos.csvx full/memos-cheap.csvx && echo identical
The diff is not decoration: a timing comparison between two templates only
means anything once they are proven to produce the same answer.
Methodology: best of five interleaved runs,
bxp-clibuiltReleaseFast,BXP_METRICS=1self-reported wall + peak RSS. Absolute milliseconds vary by machine; the ratio and the flat-RSS shape are the portable takeaways.Note what the cheap template needs and the teaching slice deliberately withholds: stable literal anchors.
make-full.shemits memos that always spellINV-and#, which is what letsCONTAINS+SPLIT_PARTreach the tokens at all. Free text in the wild does not promise that — which is the reason the example itself uses regex.
Sample data¶
Run it with bxp-cli --config ./sample.json --template freeform_payment_memos:
{
// Teaching example (advanced) — synthetic data. Pull structured tokens out of
// FREE-TEXT payment memos, where the token a downstream system needs (an
// invoice number, an order reference) sits at a *variable* position inside an
// otherwise human-written sentence. This is the one job the cheaper string
// tools cannot express: SPLIT_PART needs a stable delimiter+position, CONTAINS
// only tests presence, IN/REMAP only match whole values. REGEX_EXTRACT /
// REGEX_MATCH match by *shape*, anywhere in the string.
//
// The deliberate contrast: the `Tags` column IS cleanly delimited ("a|b|c"),
// so its first tag is pulled with SPLIT_PART — NOT regex. Reach for the regex
// engine only when a cheaper tool cannot do the job (IN/REMAP < CONTAINS/
// REPLACE < regex); paying for it on already-delimited data is waste.
conversion_templates: {
freeform_payment_memos: {
data_dir: ".",
file_pattern_in: ".csv",
file_pattern_out: ".csvx",
input_schema: {
$txn_id: "[TxnId]",
// 1 — capture group: pull the invoice number out of the memo wherever
// it sits. The group (...) returns just the inner YYYY-NNNN, dropping
// the "INV-" literal. No match → "".
$invoice: "REGEX_EXTRACT([Memo], 'INV-([0-9]{4}-[0-9]{4})')",
// 2 — capture group with a different anchor: the order ref follows a
// '#'. Position varies row to row, so SPLIT_PART cannot reach it.
$order: "REGEX_EXTRACT([Memo], '#([0-9]+)')",
// 3 — alternation gate: "does this memo carry ANY structured reference"
// — either an INV-YYYY code OR a #NNNN order — answered in one pass.
// CONTAINS would need two calls and still match a bare "INV-" with no
// digits; the pattern requires the digits.
$has_ref: "REGEX_MATCH([Memo], 'INV-[0-9]{4}|#[0-9]+')",
// 4 — the cheap-tool contrast: Tags is "a|b|c", cleanly delimited, so
// the first tag is a plain SPLIT_PART. No regex — that would be paying
// the engine for a job a delimiter split already does.
$priority: "SPLIT_PART([Tags], '|', 1)"
},
row_rules: [ { when: "1 = 1", rows: [ {} ] } ],
output_schema: {
txn_id: "$txn_id",
invoice: "$invoice",
order_ref: "$order",
has_ref: "$has_ref",
priority: "$priority"
}
}
}
}
TxnId,Memo,Tags
T001,Payment for INV-2024-0042 thank you,priority|cleared|eu
T002,SEPA credit ref order #12345 from ACME,normal|cleared|eu
T003,Card settlement no reference here,normal|pending|us
T004,Monthly wire transfer to supplier,priority|pending|eu
T005,Refund INV-2023-0911 order #88 processed,low|cleared|us
T006,Misc cash adjustment,low|cleared|eu
Scale files (the 1M-row cost comparison): make-full.sh · full.json · full-cheap.json.