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

Поиск по словам и ранжирование BM25

Теперь корпус разбит на фрагменты, но их исходный порядок не связан с вопросом. Человек спрашивает про старые PHP-адреса, а первый объект манифеста случайно может оказаться инструкцией о карте сайта. Добавим лексическое ранжирование: выделим слова вопроса и текста, посчитаем совпадения и упорядочим кандидатов. Для этого используем собственную небольшую реализацию BM25. Она прозрачна, работает локально и позволяет объяснить каждую часть оценки без подключения эмбеддингов или модели.

Состояние lesson-04 сохраняет загрузчик, разбиение и тот же корпус. Новый файл ranking.py отвечает только за поиск. В app.py появляется функция ask, которая получает вопрос, выбирает публичные фрагменты и возвращает до четырёх кандидатов. Ответа человеческими предложениями ещё нет. Числа score и coverage описывают свойства поиска, а не достоверность будущего текста.

Одинаковые слова на обеих сторонах

Вопрос и документы проходят одну функцию terms. Приводим текст к регистронезависимой форме через casefold, заменяем ё на е, выделяем последовательности букв, цифр, подчёркиваний и дефисов. Затем исключаем короткие слова и небольшой явный набор служебных слов. Благодаря общей обработке SITEMAP в вопросе и sitemap в источнике совпадут. Замена русской буквы также помогает избежать случайного расхождения «еще» и «ещё». Это нормализация записи, а не понимание значения.

Мы не выполняем морфологический разбор. Слова «старый» и «старых» считаются разными. То же относится к «сохранить» и «сохраняйте». Поэтому исходный вопрос по умолчанию выбран так, чтобы содержать реальные термы учебного корпуса: сохранить старых URL. Даже при несовпадении одного слова оставшиеся совпадения могут привести к нужному фрагменту. Но нельзя называть такой алгоритм полноценным русскоязычным семантическим поиском. Расширение морфологии, словарь синонимов или embeddings потребуют самостоятельной оценки и новых версий индекса.

Текст для подсчёта частот объединяет название документа, название раздела и сам фрагмент. Заголовки помогают выделить назначение короткой части, но пока не имеют отдельного повышающего коэффициента. Каждый их терм учитывается один раз в том же наборе. Это упрощает формулу и устраняет скрытые преимущества, которые было бы трудно объяснить читателю. Если позже понадобится отдельный вес заголовка, его стоит добавить явно и посмотреть, как он меняет положение тех же кандидатов.

Три причины изменения оценки

BM25 учитывает частоту терма в фрагменте, редкость терма в наборе и длину фрагмента. Частое слово может быть полезным, но двадцатое повторение не должно давать такой же прирост, как первое. Поэтому вклад частоты насыщается. Редкое слово помогает различать документы: термин sitemap обычно информативнее общего слова «страница». Длинный фрагмент чаще содержит случайное совпадение, поэтому его вклад корректируется относительно средней длины рассматриваемого корпуса. Эти механизмы работают совместно, а не являются тремя отдельными фильтрами.

В коде закреплены параметры k1 = 1.2 и b = 0.75, хотя ради компактности они видны в выражении как числа. Число 2.2 равно k1 + 1. Для редкости используется положительная логарифмическая форма, поэтому присутствующий терм добавляет неотрицательный вклад. Если терма нет в текущем фрагменте, его частота равна нулю и вклад исчезает. Усреднение длины происходит по тем фрагментам, которые уже разрешены для поиска. Служебный текст не должен менять даже статистику публичной выдачи.

Показатель coverage вычисляется отдельно. Это доля уникальных термов вопроса, найденных в данном фрагменте. Он лежит между нулём и единицей и не зависит от абсолютного масштаба BM25. Повторение слова в вопросе не увеличивает его важность: вопрос превращается в множество. Такое решение позволяет сравнивать «URL URL URL» и «URL» как одинаковое намерение. Однако дополнительное смысловое слово может снизить покрытие, поэтому эта величина тоже не является универсальной вероятностью правильного ответа.

Порядок кандидатов и отсутствие совпадения

После вычисления оценок оставляем только объекты с положительным score и сортируем по убыванию. При равной оценке используется id, чтобы порядок был устойчивым и не зависел от случайных особенностей списка. Возвращаем первые четыре. Устойчивое разрешение равенства важно для редакционного анализа: если один и тот же корпус и вопрос меняют результат без причины, трудно понять эффект нового разбиения или настройки. Прозрачный второй ключ делает ожидаемое поведение воспроизводимым на уровне алгоритма.

Вопрос, состоящий только из удалённых служебных слов, возвращает пустой список. То же происходит при отсутствии любых совпадений. Не подставляем «самый близкий» документ по исходному порядку. Такой запасной выбор создаёт ложное основание: помощник начнёт цитировать статью о sitemap в ответ на стоимость хостинга. Пустая выдача является полезной информацией, которую следующий слой позже преобразует в честный отказ. Ошибку чтения файла при этом нельзя смешивать с пустым поиском: она означает другую проблему и должна оставаться ошибкой выполнения.

Полный ranking.py ниже показывает все операции. При самостоятельном запуске python3 app.py "sitemap опубликованных страниц" ожидайте кандидаты из sitemap раньше нерелевантных частей. У объектов появятся поля score и coverage, а служебного документа не должно быть. Эти эффекты не измерялись при подготовке. Их следует сопоставить с конкретными термами: «sitemap» редок, «опубликованных» есть в подходящем объяснении, а слово «страниц» встречается в нескольких источниках и хуже различает тему.

Попробуйте заменить запрос на «карта сайта». Позиции могут поменяться, потому что набор термов и их документная частота стали другими. Затем используйте «тариф стоимость рублей»: в корпусе нет таких оснований, поэтому ожидается пустой список. Эти два опыта раскрывают границу лексического поиска лучше, чем обещание «AI всё поймёт». В следующем уроке сохраним подготовленные документы и фрагменты в SQLite. Формула останется прежней, чтобы изменение хранения не смешалось с изменением поиска.

Для отдельного разбора возьмите два фрагмента с одинаковым термом, но разной длиной. Даже при одинаковой частоте вклад будет различаться из-за нормализации длины. Затем повторите этот терм внутри короткой части: score вырастет, однако прирост постепенно уменьшится. Такая мысленная последовательность показывает насыщение без выдуманных измерений. Если результат нужен численно, получите его самостоятельно на фиксированных данных и сохраните параметры рядом с отчётом.

Полный файл ranking.py из состояния lesson-04:

import math
import re
from collections import Counter

STOP = {"как", "и", "в", "на", "по", "для", "что", "это", "с", "из", "ли"}

def terms(text):
    return [word for word in re.findall(r"[\w-]+", text.casefold().replace("ё", "е"))
            if len(word) > 1 and word not in STOP]

def rank(question, rows, limit=4):
    query = set(terms(question))
    if not query or not rows:
        return []
    counts = [Counter(terms(row["title"] + " " + row["section"] + " " + row["text"]))
              for row in rows]
    lengths = [sum(count.values()) for count in counts]
    average = sum(lengths) / len(lengths) or 1
    df = {word: sum(word in count for count in counts) for word in query}
    scored = []
    for row, count, length in zip(rows, counts, lengths):
        score = 0.0
        for word in query:
            frequency = count[word]
            inverse = math.log(1 + (len(rows) - df[word] + .5) / (df[word] + .5))
            score += inverse * frequency * 2.2 / (frequency + 1.2 * (.25 + .75 * length / average))
        coverage = len(query.intersection(count)) / len(query)
        if score > 0:
            scored.append({**row, "score": score, "coverage": coverage})
    return sorted(scored, key=lambda row: (-row["score"], row["id"]))[:limit]

Оглавление серии · Исходники всех шагов · Учебные маршруты

Пример остаётся локальным черновиком. При подготовке он не выполнялся; все описанные эффекты являются ожидаемыми, а не результатами запущенных проверок.