Создание простой поисковой системы, которая действительно работает
Полное руководство по реализации, индексированию и поиску
130 открытий2К показов
Зачем строить свой собственный?
Зачем вообще делать что-то своё?
Я знаю, что вы можете подумать: «Почему бы просто не использовать Elasticsearch?» или «А что насчёт Algolia?» Это вполне рабочие решения, но у них есть нюансы. Нужно разбираться с их API, поддерживать инфраструктуру под них и учитывать все тонкости их работы.
Но иногда хочется чего-то более простого — такого, что:
- работает прямо с вашей текущей базой данных;
- не требует сторонних сервисов;
- легко понять и отладить;
- действительно выдаёт релевантные результаты.
Поэтому я и сделал свою систему поиска — такую, которая использует вашу существующую БД, вписывается в архитектуру проекта и даёт полный контроль над тем, как она работает.
Основная идея
Концепция проста: разбить текст на токены, сохранить их, а затем при поиске сопоставлять токены запроса с токенами в индексе.
Процесс выглядит так:
- Индексация. Когда вы добавляете или обновляете данные, система разбивает текст на токены (слова, префиксы, n-граммы) и сохраняет их вместе с весами.
- Поиск. Когда пользователь вводит запрос, он проходит такую же токенизацию. Затем система ищет совпадающие токены и подбирает подходящие документы.
- Оценка. Сохранённые веса используются для расчёта итоговой релевантности.
Вся суть — в том, как выполняется токенизация и как рассчитываются веса. Сейчас я покажу, что именно имеется в виду.
Строительный блок 1: схема базы данных
Для начала нам нужны всего две таблицы: index_tokens и index_entries.
index_tokens
В этой таблице хранятся все уникальные токены вместе с их весами, полученными от разных токенизаторов.
Важно: один и тот же токен может встречаться несколько раз с разными весами — по одному для каждого токенизатора.
Почему так?
Потому что разные токенизаторы создают один и тот же токен, но с разным весом.
Например, токен parser:
- WordTokenizer → вес 20
- PrefixTokenizer → вес 5
Чтобы итоговый механизм оценки релевантности работал правильно, нужны отдельные записи.
Ограничение уникальности в этой таблице — (name, weight).
То есть имя токена может повторяться, но вес — нет.
index_entries
Эта таблица связывает:
- токен
- документ
- конкретное поле документа
…и хранит итоговый вес, который нужен для процедуры ранжирования.
Структура таблицы index_entries
Что такое weight?
Это итоговый вычисленный вес токена для конкретного поля конкретного документа.
Формула:
Он уже включает всё, что понадобится позже при начислении очков.
Какие индексы добавляем?
Чтобы поиск работал быстро:
(document_type, document_id)— для быстрого получения документовtoken_id— чтобы быстро находить все документы по токену(document_type, field_id)— для поиска по конкретному полюweight— для фильтрации по весам
Почему именно такая схема?
Потому что она:
- простая
- эффективно ложится на реляционные БД
- использует сильные стороны SQL
- позволяет масштабировать алгоритм без усложнений
Блок 2: токенизация
Что такое токенизация?
Это процесс разбивки текста на более мелкие части — токены, удобные для поиска.
Например, слово «parser» можно разбить разными способами:
- Как одно целое:
["parser"] - На префиксы:
["par", "pars", "parse", "parser"] - На n-граммы (последовательности символов):
["par", "ars", "rse", "ser"]
Зачем несколько токенизаторов?
Разные задачи требуют разных подходов к поиску:
- Один токенизатор для точных совпадений
- Другой — для частичных совпадений
- Третий — для учёта опечаток
Каждый из них играет свою роль в итоговом ранжировании.
Общий интерфейс токенизатора
Все токенизаторы реализуют простой интерфейс:
Простой и расширяемый контракт.
Токенизатор слов (WordTokenizer)
Разбивает текст на отдельные слова. Слово «parser» превращается в
Этот метод отлично подходит для точных совпадений.
Вес: 20 — высокий, для точных совпадений.
Префиксный токенизатор (PrefixTokenizer)
Создаёт префиксы слов, например:
→
(минимальная длина префикса — 4).
Это полезно для поиска по частям слова и автодополнения.
Вес: 5 — средний, для частичных совпадений.
Зачем минимальная длина?
Чтобы избежать слишком большого количества коротких токенов — префиксы короче 4 символов обычно слишком распространены и малоэффективны.
Токенизатор n-грамм (NGramsTokenizer)
Создаёт последовательности символов фиксированной длины (обычно 3).
Например,
→
.
Это помогает улавливать опечатки и частичные совпадения.
Вес: 1 — низкий, но улавливает редкие случаи и опечатки.
Почему длина 3?
Это компромисс между слишком большим количеством совпадений и пропущенными вариантами из-за опечаток.
Блок 3: Система весов
В нашей системе есть три уровня весов, которые работают вместе, чтобы определить важность каждого токена при поиске:
- Вес поля — например, заголовок, основное содержание или ключевые слова. Разные части документа могут иметь разный приоритет.
- Вес токенизатора — каждый тип токенизатора (слово, префикс, n-грамма) имеет свой вес. Эти веса хранятся в таблице
index_tokens. - Вес документа — итоговый вес для конкретного токена в конкретном документе. Хранится в
index_entriesи рассчитывается по формуле:
Как рассчитывается итоговый вес?
Во время индексации для каждого токена мы считаем вес так:
Например:
- Вес поля заголовка: 10
- Вес токенизатора слов: 20
- Длина токена «parser»: 6
Тогда итоговый вес:
Почему используется ceil(sqrt())?
- Более длинные токены обычно более специфичны и важны — например, «parser» конкретнее, чем «par».
- Но мы не хотим, чтобы очень длинные токены имели слишком большой вес — 100-символьный токен не должен давать вес в 100 раз больше, чем короткий.
- Функция квадратного корня даёт убывающую доходность — вес растёт с длиной, но не линейно.
ceil()округляет результат вверх, чтобы сохранить веса целыми числами.
Настройка весов под свои задачи
Вы можете гибко настраивать веса под свои нужды:
- Увеличить вес поля — например, если заголовки для вас важнее всего.
- Изменить вес токенизатора — повысить для точных совпадений (слов), понизить для менее важных (n-граммы).
- Изменить формулу для длины токена — вместо
ceil(sqrt())можно использовать логарифм или линейную функцию, чтобы по-другому влиять на вес длинных токенов.
Таким образом, вы можете точно контролировать, какие части текста и какие типы совпадений важнее при поиске, и подстроить систему под свои требования.
Блок 4: Служба индексирования
Служба индексирования отвечает за обработку документов и сохранение всех их токенов в базе данных для последующего быстрого поиска.
Интерфейс для документов
Чтобы документ мог индексироваться, он должен реализовать интерфейс
с тремя методами:
getDocumentId()— возвращает уникальный идентификатор документа.getDocumentType()— возвращает тип документа (например, статья, пост, комментарий).getIndexableFields()— возвращает поля документа, которые нужно индексировать, вместе с их весами.
Пример реализации для статьи:
Когда индексируем?
- При создании или обновлении документа (например, через события).
- По командам в консоли — например,
app:index-documentилиapp:reindex-documents. - Через задачи cron для массовой переиндексации.
Как работает индексирование — шаг за шагом
- Получаем данные документа: его тип, ID и поля с весами.
- Удаляем старый индекс для этого документа. Это важно, чтобы избежать дублирования данных.
- Для каждого поля запускаем все токенизаторы, которые разбивают текст на токены.
- Для каждого токена:Находим или создаём его в таблице токенов (чтобы не хранить одинаковые токены несколько раз).Рассчитываем итоговый вес по формуле:
вес_поля × вес_токенизатора × ceil(квадратный_корень_из_длины_токена)Добавляем информацию в пакет для массовой вставки.Вставляем все новые записи в базу данных одним запросом — так быстрее и эффективнее. - Находим или создаём его в таблице токенов (чтобы не хранить одинаковые токены несколько раз).
- Рассчитываем итоговый вес по формуле:
вес_поля × вес_токенизатора × ceil(квадратный_корень_из_длины_токена)Добавляем информацию в пакет для массовой вставки.Вставляем все новые записи в базу данных одним запросом — так быстрее и эффективнее.
Зачем искать или создавать токены?
Токены — это общие элементы для всех документов. Если токен уже есть, используем его повторно, чтобы не хранить дубли и сэкономить место и время.
- Ключевые моментыСтарый индекс удаляется перед созданием нового — это упрощает обновление.Используется пакетная вставка для производительности.Токены ищутся или создаются, чтобы избежать дубликатов.Итоговый вес считается динамически при индексации.
- Ключевые моментыСтарый индекс удаляется перед созданием нового — это упрощает обновление.Используется пакетная вставка для производительности.Токены ищутся или создаются, чтобы избежать дубликатов.Итоговый вес считается динамически при индексации.
- Старый индекс удаляется перед созданием нового — это упрощает обновление.
- Используется пакетная вставка для производительности.
- Токены ищутся или создаются, чтобы избежать дубликатов.
- Итоговый вес считается динамически при индексации.
Блок 5: Служба поиска
Поисковый сервис принимает строку запроса, разбивает её на токены, ищет эти токены в индексах и возвращает список документов, отсортированных по релевантности.
Как это работает — шаг за шагом
- Токенизация запроса
Запрос разбивается на токены с помощью того же набора токенизаторов, который использовался при индексации документов. Это важно, чтобы поиск и индексирование были синхронизированы.
Пример:
- Индексация создала токены:
par,pars,parse,parser(префиксный токенизатор). - Поиск тоже использует префиксный и обычный токенизатор — так мы найдём не только точное слово
parser, но и все его варианты.
Если запрос пустой (нет токенов), возвращаем пустой результат.
- Уникальные токены
Из всех токенов берём только уникальные значения, чтобы не искать одинаковые токены по несколько раз.
- Сортировка токенов
Токены сортируются по длине — сначала самые длинные. Это важно, потому что более длинные токены — более конкретные и дают более точные совпадения.
- Ограничение количества токенов
Если пользователь отправит очень длинный запрос, мы ограничиваем число токенов (например, максимум 300), чтобы избежать нагрузок на систему.
- Выполнение поискового SQL-запроса
Далее строится и выполняется оптимизированный SQL-запрос, который:
- Ищет документы, где встречаются эти токены.
- Считает оценку релевантности для каждого документа.
- Сортирует результаты по убыванию оценки.
- Возвращает ограниченное число результатов (например, топ-10).
Как считается оценка релевантности?
Оценка складывается из нескольких факторов:
- Базовый балл — сумма весов всех найденных токенов в документе.
- Разнообразие токенов — документы с большим количеством разных токенов получают бонус (логарифмическая шкала, чтобы не давать слишком большой перевес).
- Качество совпадений — чем выше средний вес токенов, тем лучше (например, совпадение в заголовке важнее, чем в теле текста).
- Штраф за длину документа — чтобы длинные документы не имели слишком большое преимущество.
В итоге оценка нормализуется на максимальное значение, чтобы можно было сравнивать разные поисковые запросы.
Почему нужен подзапрос с весом токенов?
В подзапросе проверяется, что документ содержит хотя бы один токен с весом выше порогового. Это исключает из результатов документы, которые совпадают только по «шумным» токенам с очень маленьким весом (например, незначительным n-граммам), что улучшает качество поиска.
Пример возвращаемого результата
Поиск возвращает список таких объектов — ID документа и его релевантность.
Как получить сами документы?
Мы берём ID из результатов поиска, и через репозиторий загружаем реальные объекты документов, сохраняя порядок релевантности.
Репозиторий гарантирует, что документы вернутся в том же порядке, что и результаты поиска (используется SQL-функция
Итог
В результате вы получаете мощную и гибкую поисковую систему, которая:
- Быстро находит релевантные документы через индексы в базе данных.
- Обрабатывает опечатки и частичные совпадения с помощью n-грамм и префиксных токенизаторов.
- Придаёт больше веса точным совпадениям (например, полным словам).
- Работает без внешних сервисов — только с базой данных.
- Легко отлаживается и настраивается за счёт прозрачного SQL и гибких весов.
Расширение системы
Добавление нового токенизатора
Чтобы добавить новый способ разбиения текста на токены (например, стемминг, лемматизацию, синонимы и т.д.), нужно:
- Реализовать интерфейс
TokenizerInterface, например:
Зарегистрировать этот токенизатор в конфигурации сервиса — и он автоматически будет использоваться и для индексации, и для поиска.
Добавление нового типа документа
Чтобы индексировать новый тип документов (например, комментарии, статьи, профили), реализуйте интерфейс:
Изменение весов и формул
- Вес токенизаторов и полей легко настраивается через конфигурацию.
- Формулы подсчёта релевантности находятся в SQL-запросе — вы можете его изменить, чтобы подстроить оценивание под свои задачи.
Заключение
- Это простая и понятная поисковая система — нет сложных «черных ящиков» и магии.
- Она легко контролируется, настраивается и отлаживается.
- Подходит для большинства приложений, где не нужна гигантская инфраструктура типа Elasticsearch.
- Главное — вы полностью управляете системой, понимаете каждый её шаг и можете улучшать её под свои нужды.
Берите и делайте под себя — это ваш поиск, и он должен работать так, как вам нужно.
130 открытий2К показов





