Post

Full text search with Searchkick

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:

16988928511916

หลังจากนี้จะ 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 มาแบบนี้:

16988929644838

แล้วก็ไป 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 ให้)

ตอนนี้ทำง่าย ๆ ไปก่อน แล้วไปลองดูผลงานกัน:

16988930135463

มาถึงขั้นนี้แล้วก็เหมือนว่าทุกอย่างจะใช้ได้ดี ที่นี้ลองใส่ข้อมูลที่เป็นภาษาไทยลงไป:

16988931025125

แล้วก็ลองค้นหาดู ก็จะเห็นว่าได้ผลออกมา:

16988931625161

เกือบดีละ ที่นี้ลองค้นหาแค่บางส่วนของคำ:

16988932201872

หาไม่เจอ 😓 มันเป็นปัญหาอย่างเดียวกับที่เคยเจอตอนใช้ Thinking Sphinx เป็นเรื่องการตัดคำภาษาไทยอีกแล้ว

ตัดคำภาษาไทย (อัพเดท 2020)

Elasticsearch/OpenSearch มี thai tokenizer built-in มาให้ (ใช้ ICU dictionary-based segmentation) ดังนั้นไม่ต้องเขียน tokenizer เองเหมือนตอนใช้ Thinking Sphinx ที่ต้องอาศัย gem thbrk มาช่วย

แก้ไข 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 มี thai tokenizer 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

ลองค้นหาผลลัพธ์ดู:

16988932763830

เป็นอันเรียบร้อย ทำให้ค้นหาแบบตัดคำภาษาไทยได้แล้ว 😉

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 ฯลฯ

References

This post is licensed under CC BY 4.0 by the author.