Разбираем рекомендательную систему экспертов: RRF, скоринг, глобальное распределение, bootstrap. Код Python и практические выводы.
Представьте: вам нужно порекомендовать пользователю эксперта, который ответит на его вопрос. Просто выдать топ-10 по рейтингу? Тогда три самых сильных специалиста получат всю нагрузку и выгорят за месяц. В этой статье я покажу, как мы строили рекомендательную систему для экспертов: от гибридного поиска с RRF до глобального распределения с ограничениями. Вы узнаете, какие алгоритмы реально работают, и получите готовый код на Python.
Рекомендация эксперта отличается от рекомендации контента тремя ограничениями. Во-первых, эксперт — человек с конечной пропускной способностью: нельзя рекомендовать его тысячам. Во-вторых, плохая рекомендация дорого обходится обеим сторонам: пользователь теряет время, эксперт — час жизни, и оба перестают доверять системе. В-третьих, утверждение должно быть проверяемым: «вам может понравиться этот пост» не требует доказательств, а «этот эксперт на уровень выше вас в системном дизайне» — требует.
Мы запускаем три независимых поиска по базе экспертов: семантическое сходство (косинус расстояния эмбеддингов), количество решённых тем и разницу в уровне компетенций. Проблема: их результаты не сопоставимы — косинус лежит в диапазоне [-1, 1], количество тем — целое число, а разница уровней — вообще другая шкала. Нормализация потребовала бы допущений о распределениях, которых у нас нет. Поэтому мы используем Reciprocal Rank Fusion (RRF).
def rrf_fuse(*ranked_lists, k=60):
"""
Слияние ранжированных списков. Оценка зависит только от ранга,
а не от масштаба отдельного поиска. Это ключевое свойство.
"""
fused = {}
for lst in ranked_lists:
for rank, key in enumerate(lst):
fused[key] = fused.get(key, 0.0) + 1.0 / (k + rank)
return fused
RRF отбрасывает величины и оставляет только порядок — это единственная информация, которая выживает на малых выборках. Константа k=60 — стандарт из оригинальной статьи Кормака и др. Она сглаживает разницу между первым и вторым местом: 1/61 - 1/62 ≈ 0.00026, поэтому один поиск не может доминировать за счёт уверенности, только за счёт стабильного высокого ранга во всех списках.
Каждая пара «запросчик-эксперт» получает составной балл. Веса мы задали экспериментально, но они отражают важность каждого фактора:
SCORE_WEIGHTS = {
"compass_gap_fit": 0.30, # явное и направленное
"semantic_fit": 0.20, # косинус эмбеддинга профиля
"skill_overlap": 0.15, # общие технологии с учётом редкости
"capacity_fit": 0.15, # также жёсткое ограничение
"expert_quality": 0.12, # рейтинг, принятие, опыт с насыщением
"specialty_match": 0.05, # грубое, но заполненное
"fairness": 0.03, # штраф за переизбыток, логарифмический
}
Два компонента требуют пояснения.
Опыт эксперта не должен расти линейно: разница между 0 и 5 завершёнными сессиями огромна, а между 40 и 45 — шум. Поэтому мы используем экспоненциальное насыщение. Для новичков без истории — нейтральный приор 0.5, иначе система превратится в «богатые становятся богаче».
def expert_quality(*, avg_rating, completed, accepted, proposed):
if completed == 0 and proposed == 0:
return 0.5 # нейтральный приор, не ноль
rating_part = ((avg_rating or 4.0) - 1.0) / 4.0
acceptance = accepted / proposed if proposed else 0.6
experience = 1.0 - math.exp(-completed / 5.0)
return 0.5 * rating_part + 0.3 * acceptance + 0.2 * experience
Экспоненциальное насыщение кодирует идею, что после определённого порога дополнительный опыт не так важен. Линейный член сделал бы ветеранов недостижимыми для новичков.
Если рекомендовать одного эксперта слишком часто, он перегрузится. Линейный штраф сделал бы его нерекомендуемым после пары циклов, поэтому мы используем логарифмический:
def fairness_factor(times_recommended):
return 1.0 / math.log(math.e + max(0, times_recommended))
Формула ln(e + n) даёт ровно 1.0 при n=0 и медленно убывает, позволяя рекомендовать хорошего эксперта, но не бесконечно.
Инстинкт подсказывает выбрать для каждого запросчика лучшего эксперта. Но тогда все запросы уйдут трём сильнейшим, они выгорят, и система уничтожит ресурс, который должна распределять. Поэтому после скоринга мы решаем задачу назначения: распределяем пары по экспертам с учётом их ёмкости и ограничения на число запросов на пользователя.
def allocate(pairs, *, capacity, per_requester):
"""
Жадное глобальное распределение. Пары отсортированы по баллу,
каждая пара потребляет единицу ёмкости эксперта.
"""
assigned = defaultdict(int)
out = []
for pair in sorted(pairs, key=lambda p: p["score"], reverse=True):
if capacity.get(pair["expert_id"], 0) <= 0:
continue
if assigned[pair["requester_id"]] >= per_requester:
continue
capacity[pair["expert_id"]] -= 1
assigned[pair["requester_id"]] += 1
out.append(pair)
return out
Это жадная аппроксимация двудольного паросочетания с ограничением на степень. На масштабе сотен пар разница с оптимальным решением через scipy.optimize.linear_sum_assignment — шум, но жадный алгоритм имеет преимущество: он проверяем в сухом прогоне, построчно, в порядке баллов. Когда оператор спрашивает «почему этот запросчик получил такого эксперта?», ответ — один проход по отсортированному списку.
Запросчики, которым не досталось эксперта, не теряются. По построению это те, чьи лучшие эксперты перегружены, — их можно сгруппировать по уровню и предложить групповую сессию.
Второй слой отвечает на вопрос «что учить дальше и сколько это стоит». Мы сравниваем взвешенную медиану данных по навыку у тех, кто его указал, и у тех, кто нет. В этой фразе три способа обмануть себя, и каждый требует защиты.
Не все данные одинаково ценны. Каждый источник имеет вес доверия и поправку на смещение, а каждое значение затухает со временем:
COALESCE(cs.base_weight, 0.50) *
(1 + COALESCE(cs.bias_correction_pct, 0) / 100.0) *
POWER(0.5, EXTRACT(EPOCH FROM (now() - sdp.scraped_at)) / (halflife * 86400.0))
Период полураспада 365 дней: публикации два года назад всё ещё учитываются, но с четвертью веса свежих. LEFT JOIN на таблицу источников намеренный: если строка источника не зарегистрирована, данные получают нейтральный вес 0.50, а не исчезают. Молча терять данные хуже, чем взвешивать их консервативно.
Агрегат — взвешенная медиана, а не среднее. Распределения скошены вправо, и в хвосте живут ошибки парсинга: одно неверное число сдвигает среднее, но не медиану.
def weighted_median(values, weights):
pairs = sorted(zip(values, weights), key=lambda p: p[0])
total = sum(max(0.0, w) for _, w in pairs)
if total <= 0:
# все веса нулевые: вырождаемся в простую медиану
mid = len(pairs) // 2
if len(pairs) % 2:
return float(pairs[mid][0])
return (float(pairs[mid-1][0]) + float(pairs[mid][0])) / 2.0
half, acc = total / 2.0, 0.0
for value, weight in pairs:
acc += max(0.0, weight)
if acc >= half:
return float(value)
return float(pairs[-1][0])
Наивное сравнение «знающие Kubernetes» против «не знающих» покажет завышенный эффект, потому что старшие разработчики знают больше инструментов. Поэтому каждое сравнение проводится внутри страты: (роль, грейд, страна, бакет опыта). Бакеты намеренно грубые (0-2, 3-5, 6-9, 10+), иначе выборка голодает и доверительный интервал взрывается. Если навык теряет значимость после стратификации, мы его отбрасываем.
Точечная оценка выглядит как обещание. Мы используем перцентильный бутстрэп: пересэмплируем обе когорты с возвратом, пересчитываем взвешенные медианы и берём 5/95 перцентили распределения разниц:
def bootstrap_ci(with_vals, with_w, without_vals, without_w, n=1000, alpha=0.10):
rng = random.Random(SEED) # детерминированно: одинаковые входы → одинаковый интервал
deltas = []
for _ in range(n):
a = [rng.choice(range(len(with_vals))) for _ in with_vals]
b = [rng.choice(range(len(without_vals))) for _ in without_vals]
med_a = weighted_median([with_vals[i] for i in a], [with_w[i] for i in a])
med_b = weighted_median([without_vals[i] for i in b], [without_w[i] for i in b])
if med_b > 0:
deltas.append((med_a - med_b) / med_b * 100.0)
deltas.sort()
lo = deltas[int(math.floor((alpha/2) * len(deltas)))]
hi = deltas[min(len(deltas)-1, int(math.ceil((1 - alpha/2) * len(deltas))) - 1)]
return round(lo, 2), round(hi, 2)
Бутстрэп — правильный инструмент, потому что распределение взвешенной медианы скошенных данных не имеет простой формулы. Семя генератора важно: оператор, обновляющий админку, не должен видеть, как число прыгает при перезагрузке; меняющийся доверительный интервал неотличим от бага.
Строка становится значимой только если выполняются все три условия:
significant = (
ci_low > 0.0 # интервал не пересекает ноль
and premium >= min_pct # достаточно велико, чтобы тратить время
and premium <= claim_cap_pct # не абсурдный выброс
)
Незначимые строки всё равно вычисляются и сохраняются. Админка показывает, что отклонено и почему, потому что число, которое система отказалась использовать, так же интересно, как и то, что использовала.
Всё вышеописанное было зелёным в CI. Но первый прогон на реальных данных показал:
requesters=82 experts=51 scored_pairs=82 affinity=3 office_hours=0
Один эксперт из 51. Причина:
requester_ids = {m.user_id for m in requesters}
eligible_experts = [
m for m in experts
if m.open_load < cap
and m.user_id not in requester_ids # <-- вот это
]
Мы хотели «не назначать человека на самого себя», а реализовали «исключить любого эксперта, который также является запросчиком». Почти все эксперты были запросчиками, потому что у них не было своих открытых запросов. Измерение:
Активных ролей, подходящих как эксперт: 51
...которые записались: 51
...ниже лимита нагрузки: 51
...исключено как запросчики: 50
Выжил только один — единственный, кто оказался в середине взаимодействия, и все три предложения указывали на него. Система сама нашла режим отказа «выгорание» на первом же прогоне, из-за строки, которая должна была предотвратить совсем другую проблему.
Возьмите из этой статьи три вещи:
Проверьте свою систему: не исключаете ли вы кого-то неявно? Напишите простой тест: выведите количество подходящих экспертов до и после каждого фильтра. Если разница в десятки раз — у вас та же ошибка, что была у нас.
Хочешь закрепить знания на практике?
Решай задачи на Algolit — интерактивная платформа для обучения
Начать бесплатно →