Собственный поисковый движок на 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, позволяющий использовать + и -.
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>';
}Можно ли написать собственный поисковый движок с инвертированным индексом на чистом 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).
Дополнительные примеры и расширенные техники
Ниже приведены детальные фрагменты для разных этапов разработки поискового движка.
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 из БД