[nevr]
· 7 мин чтения

Semantic entity resolution без LLM: когда cosine > 0.92 достаточно

Entity resolution pipeline: exact → semantic → LLM

В любом графе знаний, построенном из неструктурированного текста, возникает одна и та же проблема: один и тот же объект реального мира попадает в граф под разными именами. “Customer churn” и “user attrition”. “Маржа” и “gross margin”. “Иванов И.И.” и “Иван Иванов”.

Это проблема entity resolution — определения, что два текстовых упоминания указывают на одну сущность.

Решение в лоб — отправить каждую пару на проверку в LLM. Работает идеально. Стоит неприлично дорого. При 1000 нод в графе это 499 500 пар. Даже на дешёвой модели — десятки долларов за один проход дедупликации.

Я нашёл архитектуру, которая решает 95% случаев без единого LLM-вызова. Оставшиеся 5% — да, LLM, но точечно и дёшево.

Почему Levenshtein не работает

Первая интуиция — использовать edit distance. Levenshtein считает минимальное количество вставок, удалений и замен символов для превращения одной строки в другую.

levenshtein("customer churn", "customer chrn") = 1  // опечатка — ок
levenshtein("customer churn", "user attrition") = 12  // синоним — провал

Levenshtein отлично ловит опечатки и морфологические вариации. Но синонимы, парафразы, переводы — для него это совершенно разные строки. “Боль клиента” и “customer pain point” — edit distance максимальный, хотя это одно и то же.

В реальных данных из интервью и исследований именно синонимы составляют основную массу дубликатов. Пользователь в чате пишет “отток”, исследование говорит “churn rate”, статья использует “customer attrition”. Три ноды в графе, одна сущность в реальности.

Embedding + cosine similarity: золотая середина

Эмбеддинги превращают текст в вектор в многомерном пространстве, где семантически близкие тексты оказываются рядом. “Customer churn” и “user attrition” получают похожие векторы, потому что встречаются в одних и тех же контекстах.

Cosine similarity измеряет угол между двумя векторами:

from numpy import dot
from numpy.linalg import norm

def cosine_sim(a, b):
    return dot(a, b) / (norm(a) * norm(b))

# "customer churn" vs "user attrition"
cosine_sim(embed("customer churn"), embed("user attrition"))
# → 0.94

# "customer churn" vs "pricing strategy"
cosine_sim(embed("customer churn"), embed("pricing strategy"))
# → 0.31

Это именно то, что нужно: семантическая близость, а не символьная.

Два порога: 0.92 и 0.80

Эмпирически я пришёл к двум порогам, и это ключевое архитектурное решение:

  • cosine ≥ 0.92 — автоматический merge без подтверждения
  • 0.80 ≤ cosine ﹤ 0.92 — серая зона, отправляем в LLM для финального решения
  • cosine < 0.80 — разные сущности, не проверяем

Почему именно эти числа? Я прогнал 2000 пар сущностей из реальных проектов через ручную разметку и построил precision/recall кривую:

ПорогPrecisionRecallF1
0.950.990.710.83
0.920.970.890.93
0.900.930.920.92
0.850.820.950.88
0.800.680.970.80

При 0.92 precision = 0.97, то есть из 100 автоматических мёржей ошибочны только 3. При этом recall уже 0.89 — система ловит почти 9 из 10 реальных дубликатов. Ниже 0.92 precision падает быстро — слишком много false positives. Выше — теряем настоящие дубли.

Порог 0.80 снизу отсекает “точно не дубликаты” и экономит LLM-вызовы на бессмысленных парах.

Трёхуровневый resolver

Полная архитектура выглядит так:

class EntityResolver
  EXACT_THRESHOLD = 1.0
  SEMANTIC_AUTO_MERGE = 0.92
  SEMANTIC_LLM_CHECK = 0.80

  def resolve(candidate, existing_nodes)
    # Level 1: exact match (canonical key)
    exact = existing_nodes.find { |n| n.canonical_key == candidate.canonical_key }
    return merge(candidate, exact) if exact

    # Level 2: semantic similarity
    similarities = existing_nodes.map { |n|
      [n, cosine_similarity(candidate.embedding, n.embedding)]
    }.sort_by(&:last).reverse

    top_match, score = similarities.first
    return merge(candidate, top_match) if score >= SEMANTIC_AUTO_MERGE

    # Level 3: LLM confirmation (only for grey zone)
    if score >= SEMANTIC_LLM_CHECK
      return merge(candidate, top_match) if llm_confirms_match?(candidate, top_match)
    end

    # No match — create new node
    create_node(candidate)
  end
end

Каждый уровень — фильтр, который отсеивает часть работы для следующего:

  1. Exact match по canonical key (lowercase, strip, transliterate). Бесплатно. Ловит “Customer Churn” = “customer churn” = “customer_churn”. В моих данных это ~40% всех дубликатов.

  2. Cosine similarity по эмбеддингам. Стоимость — один forward pass через embedding model (я использую hash-based в dev, API в prod). Ловит “customer churn” = “user attrition” = “отток клиентов”. Ещё ~45% дубликатов.

  3. LLM только для серой зоны (0.80-0.92). Это ~15% пар, из которых примерно половина — реальные дубликаты. LLM получает минимальный контекст:

Are these two entities the same real-world concept?
A: "market saturation risk"
B: "competitive density threat"
Answer: YES or NO, one word.

Один токен на выход. Groq отвечает за 50ms. Стоимость — практически нулевая.

Fuzzy match как бонусный слой

Между exact и semantic я добавил Levenshtein с порогом расстояния ≤ 2 (для строк длиннее 5 символов). Это ловит опечатки, которые эмбеддинг-модель может не заметить:

# "analitics" vs "analytics" — Levenshtein = 1, merge
# "CRM система" vs "CRM-система" — Levenshtein = 1, merge

Дёшево и полезно. Добавляет ещё ~5% к recall первого уровня.

Реальные примеры из продакшена

Вот пары, которые resolver обработал за последнюю неделю:

Сущность AСущность BУровеньScore
customer churnuser attritionsemantic0.94
unit economicsюнит-экономикаsemantic0.93
MVP launchзапуск MVPLLM0.87
B2B SaaSB2B SaaS platformexact1.0
retention rateкоэффициент удержанияsemantic0.92
pain pointболь клиентаLLM0.84
competitive analysisконкурентный анализsemantic0.95

Обратите внимание на кросс-языковые пары. Мультиязычные эмбеддинг-модели (я использую bge-m3 от BAAI) отлично справляются с “unit economics” = “юнит-экономика”, потому что эти термины встречаются в одних и тех же контекстах в обучающих данных.

Экономика: 80% экономии на LLM

Допустим, в проекте 500 нод. Каждая новая сущность проверяется против всех существующих.

Без resolver (всё в LLM): 500 LLM-вызовов на каждую новую ноду. При 20 новых нодах в день = 10 000 вызовов.

С трёхуровневым resolver:

Уровень% парLLM-вызовов
Exact match40%0
Fuzzy (Levenshtein)5%0
Semantic auto-merge (более 0.92)35%0
Semantic → LLM (0.80-0.92)15%1 500
Below threshold (менее 0.80)5%0
Итого100%1 500

1 500 вместо 10 000 — экономия 85%. И это консервативная оценка. В проектах с устоявшейся терминологией exact match ловит до 60%, и экономия доходит до 92%.

Подводные камни

Несколько вещей, которые я узнал на практике:

  • Embedding drift: одна и та же модель может давать слегка разные эмбеддинги после обновления. Я храню версию модели вместе с эмбеддингом и перегенерирую при смене.

  • Короткие строки ненадёжны: cosine similarity для “CRM” и “ERP” может быть 0.88. Для строк короче 4 символов я снижаю порог автомёржа до 0.96.

  • Контекст важен: “Apple” (компания) и “apple” (фрукт) имеют похожие эмбеддинги без контекста. Я добавляю node_type в текст для эмбеддинга: “company: Apple” vs “product: apple”.

  • Batch processing: проверка каждой ноды против всех 500 — это O(n). Я использую approximate nearest neighbor (ANN) индекс для ускорения поиска кандидатов перед точным cosine расчётом.

Итого

Entity resolution — это не binary choice между “дешево и плохо” (Levenshtein) и “дорого и хорошо” (LLM). Трёхуровневая архитектура exact → semantic → LLM даёт качество на уровне чистого LLM-подхода при 80-90% экономии.

Порог 0.92 — не магическое число, а результат эмпирической калибровки на реальных данных. Для вашего домена он может быть другим. Но принцип остаётся: большинство дубликатов достаточно очевидны для косинусного расстояния, и только по-настоящему сложные случаи стоит отправлять в LLM.

Лучшая LLM-архитектура — та, где LLM вызывается только когда без него нельзя.