ГлавнаяБлогРекомендательная система экспертов: алгоритмы и код
Алгоритмы

Рекомендательная система экспертов: алгоритмы и код

Разбираем рекомендательную систему экспертов: RRF, скоринг, глобальное распределение, bootstrap. Код Python и практические выводы.

Al
Редакция Algolitalgolit.ru
10 мин чтения24 августа 2026 г.

Рекомендательная система экспертов: как построить и не сломать

Представьте: вам нужно порекомендовать пользователю эксперта, который ответит на его вопрос. Просто выдать топ-10 по рейтингу? Тогда три самых сильных специалиста получат всю нагрузку и выгорят за месяц. В этой статье я покажу, как мы строили рекомендательную систему для экспертов: от гибридного поиска с RRF до глобального распределения с ограничениями. Вы узнаете, какие алгоритмы реально работают, и получите готовый код на Python.

Постановка задачи: почему это не обычный рекомендатор

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

Гибридный поиск с RRF: как объединить несравнимые метрики

Мы запускаем три независимых поиска по базе экспертов: семантическое сходство (косинус расстояния эмбеддингов), количество решённых тем и разницу в уровне компетенций. Проблема: их результаты не сопоставимы — косинус лежит в диапазоне [-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,          # штраф за переизбыток, логарифмический
}

Два компонента требуют пояснения.

expert_quality: насыщение и холодный старт

Опыт эксперта не должен расти линейно: разница между 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

Экспоненциальное насыщение кодирует идею, что после определённого порога дополнительный опыт не так важен. Линейный член сделал бы ветеранов недостижимыми для новичков.

fairness: логарифмический штраф за частые рекомендации

Если рекомендовать одного эксперта слишком часто, он перегрузится. Линейный штраф сделал бы его нерекомендуемым после пары циклов, поэтому мы используем логарифмический:

def fairness_factor(times_recommended):
    return 1.0 / math.log(math.e + max(0, times_recommended))

Формула ln(e + n) даёт ровно 1.0 при n=0 и медленно убывает, позволяя рекомендовать хорошего эксперта, но не бесконечно.

Глобальное распределение: почему топ-N — это ошибка

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

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 — шум, но жадный алгоритм имеет преимущество: он проверяем в сухом прогоне, построчно, в порядке баллов. Когда оператор спрашивает «почему этот запросчик получил такого эксперта?», ответ — один проход по отсортированному списку.

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

Слой данных: статистика, которую можно защитить

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

1. Взвешивание источников с экспоненциальным затуханием

Не все данные одинаково ценны. Каждый источник имеет вес доверия и поправку на смещение, а каждое значение затухает со временем:

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])

2. Стратификация: защита от подмены опытом

Наивное сравнение «знающие Kubernetes» против «не знающих» покажет завышенный эффект, потому что старшие разработчики знают больше инструментов. Поэтому каждое сравнение проводится внутри страты: (роль, грейд, страна, бакет опыта). Бакеты намеренно грубые (0-2, 3-5, 6-9, 10+), иначе выборка голодает и доверительный интервал взрывается. Если навык теряет значимость после стратификации, мы его отбрасываем.

3. Bootstrap для доверительного интервала

Точечная оценка выглядит как обещание. Мы используем перцентильный бутстрэп: пересэмплируем обе когорты с возвратом, пересчитываем взвешенные медианы и берём 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

Выжил только один — единственный, кто оказался в середине взаимодействия, и все три предложения указывали на него. Система сама нашла режим отказа «выгорание» на первом же прогоне, из-за строки, которая должна была предотвратить совсем другую проблему.

Практический вывод: что делать прямо сейчас

Возьмите из этой статьи три вещи:

  • Используйте RRF для слияния разнородных ранжирований вместо нормализации — это проще и устойчивее.
  • При распределении ограниченных ресурсов (люди, время) решайте задачу глобального назначения, а не топ-N по каждому запросу. Жадный алгоритм достаточно хорош и легко объясним.
  • Для любых заявлений о влиянии навыков на зарплату или карьеру применяйте стратификацию и бутстрэп с фиксированным сидом, иначе ваши цифры будут просто шумом.

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

#рекомендательные системы#RRF#алгоритмы#Python
Al
Редакция Algolit

Пишем про алгоритмы, подготовку к собеседованиям и карьеру в IT — так, чтобы было понятно и полезно.

Хочешь закрепить знания на практике?

Решай задачи на Algolit — интерактивная платформа для обучения

Начать бесплатно →