Two company lists, and a similarity score for every pair across them. The usual first step is to take each record’s highest-scoring candidate and keep it if the score clears a threshold. That rule has a gap: nothing stops two records on one side from picking the same partner on the other.

Here is the smallest version of it, using made-up scores. Acme Foods Group Inc and Acme Farms Group Inc are different companies. The other list has Acme Foods Group, and both A records score high against it:

import numpy as np
from scipy.optimize import linear_sum_assignment

a = ["Acme Foods Group Inc", "Acme Farms Group Inc", "Birch Marine LLC"]
b = ["Acme Foods Group", "Birch Marine Ltd", "Cedar Optics Co"]
# Hypothetical similarity scores: rows are A records, columns are B records.
S = np.array([[0.97, 0.10, 0.05],
              [0.91, 0.12, 0.08],
              [0.15, 0.93, 0.20]])

def top1(S):
    return [(i, int(S[i].argmax())) for i in range(len(S))]

def mutual_best(S):
    return [(i, j) for i, j in top1(S) if int(S[:, j].argmax()) == i]

def optimal(S):
    rows, cols = linear_sum_assignment(S, maximize=True)
    return list(zip(rows.tolist(), cols.tolist()))

for name, fn in [("top-1", top1), ("mutual best", mutual_best), ("optimal assignment", optimal)]:
    print(name)
    for i, j in fn(S):
        print(f"   {a[i]}  ->  {b[j]}  ({S[i, j]:.2f})")
top-1
   Acme Foods Group Inc  ->  Acme Foods Group  (0.97)
   Acme Farms Group Inc  ->  Acme Foods Group  (0.91)
   Birch Marine LLC  ->  Birch Marine Ltd  (0.93)
mutual best
   Acme Foods Group Inc  ->  Acme Foods Group  (0.97)
   Birch Marine LLC  ->  Birch Marine Ltd  (0.93)
optimal assignment
   Acme Foods Group Inc  ->  Acme Foods Group  (0.97)
   Acme Farms Group Inc  ->  Cedar Optics Co  (0.08)
   Birch Marine LLC  ->  Birch Marine Ltd  (0.93)

Top-1 links Acme Farms Group Inc to a company it is not. Mutual best drops it. Optimal assignment gives it a leftover partner, but at a score of 0.08, which a threshold applied afterwards rejects. The rest of this post covers when that rule is correct, what each strategy does, and what happened when we measured them.

Is your relationship really one-to-one?

Enforcing one-to-one links is only right when the real world is one-to-one. If it is not, the rule creates errors instead of removing them.

  • Cross-system customer lists. Each customer normally appears once in each system, so one-to-one is a reasonable assumption, with exceptions for customers who were duplicated inside one system.
  • Parent and branch records. Five branches in list A may legitimately map to one parent in list B. One-to-one would keep at most one link for that group and drop or mis-assign the other four.
  • Product catalogs. A supplier’s pack-size variants may all map to one catalog entry, or each to its own, depending on how the catalog is built.

Test it on your labelled sample before choosing. Count how many B records have two or more true partners in A, and the reverse. If that share is small, one-to-one is a fair simplification and the leftovers are candidates for review. If it is large, leave the rule off.

Four ways to choose the pairs

Top-1 per record. Each A record takes its best-scoring B record. Simple, fast, and the version that allows collisions.

Mutual best. Keep a pair only if each record is the other’s top choice. In the example above, Acme Farms Group Inc points at Acme Foods Group, but Acme Foods Group points back at Acme Foods Group Inc, so the second pair is dropped.

Greedy by score. Sort all pairs from highest score to lowest. Accept a pair if neither record is taken yet. It is easy to explain, and an early wrong choice blocks later right ones.

Optimal assignment. Choose the one-to-one set with the highest total score over all records together, which is the classic assignment problem. SciPy’s linear_sum_assignment solves it. Two details matter. It gives every record on the smaller side a partner, however weak, so the threshold has to come after it. And the score matrix is dense: a 1,000 × 1,150 matrix of 8-byte floats is about 9 MB, but 50,000 × 50,000 is 20 GB by arithmetic, so at larger sizes you restrict it to candidate pairs from blocking first (see blocking keys).

What we measured

We tested the four strategies on the synthetic company benchmark used in our other posts: 1,000 records on side A, 1,150 on side B, 700 true matches, scored with cosine similarity of gemini-embedding-001 embeddings of normalized names. Each strategy got its own threshold, tuned on one seed and evaluated on another. By construction every B record matches at most one A record, which is the best case for enforcing the rule.

Four matching strategies on the test seed (synthetic data, one run)
Strategy Threshold Links Correct Wrong Precision Recall F1
Top-1 per record 0.899 652 603 49 0.925 0.861 0.892
Mutual best 0.866 679 629 50 0.926 0.899 0.912
Greedy by score 0.866 711 637 74 0.896 0.910 0.903
Optimal assignment 0.876 678 625 53 0.922 0.893 0.907

Source: our offline benchmark, test seed 2, 700 true matches. Embedding scores are the same ones as in the embeddings post. A few points of F1 from one run are not a reliable ranking.

What the numbers show:

  • Enforcing one-to-one helped a little here. Mutual best, greedy and optimal assignment all scored 0.903 to 0.912 F1 against 0.892 for top-1. It is one run on data where one-to-one is true by construction, so treat 1 to 2 points as an indication, not a ranking.
  • The gain came from recall, not precision. Precision stayed near 0.92 for top-1, mutual best and optimal assignment. The tuned thresholds were lower for the one-to-one strategies (0.866 to 0.876 instead of 0.899), which is consistent with the extra correct links, but we did not isolate that effect. A plausible reason is that resolving collisions makes a looser threshold safer; we did not test that explanation either.
  • Greedy was the least precise. It made 74 wrong links against 49 to 53 for the others, consistent with early wrong choices blocking later right ones.
  • Some matches were out of reach for two of the strategies. For 55 of the 700 true matches, the true partner was not the record’s top-scoring candidate. Top-1 and mutual best can never recover those. Greedy and optimal assignment can, but only when the better-scoring partner was already taken by another record, and we did not measure how many of their extra links came from that.

A collision is a useful warning sign

At the tuned top-1 threshold, 28 B records were claimed by two or more A records. Those collisions involved 57 of the 652 links, and 29 of those 57 (51%) were wrong. The other 595 links had 20 wrong (3.4%).

So a collision is not only a problem to resolve. In this run it was a useful warning: links involved in collisions were wrong far more often than the rest, which suggests collisions are worth reviewing. Rather than silently dropping the weaker claim, you can send both records to a person. Two thresholds give a natural place to put them: the clean links are accepted automatically, and the colliding ones go to review.

What not to conclude

  • The data is synthetic and one-to-one by construction. On real data with true many-to-one relationships, mutual best or optimal assignment would discard correct links.
  • The embedding scores are one model on normalized company names. Other scorers have different score distributions, and the tuned thresholds would differ.
  • It is one run without confidence intervals, so the ordering of mutual best, greedy and optimal assignment is not established.

The practical rule: decide whether the relationship is one-to-one from a labelled sample, apply mutual best or optimal assignment if it is, tune the threshold after the assignment step, and review collisions instead of discarding them. Then measure precision and recall on pairs you labelled yourself, as described in how to measure matching quality.

Try it free →

Keep reading