Elasticsearch для Ruby on Rails: руководство по Chewy Gem

Опубликовано: 2022-03-11

Elasticsearch предоставляет мощный HTTP-интерфейс RESTful для индексации и запроса данных, построенный на основе библиотеки Apache Lucene. Сразу после установки он обеспечивает масштабируемый, эффективный и надежный поиск с поддержкой UTF-8. Это мощный инструмент для индексирования и запроса больших объемов структурированных данных, и здесь, в Toptal, он обеспечивает поиск по нашей платформе и скоро будет использоваться также для автозаполнения. Мы большие фанаты.

Chewy расширяет клиент Elasticsearch-Ruby, делая его более мощным и обеспечивая более тесную интеграцию с Rails.

Поскольку наша платформа построена с использованием Ruby on Rails, наша интеграция Elasticsearch использует преимущества проекта elasticsearch-ruby (инфраструктура интеграции Ruby для Elasticsearch, которая предоставляет клиент для подключения к кластеру Elasticsearch, API Ruby для REST API Elasticsearch и различные расширения и утилиты). Опираясь на эту основу, мы разработали и выпустили собственное улучшение (и упрощение) архитектуры поиска приложений Elasticsearch, упакованное в виде драгоценного камня Ruby, который мы назвали Chewy (пример приложения доступен здесь).

Chewy расширяет клиент Elasticsearch-Ruby, делая его более мощным и обеспечивая более тесную интеграцию с Rails. В этом руководстве по Elasticsearch я расскажу (на примерах использования), как мы этого добились, включая технические препятствия, возникшие во время реализации.

Отношения между Elasticsearch и Ruby on Rails показаны в этом визуальном руководстве.

Всего пара быстрых заметок, прежде чем перейти к руководству:

  • И Chewy, и демонстрационное приложение Chewy доступны на GitHub.
  • Для тех, кто интересуется дополнительной информацией об Elasticsearch «под капотом», я включил краткую рецензию в качестве приложения к этому сообщению.

Почему Чуи?

Несмотря на масштабируемость и эффективность Elasticsearch, его интеграция с Rails оказалась не такой простой, как предполагалось. В Toptal нам потребовалось значительно расширить базовый клиент Elasticsearch-Ruby, чтобы сделать его более производительным и поддерживать дополнительные операции.

Несмотря на масштабируемость и эффективность Elasticsearch, его интеграция с Rails оказалась не такой простой, как предполагалось.

Так родился драгоценный камень Chewy.

Несколько особенно примечательных особенностей Chewy включают в себя:

  1. Каждый индекс доступен для наблюдения всеми связанными моделями.

    Большинство индексированных моделей связаны друг с другом. А иногда необходимо денормализовать эти связанные данные и связать их с одним и тем же объектом (например, если вы хотите проиндексировать массив тегов вместе со связанной с ними статьей). Chewy позволяет указать обновляемый индекс для каждой модели, поэтому соответствующие статьи будут переиндексироваться при каждом обновлении соответствующего тега.

  2. Классы индексов не зависят от моделей ORM/ODM.

    Благодаря этому усовершенствованию, например, реализовать автозаполнение между моделями стало намного проще. Вы можете просто определить индекс и работать с ним объектно-ориентированным способом. В отличие от других клиентов, Chewy gem избавляет от необходимости вручную реализовывать классы индексов, обратные вызовы импорта данных и другие компоненты.

  3. Массовый импорт есть везде .

    Chewy использует массовый API Elasticsearch для полной переиндексации и обновлений индекса. Он также использует концепцию атомарных обновлений, собирая измененные объекты в атомарном блоке и обновляя их все сразу.

  4. Chewy предоставляет DSL запросов в стиле AR.

    Будучи цепочечным, объединяемым и ленивым, это усовершенствование позволяет создавать запросы более эффективным образом.

Итак, давайте посмотрим, как все это будет реализовано в драгоценном камне…

Основное руководство по Elasticsearch

В Elasticsearch есть несколько концепций, связанных с документами. Первый — это index (аналог database в РСУБД), который состоит из набора documents , которые могут быть нескольких types (где type — это своего рода таблица РСУБД).

Каждый документ имеет набор fields . Каждое поле анализируется независимо, и его параметры анализа сохраняются в mapping для его типа. Chewy использует эту структуру «как есть» в своей объектной модели:

 class EntertainmentIndex < Chewy::Index settings analysis: { analyzer: { title: { tokenizer: 'standard', filter: ['lowercase', 'asciifolding'] } } } define_type Book.includes(:author, :tags) do field :title, analyzer: 'title' field :year, type: 'integer' field :author, value: ->{ author.name } field :author_id, type: 'integer' field :description field :tags, index: 'not_analyzed', value: ->{ tags.map(&:name) } end {movie: Video.movies, cartoon: Video.cartoons}.each do |type_name, scope| define_type scope.includes(:director, :tags), name: type_name do field :title, analyzer: 'title' field :year, type: 'integer' field :author, value: ->{ director.name } field :author_id, type: 'integer', value: ->{ director_id } field :description field :tags, index: 'not_analyzed', value: ->{ tags.map(&:name) } end end end

Выше мы определили индекс Elasticsearch под названием entertainment с тремя типами: book , movie и cartoon . Для каждого типа мы определили несколько сопоставлений полей и набор настроек для всего индекса.

Итак, мы определили EntertainmentIndex и хотим выполнить несколько запросов. В качестве первого шага нам нужно создать индекс и импортировать наши данные:

 EntertainmentIndex.create! EntertainmentIndex.import # EntertainmentIndex.reset! (which includes deletion, # creation, and import) could be used instead

Метод .import знает об импортированных данных, потому что мы передали области видимости, когда определяли наши типы; таким образом, он будет импортировать все книги, фильмы и мультфильмы, хранящиеся в постоянном хранилище.

После этого мы можем выполнить несколько запросов:

 EntertainmentIndex.query(match: {author: 'Tarantino'}).filter{ year > 1990 } EntertainmentIndex.query(match: {title: 'Shawshank'}).types(:movie) EntertainmentIndex.query(match: {author: 'Tarantino'}).only(:id).limit(10).load # the last one loads ActiveRecord objects for documents found

Теперь наш индекс почти готов для использования в нашей реализации поиска.

Интеграция с рельсами

Для интеграции с Rails первое, что нам нужно, — это иметь возможность реагировать на изменения объектов РСУБД. Chewy поддерживает такое поведение с помощью обратных вызовов, определенных в методе класса update_index . update_index принимает два аргумента:

  1. Идентификатор типа предоставляется в "index_name#type_name"
  2. Имя метода или блок для выполнения, который представляет обратную ссылку на обновленный объект или коллекцию объектов.

Нам нужно определить эти обратные вызовы для каждой зависимой модели:

 class Book < ActiveRecord::Base acts_as_taggable belongs_to :author, class_name: 'Dude' # We update the book itself on-change update_index 'entertainment#book', :self end class Video < ActiveRecord::Base acts_as_taggable belongs_to :director, class_name: 'Dude' # Update video types when changed, depending on the category update_index('entertainment#movie') { self if movie? } update_index('entertainment#cartoon') { self if cartoon? } end class Dude < ActiveRecord::Base acts_as_taggable has_many :books has_many :videos # If author or director was changed, all the corresponding # books, movies and cartoons are updated update_index 'entertainment#book', :books update_index('entertainment#movie') { videos.movies } update_index('entertainment#cartoon') { videos.cartoons } end

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

 ActsAsTaggableOn::Tag.class_eval do has_many :books, through: :taggings, source: :taggable, source_type: 'Book' has_many :videos, through: :taggings, source: :taggable, source_type: 'Video' # Updating all tag-related objects update_index 'entertainment#book', :books update_index('entertainment#movie') { videos.movies } update_index('entertainment#cartoon') { videos.cartoons } end ActsAsTaggableOn::Tagging.class_eval do # Same goes for the intermediate model update_index('entertainment#book') { taggable if taggable_type == 'Book' } update_index('entertainment#movie') { taggable if taggable_type == 'Video' && taggable.movie? } update_index('entertainment#cartoon') { taggable if taggable_type == 'Video' && taggable.cartoon? } end

На этом этапе каждое сохранение или уничтожение объекта будет обновлять соответствующий тип индекса Elasticsearch.

атомарность

У нас есть еще одна затяжная проблема. Если мы сделаем что-то вроде books.map(&:save) для сохранения нескольких книг, мы будем запрашивать обновление индекса entertainment каждый раз, когда сохраняется отдельная книга . Таким образом, если мы сохраним пять книг, мы пять раз обновим индекс Chewy. Такое поведение приемлемо для REPL, но определенно неприемлемо для действий контроллера, производительность которых критична.

Мы решаем эту проблему с помощью блока Chewy.atomic :

 class ApplicationController < ActionController::Base around_action { |&block| Chewy.atomic(&block) } end

Короче говоря, Chewy.atomic эти обновления следующим образом:

  1. Отключает обратный вызов after_save .
  2. Собирает идентификаторы сохраненных книг.
  3. По завершении блока Chewy.atomic использует собранные идентификаторы для отправки одного запроса на обновление индекса Elasticsearch.

Searching

Теперь мы готовы реализовать поисковый интерфейс. Поскольку наш пользовательский интерфейс — это форма, лучший способ ее создания — это, конечно же, FormBuilder и ActiveModel. (В Toptal мы используем ActiveData для реализации интерфейсов ActiveModel, но не стесняйтесь использовать свой любимый драгоценный камень.)

 class EntertainmentSearch include ActiveData::Model attribute :query, type: String attribute :author_id, type: Integer attribute :min_year, type: Integer attribute :max_year, type: Integer attribute :tags, mode: :arrayed, type: String, normalize: ->(value) { value.reject(&:blank?) } # This accessor is for the form. It will have a single text field # for comma-separated tag inputs. def tag_list= value self.tags = value.split(',').map(&:strip) end def tag_list self.tags.join(', ') end end

Руководство по запросам и фильтрам

Теперь, когда у нас есть объект, подобный ActiveModel, который может принимать атрибуты и приводить типы, давайте реализуем поиск:

 class EntertainmentSearch ... def index EntertainmentIndex end def search # We can merge multiple scopes [query_string, author_id_filter, year_filter, tags_filter].compact.reduce(:merge) end # Using query_string advanced query for the main query input def query_string index.query(query_string: {fields: [:title, :author, :description], query: query, default_operator: 'and'}) if query? end # Simple term filter for author id. `:author_id` is already # typecasted to integer and ignored if empty. def author_id_filter index.filter(term: {author_id: author_id}) if author_id? end # For filtering on years, we will use range filter. # Returns nil if both min_year and max_year are not passed to the model. def year_filter body = {}.tap do |body| body.merge!(gte: min_year) if min_year? body.merge!(lte: max_year) if max_year? end index.filter(range: {year: body}) if body.present? end # Same goes for `author_id_filter`, but `terms` filter used. # Returns nil if no tags passed in. def tags_filter index.filter(terms: {tags: tags}) if tags? end end

Контроллеры и представления

На этом этапе наша модель может выполнять поисковые запросы с переданными атрибутами. Использование будет выглядеть примерно так:

 EntertainmentSearch.new(query: 'Tarantino', min_year: 1990).search

Обратите внимание, что в контроллере мы хотим загружать точные объекты ActiveRecord, а не обертки документов Chewy :

 class EntertainmentController < ApplicationController def index @search = EntertainmentSearch.new(params[:search]) # In case we want to load real objects, we don't need any other # fields except for `:id` retrieved from Elasticsearch index. # Chewy query DSL supports Kaminari gem and corresponding API. # Also, we pass scopes for every requested type to the `load` method. @entertainments = @search.search.only(:id).page(params[:page]).load( book: {scope: Book.includes(:author)}, movie: {scope: Video.includes(:director)}, cartoon: {scope: Video.includes(:director)} ) end end

Теперь пришло время написать немного HAML в entertainment/index.html.haml :

 = form_for @search, as: :search, url: entertainment_index_path, method: :get do |f| = f.text_field :query = f.select :author_id, Dude.all.map { |d| [d.name, d.id] }, include_blank: true = f.text_field :min_year = f.text_field :max_year = f.text_field :tag_list = f.submit - if @entertainments.any? %dl - @entertainments.each do |entertainment| %dt %h1= entertainment.title %strong= entertainment.class %dd %p= entertainment.year %p= entertainment.description %p= entertainment.tag_list = paginate @entertainments - else Nothing to see here

Сортировка

В качестве бонуса мы также добавим сортировку в нашу функцию поиска.

Предположим, что нам нужно отсортировать по полям заголовка и года, а также по релевантности. К сожалению, название One Flew Over the Cuckoo's Nest будет разделено на отдельные термины, поэтому сортировка по этим разрозненным терминам будет слишком случайной; вместо этого мы хотели бы отсортировать по всему заголовку.

Решение — использовать специальное поле title и применять собственный анализатор:

 class EntertainmentIndex < Chewy::Index settings analysis: { analyzer: { ... sorted: { # `keyword` tokenizer will not split our titles and # will produce the whole phrase as the term, which # can be sorted easily tokenizer: 'keyword', filter: ['lowercase', 'asciifolding'] } } } define_type Book.includes(:author, :tags) do # We use the `multi_field` type to add `title.sorted` field # to the type mapping. Also, will still use just the `title` # field for search. field :title, type: 'multi_field' do field :title, index: 'analyzed', analyzer: 'title' field :sorted, index: 'analyzed', analyzer: 'sorted' end ... end {movie: Video.movies, cartoon: Video.cartoons}.each do |type_name, scope| define_type scope.includes(:director, :tags), name: type_name do # For videos as well field :title, type: 'multi_field' do field :title, index: 'analyzed', analyzer: 'title' field :sorted, index: 'analyzed', analyzer: 'sorted' end ... end end end

Кроме того, мы собираемся добавить эти новые атрибуты и этап обработки сортировки в нашу модель поиска:

 class EntertainmentSearch # we are going to use `title.sorted` field for sort SORT = {title: {'title.sorted' => :asc}, year: {year: :desc}, relevance: :_score} ... attribute :sort, type: String, enum: %w(title year relevance), default_blank: 'relevance' ... def search # we have added `sorting` scope to merge list [query_string, author_id_filter, year_filter, tags_filter, sorting].compact.reduce(:merge) end def sorting # We have one of the 3 possible values in `sort` attribute # and `SORT` mapping returns actual sorting expression index.order(SORT[sort.to_sym]) end end

Наконец, мы изменим нашу форму, добавив поле выбора параметров сортировки:

 = form_for @search, as: :search, url: entertainment_index_path, method: :get do |f| ... / `EntertainmentSearch.sort_values` will just return / enum option content from the sort attribute definition. = f.select :sort, EntertainmentSearch.sort_values ...

Обработка ошибок

Если ваши пользователи выполняют неправильные запросы, такие как ( или AND , клиент Elasticsearch выдаст ошибку. Чтобы справиться с этим, давайте внесем некоторые изменения в наш контроллер:

 class EntertainmentController < ApplicationController def index @search = EntertainmentSearch.new(params[:search]) @entertainments = @search.search.only(:id).page(params[:page]).load( book: {scope: Book.includes(:author)}, movie: {scope: Video.includes(:director)}, cartoon: {scope: Video.includes(:director)} ) rescue Elasticsearch::Transport::Transport::Errors::BadRequest => e @entertainments = [] @error = e.message.match(/QueryParsingException\[([^;]+)\]/).try(:[], 1) end end

Далее нам нужно отрендерить ошибку в представлении:

 ... - if @entertainments.any? ... - else - if @error = @error - else Nothing to see here

Тестирование запросов Elasticsearch

Базовая установка для тестирования выглядит следующим образом:

  1. Запустите сервер Elasticsearch.
  2. Очистите и создайте наши индексы.
  3. Импортируйте наши данные.
  4. Выполните наш запрос.
  5. Сопоставьте результат с нашими ожиданиями.

Для шага 1 удобно использовать тестовый кластер, определенный в геме elasticsearch-extensions. Просто добавьте следующую строку в Rakefile вашего проекта после установки gem:

 require 'elasticsearch/extensions/test/cluster/tasks'

Затем вы получите следующие Rake-задачи:

 $ rake -T elasticsearch rake elasticsearch:start # Start Elasticsearch cluster for tests rake elasticsearch:stop # Stop Elasticsearch cluster for tests

Эластичный поиск и Rspec

Во-первых, нам нужно убедиться, что наш индекс обновлен, чтобы синхронизироваться с нашими изменениями данных. К счастью, жемчужина Chewy поставляется с полезным средством сопоставления update_index update_index:

 describe EntertainmentIndex do # No need to cleanup Elasticsearch as requests are # stubbed in case of `update_index` matcher usage. describe 'Tag' do # We create several books with the same tag let(:books) { create_list :book, 2, tag_list: 'tag1' } specify do # We expect that after modifying the tag name... expect do ActsAsTaggableOn::Tag.where(name: 'tag1').update_attributes(name: 'tag2') # ... the corresponding type will be updated with previously-created books. end.to update_index('entertainment#book').and_reindex(books, with: {tags: ['tag2']}) end end end

Затем нам нужно проверить, что фактические поисковые запросы выполняются правильно и возвращают ожидаемые результаты:

 describe EntertainmentSearch do # Just defining helpers for simplifying testing def search attributes = {} EntertainmentSearch.new(attributes).search end # Import helper as well def import *args # We are using `import!` here to be sure all the objects are imported # correctly before examples run. EntertainmentIndex.import! *args end # Deletes and recreates index before every example before { EntertainmentIndex.purge! } describe '#min_year, #max_year' do let(:book) { create(:book, year: 1925) } let(:movie) { create(:movie, year: 1970) } let(:cartoon) { create(:cartoon, year: 1995) } before { import book: book, movie: movie, cartoon: cartoon } # NOTE: The sample code below provides a clear usage example but is not # optimized code. Something along the following lines would perform better: # `specify { search(min_year: 1970).map(&:id).map(&:to_i) # .should =~ [movie, cartoon].map(&:id) }` specify { search(min_year: 1970).load.should =~ [movie, cartoon] } specify { search(max_year: 1980).load.should =~ [book, movie] } specify { search(min_year: 1970, max_year: 1980).load.should == [movie] } specify { search(min_year: 1980, max_year: 1970).should == [] } end end

Устранение неполадок тестового кластера

Наконец, вот руководство по устранению неполадок в тестовом кластере:

  • Для начала используйте одноузловой кластер в памяти. Это будет намного быстрее для спецификаций. В нашем случае: TEST_CLUSTER_NODES=1 rake elasticsearch:start

  • Существуют некоторые существующие проблемы с самой реализацией тестового кластера elasticsearch-extensions , связанные с проверкой состояния кластера с одним узлом (в некоторых случаях он желтый и никогда не будет зеленым, поэтому проверка запуска кластера с зеленым статусом каждый раз будет завершаться ошибкой). Проблема была исправлена ​​в форке, но, надеюсь, она скоро будет исправлена ​​​​в основном репо.

  • Для каждого набора данных сгруппируйте запрос в спецификации (т. е. импортируйте данные один раз, а затем выполните несколько запросов). Elasticsearch долго прогревается и использует много кучи памяти при импорте данных, так что не переусердствуйте, особенно если у вас куча спецификаций.

  • Убедитесь, что на вашей машине достаточно памяти, иначе Elasticsearch зависнет (нам потребовалось около 5 ГБ для каждой тестовой виртуальной машины и около 1 ГБ для самого Elasticsearch).

Подведение итогов

Elasticsearch описывается как «гибкая и мощная распределенная поисковая и аналитическая система с открытым исходным кодом в реальном времени». Это золотой стандарт в поисковых технологиях.

В Chewy наши разработчики Rails упаковали эти преимущества в виде простой, удобной в использовании, высококачественной жемчужины Ruby с открытым исходным кодом, которая обеспечивает тесную интеграцию с Rails. Elasticsearch и Rails — какое потрясающее сочетание!

Elasticsearch и Rails — какое потрясающее сочетание!
Твитнуть


Приложение: Внутреннее устройство Elasticsearch

Вот очень краткое введение в Elasticsearch «под капотом»…

Elasticsearch построен на Lucene, который сам использует инвертированные индексы в качестве основной структуры данных. Например, если у нас есть строки «собаки высоко прыгают», «перепрыгивают через забор» и «забор был слишком высок», мы получаем следующую структуру:

 "the" [0, 0], [1, 2], [2, 0] "dogs" [0, 1] "jump" [0, 2], [1, 0] "high" [0, 3], [2, 4] "over" [1, 1] "fence" [1, 3], [2, 1] "was" [2, 2] "too" [2, 3]

Таким образом, каждый термин содержит как ссылки на текст, так и позиции в нем. Кроме того, мы решили изменить наши термины (например, удалив стоп-слова, такие как «the»), и применить фонетическое хеширование к каждому термину (вы можете угадать алгоритм?):

 "DAG" [0, 1] "JANP" [0, 2], [1, 0] "HAG" [0, 3], [2, 4] "OVAR" [1, 1] "FANC" [1, 3], [2, 1] "W" [2, 2] "T" [2, 3]

Если мы затем запросим «собака прыгает», он будет проанализирован так же, как и исходный текст, после хеширования превратится в «DAG JANP» («собака» имеет тот же хэш, что и «собаки», как и в случае с «прыжками» и "Прыгать").

Мы также добавляем некоторую логику между отдельными словами в строке (в зависимости от настроек конфигурации), выбирая между («DAG» И «JANP») или («DAG» ИЛИ «JANP»). Первый возвращает пересечение [0] & [0, 1] (т. е. документ 0), а второй — [0] | [0, 1] [0] | [0, 1] (т. е. документы 0 и 1). Позиции в тексте можно использовать для оценки результатов и запросов, зависящих от позиции.