Hola, Amigos! Меня зовут Евгений Шмулевский, я backend-разработчик на Laravel в агентстве продуктовой разработки Amiga. В статье описываю организацию поиска через Meilisearch и нюансы использования в связке с Laravel.
Из коробки, согласно документации, Laravel (scout) поддерживает следующие варианты организации системы поиска:
Algolia,
Meilisearch,
Typesense,
MySQL / PostgreSQL.
Algolia
В варианте с Algolia мы имеем облачное хранилище. У этого варианта есть как свои плюсы, так и минусы:
+ быстрое подключение;
+ масштабирование;
- данные хранятся на стороннем сервере (для кого-то может быть критично);
- с определенного лимита необходима оплата (бесплатный лимит 10k запросов в месяц).
Typesense
Далее рассмотрим Typesense — это легковесный opensource поисковый движок. Из фич заявленных на сайте разработчиков:
Typo Tolerance (поиск с опечатками).;
Геопоиск;
Настраиваемое ранжирование;
Merchandising (можно настраивать сортировку для заданных сущностей);
Vector & Semantic Search (автоматически создает embeddings используя встроенные ML модели или OpenAI / PaLM API и делает семантческий поиск или поиск ближайших соседей);
Synonyms (поддерживает поиск по синонимам);
Filtering & Faceting (поддерживает фасетный поиск).
MySQL / PostgreSQL
Есть еще вариант организации поиска штатными средствами MySQL / PostgreSQL. Подходит для проектов не требовательных к функционалу поиска. Из плюсов — простота реализации.
Meilisearch
В своей практике (для небольших и средних проектов) мы используем именно этот поисковый движок, т.к. он достаточно прост и покрывает базовые потребности. Сам движок написан на Rust.
Из плюсов:
легковесный,
мультиязычность из коробки,
поиск с опечатками,
фасетный поиск,
поддерживает гибкие настройки ранжирования,
поддерживает геопоиск.
Использовать можно напрямую через rest API, либо воспользоваться готовым пакетом-оберткой.
Сами разработчики позиционируют Meilisearch именно как поисковый движок и не рекомендуют использовать как отдельную БД.
Особенности Meilisearch
Среди особенностей Meilisearch следует учесть, что поиск осуществляется только по началу слова (prefix search). То есть если у нас есть слова: «автомобиль», «автомат», «автобус», «материал», и мы вводим поисковый запрос «мат», то в результатах у нас будет представлено только слово материал, но не слово автомат.
Также следует отметить, что поисковый запрос не может состоять более чем из 10 слов. Если запрос содержит больше 10 слов, то то, что выходит за эти границы, игнорируется.
Ключевым моментом в meilisearch являются индексы. Индекс — это набор документов, объединенных общими настройками. Документ — это запись, содержащая в себе первичный ключ и набор атрибутов. Ниже изображение с сайта офф. документации, поясняющее структуру документа.
Мы можем задавать настройки поиска на уровне индекса. Для задания настроек и, в целом, для организации взаимодействия с meilisearch, можем использовать напрямую REST API, либо воспользоваться официальным пакетом. Далее будем рассматривать изменение настроек через данный пакет.
Рассмотрим подробнее настройки доступные для индексов:
dictionary — задает список фраз, которые meilisearch будет воспринимать как одно целое.
Пример:$client->index('books')->updateDictionary(['J. R. R.', 'W. E. B.']);
displayedAttributes — задает список атрибутов возвращаемых в результатах поиска. По умолчанию при индексировании все атрибуты попадают сюда автоматически. Ниже пример для задания вручную:
$client->index('products')->updateDisplayedAttributes([
'title',
'description'
]);faceting — настройки для фасетного поиска. Параметр maxValuesPerFacet — задает максимальное количество (по умолчанию 100) возвращаемых значений при фасетном поиске. Второй параметр задает порядок сортировки результатов при данном поиске.
Пример:$client->index('books')->updateFaceting([
'maxValuesPerFacet' => 2,
'sortFacetValuesBy' => ['*' => 'alpha', 'genres' => 'count']
]);filterableAttributes — задает список атрибутов доступных для фильтрации. По умолчанию пустые. Если задать, то при поиске можно будет использовать эти атрибуты в качестве фильтров (доступные операторы =, !=, >, >=, <, <=, TO (эквивалент BETWEEN), EXISTS, IN, NOT, AND, or OR).
Пример:$client->index('products')->updateFilterableAttributes(
['id','category_id','name','_geo','city_id']);pagination — задает настройки постраничной разбивки. Содержит в себе объект только с одним полем maxTotalHits. В документации не рекомендуют задавать данное значение более 20 000.
Пример:
$client->index('products')->updateSettings([
'pagination' => [
'maxTotalHits' => 10000
]
]);proximityPrecision — точность предсказания. Возможны варианты byWord — точнее/дольше индексация, byAttribute — быстрее индексация/поиск менее точен. По умолчанию byWord.
rankingRules — задает правил ранжирования. Задает приоритетность правил ранжирования. По умолчанию имеет следующий порядок:
[
"words" (сортирует результаты по уменьшению количества совпадающих условий запроса),
"typo" (сортирует результаты по увеличению количества опечаток),
"proximity" (сортирует результаты по увеличению расстояния между совпадающими условиями запроса)
),
"attribute" (сортировка на основе порядка атрибутов),
"sort" (сортировка на основании заданного поля для сортировки при запросе),
"exactness" (сортирует результаты по сходству совпадающих слов со словами запроса
)
]
Пример обновления правил ранжирования:$client->index('products')->updateRankingRules([
'words',
'sort',
'typo',
'proximity',
'attribute',
'exactness'
]);searchableAttributes — задает список атрибутов доступных для поиска. Также в этом списке задается приоритетность атрибутов. Можно также задать runtime в самом запросе через атрибут.
Пример:
$client->index('movies')->search('products', [
'attributesToSearchOn' => ['title']
]);separatorTokens — задает список разделителей для токенов.
nonSeparatorTokens — задает список символов, не ограничивающих начало и конец одного токена.
sortableAttributes — задает список полей, по которым возможна сортировка.
Пример:$client->index('products')->updateSortableAttributes(['id','name','_geo']);
stopWords — список стоп-слов, которые игнорируются при поиске.
synonyms — задает список слов синонимов.
Пример:
$client->index('movies')->updateSynonyms([
'wolverine' => ['xmen', 'logan'],
'logan' => ['wolverine', 'xmen'],
'wow' => ['world of warcraft']
]);typoTolerance — задает чувствительность к опечаткам. Содержит в себе объект с набором полей:
– enabled - true|false — включает и выключает чувствительность к опечаткам.
– minWordSizeForTypos.oneTypo — минимальный размер слова для принятия одной опечатки.
– minWordSizeForTypos.twoTypos — минимальный размер слова для принятия двух опечаток.
– disableOnWords — отключает для слов.
– disableOnAttributes — отключает для атрибутов.
Пример:$client->index('products')->updateTypoTolerance([
'minWordSizeForTypos' => [
'oneTypo' => 4,
'twoTypos' => 10
],
'disableOnAttributes' => [
'title'
]
]);//отключаем учет ошибок в написании (можно также задавать лимит символов)
$client->index('products')->updateTypoTolerance([
'enabled' => false
]);
Использование в Laravel
Laravel из коробки поддерживает использование Meilisearch. Для этого в env указываем соответствующие настройки:
SCOUT_DRIVER=meilisearch
MEILISEARCH_HOST=http://meilisearch:7700
MEILISEARCH_KEY=someKey
Настройки для индексации можно задать в конфиг файле scout. В примере ниже задаем атрибуты для сортировки и фильтрации.
'meilisearch' => [
'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'),
'key' => env('MEILISEARCH_KEY', null),
'index-settings' => [
Product::class => [
'sortableAttributes' => ['name', '_geo'],
'filterableAttributes' => ['id', 'name', '_geo', 'category_id'],
],
],
],
Чтобы использовать поиск в модели используем трейт Searchable. И после можем делать запросы вида:
1) Product::search($q)->get()
— в результате получаем всю ту же Eloquent коллекцию.
2) Если нужны сами индексы, то используем raw. Product::search($q)->raw()
— в результате получим исходные индексы из Meilisearch.
3) Если нужна более сложная выборка, то можем вторым аргументом в search передать callback. Например:
Product::search(
'Какое-либо название',
function (SearchIndex $meilisearch, string $query, array $options) {
$options['filter'] = "category_id IN[$ids]";
$options['sort'] = ["title:desc"];
$options['limit'] = 100;
return $meilisearch->search($query, $options);
}
)->get();
В $options мы можем задавать параметры фильтрации, сортировки и ограничения выборки.
При необходимости тонкой настройки или более сложных запросов можем использовать напрямую клиент rest API из пакета.
Например, нам необходимо искать сразу в нескольких индексах, то можем сделать одним запросом, используя клиент:
$client->multiSearch([
(new SearchQuery())
->setIndexUid('products')
->setQuery(‘запрос 1’')
->setLimit(5),
(new SearchQuery())
->setIndexUid('categories')
->setQuery('запрос 2')
->setLimit(5),
(new SearchQuery())
->setIndexUid('comments')
->setQuery('запрос 3')
]);
Также meilisearch можно использовать для построения фильтров и фасетного поиска:
$client->index('products')->search('classic', [
'facets' => ['color', 'size', 'country']
]);
В результате мы увидим количество записей по каждому из атрибутов.
Можем также задавать вес атрибутов индексов, что в свою очередь будет оказывать влияние на ранжирование результатов. Например, мы бы хотели, чтобы атрибут заголовка имел значительно большее влияние, чем бренд и описание. Это можно сделать следующим запросом.
$client->index('products')->updateSearchableAttributes([
'title',
'brand',
'description'
]);
То есть этот запрос одновременно обновляет список атрибутов для поиска и задает их ранжирование при поиске.
Немного про геопоиск
Также можно использовать геопоиск. Ниже пример, с фильтрацией, сортировкой и пагинацией.
Product::search(
'Какое-либо название',
function (SearchIndex $meilisearch, string $query, array $options) {
$options['filter'] = "_geoRadius($lat,$long,$dist) AND id IN[$ids]";
$options['sort'] = ["_geoPoint($lat,$long):$dir"];
$options['limit'] = $count;
return $meilisearch->search($query, $options);
}
)->get();
В примере выше делаем фильтрацию по радиусу и определенным ID с сортировкой по удаленности.
Семантический поиск
Из недавних фич meilisearch поддерживает семантический поиск (пока что в статусе experimental) используя для этого эмбеддинги. Для этого meilisearch можно настроить использование моделей от OpenA, либо использовать модели из Hugging Face (при этом вычисление эмбеддингов производится локально. В документации в качестве примера приведена модель BAAI/bge-base-en-v1.5). Ниже пример из документации:
curl \
-X PATCH 'http://localhost:7700/indexes/movies/settings' \
-H 'Content-Type: application/json' \
--data-binary '{
"embedders": {
"default": {
"source": "huggingFace",
"model": "BAAI/bge-base-en-v1.5",
"documentTemplate": "A movie titled '{{doc.title}}' whose description starts with {{doc.overview|truncatewords: 20}}"
}
}
}'
В embedders указывается название (default), далее источник (huggingFace, либо OpenAI), наименование самой модели и шаблон для эмбеддера (documentTemplate).
Далее можно выполнять сам запрос. Параметр semanticRatio отвечает за соотношение обычного и семантического поиска. В качестве модели эмбеддинга указываем ранее созданную default.
curl -X POST -H 'content-type: application/json' \
'localhost:7700/indexes/products/search' \
--data-binary '{
"q": "kitchen utensils",
"hybrid": {
"semanticRatio": 0.9,
"embedder": "default"
}
}'
По ссылке доступно демо. В нем мы можем поиграться с ползунком отвечающим за соотношение обычного и семантического поиска и увидеть влияние на результат поиска.
В заключении следует отметить что meilisearch хорошо подойдет в качестве поискового движка для небольших и средних проектов. При выборе также не стоит забывать об особенности поиска только с начала слова (prefix search), т.к. где-то это может быть критично.
Подготовил пример проекта (Laravel 11/ Posgres SQL/ Nuxt3) с реализованным поиском meilisearch. Пишите в комментариях, если было полезно!