Зачем строить свой собственный?
Зачем вообще делать что-то своё?
Я знаю, что вы можете подумать: «Почему бы просто не использовать Elasticsearch?» или «А что насчёт Algolia?» Это вполне рабочие решения, но у них есть нюансы. Нужно разбираться с их API, поддерживать инфраструктуру под них и учитывать все тонкости их работы.
Но иногда хочется чего-то более простого — такого, что:
работает прямо с вашей текущей базой данных;
не требует сторонних сервисов;
легко понять и отладить;
действительно выдаёт релевантные результаты.
Поэтому я и сделал свою систему поиска — такую, которая использует вашу существующую БД, вписывается в архитектуру проекта и даёт полный контроль над тем, как она работает.
Основная идея
Концепция проста: разбить текст на токены, сохранить их, а затем при поиске сопоставлять токены запроса с токенами в индексе.
Процесс выглядит так:
Индексация. Когда вы добавляете или обновляете данные, система разбивает текст на токены (слова, префиксы, n-граммы) и сохраняет их вместе с весами.
Поиск. Когда пользователь вводит запрос, он проходит такую же токенизацию. Затем система ищет совпадающие токены и подбирает подходящие документы.
Оценка. Сохранённые веса используются для расчёта итоговой релевантности.
Вся суть — в том, как выполняется токенизация и как рассчитываются веса. Сейчас я покажу, что именно имеется в виду.
Строительный блок 1: схема базы данных
Для начала нам нужны всего две таблицы: index_tokens и index_entries.
index_tokens
В этой таблице хранятся все уникальные токены вместе с их весами, полученными от разных токенизаторов.
Важно: один и тот же токен может встречаться несколько раз с разными весами — по одному для каждого токенизатора.
Структура таблицы index_tokens
id |
name |
weight |
|---|---|---|
1 |
parser |
20 -- токен от WordTokenizer |
2 |
parser |
5 -- токен от PrefixTokenizer |
3 |
parser |
1 -- токен от NGramsTokenizer |
4 |
parser |
10 -- токен от SingularTokenizer |
Почему так?
Потому что разные токенизаторы создают один и тот же токен, но с разным весом.
Например, токен parser:
WordTokenizer → вес 20
PrefixTokenizer → вес 5
Чтобы итоговый механизм оценки релевантности работал правильно, нужны отдельные записи.
Ограничение уникальности в этой таблице — (name, weight).
То есть имя токена может повторяться, но вес — нет.
index_entries
Эта таблица связывает:
токен
документ
конкретное поле документа
...и хранит итоговый вес, который нужен для процедуры ранжирования.
Структура таблицы index_entries
id |
token_id |
document_type |
field_id |
document_id |
weight |
|---|---|---|---|---|---|
1 |
1 |
1 |
1 |
42 |
2000 |
2 |
2 |
1 |
1 |
42 |
500 |
Что такое weight?
Это итоговый вычисленный вес токена для конкретного поля конкретного документа.
Формула: weight = field_weight × tokenizer_weight × ceil(sqrt(token_length))
Он уже включает всё, что понадобится позже при начислении очков.
Какие индексы добавляем?
Чтобы поиск работал быстро:
(document_type, document_id)— для быстрого получения документовtoken_id— чтобы быстро находить все документы по токену(document_type, field_id)— для поиска по конкретному полюweight— для фильтрации по весам
Почему именно такая схема?
Потому что она:
простая
эффективно ложится на реляционные БД
использует сильные стороны SQL
позволяет масштабировать алгоритм без усложнений
Блок 2: токенизация
Что такое токенизация?
Это процесс разбивки текста на более мелкие части — токены, удобные для поиска.
Например, слово «parser» можно разбить разными способами:
Как одно целое:
["parser"]На префиксы:
["par", "pars", "parse", "parser"]На n-граммы (последовательности символов):
["par", "ars", "rse", "ser"]
Зачем несколько токенизаторов?
Разные задачи требуют разных подходов к поиску:
Один токенизатор для точных совпадений
Другой — для частичных совпадений
Третий — для учёта опечаток
Каждый из них играет свою роль в итоговом ранжировании.
Общий интерфейс токенизатора
Все токенизаторы реализуют простой интерфейс:
interface TokenizerInterface
{
public function tokenize(string $text): array; // Возвращает массив объектов Token
public function getWeight(): int; // Возвращает вес токенизатора
}
Простой и расширяемый контракт.
Токенизатор слов (WordTokenizer)
Разбивает текст на отдельные слова. Слово «parser» превращается в ["parser"].
Этот метод отлично подходит для точных совпадений.
class WordTokenizer implements TokenizerInterface
{
public function tokenize(string $text): array
{
// Нормализация: приводим к нижнему регистру, удаляем спецсимволы
$text = mb_strtolower(trim($text));
$text = preg_replace('/[^a-z0-9]/', ' ', $text);
$text = preg_replace('/\s+/', ' ', $text);
// Разбиваем на слова и отфильтровываем слишком короткие
$words = explode(' ', $text);
$words = array_filter($words, fn($w) => mb_strlen($w) >= 2);
// Возвращаем уникальные слова в виде объектов Token с весом токенизатора
return array_map(
fn($word) => new Token($word, $this->weight),
array_unique($words)
);
}
}
Вес: 20 — высокий, для точных совпадений.
Префиксный токенизатор (PrefixTokenizer)
Создаёт префиксы слов, например: "parser" → ["par", "pars", "parse", "parser"] (минимальная длина префикса — 4).
Это полезно для поиска по частям слова и автодополнения.
class PrefixTokenizer implements TokenizerInterface
{
public function __construct(
private int $minPrefixLength = 4,
private int $weight = 5
) {}
public function tokenize(string $text): array
{
// Нормализация такая же, как у WordTokenizer
$words = $this->extractWords($text);
$tokens = [];
foreach ($words as $word) {
$wordLength = mb_strlen($word);
// Генерируем префиксы от минимальной длины до полного слова
for ($i = $this->minPrefixLength; $i <= $wordLength; $i++) {
$prefix = mb_substr($word, 0, $i);
$tokens[$prefix] = true; // Используем ключи массива для уникальности
}
}
// Преобразуем ключи в объекты Token с весом токенизатора
return array_map(
fn($prefix) => new Token($prefix, $this->weight),
array_keys($tokens)
);
}
}
Вес: 5 — средний, для частичных совпадений.
Зачем минимальная длина?
Чтобы избежать слишком большого количества коротких токенов — префиксы короче 4 символов обычно слишком распространены и малоэффективны.
Токенизатор n-грамм (NGramsTokenizer)
Создаёт последовательности символов фиксированной длины (обычно 3).
Например, "parser" → ["par", "ars", "rse", "ser"].
Это помогает улавливать опечатки и частичные совпадения.
class NGramsTokenizer implements TokenizerInterface
{
public function __construct(
private int $ngramLength = 3,
private int $weight = 1
) {}
public function tokenize(string $text): array
{
$words = $this->extractWords($text);
$tokens = [];
foreach ($words as $word) {
$wordLength = mb_strlen($word);
// Скользящее окно фиксированной длины
for ($i = 0; $i <= $wordLength - $this->ngramLength; $i++) {
$ngram = mb_substr($word, $i, $this->ngramLength);
$tokens[$ngram] = true;
}
}
return array_map(
fn($ngram) => new Token($ngram, $this->weight),
array_keys($tokens)
);
}
}
Вес: 1 — низкий, но улавливает редкие случаи и опечатки.
Почему длина 3?
Это компромисс между слишком большим количеством совпадений и пропущенными вариантами из-за опечаток.
Блок 3: Система весов
В нашей системе есть три уровня весов, которые работают вместе, чтобы определить важность каждого токена при поиске:
Вес поля — например, заголовок, основное содержание или ключевые слова. Разные части документа могут иметь разный приоритет.
Вес токенизатора — каждый тип токенизатора (слово, префикс, n-грамма) имеет свой вес. Эти веса хранятся в таблице
index_tokens.Вес документа — итоговый вес для конкретного токена в конкретном документе. Хранится в
index_entriesи рассчитывается по формуле:
field_weight × tokenizer_weight × ceil(sqrt(token_length))
Как рассчитывается итоговый вес?
Во время индексации для каждого токена мы считаем вес так:$finalWeight = $fieldWeight * $tokenizerWeight * ceil(sqrt($tokenLength));
Например:
Вес поля заголовка: 10
Вес токенизатора слов: 20
Длина токена «parser»: 6
Тогда итоговый вес:10 × 20 × ceil(sqrt(6)) = 10 × 20 × 3 = 600
Почему используется ceil(sqrt())?
Более длинные токены обычно более специфичны и важны — например, «parser» конкретнее, чем «par».
Но мы не хотим, чтобы очень длинные токены имели слишком большой вес — 100-символьный токен не должен давать вес в 100 раз больше, чем короткий.
Функция квадратного корня даёт убывающую доходность — вес растёт с длиной, но не линейно.
ceil()округляет результат вверх, чтобы сохранить веса целыми числами.
Настройка весов под свои задачи
Вы можете гибко настраивать веса под свои нужды:
Увеличить вес поля — например, если заголовки для вас важнее всего.
Изменить вес токенизатора — повысить для точных совпадений (слов), понизить для менее важных (n-граммы).
Изменить формулу для длины токена — вместо
ceil(sqrt())можно использовать логарифм или линейную функцию, чтобы по-другому влиять на вес длинных токенов.
Таким образом, вы можете точно контролировать, какие части текста и какие типы совпадений важнее при поиске, и подстроить систему под свои требования.
Блок 4: Служба индексирования
Служба индексирования отвечает за обработку документов и сохранение всех их токенов в базе данных для последующего быстрого поиска.
Интерфейс для документов
Чтобы документ мог индексироваться, он должен реализовать интерфейс IndexableDocumentInterface с тремя методами:
getDocumentId()— возвращает уникальный идентификатор документа.getDocumentType()— возвращает тип документа (например, статья, пост, комментарий).getIndexableFields()— возвращает поля документа, которые нужно индексировать, вместе с их весами.
Пример реализации для статьи:
class Post implements IndexableDocumentInterface
{
public function getDocumentId(): int
{
return $this->id ?? 0;
}
public function getDocumentType(): DocumentType
{
return DocumentType::POST;
}
public function getIndexableFields(): IndexableFields
{
$fields = IndexableFields::create()
->addField(FieldId::TITLE, $this->title ?? '', 10)
->addField(FieldId::CONTENT, $this->content ?? '', 1);
if (!empty($this->keywords)) {
$fields->addField(FieldId::KEYWORDS, $this->keywords, 20);
}
return $fields;
}
}
Когда индексируем?
При создании или обновлении документа (например, через события).
По командам в консоли — например,
app:index-documentилиapp:reindex-documents.Через задачи cron для массовой переиндексации.
Как работает индексирование — шаг за шагом
Получаем данные документа: его тип, ID и поля с весами.
Удаляем старый индекс для этого документа. Это важно, чтобы избежать дублирования данных.
Для каждого поля запускаем все токенизаторы, которые разбивают текст на токены.
-
Для каждого токена:
Находим или создаём его в таблице токенов (чтобы не хранить одинаковые токены несколько раз).
-
Рассчитываем итоговый вес по формуле:
вес_поля × вес_токенизатора × ceil(квадратный_корень_из_длины_токена)Добавляем информацию в пакет для массовой вставки.
Вставляем все новые записи в базу данных одним запросом — так быстрее и эффективнее.
Зачем искать или создавать токены?
Токены — это общие элементы для всех документов. Если токен уже есть, используем его повторно, чтобы не хранить дубли и сэкономить место и время.
Ключевые моменты
Старый индекс удаляется перед созданием нового — это упрощает обновление.
Используется пакетная вставка для производительности.
Токены ищутся или создаются, чтобы избежать дубликатов.
Итоговый вес считается динамически при индексации.
Блок 5: Служба поиска
Поисковый сервис принимает строку запроса, разбивает её на токены, ищет эти токены в индексах и возвращает список документов, отсортированных по релевантности.
Как это работает — шаг за шагом
Токенизация запроса
Запрос разбивается на токены с помощью того же набора токенизаторов, который использовался при индексации документов. Это важно, чтобы поиск и индексирование были синхронизированы.
Пример:
Индексация создала токены:
par,pars,parse,parser(префиксный токенизатор).Поиск тоже использует префиксный и обычный токенизатор — так мы найдём не только точное слово
parser, но и все его варианты.
Если запрос пустой (нет токенов), возвращаем пустой результат.
Уникальные токены
Из всех токенов берём только уникальные значения, чтобы не искать одинаковые токены по несколько раз.
Сортировка токенов
Токены сортируются по длине — сначала самые длинные. Это важно, потому что более длинные токены — более конкретные и дают более точные совпадения.
Ограничение количества токенов
Если пользователь отправит очень длинный запрос, мы ограничиваем число токенов (например, максимум 300), чтобы избежать нагрузок на систему.
Выполнение поискового SQL-запроса
Далее строится и выполняется оптимизированный SQL-запрос, который:
Ищет документы, где встречаются эти токены.
Считает оценку релевантности для каждого документа.
Сортирует результаты по убыванию оценки.
Возвращает ограниченное число результатов (например, топ-10).
Как считается оценка релевантности?
Оценка складывается из нескольких факторов:
Базовый балл — сумма весов всех найденных токенов в документе.
Разнообразие токенов — документы с большим количеством разных токенов получают бонус (логарифмическая шкала, чтобы не давать слишком большой перевес).
Качество совпадений — чем выше средний вес токенов, тем лучше (например, совпадение в заголовке важнее, чем в теле текста).
Штраф за длину документа — чтобы длинные документы не имели слишком большое преимущество.
В итоге оценка нормализуется на максимальное значение, чтобы можно было сравнивать разные поисковые запросы.
Почему нужен подзапрос с весом токенов?
В подзапросе проверяется, что документ содержит хотя бы один токен с весом выше порогового. Это исключает из результа��ов документы, которые совпадают только по «шумным» токенам с очень маленьким весом (например, незначительным n-граммам), что улучшает качество поиска.
Пример возвращаемого результата
class SearchResult
{
public function __construct(
public readonly int $documentId,
public readonly float $score
) {}
}
Поиск возвращает список таких объектов — ID документа и его релевантность.
Как получить сами документы?
Мы берём ID из результатов поиска, и через репозиторий загружаем реальные объекты документов, сохраняя порядок релевантности.
$documentIds = array_map(fn($result) => $result->documentId, $searchResults);
$documents = $this->documentRepository->findByIds($documentIds);
Репозиторий гарантирует, что документы вернутся в том же порядке, что и результаты поиска (используется SQL-функция FIELD()).
Итог
В результате вы получаете мощную и гибкую поисковую систему, которая:
Быстро находит релевантные документы через индексы в базе данных.
Обрабатывает опечатки и частичные совпадения с помощью n-грамм и префиксных токенизаторов.
Придаёт больше веса точным совпадениям (например, полным словам).
Работает без внешних сервисов — только с базой данных.
Легко отлаживается и настраивается за счёт прозрачного SQL и гибких весов.
Расширение системы
Добавление нового токенизатора
Чтобы добавить новый способ разбиения текста на токены (например, стемминг, лемматизацию, синонимы и т.д.), нужно:
Реализовать интерфейс
TokenizerInterface, например:
class StemmingTokenizer implements TokenizerInterface
{
public function tokenize(string $text): array
{
// Ваша логика стемминга
// Вернуть массив объектов Token
}
public function getWeight(): int
{
return 15; // Вес токенизатора
}
}
Зарегистрировать этот токенизатор в конфигурации сервиса — и он автоматически будет использоваться и для индексации, и для поиска.
Добавление нового типа документа
Чтобы индексировать новый тип документов (например, комментарии, статьи, профили), реализуйте интерфейс:
class Comment implements IndexableDocumentInterface
{
public function getIndexableFields(): IndexableFields
{
return IndexableFields::create()
->addField(FieldId::CONTENT, $this->content ?? '', 5);
}
}
Изменение весов и формул
Вес токенизаторов и полей легко настраивается через конфигурацию.
Формулы подсчёта релевантности находятся в SQL-запросе — вы можете его изменить, чтобы подстроить оценивание под свои задачи.
Заключение
Это простая и понятная поисковая система — нет сложных «черных ящиков» и магии.
Она легко контролируется, настраивается и отлаживается.
Подходит для большинства приложений, где не нужна гигантская инфраструктура типа Elasticsearch.
Главное — вы полностью управляете системой, понимаете каждый её шаг и можете улучшать её под свои нужды.
Берите и делайте под себя — это ваш поиск, и он должен работать так, как вам нужно.
karminski
Знаете почему никто не будет пользоваться вашим решением? Потому что никто не станет вникать в вашу документацию (а она кстати есть?), потому что есть годами проверенные решения. Есть разработчики которые привыкли работать с эластиком и другими популярными решениями. Есть коммьюнити, которое в случае появления ошибок в движке, быстро их исправить.
JBFW
Знаете, почему никто не будет использовать TCP/IP в локальных сетях? Потому что есть IPX , он просто настраивается и прекрасно работает!
Почти дословный диалог, 1998 год.