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_id and source_hash per 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.

Scorer suitability matrix by descriptor shape A grid with four scorer rows — Levenshtein, Jaro-Winkler, token-set ratio, and partial_ratio — and six descriptor-shape columns: typos, short names, reordered tokens, truncation, OCR noise, and extra tokens. Each cell is shaded to show suitability, with a filled coloured cell for the best-fit scorer in each column and a neutral cell for a poor fit. Levenshtein fits typos and OCR noise; Jaro-Winkler fits short names and typos; token-set ratio fits reordered tokens and extra tokens; partial_ratio fits truncation. typos short reorder truncate OCR extra tok. Levenshtein Jaro-Winkler Token-set ratio partial_ratio best best good best best best best best fit weak / avoid as default

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.

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

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

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

Part of Transaction Matching Algorithms & Logic.