Поиск по задаче на PHP без Elasticsearch

0

Поиск ♡

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

Зачем свой велосипед

На портале сотня инструментов, а не миллион документов. Ставить Elasticsearch или Meilisearch ради такого объёма это как ездить за хлебом на танке: мощно, эффектно, но парковка и расход топлива потом душат. Плюс ещё один сервис, который надо ставить, обновлять и кормить памятью на небольшом сервере.

Поэтому поиск живёт в одном классе, SearchService, и работает по обычному полю search_text у инструмента. Настоящая боль тут одна, русский язык: «бетона», «бетон» и «бетоном» для человека одно слово, а для базы три разных. Спасибо, падежи.

Что случается с запросом

  1. Запрос переводится в нижний регистр, «ё» заменяется на «е», текст режется на слова по всему, что не буква и не цифра.
  2. Выкидываются слова короче двух символов и стоп-слова: «как», «сколько», «нужно», «надо», «рассчитать», «онлайн», «калькулятор», «конвертер», «перевод» и ещё несколько. Они есть в каждом втором запросе и про задачу не говорят ровным счётом ничего.
  3. У каждого оставшегося слова отрезается окончание, остаётся основа.
  4. По основам база выбирает кандидатов, а PHP расставляет им оценки и сортирует.

Стемминг на коленке

Полноценного морфологического словаря у меня нет, только список из сорока с небольшим окончаний. Он упорядочен от длинных («иями», «ями», «ого») к коротким («а», «я», «ы», «и»), и срезается первое подошедшее:

foreach ($this->endings as $ending) {
    $endingLength = mb_strlen($ending);
    if (mb_strlen($word) - $endingLength >= 4 && str_ends_with($word, $ending)) {
        return mb_substr($word, 0, mb_strlen($word) - $endingLength);
    }
}

return $word;

Условие >= 4 нужно, чтобы после стрижки осталось хотя бы четыре буквы. Иначе бедное слово «пол» превратится в непонятно что, и никто не будет счастлив.

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

База отбирает, PHP оценивает

Сначала база отвечает на простой вопрос: у каких инструментов в search_text встречается хотя бы одна из основ. Больше двухсот кандидатов не берём, мы тут не в интернете-архиве.

$tools = Tool::query()
    ->published()
    ->where(function ($builder) use ($stems) {
        foreach ($stems as $stem) {
            $builder->orWhere('search_text', 'like', '%'.$stem.'%');
        }
    })
    ->limit(200)
    ->get();

Дальше отбор делает PHP. Инструмент проходит, только если в нём нашлись все основы запроса. Основа в названии даёт 24 очка, в остальном тексте 8. Сверху накидываются бонусы: вся фраза целиком найдена в тексте (+40), название начинается с первой основы (+12) и приоритет самого инструмента, но не больше 20 очков. Ничего сложного, просто баллы за старательность.

Почему оценка в PHP, а не в SQL

Формулу оценки приходится крутить чаще, чем схему базы. Поменять пару строк на PHP проще, чем переписывать запрос, а отсортировать двести кандидатов в памяти не стоит вообще ничего.

Где эта конструкция скрипит

Стемминг грубый: «человек» и «люди» для него просто чужие. Поиск по подстроке через LIKE не использует индекс, но на сотне инструментов этого никто не заметит. Опечатки не лечатся: «бетан» ничего не найдёт, и вот тут уже начинается легаси-ощущение.

Когда инструментов будут тысячи или понадобится исправление опечаток, я вернусь к нормальному поисковому движку. А пока стоп-слов, окончаний и баллов хватает за глаза. Как этот поиск встроен в остальное SEO, я разбирала в статье про подготовку страниц к поиску.

Комментарии

Буду рада вашим мыслям, идеям и вопросам!
Давайте обсуждать ♡

Пока никто не написал.
Станьте первым! ♡