Простой и эффективный поиск на PHP и MySQL

Хочу поиск как в гугле! Эту фразу заказчики повторяют словно мантру, когда речь заходит о реализации поиска в рамках разрабатываемого проекта. Давайте быстро и просто определимся с поиском по тексту. Во-первых — будем использовать БД (совершенно неожиданно, остановим свой выбор на MySQL). Во-вторых — никаких LIKE. Несостоятельность LIKE-поиска доказана тысячами лежачих серверов. Доказывать несостоятельность получения всех ста тысяч записей из БД и поиска с помощью strstr() и подобных, надеюсь, нет необходимости.

Остался единственный вариант - полнотекстовый поиск. Что нам необходимо для полнотекстового поиска? Разумеется, полнотекстовый индекс (он называется FULLTEXT). Индексации данного типа подлежат поля VARCHAR и TEXT на движке MyISAM. В индекс может входить сразу несколько полей. Например, тема форумного поста и его текст. Давайте на примере постов и продолжим.

Возимся с MySQL:

Допустим имеется примитивная таблица, содержащая посты:

CREATE TABLE `posts` (
     `post_id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
     `user_id` BIGINT(10) UNSIGNED NOT NULL,
     `time` INT(10) UNSIGNED NOT NULL,
     `subject` VARCHAR(500) NOT NULL,
     `text` TEXT NOT NULL,
  	PRIMARY KEY (`post_id`)
);

Сейчас в таблице имеется только первичный индекс. На самом деле, при решении реальной задачи мы создали бы ещё несколько индексов для сортировки записей по времени поста, например. Однако, не будем отвлекаться от основной задачи и перейдём к созданию индекса FULLTEXT. Заранее хочу обратить внимание читателя, что вставка данных (INSERT) в таблицу с полнотекстовым индексом будет происходить медленнее. Следовательно, если мы вдруг захотим залить в таблицу много данных, будет эффективнее, если мы перед заливкой прибьём полнотекстовый индекс, а после выполнения жирных инсертов опять добавим.

Итак, добавляем в таблицу полнотекстовый индекс. Для начала, определим по каким полям мы будем в будущем искать. Очевидно, что это поля subject (тема поста) и text (сам текст поста).

Добавляем FULLTEXT-индекс:

ALTER TABLE `posts` ADD FULLTEXT (
    `subject`,
    `text` 
);

Можно было это сделать и непосредственно при создании таблицы:

CREATE TABLE `posts` (
     `post_id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
     `user_id` BIGINT(10) UNSIGNED NOT NULL,
     `time` INT(10) UNSIGNED NOT NULL,
     `subject` VARCHAR(500) NOT NULL,
     `text` TEXT NOT NULL,
  PRIMARY KEY (`post_id`),
  FULLTEXT KEY (`subject`,`text`)
);

Вот и всё, что необходимо выполнить на стороне MySQL. Получили таблицу, в которой поля `subject` VARCHAR и `text` TEXT имеют полнотекстовый индекс. Важно помнить, что по умолчанию FULLTEXT индексирует слова длиной не менее 4-x символов. Для настоящего национального поиска, обычно, необходимо индексировать слова от 3-х символов (причина, я думаю, всем понятна). Для этого нам необходим доступ к файлу конфигурации MySQL (файл my.ini). Ищем в нём параметр ft_min_word_len, который и задаёт минимальное число символов для попадающих в полнотекстовый индекс слов. Устанавливаем значение в 3 и перестраиваем индексы.

В официальной документации предлагается перестраивать полнотекстовые индексы так:

REPAIR TABLE `posts` QUICK;

Фактически, уже сейчас можно выполнять запросы на полнотекстовый поиск. Давайте найдём все посты, в теме и/или содержании которых имеется фраза "привет":

SELECT * FROM `posts` WHERE MATCH (`subject`, `text`) AGAINST ('привет');

У MySQL своё видение на алгоритм определения релевантности. Так, слова, плотность которых в общем объёме полей записей превышает 50%, игнорируются. Это значит, что если в нашей таблице будет 100 записей, и в поле «subject» каждой записи будет содержаться слово «привет», поиск ни к чему не приведёт. Считается (и не без основания), что слово, которое повторяется в половине поисковых единиц всего поискового объёма, не может нести смысловой нагрузки.

Осталось реализовать простой класс для поиска.

На стороне PHP:

/**
 * Класс для работы с полнотекстовым поиском.
 */
final class Search {
     private $countStat;
     private $resStat;
     private $handler;
     private $query;
     private $offset = 0;
     private $count = 1000;
     /**
      * Конструктор. Получаем экземпляр PDO, имя таблицы и поля.
      */
     public function __construct(PDO $PDO, $Table, $Fields) {
          $this->countStat = $PDO->prepare('SELECT COUNT(*) FROM '.$Table.' WHERE MATCH (`'.implode('`, `', $Fields).'`) AGAINST (:query);');
          $this->resStat = $PDO->prepare('SELECT * FROM '.$Table.' WHERE MATCH (`'.implode('`, `', $Fields).'`) AGAINST (:query) LIMIT :offset, :count;');
     }
     /**
      * Сеттер объекта-обработчика.
      */
     public function handler($Handler) {
          $this->handler = $Handler;
          return $this;
     }
     /**
      * Сеттер строки поискового запроса.
      */
     public function query($Query) {
          $this->query = $this->prepQuery($Query);
          return $this;
     }
     /**
      * Ограничения выборки.
      */
     public function limit($Offset, $Count = false) {
          if (!$Count) {
               $this->offset = 0;
               $this->count = $Offset;
          }
  else {
               $this->offset = $Offset;
               $this->count = $Count;               
          }
          return $this;
     }
     /**
      * Возвращает число найденных записей.
      */
     public function count() {
          $this->countStat->bindParam(':query', $this->query, PDO::PARAM_STR);
          $this->countStat->execute();
          return $this->countStat->fetchColumn();
     }
     /**
      * Возвращает результат поиска, представленный виде, заданном в логике объекта-обработчика.
      */
     public function exec() {
          $this->resStat->bindParam(':offset', $this->offset, PDO::PARAM_INT);
          $this->resStat->bindParam(':count', $this->count, PDO::PARAM_INT);
          $this->resStat->bindParam(':query', $this->query, PDO::PARAM_STR);
          $this->resStat->execute();
          $Result = array();
          while ($QRow = $this->resStat->fetch(PDO::FETCH_ASSOC)) {
               $Result[] = $this->handler->exec($QRow, $this->query);
          }
          return $Result;
     }
     /**
      * Предварительная подготовка строки поискового запроса.
      */
     private function prepQuery($Query) {
          // Тут мы делаем необходимые перед началом поиска
          // действия со строкой запроса. Например, можно намеренно 
          // удалять из строки запроса все слова короче 3-х символов, 
          // использовать стеммер и тому подобное.
          return $Query;
     }
}

Пользоваться всем этим безобразием мы будем так:

// Предположим, что $PDO — уже инициализированный экземпляр класса PDO.
// Искать будем в полях subject и text таблицы posts. Получаем экземпляр нашего объекта-поисковика.
$Search = new Search($PDO, 'posts', array('subject', 'text'));
// Выведем число найденных записей, по запросу "привет".
echo $Search->query('привет')->count(); 

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

interface SearchHandler {
     public function exec($Result, $Query);
}

Совершенно ни к чему не обязывающий интерфейс, не правда ли? Единственная задача такого объекта — получить fetch-результат каждой найденной записи и, при необходимости, произвести над ним определённые действия.

Получаем массив тем всех постов, которые будут найдены по запросу "mysql":

/**
 * Обработчик. Он получает массив значений всех полей каждой
 * найденной записи и возвращает то, что  * будет сохранено в результирующем массиве.
 */
final class ShowResults implements SearchHandler {
     public function exec($Result, $Query) {
          return $Result['subject'];
     }
}

$Handler = new ShowResults();
$Search = new Search($PDO, 'posts', array('subject', 'text'));
$Result = $Search->query('привет')->handler($Handler)->exec();

В результате, $Result — массив тем всех найденных записей.

Получаем массив всех полей первых 10-и найденных записей.

final class ShowResults implements SearchHandler {
     public function exec($Result, $Query) {
          return $Result;
     }
}

$Handler = new ShowResults();
$Search = new Search($PDO, 'posts', array('subject', 'text'));
$Result = $Search->query('привет')->handler($Handler)->limit(10)->exec();

Получаем массив полей text первых 10-и найденных записей, выделяя тегами "<strong>" слова, которые содержатся в строке поискового запроса.

final class ShowResults implements SearchHandler {
     public function exec($Result, $Query) {
     	$Text = $Result['text'];
            $Words = explode(' ', $Query);
foreach ($Words as $Word) {
         $Text = preg_replace('|('.$Word.')|', '<strong>$1</strong>', $Text);
}
return $Text;
     }
}

$Handler = new ShowResults();
$Search = new Search($PDO, 'posts', array('subject', 'text'));
$Result = $Search->query('привет')->handler($Handler)->limit(10)->exec();

Вот, собственно, и всё, что я хотел бы продемонстрировать в качестве базиса для поисковых решений на PHP и MySQL. Обозначенный подход не представляет из себя нечто совершенно новое и оригинальное. Большинство разработчиков, рано или поздно, приходят к подобным поисковым решениям. Надеюсь, что данная работа поможет некоторым из них сделать это быстрее и проще (если вообще имеются те, кто дочитал до конца). Ещё раз обращаю внимание на то, что приведённый пример — лишь базис, начальный шаблон для разработчиков. Не смотря на то, что он работает даже в текущем своём виде, для использования в действующих коммерческих системах требуется существенная доработка и переосмысление.

Записи