Список поставщиков на 10 000+ строк, где один и тот же сайт лежит под четырьмя разными написаниями, чистится не порогом похожести, а двумя скучными детерминированными стадиями. Нормализация хоста и сведение к registered domain через Public Suffix List сняли 76% дубликатов без единого similarity score. Дальше в дело идёт скоринг: fuzzy matcher оценил 33,7 миллиона пар за две секунды, и всё это работало на Python 3.12 с RapidFuzz 3.14.5 и tldextract 5.3.2 на одном vCPU облачной песочницы (разбор пайплайна и исходные цифры).
Главный вывод непривычен для тех, кто привык крутить порог fuzzy-матчинга: безопасного порога на коротких строках вроде доменов не существует. Диапазон score 88-97 содержит и настоящие дубликаты, и разные компании с похожими именами. Автоматическое слияние на любом значении внутри этого диапазона удалит часть живых поставщиков вместе с историей закупок.
Что делать вместо поиска магического порога: превратить similarity score в ранжированную очередь на ручную проверку, размер которой подбирается под доступные часы, а все слияния делаются обратимыми. Ниже пайплайн целиком: нормализация, registered domain, process.cdist, блокировка, ревью, аудит и замер качества.
Почему similarity score не спасает от дубликатов доменов
Метрика похожести сравнивает строки, а не организации. Хост из 15-20 символов несёт слишком мало информации, чтобы отличить опечатку от реально другого юрлица: обе ситуации дают score в диапазоне 88-97, и никакое значение не режет этот диапазон по смыслу.
Формальная теория тут не нова. Entity resolution получила статистическое описание в работе Fellegi и Sunter 1969 года: поля сравниваются взвешенно, а вес признака зависит от его различающей силы. Короткий домен несёт мало различающей информации, поэтому любые расстояния на нём шумят. Проблема не в библиотеке и не в выборе метрики, а в количестве информации внутри строки.
Пример: solartravelmag.com в четырёх написаниях
Один и тот же сайт приезжает в список так:
https://www.solartravelmag.com/blog?utm_source=newslettersolartravelmag.comblog.solartravelmag.comSolarTravelMag.COM
Четыре строки, один поставщик. Каждая пара внутри этого квартета даёт score около 90-100, и здесь высокий балл честно отражает реальность. Проблема в том, что ровно такие же баллы получают пары вроде solartravelmag.com и solartravelmag.de или solartravelmag.com и solartravelmag.org, за которыми стоят другие компании. Метрика не подсказывает, какой из двух случаев перед вами, а порог, который отделит один от другого, придётся выдумать.
Разбор того, почему Damerau-Levenshtein, Jaro-Winkler и q-gram Jaccard одинаково плохо работают на коротких идентификаторах, есть в отдельном материале про fuzzy matching в даталейке.
Ловушки: домены, отличающиеся на одну правку
Самый неприятный класс записей - домены, которые отличаются от существующего бренда на одну правку и принадлежат другому поставщику. В том же синтетическом наборе таких ловушек было 180, а опечаток при вводе на одну клавишу - 505: одинаковый класс ошибок с противоположным правильным решением.
Живой пример: реальный бренд quantumleap.io и домен quantumleep.io другой компании. Разница в одну букву, score выше 90, а слияние удалит отдельного поставщика со всей его историей. Ловушка и опечатка неразличимы на уровне строки: и там, и там одна правка. Различает их только внешний контекст, которого в файле нет. Это и делает авто-мерж опасным при любой настройке порога.
Детерминированная нормализация: первый этап, который убирает 76% дубликатов
Первый этап в разобранном пайплайне намеренно примитивен: lowercase, отсечение схемы, обрезка пути и строки запроса, удаление ведущего www. Никаких моделей, словарей и эвристик, которые надо тюнить. Работает бесплатно, объясняется за минуту и даёт одинаковый результат на первом прогоне и на десятом.
Именно эта стадия вместе со сведением к registered domain сняла 76% дубликатов. В синтетическом наборе ей соответствовали 2 429 поверхностных вариантов (схемы, www-префиксы, случайный регистр, пути, трекинг-параметры), плюс 888 строк с поддоменами вроде blog. и m. и 529 пар страновых доменов вроде brand.com рядом с brand.de (состав набора в разборе). Поверхностные варианты закрываются нормализацией, поддомены снимаются вторым этапом, а страновые домены остаются человеку.
Как нормализовать хост на Python: код и порядок операций
Порядок операций важен, потому что каждая следующая зависит от результата предыдущей: сначала регистр (иначе WWW. и www. не совпадут), затем схема и хост, затем путь с параметрами, в конце ведущий www.
from urllib.parse import urlparse
def normalize_host(raw: str) -> str:
host = raw.strip().lower() # 1. регистр
if "://" not in host:
host = "http://" + host
parsed = urlparse(host) # 2. схема, путь, query
host = parsed.netloc or parsed.path
host = host.split("@")[-1] # убираем user:pass
host = host.split(":")[0] # убираем порт
if host.startswith("www."): # 3. ведущий www
host = host[4:]
return host.rstrip(".")
Что важно не сломать: rstrip(".") вместо удаления всех точек, иначе пострадают домены, где точка значима. Регистр приводится до разбора URL, а www снимается только ведущий: www2.example.com и example.com это разные хосты, и склеивать их молча нельзя.
Функция возвращает хост, а не домен. На выходе у вас всё ещё blog.solartravelmag.com, и это правильно: нормализация не должна угадывать, где кончается бренд и начинается публичный суффикс.
Сведение к registered domain через tldextract и Public Suffix List
Второй детерминированный этап отвечает на вопрос, где в хосте заканчивается registered domain. Библиотека tldextract (версия 5.3.2 в разобранном пайплайне) делает это по Public Suffix List: списку публичных суффиксов, который знает, что .co.uk, .com.br и .pvt.k12.ma.us это суффиксы, а не бренды.
Без такого списка наивное правило «взять две последние метки» ломается на brand.co.uk и example.com.br. С ним blog.solartravelmag.com, m.solartravelmag.com и solartravelmag.com сходятся в одну строку, и 888 строк с поддоменами из набора перестают существовать как отдельные записи.
Пример кода: tldextract для registered domain
import tldextract
# suffix_list_urls=() отключает загрузку свежего PSL из сети,
# используется встроенный снимок списка
extract = tldextract.TLDExtract(suffix_list_urls=())
extract("blog.solartravelmag.com").registered_domain # 'solartravelmag.com'
extract("brand.co.uk").registered_domain # 'brand.co.uk'
extract("brand.de").registered_domain # 'brand.de'
Страновые домены остаются снаружи: brand.com и brand.de дадут два разных registered domain, хотя поставщик может быть один и тот же. Сводить их автоматически нельзя, потому что обратная ситуация встречается не реже: один бренд в разных странах это разные юрлица, договоры и условия оплаты. В наборе таких пар было 529, и каждая требует решения человека. Рабочее правило: держать их отдельным списком кандидатов и не сливать без подтверждения.
RapidFuzz process.cdist: скоринг оставшихся кандидатов
После двух детерминированных этапов остаётся то, что правилами не решается: опечатки, сокращения, транслитерации, перестановки слов. Здесь подключается RapidFuzz и его process.cdist, который считает матрицу оценок между двумя списками строк в один вызов.
from rapidfuzz import process, fuzz
hosts = [...] # нормализованные registered domain'ы
scores = process.cdist(
hosts, hosts,
scorer=fuzz.token_set_ratio,
score_cutoff=88,
workers=-1,
)
33,7 миллиона пар за две секунды на одном vCPU означают, что скоринг перестаёт быть узким местом. Узким местом становится человек на другом конце очереди: сколько пар он способен осмысленно разобрать за час работы.
Метрику выбирают под природу строки. Для доменов осмысленны token-based варианты вроде token_set_ratio: они устойчивее к перестановкам и лишним токенам, чем посимвольные расстояния. Настраивать метрику имеет смысл, а искать в её значениях безопасный порог нет: ловушки из набора дают такой же высокий score, как настоящие дубликаты.
Блокировка по префиксу и суффиксу: как сократить число пар
Полная матрица на нескольких десятках тысяч строк ещё укладывается в память, но квадрат растёт быстро. Блокировка режет его заранее: сравниваются только записи, у которых совпал ключ.
def blocking_keys(host: str):
label = host.split(".")[0]
return (label[:4], label[-3:]) # префикс и суффикс основного лейбла
buckets = {}
for host in hosts:
for key in blocking_keys(host):
buckets.setdefault(key, set()).add(host)
candidates = []
for group in buckets.values():
group = sorted(group)
for i, a in enumerate(group):
for b in group[i + 1:]:
candidates.append((a, b))
Сколько именно пар срезает блокировка, зависит от данных, и это надо замерять, а не оценивать на глаз. Плата за экономию - потеря пар: solartravelmag.com и xolartravelmag.com с опечаткой в первом символе попадут в разные бакеты и не будут сравнимы никогда. Поэтому recall блокировки проверяется на размеченной выборке, а не «выглядит разумно».
Ранжированная очередь на ручную проверку вместо авто-мержа
Реальный результат работы матчера - ранжированный список пар для проверки, а не готовый набор слияний. Это меняет постановку задачи: вместо «какой порог поставить» вы решаете «сколько пар в неделю готовы просмотреть».
Размер очереди считается арифметикой. Если ревьюер разбирает 100 пар в час, а на задачу есть 5 часов в неделю, очередь на этот период - 500 пар. Сортировка по score задаёт порядок, при котором самые вероятные дубликаты идут первыми; хвост очереди откладывается, а не удаляется.
| score | пара | что видно сразу | что решает человек |
|---|---|---|---|
| 95 | quantumleap.io / quantumleep.io | одна буква | ловушка или опечатка |
| 93 | brand.com / brand.de | разные страны | одно юрлицо или два |
| 91 | solar-travel-mag.com / solartravelmag.com | дефисы | проверить сайт и реквизиты |
| 89 | nordwind.io / nordwind.co | другая зона | часто разные компании |
| 88 | paintlab.io / paintlab.co | сокращение | смотреть контакты |
Ключевое свойство очереди в том, что она не выносит решений. Низкий score означает «проверить позже», а не «разные компании»: именно в хвосте могут оказаться опечатки с переставленными буквами. Верх очереди это не автоматический merge, а «смотреть первым делом».
Как сделать мержи обратимыми
Исходные строки не перезаписываются никогда. Слияние оформляется отдельной записью, и тогда откат это удаление одной строки, а не восстановление из бэкапа.
# таблица merge_log
# canonical_id | merged_id | score | reviewer | decided_at | reason | reverted_at
canonical = {row["id"]: row for row in source_rows}
for decision in merge_log:
if decision["reverted_at"] is None:
canonical.pop(decision["merged_id"], None)
В логе видно, кто и почему принял решение: при разборе ошибок это полезнее самого score. Отдельный вопрос - конфликтующие атрибуты. Если у двух записей различаются реквизиты, валюта или адрес, при слиянии придётся выбрать одно значение, и это решение тоже стоит писать в лог, а не оставлять «как получилось».
Проверка качества: recall, precision и размеченная выборка
У пайплайна две разные метрики, и путать их нельзя. Recall относится к блокировке: какую долю настоящих дубликатов она довела до сравнения. Precision относится к очереди: какую долю верхних пар ревьюер подтвердил.
Авто-мержей по построению нет, поэтому precision автоматических слияний здесь нечего считать: все слияния делает человек, а система лишь расставляет приоритеты. Зато precision очереди даёт рабочий сигнал: если из первых 100 пар подтвердились 70, ревьюер тратит треть времени на заведомый шум, и нижнюю границу очереди стоит поднять.
Как замерить recall блокировки
Берёте 200-500 пар, размеченных вручную, прогоняете через блокировку и считаете, сколько известных дубликатов осталось среди кандидатов. Арифметика простая: из 47 пар, помеченных как дубликаты, блокировка сохранила 39, значит recall = 0,83. Пропущенные 8 пар разбираются отдельно: если среди них опечатки в первом символе, ключ блокировки слишком узкий и его пора ослабить.
Все цифры точности из разобранного пайплайна получены на синтетическом наборе с готовой разметкой. Набор содержал 11 531 строку, описывающую 7 180 реальных поставщиков, и ground truth в нём есть по построению (методика и размер выборки). В реальном списке разметки нет, и её придётся создавать: 200-500 пар, размеченных вручную, дают основу для замера recall и precision. Как строить golden-выборку и считать метрики без маркетинговых процентов, разобрано на примере оценки точности детекции ПДн.
Ограничения подхода и типичные ошибки
Что пайплайн не делает:
- не снимает опечатки и транслитерации:
quantumleep.ioостанется отдельной строкой, пока человек не подтвердит слияние; - не разводит страновые домены:
brand.comиbrand.deтребуют решения о юрлице, и автоматика тут опаснее ручного просмотра; - не даёт безопасного порога: диапазон 88-97 смешивает дубликаты и разные компании, поэтому авто-мерж исключён по определению;
- не гарантирует полноту: блокировка по ключу может выбросить пару с опечаткой в начале строки, и без замера recall это останется незамеченным;
- не проверен на реальных данных: все precision-оценки сделаны на синтетическом наборе с ground truth метками, а не на живом списке поставщиков.
Про масштаб: 11 531 строка это небольшая задача, где матрица оценок и один vCPU укладываются в секунды. На миллионах строк понадобится другой класс решений, где сравниваются не все пары подряд, а кандидаты, отобранные через MinHash и LSH. Как это устроено на 1,4 ТБ кода, разобрано в материале про дедупликацию данных для LLM.
И трезвая деталь про источники данных: автор разбора приводит пример маркетплейса, который открыто заявляет, что его указанные цены соответствуют реальности примерно в 90% случаев. Шум в справочниках это норма, поэтому идеальной разметки не будет ни при каком пайплайне. Обратимые мержи и ранжированная очередь значат здесь больше, чем идеально подобранный порог.