Перейти к содержанию

Оценка кандидатов

После того как Brave возвращает список троек (artist, title, kind), они объединяются, ранжируются по match_score, и из лучшего результата выбирается canonical пара. На этой странице объясняется алгоритм оценки и особые случаи, которые на него накладываются.

Источник: src-tauri/src/resolver/mod.rs


match_score

fn match_score(query: &str, cand: &TrackCandidate) -> f32

Возвращает значение в диапазоне [0, ~4.5]. Чем выше — тем лучше.

Составляющие

Составляющая +Score Условие
Совпадение исполнителя +1.0 part_matches(query_tokens, query_norm, &cand.artist)
Совпадение названия +1.0 part_matches(query_tokens, query_norm, &cand.title)
Точное совпадение исполнителя +1.0 norm(artist_stripped) == norm(query)
Точное совпадение названия +1.0 norm(title_stripped) == norm(query)
Бонус за несколько источников +0.5 × (sources.len() − 1) Чем больше источников сообщают об одном candidate — тем выше уверенность

Бонус за несколько источников никогда не превышает реальное текстовое совпадение: 3 источника дают +1.0, что равно одному текстовому сигналу, но не может превзойти два (artist + title = 2.0).

part_matches

fn part_matches(
    query_tokens: &HashSet<String>,
    query_norm: &str,
    candidate_part: &str,
) -> bool

Два сигнала, достаточно любого одного:

  1. Перекрытие токенов ≥ 50%: не менее половины токенов части candidate присутствуют в наборе токенов запроса.
  2. "Нон стоп" (2 токена) против запроса "нон стоп молли": перекрытие = 2/2 = 100% → совпадение
  3. "Paranoid Android" (2 токена) против "radiohead paranoid": перекрытие = 1/2 = 50% → совпадение

  4. Совпадение подстроки (нормализованное, в любом направлении):

  5. norm(query) содержит norm(candidate_part), ИЛИ
  6. norm(candidate_part) содержит norm(query)
  7. Обрабатывает варианты без пробелов, например "нонстоп" совпадает с "Нон стоп"

Удаление скобочных аннотаций

Перед оценкой скобочные аннотации удаляются и из исполнителя, и из названия:

fn strip_bracket_annotations(s: &str) -> String {
    // Удаляет всё внутри (...) и [...]
}

Это обрабатывает заголовки Genius вида "Сукины дети (Sons of Bitches)", где скобочная часть — перевод на английский. Без удаления 5-токенная версия "сукины дети sons of bitches" снизила бы соотношение перекрытия токенов для кириллического запроса "сукины дети".


Порог канонизации

Минимальный score для принятия candidate в качестве canonical:

const CANONICAL_MIN_SCORE: f32 = 2.0;

Score ≥ 2.0 означает, что запросу соответствуют и исполнитель, И название (по одному текстовому сигналу каждый = 2.0). Score 1.0 означает совпадение только одной стороны — недостаточно для уверенной канонизации.

Этот порог необходим, поскольку Brave сортирует по релевантности, но не всегда корректно. Пример сбоя без порога: запрос "первый класс" → лучший трек от Brave — "1.Kla$ - АКНЕ" (исполнитель семантически соответствует "первый класс", название — нет) → score 1.0 → корректно не канонизируется.


Перекрёстный буст от artist-hub

let artist_hub_norms: HashSet<String> = merged
    .iter()
    .filter(|c| c.kind == MatchKind::Artist)
    .map(|c| norm::norm(&c.artist))
    .collect();

// Для каждого Track candidate:
if c.kind == MatchKind::Track && artist_hub_norms.contains(&norm::norm(&c.artist)) {
    s += 1.0;
}

Когда Brave возвращает одновременно страницу исполнителя и страницы треков для одного и того же исполнителя, track candidates получают +1.0. Это подтверждает имя исполнителя через два независимых типа источников (artist hub + страница трека) и позволяет правильному треку преодолеть порог 2.0, даже если запрос явно не называет название.

Пример: запрос "первый класс сукины дети" → Brave возвращает страницу исполнителя "1.Kla$" (+1.0 ко всем трекам 1.Kla$) и страницу трека "1.Kla$ - Сукины дети" (перекрытие исполнителя +1.0 + перекрёстный буст +1.0 = итого 2.0 от перекрытия, порог преодолён).


Принятие кандидатов Album / Artist

Track candidates требуют score >= 2.0. Для Album и Artist candidates планка ниже:

MatchKind::Album | MatchKind::Artist => {
    if score >= 1.0 { return true; }
    // Исключение при несоответствии алфавита: кириллический запрос + candidate только из латиницы
    let q_cyrl = trimmed.chars().any(|ch| matches!(ch, 'а'..='я' | 'А'..='Я' | 'ё' | 'Ё'));
    let c_latin_only = full.chars().all(|ch| !matches!(ch, 'а'..='я' | ...));
    q_cyrl && c_latin_only
}

Score ≥ 1.0 достаточен, потому что страница исполнителя "Пошлая Молли" против запроса "нон стоп молли" набирает ровно 1.0 (совпадение исполнителя) — это всё равно сильный сигнал о правильном исполнителе.

Исключение при несоответствии алфавита (разрешён score = 0) обрабатывает ситуацию: запрос на кириллице, candidate — полностью латиница. norm() приводит обе строки к буквенно-цифровому нижнему регистру, однако кириллическое "молли" (молли) и латинское "Molly" (molly) — разные строки, и чистое строковое сравнение не может обнаружить семантическую эквивалентность. Исключение позволяет кириллическому запросу сопоставиться со страницей исполнителя только на латинице, принимая score = 0.


Предварительное извлечение кандидатов album/artist

До усечения ранжированного списка до 10 позиций кандидаты album и artist извлекаются отдельно:

let album_page_cand = ranked
    .iter()
    .find(|(_, c)| c.kind == MatchKind::Album && special_trust(c))
    .map(|(_, c)| c.clone());
let artist_page_cand = ranked
    .iter()
    .find(|(_, c)| c.kind == MatchKind::Artist && !c.artist.is_empty() && special_trust(c))
    .map(|(_, c)| c.clone());

После усечения они добавляются обратно в ранжированный список. Это не позволяет сильному совпадению Artist или Album быть молча потерянным, если оно оказалось ниже позиции 10, потому что его текстовый score был 0 (кросс-скриптовый), тогда как Track candidates с частичным текстовым совпадением численно оценивались выше.


Определение запроса только по исполнителю

После оценки выполняется финальная проверка: не является ли сырой запрос целиком именем исполнителя:

let q_tokens_set: HashSet<String> = tokenize(trimmed).into_iter().collect();
let artist_only_query = artist_page_cand.as_ref().is_some_and(|c| {
    let stripped = strip_bracket_annotations(&c.artist);
    let a_tokens: HashSet<String> = tokenize(&stripped).into_iter().collect();
    q_tokens_set.iter().all(|t| a_tokens.contains(t))
});

Если каждый токен запроса присутствует в имени лучшего Artist candidate, запрос считается поиском только по исполнителю. Это переопределяет обычную оценку: canonical устанавливается в {artist: "...", title: ""}, а intent принудительно переключается на Artist.

Без этой проверки "Пошлая Молли" (чистый запрос по исполнителю) подхватила бы тот трек, который Brave случайно выдал первым (например, "АКНЕ"), потому что каждый трек этого исполнителя набирает ~2.0 через перекрёстный буст от artist-hub, а stable_sort при ничье откатывается к порядку Brave.