Full text search with Searchkick
Searchkick เป็น Rails wrapper สำหรับ Elasticsearch/OpenSearch ที่ทำให้เพิ่ม full-text search ลง model ได้ด้วย macro searchkick บรรทัดเดียว และจัดการ index lifecycle (auto-reindex, async, mapping) ให้อัตโนมัติ
Elasticsearch เปลี่ยน license เป็น SSPL ตั้งแต่ปี 2021 ไม่ใช่ open-source ตามนิยาม OSI แล้ว AWS จึง fork ออกมาเป็น OpenSearch ภายใต้ Apache 2.0 — Searchkick รองรับทั้งสองตัวด้วย client gem คนละตัว (
elasticsearchหรือopensearch-ruby)
Prerequisites
- Ruby + Rails
- Elasticsearch หรือ OpenSearch รันอยู่ — บนเครื่อง dev จะใช้ Homebrew (วิธีในโพสต์นี้) หรือ Docker Compose ก็ได้ ดู OpenSearch setup with Docker Compose and Kamal สำหรับ Docker Compose แบบ 2-node cluster
Getting Started
ติดตั้ง Elasticsearch หรือ OpenSearch สำหรับคนที่ใช้ macOS สามารถติดตั้งโดยใช้ brew ได้เลย:
1
2
3
4
5
brew install elastic/tap/elasticsearch-full
brew services start elasticsearch-full
# or
brew install opensearch
brew services start opensearch
จากนั้นเพิ่มบรรทัดเหล่านี้เข้าไปใน Gemfile:
1
2
3
4
gem "searchkick"
gem "elasticsearch" # select one
gem "opensearch-ruby" # select one
และเพิ่ม searchkick เข้าไปใน model ที่ต้องการจะค้นหา:
1
2
3
class Product < ActiveRecord::Base
searchkick
end
macro searchkick ทำสามอย่าง:
- เพิ่ม class method
Product.search(query)ที่ส่ง query ไปยัง search engine - ผูก ActiveRecord callback ให้ auto-reindex ตอน save/destroy
- กำหนด default mapping ของ index (ปรับได้ด้วย option)
เข้าไปอ่าน Searchkick documentation เพิ่มเติม ดูวิธีการใช้งานและ feature ที่มีให้ใช้
Demo
ต่อไปเราจะไปลองทำ demo ง่าย ๆ กัน:
1
rails new store
จากนั้นก็สร้างโมเดล product ซึ่งขอลัด ๆ ใช้ scaffold ไปก่อน:
1
2
3
rails g scaffold Product name detail:text
rails db:migrate
rails s
สร้างตัวอย่างข้อมูลใน:
1
Product.create(name: "", detail: "")
จากนั้นก็รันคำสั่งเพื่อเพิ่มข้อมูลเข้าไป:
1
rails db:seed
เปิด http://localhost:3000/products:
หลังจากนี้จะ implement search ให้กับ Product ก่อนอื่นก็เพิ่ม routes search:
1
2
3
4
5
6
7
Rails.application.routes.draw do
resources :products do
collection do
get "search"
end
end
end
เพิ่ม form การ search:
1
2
3
4
<%= form_tag search_products_path, method: :get do %>
<%= text_field_tag :q %>
<%= submit_tag :search %>
<% end %>
จะได้ form มาแบบนี้:
แล้วก็ไป implement search method:
1
2
3
4
5
6
7
8
class ProductsController < ApplicationController
...
def search
@products = Product.search(params[:q])
render :index
end
...
end
Product.search(params[:q]) คืน relation-like object ที่ chain method แบบ ActiveRecord ต่อได้ เช่น .where(active: true), .order(created_at: :desc), .limit(10) (ภายใน Searchkick แปลงเป็น Elasticsearch DSL ให้)
ตอนนี้ทำง่าย ๆ ไปก่อน แล้วไปลองดูผลงานกัน:
มาถึงขั้นนี้แล้วก็เหมือนว่าทุกอย่างจะใช้ได้ดี ที่นี้ลองใส่ข้อมูลที่เป็นภาษาไทยลงไป:
แล้วก็ลองค้นหาดู ก็จะเห็นว่าได้ผลออกมา:
เกือบดีละ ที่นี้ลองค้นหาแค่บางส่วนของคำ:
หาไม่เจอ 😓 มันเป็นปัญหาอย่างเดียวกับที่เคยเจอตอนใช้ Thinking Sphinx เป็นเรื่องการตัดคำภาษาไทยอีกแล้ว
ตัดคำภาษาไทย (อัพเดท 2020)
Elasticsearch/OpenSearch มี
thaitokenizer built-in มาให้ (ใช้ ICU dictionary-based segmentation) ดังนั้นไม่ต้องเขียน tokenizer เองเหมือนตอนใช้ Thinking Sphinx ที่ต้องอาศัย gemthbrkมาช่วย
แก้ไข Searchkick ใน model โดยเพิ่ม mapping สำหรับภาษาไทยเข้าไป ต้องกำหนดว่าจะให้ค้นหาใน column ไหนได้บ้าง — อย่างในตัวอย่างคือให้ค้นหาที่ name และ detail:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Product < ApplicationRecord
searchkick merge_mappings: true,
settings: {
analysis: {
analyzer: {
thai_analyzer: {
tokenizer: "thai"
}
}
}
},
mappings: {
properties: {
name: {
type: "keyword",
fields: {
analyzed: {
type: "text",
analyzer: "thai_analyzer"
}
}
},
detail: {
type: "keyword",
fields: {
analyzed: {
type: "text",
analyzer: "thai_analyzer"
}
}
}
}
}
end
อธิบาย config:
merge_mappings: true— รวม mapping ของเรากับ default ของ Searchkick (ไม่งั้น override ทั้งหมดและ feature default ของ Searchkick หาย)thai_analyzerใช้tokenizer: "thai"— analyzer คือชุดของ tokenizer + filter Elasticsearch มีthaitokenizer built-in ที่ตัดคำไทยให้อัตโนมัติtype: "keyword"+fields.analyzed.type: "text"— เก็บค่าเป็น 2 รูปแบบในไฟล์ index เดียว: ตัว keyword ไว้ exact match/sort/aggregation, ตัว text (name.analyzed) ไว้ search แบบ tokenize ภาษาไทย
จากนั้น reindex อีกครั้ง (จำเป็นทุกครั้งที่เปลี่ยน mapping):
1
rails searchkick:reindex CLASS=Product
ลองค้นหาผลลัพธ์ดู:
เป็นอันเรียบร้อย ทำให้ค้นหาแบบตัดคำภาษาไทยได้แล้ว 😉
Conclusion
OpenSearch (หรือ Elasticsearch) เป็น search engine ที่ดี และ Searchkick ก็ทำให้มันใช้ง่าย ๆ กับ Rails model และตอนนี้ก็หาวิธีการให้ค้นหาภาษาไทยได้อย่างมีประสิทธิภาพได้แล้ว เพราะงั้นงานต่อไปได้ใช้อย่างแน่นอน
อัพเดท 2020: Elasticsearch ไม่สามารถค้นหาภาษาไทยที่เป็นทับศัพท์ได้ (transliterated word) น่าจะเพราะการตัดคำของ tokenizer หากจะเลือกใช้ก็อย่าลืมดูเรื่องนี้ด้วย
สำหรับการเปรียบเทียบกับ search engine ทางเลือกอื่น ดูที่โพสต์ Full text search with Thinking Sphinx ซึ่งมี section “ทางเลือกอื่นๆ” ที่เทียบ Searchkick/OpenSearch กับ pg_search, Meilisearch, Typesense ฯลฯ






