Sử dụng Elasticsearch để tìm kiếm full-text trong Rails

Trong bài viết này chúng ta cùng tìm hiểu cách implement full-text search trong Ruby on Rails sử dụng Elasticsearch. Ngày nay hầu như mọi người đều đã từng sử dụng các công cụ tìm kiếm có gợi ý sẵn, nếu bạn không biết chắc từ khóa mình cần tìm là gì, thì việc các công cụ tìm kiếm có gợi ý hẵn là một tính năng hay, như kiểu Facebook hay Google chẳng hạn.

Để implement những tính năng trên mà chỉ sử dụng duy nhất các cơ sở dữ liệu quan hệ như MySQL hay Postgres ko đơn giản chút nào. Vì thế, chúng ta sẽ sử dụng Elasticsearch, nó kiểu như 1 database đặc biệt được phát triển và tối ưu hóa dành riêng cho việc tìm kiếm. Là một mã nguồn mở được build trên Apache Lucene.

Một trong những tính năng tuyệt nhất của Elasticsearch là sử dụng REST API, do đó nó có các gói thư việc chức năng cho hầu hết các ngôn ngữ lập trình.

Giới thiệu về Elasticsearch

Elasticsearch giống như 1 database đặc biệt để tìm kiếm. Rất hữu dụng nếu bạn quen thuộc với các thuật ngữ bên dưới:

  • Field: Một field giống như 1 cặp key-value. Value có thể là 1 giá trị đơn giản (string, integer, date), hay có thể là 1 cấu trúc lồng nhau như array hay object. Một field cũng tương tự như 1 cột của bảng trong db.
  • Document: Một document là một danh sách các trường. Là 1 JSON document lưu trữ trong Elasticsearch, giống như 1 hàng(row) của bảng trong db. Mỗi document lưu 1 index, có 1 type và 1 id duy nhất.
  • Type: Một type giống như 1 bảng (table) trong database. Mỗi type có 1 dánh sách các trường được chỉ định cho documents của type đó.
  • Index: Một index tương đương với 1 database.

Trong Elasticsearch, khi bạn viết document cho 1 index, các document fields sẽ được phân tích, từng chữ một để làm việc tìm kiếm trở nên đơn giản và nhanh hơn. Elasticsearch cũng hỗ trợ vị trí địa lý, vì thế bạn có thể search documents được đặt trong 1 khoảng cách nhất định của 1 vị trí nhất định nào đó. Đó là cách mà Foursquare implement tính năng seach của họ. Đọc thêm tại đây.

Cài đặt Elasticsearch

Nếu bạn dùng Linux Ubuntu, bạn có thể làm theo hướng dẫn này.

Nếu bạn dùng Mac, việc cài đặt rất đơn giản:

brew install elasticsearch

Sau khi cài xong, bạn sẽ thấy danh sách các folder liên quan trong terminal:

Để xem việc cài đặt có ổn ko, gõ elasticsearch trong terminal để khởi động. Sau đó gõ curl localhost:9200, bạn sẽ có kết quả như bên dưới:

Cài đặt Elastic HQ

Elastic HQ là một plugin giám sát dùng để quản lý Elasticsearcg từ browser, giống như phpMyAdmin cho MySQL. Để cài đặt, chỉ cần chạy lệnh sau trong terminal:

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

Khi cài đặt xong, điều hướng đến url này và xem kết quả: http://localhost:9200/_plugin/hq

Click Connect và xem status trên màn hình

OK, mọi việc hoạt động như mong đợi, Elasticseach đã được cài đặt và chạy trơn tru.

Tạo Rails Application

Chúng ta sẽ tạo một Rails app đơn giản chứa các Articles trong database và sử dụng Elasticsearch để tìm kiếm. Đầu tiên, tạo Rails app:

rails new elasticsearch-rails

Tiếp theo, generate 1 Articles mới với scaffolding:

rails generate scaffold Article tittle:string text:text

Bây giờ ta cần add thêm root route, default là list Articles. config/routes.rb:

Rails.application.routes.draw do
  root to: 'articles#index'
  resources :articles
end

Tạo db bằng cách chạy lệnh rake:migrate. Khởi động rails server, mở trình duyệt, điều hướng đến localhost:3000 và add thêm vài articles vào db để tạo dữ liệu text nào.

Thêm tính năng search

Bây giờ ta có 1 Rails app nhỏ với các articles trong db. Việc cần làm bây giờ là add thêm gem elasticsearch vào Gemfile:

gem 'elasticsearch-model'
gem 'elasticsearch-rails'

Ở nhiều websites rất phổ biến với 1 text box nhỏ trên menu hoặc header dùng để tìm kiếm. Chúng ta cũng làm 1 cái giống vậy, tạo file app/views/search/_form.html.erb. Form này dùng method get nên việc copy paste URL rất đơn giản:

<%= form_for :term, url: search_path, method: :get do |form| %>
  <p>
    <%= text_field_tag :term, params[:term] %>
    <%= submit_tag "Search", name: nil %>
  </p>
<% end %>

Add cái form trên vào layout của web, edit trong app/views/layouts/application.html.erb:

<body>
  <%= render 'search/form' %>
  <%= yield %>
</body>

Bây giờ ta cần 1 controller, chạy rails g new controller Search:

class SearchController < ApplicationController
  def search
    if params[:term].nil?
      @articles = []
    else
      @articles = Article.search params[:term]
    end
  end
end

Như bạn thấy, chúng ta đang gọi method search trên Article model, mà vẫn chưa định nghĩa :v. Vì thế nếu cứ cố tình search sẽ bắn lỗi ngay. Đầu tiên cần thêm route cho SearchController, trong file config/routes.rb:

Rails.application.routes.draw do
  root to: 'articles#index'
 
  resources :articles
  get "search", to: "search#search"
end

Nếu bạn có đọc documentation của gem elasticsearch-rails, ta cần phải include 2 models nữa, nên là trong article.rb:

require 'elasticsearch/model'
 
class Article < ActiveRecord::Base
  include Elasticsearch::Model
  include Elasticsearch::Model::Callbacks
end

Model đầu tiên inject Search method mà chúng ta sử dụng trong controller trước, model thứ 2 tích hợp với ActiveRecord callbacks để đánh index mỗi instance của 1 article mà ta lưu trong db, đồng thời cập nhật index nếu ta sửa hay xóa article trong db. Mọi thứ đều đã rõ ràng.

Nếu bạn import data từ db vào trước, thì những articles này vẫn chưa nằm trong Elasticsearch index, chỉ những record mới mới được đánh index tự động. Vậy nên ta phải index chúng bằng tay, và rất đơn giản nếu dùng rails console:

OK, mọi thứ có vẻ ổn, thử test lại xem nào, Gõ thử "ruby" và click search, đây là kết quả:

Search Highlighting

Ở rất nhiều websites, bạn có thể search kết quả và thấy các key search được highlight. Điều này rất đơn giản nếu sử dụng Elasticsearch.

Chỉnh sửa app/models/article.rb và edit hàm search mặc định:

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

Mặc định thì hàm search được định nghĩa bởi gem elasticsearch-models, và proxy object __elasticsearch__ được cung cấp để access vào wrapper class cho Elasticseach API. Vì thế ta có thể sửa query mặc định sử dụng JSON options được cung cấp bởi documentation này.

Bây giờ ta cần update lại trang kết quả search để có thể render các HTML tags an toàn. Edit file app/views/search/search.html.erb:

<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 %>

Và thêm chút CSS vào app/assets/stylesheets/search.scss để highlight:

.search_results em {
  background-color: yellow;
  font-style: normal;
  font-weight: bold;
}

Thử search ruby lại lần nữa xem nào:

Hoàn hảo.

Searchkick Gem

(to be continue ...)


All Rights Reserved