Fundamentals

Remove Duplicate Records with Embeddings in Python

Catch "Acme Ltd" and "ACME Limited" as one company: normalise with pandas, embed the survivors in batches, score every pair, and merge above a cutoff.

By the end of this guide you will have a script that reads a messy CSV of records, deletes the obvious duplicates for free, uses an embedding model to find the near-duplicates that a text comparison cannot see, and writes out a clean file that keeps the most complete version of each record. Budget about forty minutes for the first run on a few thousand rows.

The problem this solves is familiar to anyone who has merged two exports. A contact list contains Acme Ltd, ACME Limited and Acme Ltd. on three separate rows. Every one of them is the same company, but no exact comparison will ever match them, because as far as your computer is concerned those are three unrelated strings. This guide is part of Data Cleaning for AI, and it assumes you have already worked through Cleaning CSV Data with Pandas for AI so your file loads without errors.

An embedding is a long list of numbers that a model produces to capture what a piece of text means, arranged so that two texts with similar meaning produce number lists pointing in nearly the same direction. That is the whole trick: once each record is a list of numbers, "how alike are these two records?" becomes arithmetic you can do on your own laptop.

Prerequisites

You need Python 3.10 or newer inside a virtual environment. If you have not made one, follow Create a Python Virtual Environment for AI first, then install the four packages this guide uses:

pip install "pandas>=2.2" "numpy>=1.26" "openai>=1.40" "python-dotenv>=1.0"
pip freeze > requirements.txt

You also need an OpenAI API key, stored in a .env file in the same folder as your script:

OPENAI_API_KEY=sk-your-key-here

Add that file to .gitignore before you write another line, because a key committed to a public repository will be found and spent by strangers:

echo ".env" >> .gitignore

Finally, have a CSV to work on. The examples assume a file called companies.csv with a company_name column plus whatever other columns you happen to have — email, city, phone, anything.

Step 1 — Normalise the text and drop exact duplicates first

Never send a row to an API before you have tried to kill it for free. Normalising means rewriting each value into a plain, predictable form: all lowercase, no punctuation, single spaces. Acme Ltd. and ACME Ltd both collapse to acme ltd, and pandas can then throw one of them away without any model being involved.

This matters for two reasons. It cuts your bill, because you never pay to embed the same string twice. It also cuts the work in the expensive step later, where the cost grows with the square of the row count, so removing a tenth of your rows here removes about a fifth of the comparisons later.

import re
import pandas as pd

def normalise(value: object) -> str:
    """Lowercase, strip punctuation, and collapse runs of whitespace."""
    text = str(value).lower().strip()
    text = re.sub(r"[^\w\s]", " ", text)   # punctuation becomes a space
    text = re.sub(r"\s+", " ", text)       # many spaces become one
    return text.strip()

df = pd.read_csv("companies.csv")
df["name_key"] = df["company_name"].map(normalise)

before = len(df)
df = df.drop_duplicates(subset="name_key", keep="first").reset_index(drop=True)
print(f"removed {before - len(df)} exact duplicates, {len(df)} rows remain")

df.to_csv("companies_stage1.csv", index=False)

The reset_index(drop=True) call is not decoration. Later steps refer to rows by position, so the index has to be a clean 0, 1, 2, … run with no gaps left by the deleted rows.

The diagram below shows what this cheap pass catches and what it cannot, next to what the embedding pass adds.

Exact matching compared with embedding matching on three dimensions A three-row comparison matrix contrasting exact duplicate removal in pandas with embedding-based similarity on which duplicates are caught, what each run costs, and how many comparisons each performs. What changes Exact match Embeddings Acme Ltd vs ACME Limited missed keys differ flagged score near 0.95 Cost to run on 10k rows nothing runs in pandas tokens billed one call per batch Comparisons made per row one lookup scales linearly every other row needs blocking
Exact matching is free and instant but blind to wording changes; embeddings see the meaning but cost tokens and compare every pair, which is exactly why you run the free pass first.

Step 2 — Embed the surviving rows in batches

Now turn each remaining name_key into numbers. Send them in batches rather than one row at a time: a batch is a single HTTP request carrying many strings, so a hundred rows in one call is roughly a hundred times faster than a hundred calls, and it puts far less strain on your rate limit.

Two details are easy to get wrong. The API returns results in a data list where each item carries its own index, so sort by that before you collect the vectors and your rows can never drift out of alignment with your spreadsheet. And store the result as float32 instead of the default float64, which halves the memory for no meaningful loss of precision here.

import os

import numpy as np
import pandas as pd
from dotenv import load_dotenv
from openai import OpenAI

load_dotenv()                                   # reads .env, which is in .gitignore
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

EMBED_MODEL = "text-embedding-3-small"
BATCH_SIZE = 100

df = pd.read_csv("companies_stage1.csv")
texts = df["name_key"].fillna("").astype(str).tolist()

vectors: list[list[float]] = []
for start in range(0, len(texts), BATCH_SIZE):
    batch = texts[start:start + BATCH_SIZE]
    response = client.embeddings.create(model=EMBED_MODEL, input=batch)
    ordered = sorted(response.data, key=lambda item: item.index)
    vectors.extend(item.embedding for item in ordered)
    print(f"embedded {start + len(batch)} of {len(texts)}")

matrix = np.array(vectors, dtype="float32")
np.save("vectors.npy", matrix)
print("matrix shape:", matrix.shape)

The printed shape is one row per record and 1,536 columns, because that is how many numbers text-embedding-3-small returns per input. Saving to vectors.npy means you only pay for this once — every later experiment with thresholds reloads the file instead of calling the API again.

How cleaned rows become a saved matrix of vectors A six-stage data flow showing cleaned rows split into batches of one hundred, sent to the embeddings endpoint, returned as one vector per row, stacked into a numpy array and saved to disk as a npy file. Cleaned rows companies_stage1 Batch of 100 one request each Embeddings API billed per token One vector 1,536 numbers Stacked array float32 in numpy Saved to disk vectors.npy
Batching keeps the number of API calls small, and saving the finished array means every later threshold experiment is free.

Step 3 — Score every pair with cosine similarity

Cosine similarity measures the angle between two number lists and reports it as a value between roughly -1 and 1, where 1 means "pointing the same way" and 0 means "unrelated". If you first scale every vector to length one, the whole calculation becomes a single matrix multiplication, which numpy does in a fraction of a second for a few thousand rows.

Use a deliberately generous cutoff on this pass. You are not deciding anything yet, only producing a shortlist to read in the next step.

import numpy as np
import pandas as pd

SHORTLIST_CUTOFF = 0.85          # deliberately loose; you will tighten it in Step 4

df = pd.read_csv("companies_stage1.csv")
matrix = np.load("vectors.npy")

unit = matrix / np.linalg.norm(matrix, axis=1, keepdims=True)
similarity = unit @ unit.T                 # every row against every other row
upper = np.triu(similarity, k=1)           # keep each pair once, ignore self-matches

left, right = np.where(upper >= SHORTLIST_CUTOFF)
pairs = pd.DataFrame({
    "left": left,
    "right": right,
    "similarity": upper[left, right],
    "left_name": df.loc[left, "company_name"].to_numpy(),
    "right_name": df.loc[right, "company_name"].to_numpy(),
}).sort_values("similarity", ascending=False)

pairs.to_csv("flagged_pairs.csv", index=False)
print(f"{len(pairs)} candidate pairs flagged")
print(pairs.head(15).to_string(index=False))

np.triu(similarity, k=1) keeps only the upper triangle of the square, above the diagonal. Without it you would get each pair twice — once as A-B and once as B-A — plus every row matched against itself at a perfect 1.0.

Step 4 — Pick your threshold by reading borderline pairs

There is no correct threshold you can look up, because the right value depends on how much wording varies in your particular data. Product descriptions need a different line than two-word company names. The reliable method is to look at real pairs from your own file and find where the model stops being right.

Print a sample from the grey zone and read it with your own eyes:

import pandas as pd

pairs = pd.read_csv("flagged_pairs.csv")
band = pairs[(pairs["similarity"] >= 0.85) & (pairs["similarity"] < 0.97)]
sample = band.sample(min(40, len(band)), random_state=0).sort_values(
    "similarity", ascending=False
)

for row in sample.itertuples():
    print(f"{row.similarity:.3f}  {row.left_name}  <->  {row.right_name}")

Read the list from the top down. The first rows will be obvious duplicates. Somewhere in the middle you will hit a pair that is clearly two different businesses that happen to sound alike — Northside Dental and Southside Dental, say. That crossover point is your threshold. Set it just above the highest score you disagreed with, then re-run the shortlist with the new number.

Two habits keep this honest. Pick the threshold from a sample you have not tuned on before, and write the chosen value down in a comment with the date, because the right cutoff drifts as your data changes.

Decision path for a single flagged pair based on its similarity score A decision tree that sends pairs scoring 0.95 or higher straight to an automatic merge, pairs between 0.88 and 0.95 to a human reviewer, and everything below 0.88 back to the file untouched. Pair scores 0.95 or higher? yes Merge the rows no human needed no Score sits in 0.88 to 0.95? yes Queue for review one person, one pass no Score under 0.88 Keep both rows no change made
Only the middle band costs human time, so moving your threshold up or down is really a decision about how much reviewing you are willing to do.

Step 5 — Merge each group, keeping the most complete record

A duplicate rarely arrives alone. If row 4 matches row 90 and row 90 matches row 300, all three are the same entity even though row 4 and row 300 might score below your threshold against each other. So group first, then pick a survivor per group.

The grouping uses a small union-find helper: every row starts in its own group, and each accepted pair welds two groups together. Once the groups exist, keep the row with the fewest empty cells, since that is the version carrying the most information.

import pandas as pd

MERGE_THRESHOLD = 0.93        # the value you settled on in Step 4

df = pd.read_csv("companies_stage1.csv")
pairs = pd.read_csv("flagged_pairs.csv")

parent = list(range(len(df)))

def find(i: int) -> int:
    while parent[i] != i:
        parent[i] = parent[parent[i]]   # flatten the chain as we walk it
        i = parent[i]
    return i

def union(a: int, b: int) -> None:
    root_a, root_b = find(a), find(b)
    if root_a != root_b:
        parent[root_b] = root_a

for row in pairs.itertuples():
    if row.similarity >= MERGE_THRESHOLD:
        union(int(row.left), int(row.right))

df["filled_fields"] = df.notna().sum(axis=1)
df["group"] = [find(i) for i in range(len(df))]

survivors = (
    df.sort_values("filled_fields", ascending=False)
      .drop_duplicates(subset="group", keep="first")
      .sort_index()
      .drop(columns=["filled_fields", "group", "name_key"])
)

survivors.to_csv("companies_deduped.csv", index=False)
print(f"merged {len(df) - len(survivors)} near-duplicates, {len(survivors)} rows kept")

Keep companies_stage1.csv and flagged_pairs.csv after the run. If someone later asks why two rows became one, those two files plus the threshold in your script are the complete answer.

The cost of comparing everything, and how to shrink it

Comparing every row against every other row means the number of comparisons grows with the square of the row count. Ten thousand rows is about fifty million pairs, which numpy handles comfortably. Twenty thousand rows produces a similarity matrix of 400 million values, and at four bytes each that is roughly 1.6 GB of memory for a single array — enough to stop a laptop.

The fix is blocking: only compare rows that already share something cheap and reliable. If two companies are duplicates, their normalised names almost certainly start with the same letter, so use that letter as a block key and compare inside each block only. A country, postcode or first-word key works just as well, and picking a key with more distinct values makes each block smaller.

import numpy as np
import pandas as pd

df = pd.read_csv("companies_stage1.csv")
matrix = np.load("vectors.npy")
unit = matrix / np.linalg.norm(matrix, axis=1, keepdims=True)

df["block"] = df["name_key"].str[0].fillna("?")     # first letter of the clean name

found = []
for _, group in df.groupby("block"):
    idx = group.index.to_numpy()
    if len(idx) < 2:
        continue
    sub = np.triu(unit[idx] @ unit[idx].T, k=1)
    a, b = np.where(sub >= 0.85)
    found.append(pd.DataFrame({
        "left": idx[a], "right": idx[b], "similarity": sub[a, b],
    }))

pairs = pd.concat(found, ignore_index=True)
print(f"{len(pairs)} candidate pairs across {df['block'].nunique()} blocks")

Blocking trades a little recall for a lot of memory: a pair whose names start with different letters will never be compared, so Acme Ltd and The Acme Company slip through. Strip leading articles like "the" during normalisation and that particular gap mostly closes. If your records are long documents rather than short names, split them before embedding — Split Long Text into Chunks for AI APIs covers how.

Similarity bands and what to do with each

Treat these as a starting frame, then shift the boundaries using what you saw in Step 4.

SimilarityWhat it usually meansAction
0.97 and upSame record, different formattingMerge automatically
0.93 to 0.97Almost always the same entityMerge, spot-check a sample
0.88 to 0.93Genuinely ambiguousSend to a human queue
Below 0.88Same industry, different entityLeave both rows alone

Troubleshooting

  • openai.BadRequestError: '$.input' is invalid — one string in your batch is empty, which the endpoint rejects. Cause: blank cells survived normalisation. Fix: drop them with df = df[df["name_key"].str.len() > 0] before you build the batches.
  • openai.RateLimitError: Rate limit reached for requests — you sent batches faster than your account tier allows. Cause: a tight loop with no pause. Fix: add time.sleep(1) between batches, or follow Fix the 429 Rate-Limit Error in Python for proper backoff.
  • numpy._core._exceptions._ArrayMemoryError: Unable to allocate 1.49 GiB — the all-pairs matrix does not fit in RAM. Cause: too many rows for one square matrix. Fix: use the blocking loop above so you only ever build small matrices.
  • openai.AuthenticationError: Incorrect API key provided — the key never reached the client. Cause: load_dotenv() missing, or the .env file sitting in a different folder than the script. Fix: check the diagnosis in Fix the 401 Unauthorized Error in OpenAI Python.
  • ValueError: cannot reindex on an axis with duplicate labels — you skipped reset_index(drop=True) in Step 1, so positions no longer line up with labels. Fix: rerun Step 1 as written. If the traceback is unfamiliar, Read a Python Traceback in Five Minutes shows how to find the failing line.

When to use this vs. alternatives

  • Versus fuzzy string matching (rapidfuzz and friends): character-based matching is free, instant and better at typos, but it cannot know that "Ltd" and "Limited" are the same word. Run it alongside embeddings rather than instead of them — the two catch different mistakes.
  • Versus asking a model to judge each pair: sending both records to gpt-4o-mini and asking "are these the same company?" gives the most accurate verdict, but it is one paid call per pair. Use it only on the middle band, where the number of pairs is small enough to justify the spend. Estimate OpenAI API Costs with Python helps you size that bill before you commit.
  • Versus a dedicated record-linkage library: if you are matching on several structured columns at once — name plus address plus phone — a purpose-built matching library will beat a single embedding of one field. Embeddings shine when the deciding signal is one blob of free text.

Once your file is clean, everything downstream gets cheaper and more accurate, because you stop paying to process the same record three times and stop reporting one customer as three. If you are pushing the result into a system of record, Sync HubSpot Contacts with Python picks up where this script leaves off, and Count Tokens in Python Before You Send lets you price a run before you start it. Back to Data Cleaning for AI.

Frequently asked questions

What is an embedding, in plain terms?

An embedding is a long list of numbers that a model produces to represent the meaning of a piece of text. Two texts that mean roughly the same thing get lists of numbers that point in nearly the same direction, which is why you can measure how alike they are with arithmetic instead of character-by-character comparison.

Why not just use fuzzy string matching instead of embeddings?

Fuzzy matching compares letters, so it is excellent at typos like "Acme" versus "Acmee" and free to run. It cannot tell that "Ltd" and "Limited" mean the same thing, or that "Northside Dental Care" and "Northside Dentistry" are one business. Embeddings compare meaning, so they catch that second family of duplicates.

What similarity threshold should I use for deduplication?

There is no universal number, because it depends on how varied your text is. Start by flagging everything above 0.85, read fifty flagged pairs sorted by score, and find the point where real duplicates stop and coincidental matches begin. For short company or product names that line usually sits somewhere between 0.90 and 0.95.

How much does it cost to embed a spreadsheet of records?

Embedding models are billed per token, and short records such as names or addresses are only a handful of tokens each, so a few thousand rows is a very small charge. Check the current pricing page for the exact rate, and always drop exact duplicates first so you never pay to embed the same string twice.

Can I deduplicate a hundred thousand rows this way?

Not with a single all-pairs similarity matrix, because the memory needed grows with the square of the row count. Split the file into blocks that share a cheap key, such as the first letter of the name or the country column, and compare rows only within each block. That turns one enormous matrix into many small ones.