Статьи

Полнотекстовый поиск в Rails с использованием Elasticsearch

В этой статье я собираюсь показать вам, как реализовать полнотекстовый поиск с использованием Ruby on Rails и Elasticsearch. В настоящее время каждый привык вводить поисковый термин и получать предложения, а также результаты с выделенным поисковым термином. Если вы неправильно пишете то, что пытаетесь найти, автоматическая коррекция также является хорошей функцией, как мы видим на таких веб-сайтах, как Google или Facebook.

Реализовать все эти функции, используя только реляционную базу данных, такую ​​как MySQL или Postgres, непросто. По этой причине мы используем Elasticsearch, который можно рассматривать как базу данных, специально созданную и оптимизированную для поиска. Это открытый исходный код, и он построен на основе Apache Lucene.

Одна из самых приятных особенностей Elasticsearch — это предоставление его функциональности с использованием REST API, поэтому есть библиотеки, обертывающие эту функциональность для большинства языков программирования.

Ранее я упоминал, что Elasticsearch — это база данных для поиска. Было бы полезно, если вы знакомы с некоторой терминологией вокруг него.

  • Поле: поле похоже на пару ключ-значение. Значение может быть простым значением (строка, целое число, дата) или вложенной структурой, такой как массив или объект. Поле аналогично столбцу в таблице в реляционной базе данных.
  • Документ : документ представляет собой список полей. Это документ JSON, который хранится в Elasticsearch. Это как строка в таблице в реляционной базе данных. Каждый документ хранится в индексе и имеет тип и уникальный идентификатор.
  • Тип : тип похож на таблицу в реляционной базе данных. Каждый тип имеет список полей, которые могут быть указаны для документов этого типа.
  • Индекс : Индекс является эквивалентом реляционной базы данных. Он содержит определение для нескольких типов и хранит несколько документов.

Здесь нужно отметить одну вещь: в Elasticsearch, когда вы записываете документ в индекс, поля документа анализируются слово за словом, чтобы сделать поиск простым и быстрым. Elasticsearch также поддерживает геолокацию, поэтому вы можете искать документы, которые находятся на определенном расстоянии от заданного местоположения. Именно так Foursquare выполняет поиск .

Я хотел бы отметить, что Elasticsearch был создан с учетом высокой масштабируемости, поэтому очень легко создать кластер с несколькими серверами и иметь высокую доступность, даже если некоторые серверы выйдут из строя. Я не собираюсь рассказывать о специфике планирования и развертывания кластеров разных типов в этой статье.

Если вы используете Linux, возможно, вы можете установить Elasticsearch из одного из репозиториев . Это доступно в APT и YUM.

Если вы используете Mac, вы можете установить его с помощью Homebrew: brew install elasticsearch . После установкиasticsearch вы увидите список соответствующих папок в вашем терминале:

Elasticsearch папки

Чтобы убедиться, что установка работает, введите в вашем терминале elasticsearch чтобы запустить его. Затем запустите curl localhost:9200 в своем терминале, и вы должны увидеть что-то вроде:

Elasticsearch работает

Elastic HQ — это подключаемый модуль мониторинга, который мы можем использовать для управления Elasticsearch из браузера, аналогично phpMyAdmin для MySQL. Чтобы установить его, просто запустите в своем терминале:

/usr/local/Cellar/elasticsearch/2.2.0_1/libexec/bin/plugin -install royrusso/elasticsearch-HQ

После установки перейдите по адресу http: // localhost: 9200 / _plugin / hq в вашем браузере:

Elastic HQ Plugin

Нажмите на Connect, и вы увидите экран, показывающий состояние кластера:

Обзор Elastic HQ Cluster

В настоящее время, как и следовало ожидать, индексы или документы еще не созданы, но у нас установлен и работает наш локальный экземпляр Elasticsearch.

Я собираюсь создать очень простое приложение на Rails, где вы можете добавлять статьи в базу данных, чтобы мы могли выполнять полнотекстовый поиск по ним, используя Elasticsearch. Начните с создания нового приложения Rails:

rails new elasticsearch-rails

Затем мы генерируем новый ресурс Article со скаффолдингами:

rails generate scaffold Article title:string text:text

Теперь нам нужно добавить новый корневой маршрут, чтобы мы могли видеть по умолчанию список статей. Отредактируйте файл config / rout.rb :

1
2
3
4
Rails.application.routes.draw do
  root to: ‘articles#index’
  resources :articles
end

Создайте базу данных, выполнив команду rake db:migrate . Если вы запускаете rails server , откройте браузер, перейдите к localhost: 3000 и добавьте несколько статей в базу данных, или просто загрузите файл db / seeds.rb с фиктивными данными, которые я создал, чтобы вам не пришлось тратить много времени на заполнение форм.

Теперь, когда у нас есть небольшое приложение Rails со статьями в базе данных, мы готовы добавить нашу функцию поиска. Мы собираемся начать с добавления ссылки на официальные драгоценные камни Elasticsearch:

1
2
gem ‘elasticsearch-model’
gem ‘elasticsearch-rails’

На многих веб-сайтах часто встречается текстовое поле для поиска в верхнем меню на всех страницах. По этой причине я собираюсь создать частичную форму в app / views / search / _form.html.erb .   Как видите, я отправляю форму с помощью GET, поэтому легко скопировать и вставить URL для определенного поиска.

1
2
3
4
5
6
<%= form_for :term, url: search_path, method: :get do |form|
  <p>
    <%= text_field_tag :term, params[:term] %>
    <%= submit_tag «Search», name: nil %>
  </p>
<% end %>

Добавьте ссылку на форму в основной макет сайта. Отредактируйте app / views / layouts / application.html.erb.

1
2
3
4
<body>
  <%= render ‘search/form’ %>
  <%= yield %>
</body>

Теперь нам также нужен контроллер для выполнения фактического поиска и отображения результатов, поэтому мы сгенерируем его, запустив команду rails g new controller Search .

1
2
3
4
5
6
7
8
9
class SearchController < ApplicationController
  def search
    if params[:term].nil?
      @articles = []
    else
      @articles = Article.search params[:term]
    end
  end
end

Как видите, я вызываю метод search по модели Article. Мы еще не определили это, поэтому, если мы попытаемся выполнить поиск в этой точке, мы получим ошибку. Также мы не добавили маршрут для SearchController в файл config / rout.rb , поэтому давайте сделаем так:

1
2
3
4
5
6
Rails.application.routes.draw do
  root to: ‘articles#index’
 
  resources :articles
  get «search», to: «search#search»
end

Если мы посмотрим на документацию для драгоценного камня’asticsearch-rails ‘ , нам нужно включить два модуля в модели, которые мы хотим индексировать в Elasticsearch, в нашем случае Article.rb .

1
2
3
4
5
6
require ‘elasticsearch/model’
 
class Article < ActiveRecord::Base
  include Elasticsearch::Model
  include Elasticsearch::Model::Callbacks
end

Первая модель внедряет метод поиска, который мы использовали в нашем предыдущем контроллере среди других. Второй модуль интегрируется с обратными вызовами ActiveRecord для индексации каждого экземпляра статьи, которую мы сохраняем в базе данных, а также обновляет индекс, если мы модифицируем или удаляем статью из базы данных. Так что для нас все прозрачно.

Если вы импортировали данные в базу данных ранее, эти статьи все еще не включены в индекс Elasticsearch; только новые индексируются автоматически. По этой причине мы должны индексировать их вручную, и это легко, если мы запустим rails console . Тогда нам нужно только запустить irb(main) > Article.import .

Articleimport

Теперь мы готовы попробовать функциональность поиска. Если я наберу ‘ruby’ и нажму поиск, вот результаты:

Результат поиска

На многих веб-сайтах вы можете увидеть на странице результатов поиска, как выделяется искомый термин. Это очень легко сделать с помощью Elasticsearch.

Отредактируйте app / models / article.rb и измените метод поиска по умолчанию:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
def self.search(query)
  __elasticsearch__.search(
    {
      query: {
        multi_match: {
          query: query,
          fields: [‘title’, ‘text’]
        }
      },
      highlight: {
        pre_tags: [‘<em>’],
        post_tags: [‘</em>’],
        fields: {
          title: {},
          text: {}
        }
      }
    }
  )
end

По умолчанию метод search определяется gem’asticsearch-models ‘, а прокси-объект __elasticsearch__ предоставляется для доступа к классу оболочки для API Elasticsearch. Таким образом, мы можем изменить запрос по умолчанию, используя стандартные параметры JSON, как указано в документации .

Теперь метод поиска обернет результаты, соответствующие запросу, указанными тегами HTML. По этой причине нам также необходимо обновить страницу результатов поиска, чтобы мы могли безопасно отображать HTML-теги. Для этого отредактируйте app / views / search / search.html.erb .

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
<h1>Search Results</h1>
 
<% if @articles %>
  <ul class=»search_results»>
    <% @articles.each do |article|
      <li>
        <h3>
          <%= link_to article.try(:highlight).try(:title) ?
              article.highlight.title[0].html_safe : article.title,
              controller: «articles», action: «show», id: article._id %>
        </h3>
        <% if article.try(:highlight).try(:text) %>
          <% article.highlight.text.each do |snippet|
          <p><%= snippet.html_safe %>…</p>
        <% end %>
      <% end %>
    </li>
  <% end %>
</ul>
<% else %>
  <p>Your search did not match any documents.</p>
<% end %>

Добавьте стиль CSS в app / assets / stylesheets / search.scss для выделенного тега:

1
2
3
4
5
.search_results em {
  background-color: yellow;
  font-style: normal;
  font-weight: bold;
}

Попробуйте снова найти ‘ruby’:

Результат поиска

Как видите, термин поиска легко выделить, но он не идеален, так как нам нужно отправить запрос JSON, как указано в документации Elasticsearch, и у нас нет какой-либо абстракции.

Searchkick Gem предоставляется Instacart , и это абстракция поверх официальных драгоценных камней Elasticsearch. Я собираюсь реорганизовать функциональность выделения, поэтому мы начнем с добавления gem 'searchkick' в gemfile. Первый класс, который нам нужно изменить, это модель Article.rb:

1
2
3
class Article < ActiveRecord::Base
  searchkick
end

Как видите, все гораздо проще. Нам нужно еще раз переиндексировать статьи и выполнить команду rake searchkick:reindex CLASS=Article . Чтобы выделить критерий поиска, нам нужно передать дополнительный параметр в метод поиска из нашего search_controller.rb .

01
02
03
04
05
06
07
08
09
10
class SearchController < ApplicationController
  def search
    if params[:term].nil?
      @articles = []
    else
      term = params[:term]
      @articles = Article.search term, fields: [:text], highlight: true
    end
  end
end

Последний файл, который нам нужно изменить, это views / search / search.html.erb, поскольку результаты теперь возвращаются поиском в другом формате:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
<h2>Search Results for: <i><%= params[:term] %></i></h2>
 
<% if @articles %>
<ul class=»search_results»>
  <% @articles.with_details.each do |article, details|
    <li>
      <h3>
        <%= link_to article.title, controller: «articles», action: «show», id: article.id %>
      </h3>
      <p><%= details[:highlight][:text].html_safe %>…</p>
    </li>
  <% end %>
</ul>
<% else %>
  <p>Your search did not match any documents.</p>
<% end %>

Теперь пришло время снова запустить приложение и проверить функциональность поиска:

Результат поиска

Обратите внимание, что я ввел в качестве поискового термина «dato». Я сделал это специально, чтобы показать вам, что по умолчанию настроен для анализа проиндексированного текста и более допустимого с ошибками.

Autosuggest или typeahead предсказывают, что пользователь будет печатать, делая поиск быстрее и проще. Имейте в виду, что, если у вас нет тысяч записей, может быть лучше выполнить фильтрацию на стороне клиента.

Давайте начнем с добавления плагина typeahead , который доступен через gem 'bootstrap-typeahead-rails' , и добавим его в ваш Gemfile. Затем нам нужно добавить JavaScript в app / assets / javascripts / application.js, чтобы при вводе в поле поиска появлялись некоторые предложения.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
//= require jquery
//= require jquery_ujs
//= require turbolinks
//= require bootstrap-typeahead-rails
//= require_tree .
 
var ready = function() {
  var engine = new Bloodhound({
      datumTokenizer: function(d) {
          console.log(d);
          return Bloodhound.tokenizers.whitespace(d.title);
      },
      queryTokenizer: Bloodhound.tokenizers.whitespace,
      remote: {
          url: ‘../search/typeahead/%QUERY’
      }
  });
 
  var promise = engine.initialize();
 
  promise
      .done(function() { console.log(‘success’); })
      .fail(function() { console.log(‘error’) });
 
  $(«#term»).typeahead(null, {
    name: «article»,
    displayKey: «title»,
    source: engine.ttAdapter()
  })
};
 
$(document).ready(ready);
$(document).on(‘page:load’, ready);

Несколько комментариев о предыдущем фрагменте. В последних двух строках, поскольку я не отключил турболинки, это способ подключить код, который я хочу запустить при загрузке страницы. В первой части сценария вы можете видеть, что я использую Bloodhound . Это механизм предложений typeahead.js, и я также настраиваю конечную точку JSON для выполнения запросов AJAX для получения предложений. После этого я вызываю initialize() на движке и настраиваю typeahead в текстовом поле поиска, используя его идентификатор «term».

Теперь нам нужно выполнить внутреннюю реализацию для предложений, давайте начнем с добавления маршрута, отредактируем app / config / rout.rb.

1
2
3
4
5
6
7
Rails.application.routes.draw do
  root to: ‘articles#index’
 
  resources :articles
  get «search», to: «search#search»
  get ‘search/typeahead/:term’ => ‘search#typeahead’
end

Далее я собираюсь добавить реализацию в app / controllers / search_controller.rb .

1
2
3
4
5
6
7
8
def typeahead
  render json: Article.search(params[:term], {
    fields: [«title»],
    limit: 10,
    load: false,
    misspellings: {below: 5},
  }).map do |article|
end

Этот метод возвращает результаты поиска для термина, введенного с использованием JSON. Я ищу только по названию, но могу указать и текст статьи. Я также ограничиваю количество результатов поиска до 10 максимум.

Теперь мы готовы попробовать реализацию typeahead:

машинописный

Как видите, использование Elasticsearch с Rails делает поиск наших данных действительно простым и очень быстрым. Здесь я показал вам, как использовать низкоуровневые самоцветы, предоставляемые Elasticsearch, а также самоцвет Searchkick, который является абстракцией, скрывающей некоторые детали того, как работает Elasticsearch.

В зависимости от ваших конкретных потребностей вы можете использовать Searchkick и быстро и легко внедрить полнотекстовый поиск. С другой стороны, если у вас есть некоторые другие сложные запросы, включая фильтры или группы, вам может понадобиться больше узнать о деталях языка запросов в Elasticsearch и в конечном итоге использовать гемы низкого уровня «asticsearch-models »и «asticsearch-». рельсы.