Elasticsearch untuk Ruby on Rails: Tutorial untuk Permata yang kenyal

Diterbitkan: 2022-03-11

Elasticsearch menyediakan antarmuka HTTP yang andal dan RESTful untuk pengindeksan dan kueri data, yang dibangun di atas pustaka Apache Lucene. Langsung dari kotak, ini menyediakan pencarian yang skalabel, efisien, dan kuat, dengan dukungan UTF-8. Ini adalah alat yang ampuh untuk mengindeks dan menanyakan sejumlah besar data terstruktur dan, di sini, di Toptal, ini mendukung pencarian platform kami dan akan segera digunakan untuk pelengkapan otomatis juga. Kami penggemar berat.

Chewy memperluas klien Elasticsearch-Ruby, membuatnya lebih kuat dan menyediakan integrasi yang lebih erat dengan Rails.

Karena platform kami dibangun menggunakan Ruby on Rails, integrasi Elasticsearch kami memanfaatkan proyek elasticsearch-ruby (kerangka kerja integrasi Ruby untuk Elasticsearch yang menyediakan klien untuk terhubung ke cluster Elasticsearch, API Ruby untuk REST API Elasticsearch, dan berbagai ekstensi dan utilitas). Berdasarkan fondasi ini, kami telah mengembangkan dan merilis peningkatan (dan penyederhanaan) kami sendiri dari arsitektur pencarian aplikasi Elasticsearch, dikemas sebagai permata Ruby yang kami beri nama Chewy (dengan contoh aplikasi tersedia di sini).

Chewy memperluas klien Elasticsearch-Ruby, membuatnya lebih kuat dan menyediakan integrasi yang lebih erat dengan Rails. Dalam panduan Elasticsearch ini, saya membahas (melalui contoh penggunaan) bagaimana kami mencapai ini, termasuk hambatan teknis yang muncul selama implementasi.

Hubungan antara Elasticsearch dan Ruby on Rails digambarkan dalam panduan visual ini.

Hanya beberapa catatan singkat sebelum melanjutkan ke panduan:

  • Aplikasi demo Chewy dan Chewy tersedia di GitHub.
  • Bagi mereka yang tertarik dengan lebih banyak info "di balik kap mesin" tentang Elasticsearch, saya telah menyertakan tulisan singkat sebagai Lampiran pada posting ini.

Mengapa kenyal?

Terlepas dari skalabilitas dan efisiensi Elasticsearch, mengintegrasikannya dengan Rails ternyata tidak sesederhana yang diperkirakan. Di Toptal, kami mendapati diri kami perlu secara signifikan menambah klien Elasticsearch-Ruby dasar untuk membuatnya lebih berkinerja dan untuk mendukung operasi tambahan.

Terlepas dari skalabilitas dan efisiensi Elasticsearch, mengintegrasikannya dengan Rails ternyata tidak sesederhana yang diperkirakan.

Dan dengan demikian, permata Chewy lahir.

Beberapa fitur yang sangat penting dari Chewy meliputi:

  1. Setiap indeks dapat diamati oleh semua model terkait.

    Sebagian besar model yang diindeks terkait satu sama lain. Dan terkadang, perlu untuk mendenormalisasi data terkait ini dan mengikatnya ke objek yang sama (misalnya, jika Anda ingin mengindeks larik tag bersama dengan artikel terkaitnya). Chewy memungkinkan Anda menentukan indeks yang dapat diperbarui untuk setiap model, sehingga artikel terkait akan diindeks ulang setiap kali tag yang relevan diperbarui.

  2. Kelas indeks independen dari model ORM/ODM.

    Dengan peningkatan ini, penerapan pelengkapan otomatis lintas model, misalnya, jauh lebih mudah. Anda bisa mendefinisikan indeks dan bekerja dengannya dengan cara berorientasi objek. Tidak seperti klien lain, permata Chewy menghilangkan kebutuhan untuk mengimplementasikan kelas indeks secara manual, panggilan balik impor data, dan komponen lainnya.

  3. Impor massal ada di mana- mana .

    Chewy menggunakan API Elasticsearch massal untuk pengindeksan ulang dan pembaruan indeks penuh. Itu juga menggunakan konsep pembaruan atom, mengumpulkan objek yang diubah dalam blok atom dan memperbarui semuanya sekaligus.

  4. Chewy menyediakan DSL kueri gaya AR.

    Dengan menjadi chainable, mergable, dan lazy, peningkatan ini memungkinkan kueri diproduksi dengan cara yang lebih efisien.

Oke, jadi mari kita lihat bagaimana semua ini dimainkan di permata…

Panduan dasar untuk Elasticsearch

Elasticsearch memiliki beberapa konsep terkait dokumen. Yang pertama adalah index (analog database di RDBMS), yang terdiri dari sekumpulan documents , yang dapat terdiri dari beberapa types (di mana type adalah jenis tabel RDBMS).

Setiap dokumen memiliki satu set fields . Setiap bidang dianalisis secara independen dan opsi analisisnya disimpan dalam mapping untuk jenisnya. Chewy menggunakan struktur ini "sebagaimana adanya" dalam model objeknya:

 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

Di atas, kami mendefinisikan indeks Elasticsearch yang disebut entertainment dengan tiga jenis: book , movie , dan cartoon . Untuk setiap jenis, kami mendefinisikan beberapa pemetaan bidang dan hash pengaturan untuk seluruh indeks.

Jadi, kami telah mendefinisikan EntertainmentIndex dan kami ingin menjalankan beberapa kueri. Sebagai langkah pertama, kita perlu membuat indeks dan mengimpor data kita:

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

Metode .import menyadari data yang diimpor karena kami melewati cakupan saat kami mendefinisikan tipe kami; dengan demikian, itu akan mengimpor semua buku, film, dan kartun yang disimpan di penyimpanan persisten.

Setelah itu selesai, kita dapat melakukan beberapa query:

 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

Sekarang indeks kami hampir siap untuk digunakan dalam implementasi pencarian kami.

Integrasi rel

Untuk integrasi dengan Rails, hal pertama yang kita butuhkan adalah mampu bereaksi terhadap perubahan objek RDBMS. Chewy mendukung perilaku ini melalui panggilan balik yang ditentukan dalam metode kelas update_index . update_index membutuhkan dua argumen:

  1. Pengidentifikasi tipe yang disediakan dalam format "index_name#type_name"
  2. Nama metode atau blok untuk dieksekusi, yang mewakili referensi balik ke objek atau koleksi objek yang diperbarui

Kita perlu mendefinisikan callback ini untuk setiap model dependen:

 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

Karena tag juga diindeks, selanjutnya kita perlu menambal monyet beberapa model eksternal sehingga mereka bereaksi terhadap perubahan:

 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

Pada titik ini, setiap objek yang disimpan atau dihancurkan akan memperbarui tipe indeks Elasticsearch yang sesuai.

atomisitas

Kami masih memiliki satu masalah yang tersisa. Jika kami melakukan sesuatu seperti books.map(&:save) untuk menyimpan beberapa buku, kami akan meminta pembaruan indeks entertainment setiap kali satu buku disimpan . Jadi, jika kita menyimpan lima buku, kita akan memperbarui indeks Chewy lima kali. Perilaku ini dapat diterima untuk REPL, tetapi tentu saja tidak dapat diterima untuk tindakan pengontrol di mana kinerja sangat penting.

Kami mengatasi masalah ini dengan blok Chewy.atomic :

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

Singkatnya, Chewy.atomic pembaruan ini sebagai berikut:

  1. Menonaktifkan panggilan balik after_save .
  2. Mengumpulkan ID buku yang disimpan.
  3. Setelah menyelesaikan blok Chewy.atomic , menggunakan ID yang dikumpulkan untuk membuat permintaan pembaruan indeks Elasticsearch tunggal.

mencari

Sekarang kita siap untuk mengimplementasikan antarmuka pencarian. Karena antarmuka pengguna kami adalah formulir, cara terbaik untuk membuatnya, tentu saja, adalah dengan FormBuilder dan ActiveModel. (Di Toptal, kami menggunakan ActiveData untuk mengimplementasikan antarmuka ActiveModel, tetapi jangan ragu untuk menggunakan permata favorit Anda.)

 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

Tutorial kueri dan filter

Sekarang kita memiliki objek seperti ActiveModel yang dapat menerima dan mengetik atribut, mari implementasikan pencarian:

 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

Pengontrol dan tampilan

Pada titik ini, model kami dapat melakukan permintaan pencarian dengan atribut yang diteruskan. Penggunaan akan terlihat seperti:

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

Perhatikan bahwa di pengontrol, kami ingin memuat objek ActiveRecord yang tepat alih-alih pembungkus dokumen 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

Sekarang, saatnya menulis beberapa HAML di 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

Penyortiran

Sebagai bonus, kami juga akan menambahkan pengurutan ke fungsi pencarian kami.

Asumsikan bahwa kita perlu mengurutkan pada bidang judul dan tahun, serta menurut relevansinya. Sayangnya, judul One Flew Over the Cuckoo's Nest akan dibagi menjadi beberapa istilah, jadi menyortir berdasarkan istilah yang berbeda ini akan terlalu acak; sebagai gantinya, kami ingin mengurutkan berdasarkan seluruh judul.

Solusinya adalah dengan menggunakan bidang judul khusus dan menerapkan penganalisisnya sendiri:

 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

Selain itu, kami akan menambahkan atribut baru ini dan langkah pemrosesan pengurutan ke model pencarian kami:

 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

Terakhir, kami akan memodifikasi formulir kami dengan menambahkan kotak pilihan opsi pengurutan:

 = 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 ...

Penanganan kesalahan

Jika pengguna Anda melakukan kueri yang salah seperti ( atau AND , klien Elasticsearch akan memunculkan kesalahan. Untuk mengatasinya, mari buat beberapa perubahan pada pengontrol kami:

 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

Selanjutnya, kita perlu membuat kesalahan dalam tampilan:

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

Menguji kueri Elasticsearch

Pengaturan pengujian dasar adalah sebagai berikut:

  1. Mulai server Elasticsearch.
  2. Bersihkan dan buat indeks kami.
  3. Impor data kami.
  4. Lakukan kueri kami.
  5. Referensi silang hasil dengan harapan kami.

Untuk langkah 1, lebih mudah menggunakan kluster uji yang ditentukan dalam permata elasticsearch-extensions. Cukup tambahkan baris berikut ke instalasi pasca-gem Rakefile proyek Anda:

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

Kemudian, Anda akan mendapatkan tugas Rake berikut:

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

Elasticsearch dan Rspec

Pertama, kita perlu memastikan bahwa indeks kita diperbarui agar sinkron dengan perubahan data kita. Untungnya, permata Chewy hadir dengan update_index rspec matcher yang bermanfaat:

 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

Selanjutnya, kita perlu menguji apakah kueri penelusuran yang sebenarnya dilakukan dengan benar dan memberikan hasil yang diharapkan:

 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

Uji pemecahan masalah kluster

Terakhir, berikut adalah panduan untuk memecahkan masalah kluster pengujian Anda:

  • Untuk memulai, gunakan cluster satu node dalam memori. Ini akan jauh lebih cepat untuk spesifikasi. Dalam kasus kami: TEST_CLUSTER_NODES=1 rake elasticsearch:start

  • Ada beberapa masalah yang ada dengan implementasi cluster uji elasticsearch-extensions itu sendiri terkait dengan pemeriksaan status cluster satu node (berwarna kuning dalam beberapa kasus dan tidak akan pernah menjadi hijau, sehingga pemeriksaan awal cluster status hijau akan gagal setiap saat). Masalah telah diperbaiki di garpu, tetapi mudah-mudahan akan segera diperbaiki di repo utama.

  • Untuk setiap kumpulan data, kelompokkan permintaan Anda dalam spesifikasi (yaitu, impor data Anda sekali dan kemudian lakukan beberapa permintaan). Elasticsearch melakukan pemanasan untuk waktu yang lama dan menggunakan banyak memori tumpukan saat mengimpor data, jadi jangan berlebihan, terutama jika Anda memiliki banyak spesifikasi.

  • Pastikan mesin Anda memiliki memori yang cukup atau Elasticsearch akan membeku (kami membutuhkan sekitar 5GB untuk setiap mesin virtual pengujian dan sekitar 1GB untuk Elasticsearch itu sendiri).

Membungkus

Elasticsearch dideskripsikan sendiri sebagai "mesin analitik, sumber terbuka, terdistribusi, dan real-time yang fleksibel dan kuat." Ini adalah standar emas dalam teknologi pencarian.

Dengan Chewy, pengembang Rails kami telah mengemas manfaat ini sebagai permata Ruby sumber terbuka yang sederhana, mudah digunakan, berkualitas produksi, yang menyediakan integrasi erat dengan Rails. Elasticsearch dan Rails – kombinasi yang luar biasa!

Elasticsearch dan Rails -- kombinasi yang luar biasa!
Menciak


Lampiran: Elasticsearch internal

Berikut adalah pengantar yang sangat singkat untuk Elasticsearch "di bawah tenda"…

Elasticsearch dibangun di atas Lucene, yang menggunakan indeks terbalik sebagai struktur data utamanya. Misalnya, jika kita memiliki string "anjing melompat tinggi", "melompati pagar", dan "pagar terlalu tinggi", kita mendapatkan struktur berikut:

 "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]

Jadi, setiap istilah mengandung baik referensi maupun posisi di dalam teks. Selanjutnya, kami memilih untuk memodifikasi istilah kami (misalnya, dengan menghapus stop-word seperti "the") dan menerapkan hashing fonetik untuk setiap istilah (dapatkah Anda menebak algoritmenya?):

 "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]

Jika kita kemudian meminta "anjing melompat", itu dianalisis dengan cara yang sama seperti teks sumber, menjadi "DAG JANP" setelah hashing ("anjing" memiliki hash yang sama dengan "anjing", seperti halnya dengan "melompat" dan "melompat").

Kami juga menambahkan beberapa logika antara kata-kata individual dalam string (berdasarkan pengaturan konfigurasi), memilih antara ("DAG" DAN "JANP") atau ("DAG" ATAU "JANP"). Yang pertama mengembalikan persimpangan [0] & [0, 1] (yaitu, dokumen 0) dan yang terakhir, [0] | [0, 1] [0] | [0, 1] (yaitu, dokumen 0 dan 1). Posisi dalam teks dapat digunakan untuk mencetak hasil dan kueri yang bergantung pada posisi.