Semantic entity resolution без LLM: когда cosine > 0.92 достаточно
В любом графе знаний, построенном из неструктурированного текста, возникает одна и та же проблема: один и тот же объект реального мира попадает в граф под разными именами. “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 кривую:
| Порог | Precision | Recall | F1 |
|---|---|---|---|
| 0.95 | 0.99 | 0.71 | 0.83 |
| 0.92 | 0.97 | 0.89 | 0.93 |
| 0.90 | 0.93 | 0.92 | 0.92 |
| 0.85 | 0.82 | 0.95 | 0.88 |
| 0.80 | 0.68 | 0.97 | 0.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
Каждый уровень — фильтр, который отсеивает часть работы для следующего:
-
Exact match по canonical key (lowercase, strip, transliterate). Бесплатно. Ловит “Customer Churn” = “customer churn” = “customer_churn”. В моих данных это ~40% всех дубликатов.
-
Cosine similarity по эмбеддингам. Стоимость — один forward pass через embedding model (я использую hash-based в dev, API в prod). Ловит “customer churn” = “user attrition” = “отток клиентов”. Ещё ~45% дубликатов.
-
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 churn | user attrition | semantic | 0.94 |
| unit economics | юнит-экономика | semantic | 0.93 |
| MVP launch | запуск MVP | LLM | 0.87 |
| B2B SaaS | B2B SaaS platform | exact | 1.0 |
| retention rate | коэффициент удержания | semantic | 0.92 |
| pain point | боль клиента | LLM | 0.84 |
| competitive analysis | конкурентный анализ | semantic | 0.95 |
Обратите внимание на кросс-языковые пары. Мультиязычные эмбеддинг-модели (я использую bge-m3 от BAAI) отлично справляются с “unit economics” = “юнит-экономика”, потому что эти термины встречаются в одних и тех же контекстах в обучающих данных.
Экономика: 80% экономии на LLM
Допустим, в проекте 500 нод. Каждая новая сущность проверяется против всех существующих.
Без resolver (всё в LLM): 500 LLM-вызовов на каждую новую ноду. При 20 новых нодах в день = 10 000 вызовов.
С трёхуровневым resolver:
| Уровень | % пар | LLM-вызовов |
|---|---|---|
| Exact match | 40% | 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 вызывается только когда без него нельзя.