Поиск по задаче на PHP без Elasticsearch
Поиск ♡
Никто не пишет в поиск «калькулятор бетона». Пишут «сколько бетона нужно на фундамент», причём иногда с опечатками и в стиле «ну короче». Человек описывает задачу, а не название инструмента, и поиск должен это переварить. При этом мне совсем не хотелось поднимать ради него отдельный сервер.
Зачем свой велосипед
На портале сотня инструментов, а не миллион документов. Ставить Elasticsearch или Meilisearch ради такого объёма это как ездить за хлебом на танке: мощно, эффектно, но парковка и расход топлива потом душат. Плюс ещё один сервис, который надо ставить, обновлять и кормить памятью на небольшом сервере.
Поэтому поиск живёт в одном классе, SearchService, и работает по обычному полю search_text у инструмента. Настоящая боль тут одна, русский язык: «бетона», «бетон» и «бетоном» для человека одно слово, а для базы три разных. Спасибо, падежи.
Что случается с запросом
- Запрос переводится в нижний регистр, «ё» заменяется на «е», текст режется на слова по всему, что не буква и не цифра.
- Выкидываются слова короче двух символов и стоп-слова: «как», «сколько», «нужно», «надо», «рассчитать», «онлайн», «калькулятор», «конвертер», «перевод» и ещё несколько. Они есть в каждом втором запросе и про задачу не говорят ровным счётом ничего.
- У каждого оставшегося слова отрезается окончание, остаётся основа.
- По основам база выбирает кандидатов, а 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, я разбирала в статье про подготовку страниц к поиску.
Комментарии
Буду рада вашим мыслям, идеям и вопросам!
Давайте обсуждать ♡
Делитесь
своими мыслями
— мне очень
это важно ♡