Собственный поисковый движок на PHP: пошаговое руководство

Раздел: Сложные веб-приложения -> Создание поискового движка

Основные подходы к созданию поискового движка

Как организовать простейший поиск по тексту без внешних инструментов?

Самый примитивный вариант - использование SQL оператора LIKE. Он подходит для маленьких объёмов данных (до нескольких тысяч записей) и демонстрационных проектов. Пример запроса: SELECT * FROM articles WHERE content LIKE '%искомое слово%'. Недостатки: отсутствие индексации, регистрозависимость (в MySQL по умолчанию), невозможность ранжирования. Для ускорения можно создать индекс FULLTEXT, но LIKE всё равно не использует его.

Типичная ошибка: попытка использовать LIKE на больших таблицах (сотни тысяч строк) приводит к полному сканированию и медленным ответам. Решение - перейти на полнотекстовые индексы.

Какое решение обеспечивает наилучшую производительность и гибкость при поиске на PHP?

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

CREATE TABLE articles (
    id INT AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(255),
    content TEXT,
    FULLTEXT(title, content)
) ENGINE=InnoDB;

Php search engine (создание поискового движка на php)

Поисковый запрос на PHP:

$search = $mysqli->real_escape_string('искомый текст');
$query = "SELECT *, MATCH(title, content) AGAINST('$search' IN NATURAL LANGUAGE MODE) AS relevance
          FROM articles
          WHERE MATCH(title, content) AGAINST('$search' IN NATURAL LANGUAGE MODE)
          ORDER BY relevance DESC";
$result = $mysqli->query($query);

Проблема - встроенный анализатор русского языка не всегда корректно обрабатывает окончания. Решается установкой плагина mecab или использованием собственных стоп-слов. Для лучшего контроля применяется режим BOOLEAN MODE, позволяющий использовать + и -.

Частая ошибка: игнорирование стоп-слов (например, 'и', 'в', 'на') приводит к тому, что короткие запросы не возвращают результатов. Необходимо настроить конфигурацию MySQL или использовать IN BOOLEAN MODE.

Как реализовать поиск с высокой производительностью при миллионах записей?

Для масштабируемых проектов применяются внешние поисковые движки: Sphinx или Elasticsearch. Они работают с инвертированным индексом, поддерживают русскую морфологию, синонимы, фасетный поиск. Интеграция с PHP осуществляется через клиентские библиотеки (например, sphinxapi.php для Sphinx или официальный клиент Elasticsearch). Пример конфигурации Sphinx:

# sphinx.conf
source articles_source {
    type = mysql
    sql_host = localhost
    sql_user = root
    sql_pass = 
    sql_db = test
    sql_query = SELECT id, title, content FROM articles
    sql_field_string = title
    sql_field_string = content
}

index articles_index {
    source = articles_source
    path = /var/data/sphinx/articles
    min_word_len = 2
    charset_table = 0..9, A..Z->a..z, _, a..z, U+410..U+42F->U+430..U+44F, U+430..U+44F
    morphology = stem_enru, soundex
}

PHP код для поиска через Sphinx:

require('sphinxapi.php');
$cl = new SphinxClient();
$cl->SetServer('localhost', 9312);
$cl->SetMatchMode(SPH_MATCH_EXTENDED2);
$cl->SetLimits(0, 20);
$result = $cl->Query('искомый текст', 'articles_index');
if ($result['total'] > 0) {
    foreach ($result['matches'] as $id => $data) {
        echo 'ID: ' . $id . ' (relevance: ' . $data['weight'] . ')<br>';
    }
}

Альтернатива - Elasticsearch с его REST API и JSON-запросами. Пример запроса через PHP (cURL):

$curl = curl_init();
curl_setopt_array($curl, [
    CURLOPT_URL => 'http://localhost:9200/articles/_search',
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => json_encode([
        'query' => ['match' => ['content' => 'искомый текст']]
    ]),
    CURLOPT_HTTPHEADER => ['Content-Type: application/json']
]);
$response = curl_exec($curl);
$data = json_decode($response, true);
foreach ($data['hits']['hits'] as $hit) {
    echo $hit['_source']['title'] . ' (score: ' . $hit['_score'] . ')<br>';
}
Проблема: сложность настройки и необходимость поддержки отдельного сервера. Решение - использовать готовые Docker-образы или облачные сервисы.

Можно ли написать собственный поисковый движок с инвертированным индексом на чистом PHP?

Да, для образовательных целей или очень специфичных требований. Идея: разбить тексты на слова (токены), создать ассоциативный массив word => [doc_id => count]. При поиске находить документы, содержащие все слова запроса, и ранжировать по частоте. Пример упрощённой реализации:

class SimpleSearchEngine {
    private array $index = [];
    private array $documents = [];

    public function addDocument(int $id, string $content): void {
        $this->documents[$id] = $content;
        $words = array_unique(str_word_count(mb_strtolower($content), 1));
        foreach ($words as $word) {
            $this->index[$word][$id] = ($this->index[$word][$id] ?? 0) + 1;
        }
    }

    public function search(string $query): array {
        $queryWords = array_unique(str_word_count(mb_strtolower($query), 1));
        if (empty($queryWords)) return [];
        $candidates = null;
        foreach ($queryWords as $word) {
            if (!isset($this->index[$word])) return [];
            $docIds = array_keys($this->index[$word]);
            if ($candidates === null) {
                $candidates = $docIds;
            } else {
                $candidates = array_intersect($candidates, $docIds);
            }
        }
        $results = [];
        foreach ($candidates as $docId) {
            $score = 0;
            foreach ($queryWords as $word) {
                $score += $this->index[$word][$docId];
            }
            $results[$docId] = $score;
        }
        arsort($results);
        return $results;
    }
}

Для работы с русским языком понадобится лемматизация или стемминг (например, библиотека php-stemmer).

Ошибки: не учтены позиции слов, нет поддержки фразового поиска, скорость падает на больших объёмах. Решение - хранить индекс на диске (например, в SQLite) или использовать битовые карты.

Дополнительные примеры и расширенные техники

Ниже приведены детальные фрагменты для разных этапов разработки поискового движка.

1. Полнотекстовый поиск с булевыми операторами (MySQL)

Запрос, требующий обязательно слово "кот" и исключающий слово "собака":

Пример
$search = '+кот -собака';
$query = "SELECT * FROM articles WHERE MATCH(title, content) AGAINST('$search' IN BOOLEAN MODE)";
$result = $mysqli->query($query);
while ($row = $result->fetch_assoc()) {
    echo $row['title'] . "<br>";
}

Результат: выводятся только те статьи, где есть "кот" и нет "собака".

2. Создание инвертированного индекса с сохранением позиций (продвинутая версия)

Пример
class AdvancedSearchEngine {
    private array $index = [];  // word => [docId => [positions]]
    private array $documents = [];

    public function addDocument(int $id, string $content): void {
        $this->documents[$id] = $content;
        $words = mb_strtolower($content);
        $words = preg_split('/\s+/u', $words, -1, PREG_SPLIT_NO_EMPTY);
        foreach ($words as $position => $word) {
            $this->index[$word][$id][] = $position;
        }
    }

    public function phraseSearch(string $phrase): array {
        $phraseWords = preg_split('/\s+/u', mb_strtolower($phrase), -1, PREG_SPLIT_NO_EMPTY);
        if (count($phraseWords) === 0) return [];
        $firstWord = array_shift($phraseWords);
        $candidates = $this->index[$firstWord] ?? [];
        $resultDocs = [];
        foreach ($candidates as $docId => $positions) {
            if (count($positions) === 0) continue;
            // Для каждого вхождения первого слова проверяем последовательность
            foreach ($positions as $pos) {
                $found = true;
                $nextPos = $pos + 1;
                foreach ($phraseWords as $word) {
                    if (!isset($this->index[$word][$docId]) ||
                        !in_array($nextPos, $this->index[$word][$docId])) {
                        $found = false;
                        break;
                    }
                    $nextPos++;
                }
                if ($found) {
                    $resultDocs[$docId] = ($resultDocs[$docId] ?? 0) + 1;
                }
            }
        }
        arsort($resultDocs);
        return $resultDocs;
    }
}

Использование:

Пример
$engine = new AdvancedSearchEngine();
$engine->addDocument(1, 'черный кот белый пес');
$engine->addDocument(2, 'кот и пес белый');
$result = $engine->phraseSearch('кот белый');
// $result: [2 => 1, ...] (только документ 2 содержит последовательность 'кот белый', т.к. в док 1 между ними 'пес')

3. Интеграция с Elasticsearch через официальный PHP-клиент

Установка библиотеки: composer require elasticsearch/elasticsearch

Пример
require 'vendor/autoload.php';
use Elastic\Elasticsearch\ClientBuilder;

$client = ClientBuilder::create()->setHosts(['localhost:9200'])->build();

$params = [
    'index' => 'articles',
    'body'  => [
        'query' => [
            'multi_match' => [
                'query'  => 'искомый текст',
                'fields' => ['title^3', 'content'] // title весомее
            ]
        ]
    ]
];
$response = $client->search($params);
$hits = $response['hits']['hits'];
foreach ($hits as $hit) {
    echo $hit['_source']['title'] . ' (score: ' . $hit['_score'] . ')<br>';
}

4. Использование Sphinx с различными режимами соответствия

Для нечёткого поиска включается SPH_MATCH_EXTENDED2, поддерживающий операторы ~ (похожесть) и ^ (обязательное начало).

Пример
$cl->SetMatchMode(SPH_MATCH_EXTENDED2);
$cl->SetWeights([10, 1]); // title вес 10, content вес 1
$result = $cl->Query('@title кот @content (пес | собака)');
// Ищет документы, где title содержит 'кот', а content - 'пес' или 'собака'

5. Обработка русской морфологии с помощью библиотеки php-stemmer

Установка: composer require wamania/php-stemmer

Пример
require 'vendor/autoload.php';
use Wamania\Snowball\StemmerFactory;

$stemmer = StemmerFactory::create('russian');
echo $stemmer->stem('бегали'); // 'бега'
echo $stemmer->stem('бежали'); // 'беж' (неточность, но приближение)

Использование в самодельном движке для приведения слов к основе перед индексацией и поиском.

6. Решение проблемы stop-слов в MySQL

Чтобы игнорировать список стоп-слов при полнотекстовом поиске, можно изменить переменную ft_stopword_file в конфигурации MySQL или использовать IN BOOLEAN MODE, где стоп-слова игнорируются не всегда. Альтернативно создать свой список и исключать слова из запроса программно.

Пример
$stopwords = ['и', 'в', 'на', 'с', 'по'];
$searchWords = preg_split('/\s+/u', mb_strtolower($search));
$filtered = array_filter($searchWords, function($word) use ($stopwords) {
    return !in_array($word, $stopwords);
});
$search = implode(' ', $filtered);
// Затем формируем запрос с этим отфильтрованным текстом

Результат: уменьшается количество нулевых результатов из-за коротких предлогов.

7. Кэширование результатов поиска

Для частых запросов полезно кэшировать идентификаторы найденных документов.

Пример
$cacheKey = 'search_' . md5($search);
$cachedIds = $cache->get($cacheKey);
if ($cachedIds === null) {
    // выполнить поиск, получить $ids
    $cache->set($cacheKey, $ids, 3600);
} else {
    $ids = $cachedIds;
}
// загрузить данные по $ids из БД

Создание поискового движка на PHP - comments

En
Php search engine (php)