Choosing a String-Similarity Algorithm for Descriptor and Vendor-Name Matching
Fuzzy matching in reconciliation is not one problem; it is a family of problems that share a signature — two strings that mean the same counterparty but do not compare equal — and diverge sharply in how they differ. A truncated card descriptor, a re-ordered multi-word merchant name, an OCR’d invoice line, and a keyed-in supplier abbreviation each break string equality in a structurally different way, and each rewards a different similarity metric. The mistake that quietly wrecks match rates is picking one scorer — usually plain edit distance — and pointing it at every descriptor shape in the feed. This page is the selection guide: it defines Levenshtein, Jaro-Winkler, token-set ratio, and partial_ratio formally, maps each to the descriptor shapes it handles best and worst, and gives a per-scenario scorer-and-threshold matrix you can lift straight into a routing layer. It sits inside Transaction Matching Algorithms & Logic and deliberately builds on the mechanics established in Fuzzy String-Matching Techniques — it decides which scorer to run rather than re-deriving how any single one works.
Where This Decision Sits in the Pipeline
Scorer selection runs after normalisation and exact matching have both failed to close a break. By the time a candidate pair reaches the fuzzy stage, the deterministic path — hashing on composite keys, exact amount-and-date equality — has already claimed everything it can, and what remains is the residue of descriptor drift. Selecting a scorer at this point presumes several upstream guarantees are in place:
- Both descriptors are already normalised. Case-folded, whitespace-collapsed, punctuation-stripped, and with known payment-processor noise (
SQ *,PAYPAL *, trailing store numbers, city/state tails) removed. Un-normalised input is the single largest source of false negatives, and the normalisation contract itself is specified in Normalising Merchant Descriptors Before Matching. - A candidate set, not the full cross-join. Blocking or a first-pass amount/date filter must have reduced the comparison space, because similarity scoring is quadratic in the naive case and you do not want to score every ledger row against every statement row.
- A stable
trace_idandsource_hashper candidate pair, so the scorer decision is replayable and every score that crosses or misses a threshold can be reconstructed for audit. - Amount and date fields carried alongside the strings, because a string score is never sufficient on its own — it is one axis of a multi-dimensional decision described later on this page.
If a pair arrives here without normalised text, the correct response is to reject it back to the normalisation stage rather than score raw input, because a good score on dirty strings is indistinguishable from luck.
The Four Scorers, Formally
Each scorer answers a different question about how two strings relate. Understanding the question each one asks is what lets you match the scorer to the descriptor shape rather than guessing.
Levenshtein edit distance counts the minimum number of single-character insertions, deletions, and substitutions to turn string a into string b. It is computed with a dynamic-programming table in O(m·n) time and O(min(m,n)) space, and libraries expose it as a normalised ratio, 1 - distance / max_len, scaled to 0–100. Its strength is typographic noise: transposed letters, a dropped character, a doubled character, a keyed-in typo. Its weakness is reordering — “ACME GLOBAL LOGISTICS” versus “GLOBAL LOGISTICS ACME” is a small semantic difference but a large edit distance, because moving a token to the front is many character operations. It also punishes length differences hard, so a truncated descriptor scores poorly against its full form. The implementation mechanics live in Implementing Levenshtein Distance for Vendor-Name Matching; this page does not repeat them.
Jaro-Winkler similarity measures the proportion of matching characters within a sliding window and the number of transpositions, then applies a prefix bonus that rewards strings sharing a common start. It runs in roughly O(m·n) worst case but is fast in practice on the short strings it targets, and returns a 0–1 score commonly scaled to 0–100. The Winkler prefix weight (default emphasis on the first four characters) makes it excellent for short vendor names and person-style names where the beginning is the discriminating part — “Nakatomi” versus “Nakatomo” scores very high. Its blind spot mirrors Levenshtein’s: it does not understand token reordering, and its prefix bias actively misleads on descriptors that share a generic prefix but denote different entities (“FIRST NATIONAL BANK” versus “FIRST NATIONAL INSURANCE”).
Token-set ratio tokenises both strings, splits them into the shared-token intersection and each side’s remainder, and computes a similarity over those sorted groups — taking the best comparison across the intersection-plus-remainder combinations. Cost is dominated by the tokenise-and-sort step, roughly O(k log k) in the token count plus the underlying ratio, and it returns 0–100. This is the order-independent scorer: it treats “ACME GLOBAL LOGISTICS” and “GLOBAL LOGISTICS ACME” as near-identical because the token sets match, and it tolerates one side carrying extra words the other lacks. That order-independence is also its risk — it can over-match two strings that share common filler tokens (“LTD”, “INC”, “SERVICES”) while differing in the token that actually identifies the counterparty, which is why stop-token stripping in normalisation matters so much before you reach it.
partial_ratio finds the best-matching substring of the longer string against the shorter one and scores that window, rather than comparing the strings end to end. It is the truncation-and-containment scorer: a 22-character card descriptor that is a clipped prefix of a full 40-character supplier name scores high because the short form is found intact inside the long form. Its danger is the inverse — a short string will match against any long string that happens to contain it, so a generic fragment like “CORP” can spuriously align inside many descriptors. Use it where one side is known to be a truncation of the other, not as a general-purpose default.
Decision Guide: Descriptor Shape to Scorer
The selection collapses to a small table. Read it as: identify the dominant way this feed’s descriptors drift, pick the scorer whose question matches that drift, and start at the listed threshold before calibrating against replayed history.
| Descriptor shape | Example drift | Recommended scorer | Starting threshold |
|---|---|---|---|
| Typos / keyed-in noise | NAKATOMI vs NAKATOMO |
Jaro-Winkler (short) or Levenshtein | 88 |
| Short vendor / person names | J P Morgan vs JP Morgan |
Jaro-Winkler | 90 |
| Reordered multi-token merchant | ACME GLOBAL vs GLOBAL ACME |
Token-set ratio | 87 |
| Truncated / clipped descriptor | SQ *BLUEBOTTLE COF vs full name |
partial_ratio |
85 |
| OCR’d invoice lines | RN0LDS vs REYNOLDS |
Levenshtein | 82 |
| Long descriptor with extra tokens | name + city + store no. | Token-set ratio | 84 |
No single row generalises to the others, which is the whole point: a feed dominated by reordered merchant strings that is scored with Levenshtein will bleed true matches into the exception queue, and the same feed scored with partial_ratio at a low threshold will fabricate matches on shared fragments. Choose per scenario, then verify the choice empirically — the sibling page Benchmarking rapidfuzz Scorers on Vendor Names walks through measuring precision and recall for each scorer against a labelled vendor set so these starting thresholds become data-driven ones.
A Production-Grade Scorer Selector
The selector below is the operational form of the decision table. It takes a candidate pair tagged with a scenario, chooses the scorer that fits that scenario, computes a normalised 0–100 score with rapidfuzz, compares it to the per-scenario threshold, and emits a structured audit record carrying trace_id, source_hash, and match_decision. Monetary context is carried as Decimal throughout so the string decision can later be fused with amount tolerance without a float ever entering the ledger path.
import hashlib
import logging
import uuid
from dataclasses import dataclass, field
from decimal import Decimal
from datetime import datetime, timezone
from enum import Enum
from typing import Callable
from rapidfuzz import fuzz
from rapidfuzz.distance import JaroWinkler
logger = logging.getLogger("reconciliation.scorer_select")
class Scenario(str, Enum):
TYPO = "TYPO" # keyed-in / OCR noise
SHORT_NAME = "SHORT_NAME" # short vendor or person names
REORDERED = "REORDERED" # multi-token merchant, order drift
TRUNCATED = "TRUNCATED" # clipped card descriptors
EXTRA_TOKENS = "EXTRA_TOKENS" # name + city + store number
# Each scorer returns a normalised 0..100 float on two already-normalised strings.
def _jaro_winkler_100(a: str, b: str) -> float:
return JaroWinkler.similarity(a, b, prefix_weight=0.1) * 100.0
SCORERS: dict[Scenario, Callable[[str, str], float]] = {
Scenario.TYPO: fuzz.ratio, # Levenshtein-derived ratio
Scenario.SHORT_NAME: _jaro_winkler_100, # prefix-weighted
Scenario.REORDERED: fuzz.token_set_ratio, # order-independent
Scenario.TRUNCATED: fuzz.partial_ratio, # substring / containment
Scenario.EXTRA_TOKENS: fuzz.token_set_ratio,
}
THRESHOLDS: dict[Scenario, float] = {
Scenario.TYPO: 82.0,
Scenario.SHORT_NAME: 90.0,
Scenario.REORDERED: 87.0,
Scenario.TRUNCATED: 85.0,
Scenario.EXTRA_TOKENS: 84.0,
}
@dataclass(frozen=True)
class Candidate:
left: str # normalised ledger descriptor
right: str # normalised statement descriptor
scenario: Scenario
amount: Decimal
trace_id: str = field(default_factory=lambda: str(uuid.uuid4()))
@dataclass(frozen=True)
class ScoreResult:
score: float
scorer: str
threshold: float
match_decision: str # MATCHED | REVIEW | UNMATCHED
source_hash: str
def _source_hash(c: Candidate, scorer: str) -> str:
material = f"{c.left}\x1f{c.right}\x1f{c.scenario.value}\x1f{scorer}\x1f{c.amount}"
return hashlib.sha256(material.encode("utf-8")).hexdigest()
def score_candidate(c: Candidate, review_margin: float = 4.0) -> ScoreResult:
if not c.left or not c.right:
raise ValueError("UNNORMALISED_INPUT: empty descriptor reached scorer")
scorer = SCORERS[c.scenario]
threshold = THRESHOLDS[c.scenario]
score = round(float(scorer(c.left, c.right)), 2)
if score >= threshold:
decision = "MATCHED"
elif score >= threshold - review_margin:
decision = "REVIEW" # borderline band → human-in-the-loop
else:
decision = "UNMATCHED"
src = _source_hash(c, scorer.__name__ if hasattr(scorer, "__name__") else str(scorer))
logger.info(
"scorer_decision",
extra={
"trace_id": c.trace_id,
"source_hash": src,
"match_decision": decision,
"scenario": c.scenario.value,
"scorer": getattr(scorer, "__name__", str(scorer)),
"score": score,
"threshold": threshold,
"amount": str(c.amount),
"ts": datetime.now(timezone.utc).isoformat(),
},
)
return ScoreResult(score, getattr(scorer, "__name__", str(scorer)),
threshold, decision, src)
Three design choices are load-bearing. First, the scenario is an explicit input, not something the scorer infers — the caller (a blocking stage or a per-feed rule) declares the descriptor shape, which keeps the choice auditable and testable. Second, the borderline REVIEW band means a near-miss is routed to human judgement rather than silently discarded, feeding the exception path rather than inflating the false-negative rate. Third, every decision is hashed and logged before it returns, so a regulator can replay exactly which scorer ran, on which strings, at which threshold, and why the pair matched or did not.
Configuration Reference
Scorer selection is governed by version-controlled configuration, not code edits, so a threshold change is a reviewable, timestamped event rather than a deploy. The parameters below are the tunable surface.
| Parameter | Applies to | Default | Tuning guidance |
|---|---|---|---|
scorer |
per scenario | see table | The scorer bound to a descriptor shape; change only with benchmark evidence. |
threshold |
per scorer | 82–90 | Raise to cut false positives, lower to lift recall; calibrate on labelled history. |
review_margin |
global | 4.0 |
Width of the borderline band routed to human review below the threshold. |
prefix_weight |
Jaro-Winkler | 0.1 |
Prefix bonus emphasis; lower it when generic prefixes cause over-matching. |
tokenization |
token-set / partial | whitespace + stop-token strip | Must match the normalisation contract exactly to avoid tokenisation drift. |
The tokenization row deserves emphasis: token-set ratio and partial_ratio are only as good as the tokeniser feeding them, and if the scorer’s notion of a token diverges from the one used during normalisation, scores silently degrade. Keep a single tokenisation definition shared across both stages and version it alongside the thresholds.
Multi-Dimensional Validation
A string score is one axis, never the verdict. A descriptor can score 95 against the wrong counterparty when two vendors share a near-identical name, and only the amount and date disagree. Robust matching composes the string decision with independent gates and confirms a match only when the signals intersect — the string clears its threshold and the amount is within tolerance and the posting dates fall inside the reconciliation window. This is a logical AND across orthogonal axes, not a blended average that lets a strong name score paper over a wrong amount.
def confirm_match(
result: ScoreResult,
ledger_amount: Decimal,
feed_amount: Decimal,
amount_tolerance: Decimal,
within_date_window: bool,
) -> str:
string_ok = result.match_decision == "MATCHED"
amount_ok = abs(ledger_amount - feed_amount) <= amount_tolerance
if string_ok and amount_ok and within_date_window:
return "CONFIRMED"
if result.match_decision in ("MATCHED", "REVIEW") and (amount_ok or within_date_window):
return "REVIEW" # partial agreement → human adjudication
return "REJECTED"
The amount and date gates come from the rules defined in Date-Window & Amount-Tolerance Rules; the string scorer never overrides them. Because the axes are independent, partial agreement is informative: a high string score with an out-of-tolerance amount is exactly the profile of a mis-posted or split payment, and routing it to review rather than auto-confirming it is what keeps the general ledger honest.
Async and Vectorised Batch Scoring
During a close, the fuzzy stage may face hundreds of thousands of candidate pairs, and scoring them one Python call at a time is wasteful. rapidfuzz.process.cdist computes a full score matrix between two string collections in vectorised C, releasing the GIL, so a block of ledger descriptors can be scored against a block of statement descriptors in a single call. The result is a matrix from which the best match per row is a cheap argmax.
import asyncio
from rapidfuzz import fuzz, process
def _cdist_block(ledger: list[str], feed: list[str], threshold: float):
# Vectorised score matrix; released GIL means real parallelism off the loop.
matrix = process.cdist(
ledger, feed, scorer=fuzz.token_set_ratio, score_cutoff=threshold, workers=-1
)
best = []
for i, row in enumerate(matrix):
j = int(row.argmax())
best.append((i, j, float(row[j])))
return best
async def score_batches(
blocks: list[tuple[list[str], list[str]]], threshold: float = 87.0
):
# Each block is dispatched off the event loop so ingestion stays responsive.
tasks = [asyncio.to_thread(_cdist_block, led, feed, threshold) for led, feed in blocks]
return await asyncio.gather(*tasks)
Vectorised scoring must still respect scenario selection — you run one cdist pass per scorer, over the blocks whose descriptors share that shape, rather than forcing a single scorer across a mixed feed. The score_cutoff argument lets the C layer skip pairs that cannot clear the threshold, which is where most of the speed-up comes from on sparse matches. Dispatching each block with asyncio.to_thread keeps the event loop free for ingestion and audit I/O while the CPU-bound matrix work runs in parallel.
Failure Modes and Remediation
The selection layer has its own catalogue of named failures. Each is emitted with the trace_id and source_hash so it can be triaged from the audit stream rather than reproduced by hand.
| Code | Root cause | Remediation |
|---|---|---|
WRONG_SCORER |
Scenario mislabelled, so a reordered string was scored with Levenshtein | Re-classify the descriptor shape; fall back to token-set ratio for order-uncertain feeds. |
THRESHOLD_MISCALIBRATED |
Cut-off set from intuition, not from labelled history | Re-derive on a benchmark set; track precision/recall per scenario before shipping. |
UNNORMALISED_INPUT |
Raw descriptor reached the scorer with case, punctuation, or processor noise intact | Reject to the normalisation stage; never score raw text. |
TOKENIZATION_DRIFT |
Tokeniser in scoring differs from the one used at normalisation | Share one versioned tokenisation definition across both stages. |
The unifying principle is that no failure is allowed to masquerade as a confident match: a mislabelled scenario, an un-normalised string, or a drifting tokeniser must surface as an exception or a review item, never as an auto-confirmed posting. When a score lands in the borderline band, the correct destination is the human-in-the-loop path, not a silently loosened threshold.
Compliance and Audit-Trail Requirements
Every fuzzy decision that closes or fails to close a reconciliation break is a control point, and SOX Section 404 and PCI-DSS Requirement 10 both demand that it be reconstructable. The selector satisfies this through three properties. First, decision provenance: each score emits a record carrying trace_id, source_hash, match_decision, the scorer name, the score, and the active threshold, so an auditor can replay precisely why any pair matched. Second, configuration versioning: the scorer-per-scenario map, the thresholds, and the tokenisation definition are version-controlled and timestamped, so the exact matching envelope active at execution time can be reconstructed months later. Third, deterministic replay: because the scorers are pure functions of normalised input and every input is hashed, re-running the stored candidates reproduces the identical decisions bit for bit, which is what turns a fuzzy heuristic into an auditable control.
Treating scorer selection as an explicit, per-scenario, empirically calibrated decision — rather than a single default pointed at every descriptor — is what lets a reconciliation system absorb the full range of descriptor drift while keeping its match decisions defensible to a regulator.