Зачем строить свой собственный?
Зачем вообще делать что-то своё?
Я знаю, что вы можете подумать: «Почему бы просто не использовать 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.
-
Главное — вы полностью управляете системой, понимаете каждый её шаг и можете улучшать её под свои нужды.
Берите и делайте под себя — это ваш поиск, и он должен работать так, как вам нужно.
Автор: adlayers
