Полнотекстовый мульти-модельный поиск в Rails c помощью ElasticSearch
В этой статьe я хочу поделится опытом реализации поискового скрипта. Передо мной стояла задача реализовать не просто поисковик по нескольким текстовым полям, а сделать поиск по нескольким моделям с учетом морфологии языка и префиксного анализа. Старшие товарищи порекомендовали использовать для этой задачи ElasticSearch. Такая реализации не будет нагружать основное приложение, а сам ElasticSearch имеет хороший API на все возможные случаи использования и легок в настройке.
Для данной статьи достаточно будет использовать три модели Article, News и BlogPost. Поиск в них будет проходить по двум атрибутам title и description. При этом, важно чтобы в поисковую выдачу попадали только те записи у которых атрибут searching имеет значение true.
Начинаем работу
Сперва устанавливаем ElasticSearch на свою машину. Если вы используете MacOS то это можно сделать одной командой в терминале.
brew install elasticsearch
Создаем необходимые модели по которым будет проходить поиск. Создаем для каждой из них миграции и запускаем их.
class CreateArticles < ActiveRecord::Migration
def change
create_table :articles do |t|
t.string :title
t.string :description
t.boolean :searching, default: false
t.timestamps null: false
end
end
endclass CreateNews < ActiveRecord::Migration
def change
create_table :news do |t|
t.string :title
t.string :description
t.boolean :searching, default: false
t.timestamps null: false
end
end
endclass CreateBlogPosts < ActiveRecord::Migration
def change
create_table :blog_posts do |t|
t.string :title
t.string :description
t.boolean :searching, default: false
t.timestamps null: false
end
end
end
Для морфологического анализа слов, есть одна очень хорошая библиотека. Она поддерживает много различных версий ElasticSearch и работает с русским и английским языком.
Для того чтобы поставить это плагин на ElasticSearch версия 5.2.0 на macOS нужно выполнить следующую команду в терминале
/usr/local/opt/elasticsearch/libexec/bin/elasticsearch-plugin install http://dl.bintray.com/content/imotov/elasticsearch-plugins/org/elasticsearch/elasticsearch-analysis-morphology/5.2.2/elasticsearch-analysis-morphology-5.2.2.zip
Теперь запускаем ElasticSearch и проверяем что он работает. Для его запуска на macOS достаточно ввести.
brew services start elasticsearch
А для проверки работы выполним get-запрос на 9200 порт (порт на котором запускается ElasticSearch по-умолчанию) и получим от него стандартный ответ:
curl http://127.0.0.1:9200/
{
"name" : "YtpgR_F",
"cluster_name" : "elasticsearch_vladislavkopylov",
"cluster_uuid" : "groLpJgrR5iayFPDtRUzzz",
"version" : {
"number" : "5.2.1",
"build_hash" : "db0d481",
"build_date" : "2017-02-09T22:05:32.386Z",
"build_snapshot" : false,
"lucene_version" : "6.4.1"
},
"tagline" : "You Know, for Search"
}
Ура, он работает 😀.
Настройка Rails
Поставим необходимые gem’ы. В gem elasticsearch-rails встроена поддержка работы постраничной навигации с помощью библиотек Kaminari или WillPaginate, чтобы включить этот функционал достаточно объявить один из этих gem’ов перед elasticsearch-rails.
# Gemfile
gem ‘will_paginate'
gem 'elasticsearch-rails'
gem 'elasticsearch-model'
Включим функционал в нужные модели. Для этого в модели Article, News и BlogPost нужно вставить две строчки.
include Elasticsearch::Model
include Elasticsearch::Model::Callbacks
Для каждой из этих моделей ElasticSearch создаст индексы в своем пространстве имен /articles для модели Article, /news для модели News и т.д. По-умолчанию название индекса находится в методе index_name самой модели. Пример:
Article.index_name
Перед созданием индексов удостоверимся что их нет. Для этого выполним get-запрос и получим сообщение об ошибке. Пример.
curl http://127.0.0.1:9200/news
{"error":{"root_cause":[{"type":"index_not_found_exception","reason":"no such index","resource.type":"index_or_alias","resource.id":"news","index_uuid":"_na_","index":"news"}],"type":"index_not_found_exception","reason":"no such index","resource.type":"index_or_alias","resource.id":"news","index_uuid":"_na_","index":"news"},"status":404}
Для добавления индекса, воспользуемся rake командой из гема. Для этого создаем файл lib/tasks/elasticsearch.rake и пропишем туда
require 'elasticsearch/rails/tasks/import'
Теперь нам доступны команды:
rake elasticsearch:import:all
rake elasticsearch:import:model
Но не будем спешить. В данном примере модели имеют всего три атрибута title, description, searching которые мы все хотим добавить в ElasticSearch. В настоящем проекте каждая модель может содержать дополнительные поля/информацию которую не нужно дублировать в ElasticSearch. Благо, мы может контролировать какие атрибуты будут переданы в из нашего Rails приложения. Создадим файл lib/es_helper.rb
И включим его в каждую нужную модель.
include EsHelper
Добавляем анализатор для наших атрибутов. Это нам нужно чтобы в поисковике заработал префикс и морфология.
Создадим еще один файл lib/elastic_my_analyzer.rb который будет содержать хэш с настройками. За основу я взял настройки из elasticsearch-analysis-morphology (demo.sh) и добавил ngram.
Немного о том что такое ngram и как он работает. Это называется префиксный поиск или поиск с ошибками. Сначала нужно указать конфигурацию, минимальный и максимальный грамм. Например, если указываем от 1 до 4, то при индексации каждого элемента, анализатор будет разбивать слово на под-слова, каждый длинной от 1 до 4 символов.
Например слово ‘Африка’ будет разбито на: a, аф, афр, афри, ф, фр, фри, фрик, р, ри, рик, рика, и, ик, ика, к, ка, а. При самом поиске будет проходит анализ по каждому под-слову. Чем больше совпадений найдет поиск, тем более выше этот элемент будет в поисковой выдаче.
Ссылки на подробную информацию:
Не все любят использовать морфология и ngram одновременно 😬. Если что-то из этого не нужно для вашей реализации, то смело удаляйте это из значения filter и заново проиндексируйте ваши записи.
Теперь добавить в каждую интересующую нас модель настройки и установим анализатор на некоторые аттрибуты.
include ElasticMyAnalyzersettings ES_SETTING do
mappings dynamic: 'true' do
indexes :title, type: 'string', analyzer: 'my_analyzer'
indexes :description, type: 'string', analyzer: 'my_analyzer'
indexes :searching, type: 'boolean'
end
end
В итоге каждая модель должна выглядеть примерно так.
Давайте добавим автозагрузку всех файлов что находятся в каталоге lib/. Для этого в файле application.rb добавим две строчки.
config.autoload_paths << Rails.root.join('lib')
config.autoload_paths += Dir["#{config.root}/lib/**/"]
Теперь можно добавить индексы в ElasticSearch. Для этого воспользуемся rake таском.
bundle exec rake environment elasticsearch:import:model CLASS='Article' FORCE=truebundle exec rake environment elasticsearch:import:model CLASS='BlogPost' FORCE=truebundle exec rake environment elasticsearch:import:model CLASS='News' FORCE=true
Чтобы проверить правильность достаточно сделать curl запрос на 9200 порт. Например curl http://127.0.0.1:9200/news. Вместо сообщения об ошибке, вы увидите конфиги 🎉.
Если по какой то причине созданные индексы нужно удалить то это можно сделать через рельсовую консоль.
rails s
> Article.__elasticsearch__.client.indices.delete index: Article.index_name rescue nil
> BlogPost.__elasticsearch__.client.indices.delete index: BlogPost.index_name rescue nil
> News.__elasticsearch__.client.indices.delete index: News.index_name rescue nil
Еще немного кода =)
Уже почти все готово. Создаем класс который будет производить поиск по нескольким моделям. Создадим файл app/models/multy_search.rb и в нем пропишем следующее.
Пример использования описан ниже. Метод results хранит в себе результат поиска, а метод raw_data сырые данные из ElasticSearch.
MultySearch.new.search('Япония').results
MultySearch.new.search('Япония').raw_data
Заполняем БД информацией из википедии
Для этого воспользуемся файлом db/seeds.rb
И загрузим это все простой командой.
rake db:seed
Перед этим этапом нужно обязательно создать индексы в ElasticSearch иначе колбеки из Elasticsearch::Model::Callbacks будут запускаться с ошибкой.
Произведем небольшой тест в консоли. Сделаем несколько поисковых запросов и выведем только title.
rails c
> MultySearch.new.search('Япония').results.map{|r| r[:hint][:title]}
["Японская Иена", "Факты про японию часть 2", "Факты про японию часть 1", "Факты про японию часть 3"]> MultySearch.new.search('Холмс').results.map{|r| r[:hint][:title]}["Шерлок Холмс цитата 1", "Шерлок Холмс цитата 2", "Шерлок Холмс цитата 3”]> MultySearch.new.search('япониа').results.map{|r| r[:hint][:title]}
["Японская Иена", "Факты про японию часть 2", "Факты про японию часть 1", "Факты про японию часть 3”]> MultySearch.new.search('Самое').results.map{|r| r[:hint][:title]}
["Шерлок Холмс цитата 1", "Шерлок Холмс цитата 2", "Шерлок Холмс цитата 3"]> MultySearch.new.search('Самая').results.map{|r| r[:hint][:title]}["Шерлок Холмс цитата 1", "Шерлок Холмс цитата 2", "Шерлок Холмс цитата 3"]> MultySearch.new.search('альпы').results.map{|r| r[:hint][:title]}["Вершина Маттерхорн"]
В целом все хорошо. Конечно, для ElasticSearch, мы имеем очень мало данных, поэтому могут появляться всякие артефакты в поисковой выдаче. Если вы недовольны результатами поиска, то можете изменить конфиги в файле lib/elastic_my_analyzer.rb. Отключить ngram, добавить свой фильтр и прочее.
Работаем со вью
То что поиск работает в консоли — это очень хорошо. Для полного счастья не хватает сделать отдельную страничку с результатами поисковой выдаче в нашем rails приложении. И удостоверимся что у нас работает постраничная навигации.
Прописываем в путь роутере.
get '/search' => 'home#search’
Создаем файл app/controllers/home_controller.rb и сделаем простеньких экшн.
class HomeController < ApplicationController
def search
if params[:query].present?
page = params[:page] || 1
@searching = MultySearch.new.search(params[:query], page)
else
@searching = nil
end
end
end
Сделаем вью app/view/home/search.html.slim
И небольшой метод в хелпере app/helper/application_helper.rb
module ApplicationHelper
def search_result_link(result)
case result[:record_type]
when 'BlogPost'
blog_post_path(result[:record_id])
when 'Article'
article_path(result[:record_id])
when 'News'
news_path(result[:record_id])
end
end
end
Теперь по url http://localhost:3000/search мы видим результаты нашей поисковой выдаче.
Надеюсь что данная статья была очень полезна.
Дополнительные вопросы можете писать мне в twitter: Kopylov_vlad