Полнотекстовый мульти-модельный поиск в 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 :titletitle
t.string :description
t.boolean :searching, default: false
t.timestamps null: false
end
end
end
class 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
end
class 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 ElasticMyAnalyzer
settings 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=true
bundle exec rake environment elasticsearch:import:model CLASS='BlogPost' FORCE=true
bundle 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