Elasticsearch là một trong những search engine phổ biến nhất. Giữa nhiều công ty lớn yêu thích và dùng nó production, có những khổng lồ như Netflix, Medium, GitHub.
Elasticsearch rất mạnh, với use case chính: full-text search, real-time log, và security analysis.
Thật tiếc, Elasticsearch không nhận nhiều attention từ cộng đồng Rails, nên bài này cố gắng thay đổi với hai mục tiêu: giới thiệu concept Elasticsearch và chỉ cách dùng với Ruby on Rails.
Bạn tìm thấy source code example project chúng ta sẽ build ở đây. Commit history tương đối tương ứng thứ tự section trong bài.
Giới thiệu
Từ góc nhìn rộng, Elasticsearch là search engine:
- xây trên Apache Lucene;
- lưu và index hiệu quả JSON document;
- open-source;
- cung cấp set REST API tương tác;
- mặc định không security (ai cũng query qua public endpoint);
- scale horizontally khá tốt.
Hãy nhìn nhanh khái niệm cơ bản.
Với Elasticsearch, ta đưa document vào indices, sau đó query cho data.
Index tương tự table trong relational database; là store chứa document (row) có thể query sau.
Document là collection field (tương tự row trong relational database).
Mapping như schema definition trong relational database. Mapping có thể define explicit hoặc guess bởi Elasticsearch lúc insert; luôn tốt hơn define index mapping upfront.
Với cơ sở đó, setup môi trường.
Cài đặt Elasticsearch
Cách dễ nhất cài Elasticsearch trên macOS dùng brew:
brew tap elastic/tap
brew install elastic/tap/elasticsearch-full
Thay thế, chạy qua docker:
docker run \
-p 127.0.0.1:9200:9200 \
-p 127.0.0.1:9300:9300 \
-e "discovery.type=single-node" \
docker.elastic.co/elasticsearch/elasticsearch:7.16.2
Cho option khác, tham khảo official reference.
Elasticsearch chấp nhận request port 9200 mặc định. Check chạy với curl request (hoặc mở browser):
curl https://localhost:9200
APIs
Elasticsearch cung cấp set REST API tương tác mọi task type. Ví dụ, chạy POST request JSON content type tạo document:
curl -X POST https://localhost:9200/my-index/_doc \
-H 'Content-Type: application/json' \
-d '{"title": "Banana Cake"}'
Trong trường hợp này, my-index là tên index (nếu không có, tự tạo).
_doc là system route (tất cả system route bắt đầu underscore).
Có nhiều cách tương tác APIs.
- Dùng
curlcommand-line (có thể dùng jq handy). - Chạy GET query từ browser với extension pretty print JSON.
- Cài Kibana dùng Dev Tools console, cách tôi thích nhất.
- Cuối cùng có Chrome extension hay.
Cho bài này, không quan trọng chọn cách nào — chúng ta không interact API trực tiếp. Thay vào đó dùng gem, talk to REST API under the hood.
Khởi động app mới
Ý tưởng tạo ứng dụng lyrics bài hát dùng public dataset 26K+ bài. Mỗi bài có title, artist, genre, text lyrics. Chúng ta dùng Elasticsearch cho full-text search.
Bắt đầu tạo Rails app đơn giản:
rails new songs_api --api -d postgresql
Vì chỉ dùng API, cung cấp flag --api limit middleware set.
Scaffold app:
bin/rails generate scaffold Song title:string artist:string genre:string lyrics:text
Chạy migration và start server:
bin/rails db:create db:migrate
bin/rails server
Sau đó verify GET endpoint work:
curl https://localhost:3000/songs
Trả về empty array, không ngạc nhiên vì chưa có data.
Giới thiệu Elasticsearch
Thêm Elasticsearch vào mix. Cần gem elasticsearch-model. Là official Elasticsearch gem tích hợp đẹp với Rails model.
Thêm vào Gemfile:
gem 'elasticsearch-model'
Mặc định connect port 9200 localhost, phù hợp, nhưng muốn đổi, init client bằng
Song.__elasticsearch__.client = Elasticsearch::Client.new host: 'myserver.com', port: 9876
Tiếp, làm model indexable bởi Elasticsearch, cần hai việc. Trước, chuẩn bị mapping (báo Elasticsearch cấu trúc data), hai, xây dựng search request. Gem làm được cả hai, xem cách dùng.
Luôn tốt giữ code Elasticsearch trong module riêng, tạo concern tại app/models/concerns/searchable.rb thêm
# app/models/concerns/searchable.rb
module Searchable
extend ActiveSupport::Concern
included do
include Elasticsearch::Model
include Elasticsearch::Model::Callbacks
mapping do
# mapping definition goes here
end
def self.search(query)
# build and run search
end
end
end
Dù chỉ skeleton, có gì unpack.
Thứ nhất quan trọng là Elasticsearch::Model, thêm functionality interact với ES. Module Elasticsearch::Model::Callbacks đảm bảo update record tự động update data trong Elasticsearch. Block mapping đặt Elasticsearch index mapping, define field nào lưu trong Elasticsearch và type gì. Cuối cùng method search dùng search Elasticsearch cho lyrics bài hát. Gem cung cấp method search dùng query đơn giản như Song.search("genesis"), nhưng chúng ta dùng query phức tạp hơn với query DSL (chi tiết sau).
Đừng quên include concern vào model class:
# /app/models/song.rb
class Song < ApplicationRecord
include Searchable
end
Mappings
Trong Elasticsearch, mapping như schema definition relational database. Mô tả cấu trúc document muốn lưu. Khác relational database, không phải define mapping upfront: Elasticsearch guess type cho chúng ta. Vẫn, vì không muốn surprise, define mapping trước explicit.
Mapping update qua REST endpoint PUT /my-index/_mapping và read qua GET /my-index/_mapping, nhưng gem elasticsearch abstract đó, chúng ta chỉ cung cấp block mapping:
# app/models/concerns/searchable.rb
mapping do
indexes :artist, type: :text
indexes :title, type: :text
indexes :lyrics, type: :text
indexes :genre, type: :keyword
end
Chúng ta index field artist, title, lyrics dùng type text. Là type duy nhất indexed cho full-text search. Cho genre, dùng keyword type, ideal filter exact value.
Chạy rails console bin/rails console rồi chạy
Song.__elasticsearch__.create_index!
Tạo index trong Elasticsearch. Object __elasticsearch__ là gate đến thế giới Elasticsearch, packed nhiều method hữu ích interact với Elasticsearch.
Import dữ liệu
Mỗi lần tạo record, tự động gửi data đến Elasticsearch. Vì vậy, tải dataset lyrics bài hát và import vào app. Trước, tải từ link này (dataset dưới Creative Commons Attribution 4.0 International license). File CSV chứa 26,000+ record, import vào database và Elasticsearch với code:
require 'csv'
class Song < ApplicationRecord
include Searchable
def self.import_csv!
filepath = "/path/to/your/file/tcc_ceds_music.csv"
res = CSV.parse(File.read(filepath), headers: true)
res.each_with_index do |s, ind|
Song.create!(
artist: s["artist_name"],
title: s["track_name"],
genre: s["genre"],
lyrics: s["lyrics"]
)
end
end
end
Mở rails console chạy Song.import_csv! (sẽ mất thời gian). Thay vào đó import bulk, nhanh hơn, nhưng trường hợp này muốn đảm bảo tạo record trong PostgreSQL và Elasticsearch.
Khi import xong, có đống lyrics search.
Tìm kiếm dữ liệu
Gem elasticsearch-model thêm method search search среди tất cả indexed field. Dùng trong searchable concern:
# app/models/concerns/searchable.rb
# ...
def self.search(query)
self.__elasticsearch__.search(query)
end
# ...
Mở rails console chạy res = Song.search('genesis'). Response object chứa nhiều meta info: request time, node dùng, v.v. Chúng ta quan tâm hits, tại res.response["hits"]["hits"].
Đổi controller index method query ES thay vì DB.
# app/controllers/songs_controller.rb
def index
query = params["query"] || ""
res = Song.search(query)
render json: res.response["hits"]["hits"]
end
Thử load browser hoặc curl https://localhost:3000/songs?query=genesis. Response trông như:
[
{
"_index": "songs",
"_type": "_doc",
"_id": "22676",
"_score": 12.540506,
"_source": {
"id": 22676,
"title": "genesis",
"artist": "grimes",
"genre": "pop",
"lyrics": "heart know heart ...",
"created_at": "...",
"updated_at": "..."
}
},
...
]
Nhìn thấy, data thực ở _source, field khác là metadata, quan trọng nhất _score chỉ relevance document cho search cụ thể. Sớm nói đến, nhưng trước học cách query.
Query DSL
Elasticsearch query DSL cung cấp cách xây dựng query phức tạp, dùng từ ruby code. Ví dụ, sửa search method chỉ search field artist:
# app/models/concerns/searchable.rb
module Searchable
extend ActiveSupport::Concern
included do
# ...
def self.search(query)
params = {
query: {
match: {
artist: query,
},
},
}
self.__elasticsearch__.search(params)
end
end
end
Query-match construct cho phép search chỉ field cụ thể (trường hợp artist). Giờ query songs với "genesis" (thử load https://localhost:3000/songs?query=genesis), chỉ lấy bài hát band "Genesis", không phải bài có "genesis" trong title. Muốn query nhiều field, thường thấy, dùng multi-match query:
# app/models/concerns/searchable.rb
def self.search(query)
params = {
query: {
multi_match: {
query: query,
fields: [ :title, :artist, :lyrics ]
},
},
}
self.__elasticsearch__.search(params)
end
Filtering
Nếu chỉ muốn search, ví dụ, bài rock? Cần filter by genre! Làm search phức tạp hơn, nhưng đừng lo — giải thích từng bước!
def self.search(query, genre = nil)
params = {
query: {
bool: {
must: [
{ multi_match: {
query: query,
fields: [ :title, :artist, :lyrics ]
}
},
],
filter: [
{ term: { genre: genre } }
]
}
}
}
self.__elasticsearch__.search(params)
end
Từ khóa mới đầu là bool, cách combine multiple query thành một. Trường hợp này combine must và filter. Thứ nhất (must) contribute score và chứa query đã dùng. Thứ hai (filter) không contribute score, chỉ filter document không match query. Chúng ta filter record by genre, dùng term query.
Quan trọng lưu ý combo filter-term không liên quan full-text search. Chỉ filter exact value, như clause WHERE SQL (WHERE genre = 'rock'). Tốt biết dùng term filtering, nhưng không cần ở đây.
Scoring
Kết quả search ordered by _score chỉ relevance item cho search cụ thể. Score càng cao, document càng relevant. Bạn có thể nhận ra search "genesis", kết quả đầu là bài Grimes, trong khi tôi quan tâm band Genesis. Có alter scoring mechanism chú ý artist field hơn? Có, nhưng cần tweak query trước:
def self.search(query)
params = {
query: {
bool: {
should: [
{ match: { title: query }},
{ match: { artist: { query: query, boost: 5 } }},
{ match: { lyrics: query }},
],
},
},
}
self.__elasticsearch__.search(params)
end
Query này essentially equivalent trước trừ dùng bool keyword, combine multiple query thành một. Dùng should, chứa ba query riêng (mỗi field): combine logical OR. Dùng must thì logical AND. Tại sao cần match riêng per field? Vì giờ specify property boost, coefficient nhân score từ query cụ thể:
def self.search(query)
params = {
query: {
bool: {
should: [
{ match: { title: query }},
{ match: { artist: { query: query, boost: 5 } }},
{ match: { lyrics: query }},
],
},
},
}
self.__elasticsearch__.search(params)
end
Ceteris paribus, score gấp 5 lần nếu query match artist. Thử query genesis lại, https://localhost:3000/songs?query=genesis, bài band Genesis lên đầu. Sweet!
Highlighting
Tính năng hữu ích khác Elasticsearch: highlight match trong document, giúp user hiểu tại sao kết quả xuất hiện trong search.
Trong HTML, có tag đặc biệt cho việc đó, Elasticsearch tự thêm.
Mở searchable.rb concern thêm keyword mới:
def self.search(query)
params = {
query: {
bool: {
should: [
{ match: { title: query }},
{ match: { artist: { query: query, boost: 5 } }},
{ match: { lyrics: query }},
],
},
},
highlight: {
fields: {
title: {},
artist: {},
lyrics: {}
}
}
}
self.__elasticsearch__.search(params)
end
Field highlight mới chỉ định field nào highlight. Chọn tất cả. Giờ load https://localhost:3000/query=genesis, thấy field mới "highlight" chứa document field với matched phrase wrapped in em tag.
Tham khảo thêm highlighting tại official guide.
Fuzziness
Được rồi, nếu gõ nhầm benesis thay genesis? Không return result, nhưng bảo Elasticsearch kén chọn ít hơn cho fuzzy search, hiển thị kết quả genesis.
Cách làm: đổi artist query từ { match: { artist: { query: query, boost: 5 } }} thành { match: { artist: { query: query, boost: 5, fuzziness: "AUTO" } }}. Cơ chế fuzziness chính xác config được. Tham khảo official docs chi tiết.
Tiếp theo làm gì?
Hy vọng bài này convince bạn Elasticsearch là tool mạnh dùng khi implement search non-trivial. Sẵn sàng học thêm, đây link hữu ích:
Resources
- The official Elasticsearch reference
- The Ruby gem
- The Rails gem
- Cuốn sách hay loaded practical knowledge
- Building autocomplete
Alternative gems
- Searchkick
- Chewy