A controller I respect once told me she had "AI category suggestion" muted in three accounting tools because all of them had, at various points, coded a federal payroll deposit as office supplies. The conversation that ended each tool went the same way: she'd ask the vendor why the system suggested that account, and nobody could answer. The model just did.
We started building Cadense's categorization engine after a similar demo. The pitch was that you could replace your bookkeeper with a single LLM call. The reality was that the LLM picked plausible-sounding accounts, often confidently, with no way to challenge the answer. That's not a categorization engine. That's a generator of accounting questions.
TL;DR
- The categorization engine is a five-stage cascade. Earlier stages are deterministic; later stages get progressively more ML-flavored. The first stage to clear its threshold wins.
- Suppression rules at confidence
0.99block dangerous categorizations (credit-card payments, internal transfers) before any embedding can interfere.- Embeddings sit at stage 3 with a default threshold of
0.55— deliberately conservative. Below that, we fall back to a keyword heuristic; below0.25, we abstain.- Every result carries a decision trace (which stage matched, what the score was, why fallback kicked in) so a controller can audit the suggestion in seconds.
The three things finance teams actually need
Before a line of code, we wrote down what a "good" categorization looks like from an AP team's perspective. Three constraints stuck:
- Determinism for the obvious. A check to American Express should never be coded as office supplies because some embedding score happened to favor it.
- Coverage for the long tail. The 5% that's hard — obfuscated merchant names, new vendors, multi-currency invoices — has to land somewhere reasonable.
- A graceful "I don't know." When the system isn't confident, it should abstain rather than guess. False negatives (a transaction in the review queue) cost a controller minutes. False positives (a wrong code that gets posted to the GL) cost an audit cycle.
These are the three pressure points the cascade is engineered around.
The five-stage cascade, from above
incoming transaction
│
▼
[ 1. Rule pack (incl. suppression rules) ] ──► matched? ──► done
│
▼
[ 2. Taxonomy phrase / MCC matcher ] ──► matched? ──► done
│
▼
[ 3. Embedding-based semantic search ] ──► above 0.55? ──► done
│
▼
[ 4. Keyword fallback ] ──► above 0.25? ──► done
│
▼
[ 5. Abstain (confidence 0) ] ──► review queue
The categorizer advances from deterministic to probabilistic stages. A winning stage emits a category, confidence, and decision trace; no winning stage emits a review item instead of a weak guess.
Earlier stages are cheap, deterministic, and high precision. Later stages get progressively more ML-flavored. The first stage that passes its threshold wins.
The entry point in packages/banking-categorization/src/classify-transactions.ts looks roughly like this:
export async function classifyTransactions(
transactions: BankTransaction[],
context: ClassificationContext
): Promise<ClassificationResult[]> {
const results: ClassificationResult[] = []
for (const tx of transactions) {
const trace = createDecisionTrace(tx)
// Stage 1: rules + suppressionconst ruleHit = evaluateRulePack(tx, context.rulePack, trace)
if (ruleHit) { results.push(ruleHit); continue }
// Stage 2: taxonomy phrase + MCCconst phraseHit = evaluatePhraseIndex(tx, context.phraseIndex, trace)
if (phraseHit) { results.push(phraseHit); continue }
// Stage 3: embeddings (cosine similarity over cached category vectors)const embeddingHit = await scoreAgainstEmbeddingIndex(tx, context, trace)
if (embeddingHit && embeddingHit.confidence >= context.embeddingConfidenceThreshold) {
results.push(embeddingHit); continue
} // Stage 4: token-overlap keyword fallbackconst keywordHit = scoreByKeywordOverlap(tx, context.taxonomy, trace)
if (keywordHit && keywordHit.confidence >= context.keywordConfidenceFloor) {
results.push(keywordHit); continue
} // Stage 5: abstainresults.push({ confidence: 0, status: 'requires_review', trace })
} return results}Every result carries the same trace — the audit trail that turns a suggestion into a defensible recommendation.
A categorization result records the transaction, selected category, stage, confidence, threshold snapshot, and model version when embeddings participate. Controller corrections become review feedback without silently mutating past explanations.
Walking a real transaction through the cascade
Let's run a real-looking example end to end. Say the bank feed delivers:
date: 2026-04-15
amount: -412.40 USD
descriptor: ACME OFFICE SUP 412.40
mcc: 5943 // "Stationery, Office, School Supply Stores"
provider: Visa
Stage 1 — Rules. No suppression rule fires (this isn't a credit-card payment, not an internal transfer). No user rule for "Acme Office" exists in this workspace. Rule pack returns nothing. Move on.
Stage 2 — Phrase + MCC. The MCC 5943 maps cleanly to Office Supplies via the MCC rule path at confidence 0.98. Match. Cascade stops here. The trace shows: mcc:5943 → office_supplies (0.98).
Now imagine the same transaction without an MCC (very common — most small merchants don't transmit one):
Stage 2 — Phrase + MCC. No MCC. We try merchant phrase matching against the taxonomy. "Acme office sup" tokenizes to [acme, office, sup]. The category index built from category-hints.ts includes office, supplies, stationery for Office Supplies. Token overlap is partial but the phrase score doesn't clear the merchant phrase ceiling of 0.86. Move on.
Stage 3 — Embeddings. We generate an embedding for acme office sup outflow visa usd and compare to every category vector in the cached index. Office Supplies scores 0.71, well above the 0.55 threshold. Match. Trace: embedding → office_supplies (0.71).
If the merchant string had been more obfuscated — say AMZ*8KYT4Q OUTFLOW USD — embeddings might score the top category at only 0.47. We'd drop to Stage 4 (keyword fallback). If the keyword overlap also fails to clear 0.25, Stage 5 abstains and the transaction lands in the review queue with confidence 0.
That's the point of the design: the answer always lives at the highest-confidence stage that fires, and the trace tells you which stage that was.
Stage 1 deep dive: suppression and the priority game
The rule pack is the highest-priority stage and it's deliberately rigid. Rules live in default-rulepack.ts and can be extended per workspace. Each rule has a source — suppression-rule, merchant-rule, provider-rule, mcc-rule, or user-rule — and a confidence baseline.
Suppression rules sit at confidence 0.99 and exist to block categorization in cases where any other signal would mislead the system. Examples:
- A credit-card payment from your operating account to your AP card. Suppression rule fires; categorization halts. Without it, the embedding stage would happily code this as "Software & SaaS" because that's what your card paid for last month.
- An intra-company transfer between two of your own accounts. Should not be categorized as anything.
- A bank fee. Has its own ledger account; should not be inferred from the descriptor.
The rule engine in rule-engine.ts:evaluateRulePack ranks matches by three things in order:
- Source priority — suppression beats user beats merchant beats provider beats MCC.
- Specificity — a 3-token phrase match beats a 1-token match. Longer phrases are less ambiguous.
- Confidence — ties broken by the rule's stated confidence.
If any rule fires, the cascade ends. No embedding, no LLM, no fuzziness. That covers a surprising fraction of real AP volume — payroll, rent, recurring SaaS, intra-company transfers — and it covers it deterministically.
Stage 2 deep dive: phrase + MCC matching
If no rule matches, we hit the phrase index built from the workspace's chart of accounts, the category taxonomy, payment-channel hints, and a hand-curated category-hints.ts file (entries like anthropic, openai, perplexity map to AI/ML services). This is still deterministic — it's just lookup, not learning.
| Sub-path | Confidence ceiling | Notes |
|---|---|---|
| MCC code match | 0.98 | When the bank feed includes one |
| Provider phrase exact match | 0.93 | E.g. provider category "Restaurants" → Meals |
| Provider phrase contains | 0.88 | Substring match within a longer string |
| Merchant phrase match | 0.86 | The most common path |
In practice, merchant phrase matching carries this stage because most small merchants don't transmit MCC.
Stage 3 deep dive: embeddings, conservatively
If we still don't have an answer, we generate an text-embedding-3-small vector for the transaction's query — merchant, provider category, payment channel, amount direction (inflow vs. outflow), currency. We score this query against a pre-computed embedding for every category in the taxonomy and pick the top match.
A few opinions baked into this stage:
- The threshold defaults to
0.55, not0.7or higher. That's deliberately conservative for finance. We'd rather drop into keyword fallback than return a confident-sounding semantic match that's actually weak. - The embedding index is cached. Vectors are computed once per category definition and held in Redis with a 24h TTL. Reclassifying a 50k-transaction history isn't an OpenAI bill problem; it's a single batch over the bank-side query embeddings.
- The model name carries a version suffix (
text-embedding-3-small@v1). Switching embedding models is a one-line config change and it's tracked alongside every classification. If we move to a new model, the trace tells you which model categorized which transaction.
Stage 4 deep dive: keyword fallback
If the embedding score is below 0.55, we drop to a token-overlap heuristic. We tokenize the transaction text and the category definition, count shared tokens, and weight "primary" tokens (category name, aliases, examples, hints) higher than secondary tokens (description, parent labels). Provider tokens get an additional 0.75x weight because Visa/Mastercard processors so often obscure merchant identity.
The keyword fallback is honest about what it is — a pattern match of last resort. The floor is 0.25. Below that, we abstain.
Stage 5: abstention beats a wrong answer
If nothing in the cascade passes its threshold, the engine returns confidence 0 and tags the transaction requires_review. It shows up in the AP team's coding queue with no suggestion attached.
This is the most controversial choice in the whole engine. Many vendors return something — a low-confidence guess — because empty suggestions feel like the system isn't doing its job. We came down on the other side. A wrong code that gets posted to the GL is much more expensive to unwind than a transaction that sits in a review queue for an extra 30 minutes.
The naive approach vs. the cascade
| Concern | Naive single-LLM call | The cascade |
|---|---|---|
| Common cases (payroll, rent, SaaS) | Generates an answer; quality depends on prompt | Suppression and rule packs cover them deterministically |
| Auditability | "The model picked it" | Decision trace shows which stage matched and why |
| Failure mode | Confident wrong answer | Drops to fallback, then abstains |
| Drift on model upgrades | Silent | Versioned model names; old classifications can be re-scored |
| Cost per transaction | One LLM call | One embedding call (only for stage 3+) |
| Threshold control | None | Per-workspace config |
The cascade is more code. It's also the only shape that holds up to a controller asking "why did you suggest that?".
Tunable per workspace
Every threshold mentioned above (embeddingConfidenceThreshold, keywordConfidenceFloor, mccBaseConfidence, etc.) is exposed via apps/sim/app/api/workspaces/[id]/banking/categorization. A controller can dial precision/recall to taste:
0.7if they want fewer auto-coded items but higher trust per item.0.4if they want broader coverage and are willing to review more.
The defaults are tuned for the median finance team: conservative enough to keep the GL clean, aggressive enough to clear the high-volume recurring stuff without human input.
What we deliberately left out
A few things we get asked about that aren't in the engine yet, with the reasons:
- No feedback loop. When a user overrides a suggestion, that signal isn't fed back into the embedding index or the rule pack. We'd rather wait until we have enough volume to do this responsibly than ship a learning loop that drifts in an opaque way.
- No cross-currency normalization. A USD merchant and a EUR merchant with the same name are scored independently; we don't FX-normalize the embedding space.
- No LLM in the hot path. We use embeddings, not generative models, for the core classification step. LLMs sit one layer up — generating category descriptions, alias lists, and examples that feed into the deterministic stages — but they don't make the live decision. That's been good for cost, latency, and predictability.
The point of all this
Categorization isn't a place to be clever. It's a place to be right when you can be, honest when you can't, and traceable in both cases. The cascade gives us a way to push every transaction as far down the determinism axis as the data allows, and to stop at the right step. The rest is a tuning exercise, and we let each workspace own that.
This same shape — deterministic-first, ML-as-fallback, abstain-when-unsure — is the bias that runs through everything we build on the banking side. The next two posts in this series describe the systems that sit alongside the categorizer: the reconciliation engine that matches bank transactions to invoices, and the anomaly detector that flags unusual spend without crying wolf. They share the cached category index, the abstention philosophy, and the same allergy to confident wrong answers.
If you want to dig in further, the engine lives in packages/banking-categorization. The entry point is classifyTransactions in classify-transactions.ts. The rule pack in default-rulepack.ts and the category hints in category-hints.ts are both worth a read.
FAQ
Why use a cascade instead of just one ML model?
A cascade lets us hit the easy 70-80% of cases deterministically (rules, phrase matching), where confidence is justified by structure rather than learned weights. Then we use embeddings only on the harder long tail. This keeps the average GL code defensible to a controller while still covering novel vendors.
What model does Cadense use for embeddings?
OpenAI text-embedding-3-small. The model name is versioned (e.g. text-embedding-3-small@v1) and stored alongside every classification, so if we change models we know which classifications to recompute.
Will the engine learn from my corrections?
Not yet. Today, when a user overrides a suggestion, that signal is captured but does not retrain the embedding index or the rule pack. We plan to add a feedback loop, but only when we can do it without introducing opaque drift.
How are confidence thresholds set?
They default to safe values (0.55 for embeddings, 0.25 for keyword fallback, 0.99 for suppression rules) and are exposed per workspace via the categorization settings API. A controller can tighten thresholds for higher precision or loosen them for more coverage.
What happens when nothing in the cascade matches?
The transaction is returned with confidence 0 and tagged for manual review. We deliberately do not return a low-confidence guess — a wrong GL code is much more expensive to unwind than a transaction that sits in a coding queue.
Does the engine handle MCC (Merchant Category Codes)?
Yes. When the bank feed includes an MCC, we map it via a dedicated rule path with 0.98 confidence. Most small merchants do not transmit MCC, so this path is taken less than you might expect.