Как я склеиваю 23 тысячи событий из пяти афиш — и почему дедуп нельзя делать необратимым

от автора

Зашёл тут на карту и вижу странную картину. На Чистых прудах висят три пина ровно друг на друге. Тыкаю, а там один и тот же «Вишнёвый сад» в Ленкоме. Совпадает всё, вплоть до времени и зала. Просто данные прилетели из трёх разных мест. Где-то площадка записана просто как Ленком, где-то полностью с именем Марка Захарова, а в третьем случае вообще пусто. Для пользователя это три разных события на карте, хотя спектакль на самом деле один.

У меня сейчас Окрест тянет афиши по шестнадцати городам из Яндекс Афиши, Afisha.ru, Timepad, KudaGo и телеграм-каналов самих площадок. Сейчас в базе 23 097 активных событий, и пересечений между источниками много. 8260 событий приходят из двух источников, 533 из трёх, десять встречаются сразу в четырёх. На карте всё это должно превращаться в одну точку, а не в гирлянду пинов.


Вот как выглядит путь события от источника до карты:

Почему одного названия недостаточно

Казалось бы, возьми да сравни названия. Но точное сравнение ломается сразу. Одна афиша пишет Polnalyubvi латиницей, другая — «Полналюбви» кириллицей. Для кода это совершенно разные строки, совпадений ноль.

Если включить нечёткое сравнение, начинается обратная беда. Какой-нибудь «Квиз, плиз» идёт пятничным вечером в двадцати барах. Названия идентичные, но это двадцать разных игр. Склеишь их по имени, и девятнадцать штук просто испарятся с карты.

Поэтому название нельзя рассматривать отдельно от места и времени. Сначала отбираются кандидаты: та же площадка и тот же календарный день. День я считаю в одном фиксированном поясе — по Москве, — и это не про «день Москвы», а про общую систему отсчёта: date_start у меня хранится абсолютным моментом, и два источника про одно новосибирское событие переведутся в один и тот же московский день независимо от местного времени. Важно, чтобы пояс был один для обоих, а какой именно — уже неважно. SQL при этом берёт с запасом плюс-минус сутки, чтобы не потерять событие на границе суток, а решающее сравнение уже в питоне требует точного совпадения дня. И только внутри этого окна я смотрю на текст.

Нормализация: один ключ на все алфавиты

Сначала оба названия транслитерируются: кириллица переводится в латиницу, регистр опускается. Из этой транслитерации я строю два представления — они пригодятся дальше по-разному. Первое, компактный ключ, оставляет только [0-9a-z] и склеивает всё встык, без пробелов и пунктуации. Второе сохраняет границы слов. Таблица транслитерации общая:

_CYR2LAT = str.maketrans({    "а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "е": "e", "ё": "e",    "ж": "zh", "з": "z", "и": "i", "й": "y", "к": "k", "л": "l", "м": "m",    # ...    "щ": "shch", "ъ": "", "ы": "y", "ь": "", "э": "e", "ю": "yu", "я": "ya",})

В компактном ключе пунктуация и пробелы отваливаются сами, «ё» и «е» схлопываются, мягкий и твёрдый знаки выбрасываются. Побочный эффект — «Полна любви» и «Полналюбви» дают один и тот же ключ. На нём же держится точное сравнение: если ключи двух названий совпали, это сильнейший сигнал, что событие одно.

Транслитерация тут заведомо с потерями, и об этом стоит сказать честно: «э» тоже даёт e, поэтому «Мэр» и «Мер» на уровне ключа совпадают. А фильтр [0-9a-z] режет не только пунктуацию, но и не-ASCII латиницу — Ömankö Day превращается в mankday. Пока это не выстрелило, но это мина.

Чего компактный ключ не делает — не нормализует порядок слов. «Иван Петров» и «Петров Иван» дают разные ключи. Порядок разбирается дальше, на втором представлении, где пробелы сохранены.

Три уровня строгости

Поначалу думал обойтись одним порогом похожести, скажем, 90. Не вышло. Ставишь высоко — лезут дубли. Опускаешь ниже — разные мероприятия схлопываются в одно. А ложная склейка это самое паршивое. Дубль человек хотя бы увидит, а ошибочно склеенное событие просто пропадает из афиши без следа. Я исхожу из того, что лучше лишний пин на карте, чем кто-то пропустит свой концерт.

Поэтому решение принимает не число, а функция с уровнями строгости:

def same_event(    a: str,    b: str,    level: str = "auto",    strict_numbers: bool = True,) -> bool:    ...

Совпал компактный ключ — склеиваю. Да, ключ с потерями, и «Мэр» с «Мер» на нём неразличимы — но к этому моменту пара уже прошла отбор по площадке и дню, так что для ложной склейки два разных события должны совпасть ещё и залом, и датой. Не невозможно, но настолько редко, что я это принял. «Без разговоров» — это не про то, что ключ безопасен сам по себе, а про то, что связка «ключ + площадка + день» достаточно надёжна.

Замечу сразу про сам контракт: функция возвращает bool, но True на уровне auto означает «сливай», а на fuzzy — «это только кандидат». Один тип на два разных смысла — так себе решение, по-хорошему тут просился бы enum. Не переписал; просто честно, что это шов.

Дальше идёт разбор подмножеств, и вот тут важная деталь, которая обычно теряется в пересказах. Формально и «Света» ⊂ «Света. Большой сольный концерт», и «Женитьба» ⊂ «Женитьба Фигаро» — одна строка входит в другую. Различает их зашитый список служебных слов:

extra_is_filler = all(    t in _FILLER or len(t) <= 2 or _YEAR_RE.fullmatch(t) for t in extra)if level == "auto":    if extra_is_filler:        return Trueelse:  # fuzzy: любое отличительное лишнее слово — кандидат на разбор    return True

_FILLER — 51 слово: форматы подачи («концерт», «шоу», «вечер», «программа», «премьера», show, live), дескрипторы («большой», «сольный», «юбилейный», «новогодний», «гала») и предлоги. Плюс прощаются токены короче трёх букв и год. «Большой сольный концерт» — три филлера подряд, поэтому «Света» сливается автоматически. «Фигаро» ни в каком списке нет, поэтому «Женитьба» на уровне auto не проходит.

Отдельно лежит _ACTIVITY — жанры: «спектакль», «выставка», «экскурсия», «фестиваль». Это не филлеры, и вдобавок они не могут быть якорем подмножества: «Спектакль» и «Спектакль для детей» не склеятся, потому что общее слово у них слишком общее.

Если подмножество не сработало, считается похожесть — token_sort_ratio, с порогами 92 для автоматического объединения и 85 для спорных кандидатов. И вот здесь работает как раз второе представление, со словами, а не компактный ключ: token_sort_ratio разбивает строку на токены, сортирует их и сравнивает — на слитном mezhdunarodnyyfestival сортировать было бы нечего. Поэтому для точного сравнения у меня ключ без пробелов, а для fuzzy — отдельная транслит-строка, где границы слов сохранены. Важно было взять именно эту метрику. Есть ещё token_set_ratio, но она почти не штрафует строку за лишние токены, если короткая полностью входит в длинную. Для неё «Концерт» и «Концерт Баха» — почти одно и то же. Sort-ratio учитывает длину и ведёт себя осторожнее.

Тут же стоит признать, что порог 92 — это дыра в стройной картине со списком филлеров. Новое смысловое слово уводит пару в спорные только пока оно заметно меняет строку. «Международный фестиваль современного искусства» и «Международный фестиваль современного искусства Арт» дают 95.8 и сливаются автоматически, хотя «Арт» ни в каком списке нет.

Ещё одна защита — числа. Берём «Лето 2025» и «Лето 2026». Строки почти близнецы, но события разные:

ya, yb = _years(a), _years(b)if ya and yb and ya != yb:    return False

Если год указан в обеих строках и он разный, склейка блокируется. То же самое с «Часть 1» и «Часть 2». При этом если год есть только в одном названии, шанс на объединение остаётся — источники часто пишут по-разному.

Что получается на реальных парах:

Название A

Название B

Ключ

auto

fuzzy

Polnalyubvi

Полналюбви

совпал

склеить

склеить

Полна любви

Полналюбви

совпал

склеить

склеить

Света

Света. Большой сольный концерт

склеить

склеить

Женитьба

Женитьба Фигаро

нет

кандидат

Иван Петров

Петров Иван

склеить

склеить

Концерт

Концерт Баха

нет

нет

Спектакль

Спектакль для детей

нет

нет

Лето 2025

Лето 2026

нет

нет

Почему точное время позволяет ослабить правила

Одна оговорка, без которой таблица врёт. Есть отдельная функция same_slot_title, которая срабатывает, когда два события делят площадку (тот же venue_id) и точный момент начала — не день, а посекундное совпадение timestamp. Там достаточно, чтобы одно название было подмножеством другого по токенам, без фильтра филлеров. Логика: одна сцена не играет два спектакля в одну и ту же секунду, поэтому сам слот и есть якорь. В этих условиях «Женитьба» и «Женитьба Фигаро» как раз сливаются автоматически. То есть точное время не сужает окно, а наоборот — покупает право сравнивать названия мягче.

Тут честно стоит назвать допущение по имени. venue_id — это не всегда один зал: у мультиплекса под одним id несколько экранов, и в 19:00 там реально идут «Дюна» и «Дюна 2». Поэтому номера-сиквелы защищены отдельно и не сливаются даже в один слот. Но если два разных события совпадут и площадкой, и секундой старта, и при этом одно название окажется подмножеством другого — они склеятся ошибочно. Требование подмножества делает это редким (произвольные разные названия так не сойдутся), но дыра тут есть, и держится всё на том, что в один зал два показа в одну секунду не ставят.

Почему сначала пришлось склеивать площадки

Дальше выяснилось, что пока не наведёшь порядок в площадках, события нормально не склеишь. Мумий Тролль Music Bar в разных источниках записан по-разному. Для базы это три отдельные площадки, поэтому связанные с ними события даже не попадают в одно окно сравнения.

Площадки клеить проще, там смотрю на название, адрес, город и расстояние. Но и тут есть засада. Большой и Малый залы консерватории. Одно здание, похожие названия, координаты рядом. Склеишь их в одно, и расписания залов превратятся в кашу. Пришлось собрать словарь антонимов: большой-малый, новый-старый, верхний-нижний. Если в одном названии есть «Большой зал», а в другом «Малый», объединение сразу блокируется, каким бы высоким ни был общий score.

Где правила не справились и понадобилась LLM

Некоторые пары уже неудобно покрывать отдельными эвристиками. Например, «Концерт ансамбля имени В. С. Локтева» и «Ансамбль им. Локтева» — склонения, инициалы, слова-обёртки.

Такие пары отправляю в LLM. Чтобы не разориться на запросах, модель зовётся в двух режимах с разными якорями. Первый, строгий: та же площадка, совпадение момента начала до точного значения timestamp, и общее слово длиной от четырёх букв после транслитерации — тут решение принимается от уверенности 0.7. Второй, послабее: та же площадка и тот же день, но без совпадения времени, — за более слабый якорь плачу порогом 0.95. Оба гейта отсекают почти всё: до модели доходят единицы пар в час.

В промпт при этом уходят только два названия, больше ничего:

f"A: {title_a}\nB: {title_b}"

Ни площадки, ни времени, ни категории модель не видит — они работают как условие вызова, а не как часть вопроса. Модели остаётся ровно та задача, где она сильна: два человекочитаемых заголовка и вопрос «это один и тот же показ?». Отвечает она не свободным текстом, а JSON:

{"same": true, "confidence": 0.94}

Confidence — рабочая ручка, а не украшение: это не калиброванная вероятность, а скор, который вернула модель, и пороги 0.7 и 0.95 из абзаца выше подобраны на глаз. Любая ошибка сети или парсинга трактуется как «не совпало»: на плохом вызове объединения не происходит никогда.

Вердикты лежат в Redis тридцать дней, ключ — хеш от пары названий, отсортированной, так что порядок аргументов не важен, и (A, B) с (B, A) — один и тот же кэш-хит. Отрицательные ответы кэшируются наравне с положительными, поэтому в устоявшемся режиме проход почти ничего не стоит. Тут есть свой шов: в ключ входят только названия, а версия модели и промпта — нет. Меняю промпт — старые вердикты живут в кэше ещё тридцать дней. Пока обхожусь ручным префиксом версии в ключе, но это заплатка.

Как одна миграция сломала загрузку

Из-за этой всей борьбы за уникальность я недавно умудрился сломать загрузку. Заметил старый баг, когда пермские театры почему-то показывались в Москве. Оказалось, уникальность площадки проверялась по имени и адресу, без учёта города. Пермский планетарий радостно клеился к московскому с тем же названием улицы. Я пошёл и расширил ключ, добавил город в проверку уникальности. Всё встало на свои места.

А через день смотрю, обновления по части событий встали колом. В логах появляется конкретная ошибка:

there is no unique or exclusion constraintmatching the ON CONFLICT specification

В другом куске кода висел вот такой инсерт:

insert into events.venues (name, address, city, ...)values (:n, :a, :c, ...)on conflict (name, address) do nothing

PostgreSQL требует, чтобы для колонок из ON CONFLICT существовало подходящее уникальное или exclusion-ограничение. Я снёс старую уникальность по (name, address), и запрос стал падать целиком. Упал как раз тот флоу, который переносил события с площадки-заглушки Unknown venue на найденную реальную площадку. Получилось, что починка городов сломала починку залов.

Лечится дописыванием города:

on conflict (name, address, city) do nothing

Две строки в двух файлах. Но сам факт. Меняешь ограничение в базе, и надо идти искать все его отражения по проекту, даже в тех скриптах, про которые ты давно забыл.

Как я ищу ложные объединения

Ещё фоном крутится проверка на лишние склейки. В моей модели одно событие не должно одновременно оказаться на двух несвязанных площадках. Первый фильтр грубый:

select event_idfrom events.event_occurrenceswhere venue_id is not nullgroup by event_idhaving count(distinct venue_id) > 1;

Дальше площадки события группируются в «физические места»: две попадают в одну группу, если они ближе двухсот метров по гаверсинусу или если их названия матчатся фаззи-матчером. Тот же гард на антонимы работает и здесь — «Большой зал» и «Малый зал» не объединятся никогда, даже если пины стоят в двадцати метрах друг от друга. Иначе два разных зала одного здания слиплись бы в одно место, и разбиение не сработало бы.

Отдельного исключения для фестивалей и гастрольных серий тут нет, и это осознанно. Логика прямо обратная: событие, висящее на нескольких физических местах, считается артефактом слияния и разбирается на несколько событий, по одному на место. Гастрольный спектакль на четырёх сценах становится четырьмя событиями, а не одним с четырьмя пинами. Это закреплено как инвариант здоровья: число событий, охватывающих больше одного физического места, должно быть равно нулю.

Раз в пятнадцать минут скрипт находит такие аномалии и разбирает их обратно.

Проблема, которую я пока не решил

Сейчас из 23 тысяч событий почти все дубли разбираются сами. Модель дёргается пару раз в час на сложных случаях.

Напрягает меня другое. У меня нет очереди на ручную проверку — вообще. Спорная пара либо уходит в LLM, либо просто не сливается, и нигде не оседает. Человека в цикле нет ни на одном шаге.

И это было бы полбеды, если бы слияние можно было откатить. Но оно необратимо: дубль перецеливается на каноническое событие и удаляется. Отменить это нечем — исходных строк уже нет. То есть единственная защита от неверного объединения — та самая фоновая проверка, которая ловит только один класс ошибок: когда событие расползлось по двум местам. Если алгоритм склеил два разных спектакля в одном и том же зале, ничто этого не заметит, и второй просто исчезнет.

Меня это устраивало ровно до того момента, пока я не сел это описывать.

Во что вся эта склейка собирается на карте — можно посмотреть в боте: Окрест. Весь разбор выше — про его кишки.

Интересно, где вы проводите границу между лишним дублем и потерянным событием. Поднимаете порог, храните исходные строки ради возможности откатить, обучаете отдельную модель — или тоже миритесь с тем, что часть ошибок необратима, а инвариант ловит только те, что видно на карте?

ссылка на оригинал статьи https://habr.com/ru/articles/1061956/