มาลองใช้ Elasticsearch ทำระบบ full text search กันเถอะ

รีวิวการ optimize search engine ของ CU Get Reg ด้วย Elasticsearch

Samithiwat
Thinc.
13 min readSep 2, 2023

--

ทำ Full Text Search ด้วย Elastic Search

เกริ่นนำ

ในช่วงไม่กี่เดือนมานี้ผมได้มีโอกาสช่วยทีม CU Get Reg ปรับปรุงระบบ search engine จึงได้มีโอกาสได้ลองใช้ Database ที่เป็นตัวตึงของฝั่ง Search Engine อย่าง Elasticserach มาช่วย optimize ความแม่นยำของการ search course และความรู้เหล่านี้มีความน่าสนใจจึงอยากมาแบ่งปันให้ทุกคนฟังกัน

Outline

โดยเนื้อหาจะเป็นการจดสรุปความรู้ที่ใช้ Elasticsearch ในการ optimize ระบบ search ของ CU Get Reg ดังนี้

  • Elasticsearch คืออะไร
  • ทำไม Elasticsearch ดีกว่า Database ปกติ
  • Inverted Index คืออะไร
  • Analysis และ Analyzer ใน Elasticsearch
  • Roadmap ในการ implement ระบบ Search ใน Elasticsearch
  • Structure ของ Index ใน Elasticsearch
  • Index Aliases, Index Setting, และ Index Mapping
  • การสร้าง Index และ Reindex ใน Elasticsearch
  • การใช้ Query ใน Elasticsearch
  • Architecture ของ CU Get Reg หลังจากใช้ Elasticsearch

ถ้าพร้อมแล้วมาดูกันเลยย 🔥

Elasticsearch คืออะไร

Elasticsearch is a distributed, free and open search and analytics engine for all types of data, including textual, numerical, geospatial, structured, and unstructured.

original by https://www.elastic.co/what-is/elasticsearch

แปลง่ายๆเลยก็คือ Elasticserach คือ database ที่เป็น opensource ที่เกิดมาเพื่อทำการ serach โดยเฉพาะและตัวมันเองสามารถเก็บ data ได้หลากหลายรูปแบบมาก

ทำไม Elasticsearch ดีกว่า Database ปกติ

ต้องอธิบายกันก่อนว่า Elasticsearch สร้างมา on-top Apache Lucene อีกชั้นนึงซึ่งเจ้า Apache Lucene คือ search library ของ Java ที่นิยมมากในสมัยก่อน

นอกจากนี้ข้อมูล index ใน Elasticsearch จะอยู่ในรูปแบบ shard ทำให้เราสามารถทำ horizontal scale ได้

สามารถอ่านเรื่อง elasticsearch กับ shard แบบละเอียดได้ที่

คำศัพท์ที่ควรรู้ก่อนพูดถึง Inverted Index

Index

คือที่เก็บ document เปรียบเสมอเป็น table ใน SQL หรือ collection ใน Mongodb

ใน index จะมีการ define datatype ที่เก็บข้างในเหมือนกับ SQL แต่จะมีรายละเอียดมากกว่านิดหน่อยซึ่งจะพูดถึงใน section ถัดไปของบทความนี้

Document

ข้อมูลที่ถูกเก็บอยู่ใน index ถ้าเปรียบเทียบง่ายๆ ก็คือแถวนึงที่อยู่ใน table ของ SQL database

Content

ข้อมูลที่เรา input เข้าไปเก็บใน document

Tokenizer

การแบ่งข้อความออกมาเป็นคำหนึ่งคำซึ่งเราจะเรียกคำที่ถูกแบ่งออกมาว่า token

Token

ตัวอย่างข้อความที่ถูก tokenizer ออกมาเป็น token

คำศัพท์ที่ถูกแบ่งจากการ tokenizer ข้อความที่ input เข้ามาเช่นในรูปข้อความ The red apple is on the table จะถูกแบ่งออกมาเป็น 7 tokens

อ่านเพิ่มเติมรายละเอียดการ tokenizer ของ elasticsearch ได้ที่ https://www.elastic.co/blog/found-indexing-for-beginners-part2/

Inverted Index คืออะไร

ตัวอย่าง Inverted Index เมื่อเทียบกับ Forward Index

Inverted Index คือ index data structure ที่ถูกสร้างมาเพื่อเพิ่มประสิทธิภาพในการ search ด้วยการ mapping keyword กับ document (มี keyword นี้อยู่ใน document) ซึ่งตรงข้ามกับ forward index ที่จะเป็นการ mapping document กับ content (words ใน document)

ก่อนที่จะนำ input ไปเก็บเป็น document จึงต้องทำการ tokenizer แบ่งประโยคเป็น token เพื่อให้สามารถนำมาสร้างเป็น index ได้ด้วยนั่นเอง

ถ้าสนใจเรื่อง inverted index สามารถไปอ่านเพิ่มเติมได้ที่ https://www.baeldung.com/cs/indexing-inverted-index

Analysis และ Analyzer ใน Elasticsearch

Workflow ตั้งแต่ input text จนเป็น final token

Analysis

โดยปกติแล้วการ tokenizer จะทำการแบ่ง input text ออกมาเป็น token แต่ในบางครั้ง token ที่แบ่งออกมาอาจจะยังไม่ดีพอที่จะนำมาใช้งานเราจึงมีการทำ Token Filter เพิ่มขึ้นมาอีกหนึ่งขั้นตอนเพื่อแปลงให้ token เหมาะสมต่อการใช้งานต่อไป

ตัวอย่าง lowercase filter

  • AdAm = “adam”
  • ChatGPT = “chatgpt”

โดยใน elasticsearch จะเรียกว่า “Analysis” หรือก็คือการทำทั้ง 2 ขั้นตอนนี้เพื่อนำ input ไปเก็บใน index

Analysis เกิดขึ้นในตอนไหนบ้าง

ใน elasticsearch จะมีการ analysis input text ทั้งหมด 2 ครั้ง

  • สร้าง index
  • full text search กับ attribute ที่มี datatype เป็น text

โดยเราสามารถเลือกได้ด้วยนะว่า attribute ตัวไหนจะใช้ analyzer แบบไหนซึ่งในบทความนี้จะไปพูดถึงในส่วนของ index mapping

ถ้าสนใจเรื่อง analysis ของ elasticsearch สามารถไปอ่านเพิ่มเติมได้ที่นี่

Analyzer

ตัวอย่าง input text ที่ผ่าน Analyzer

ในการ analysis ของ elasticsearch เราสามารถ customize สิ่งที่จะใช้ในการแบ่งคำศัพท์ได้หลายรูปแบบผ่านการเลือกใช้ Analyzer

  • ใช้การเว้นวรรคในการแบ่ง token
  • การใช้ . ในการแบ่ง token
  • ใช้ keyword ที่เราตั้งขึ้นมาในการแบ่ง token

และด้วยความที่ในแต่ละภาษาจะมีวิธีการแบ่งคำศัพท์ที่ไม่เหมือนกัน (ตัวอย่างที่เห็นได้ชัดคือ eng ใช้การเว้นวรรคแต่ภาษาไทยใช้อย่างอื่น)

การเลือกใช้ analyzer ที่เหมาะสมกับ input text จะเป็นตัวช่วยทำให้ประสิทธิภาพของการทำ tokenizer ดียิ่งขึ้น

Custom Analyzer

Elasticsearch ได้ provide analyzer บางส่วนให้เราเลือกใช้รวมไปถึงเราสามารถสร้าง analyzer ขึ้นมาใช้ได้เองด้วย

โดยรายชื่อ built-in ที่ elasticsearch provide มาให้สามารถดูได้ที่ https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-analyzers.html

การเลือกใช้ analyzer เราจะตั้งค่าอยู่ใน setting ของ index ซึ่งจะพูดถึงในหัวข้อถัดไป

คำศัพท์ที่ควรรู้ก่อนพูดถึงการใช้งาน Elasticsearch

Index

data structure ที่เก็บ document ใน elasticsearch (ถ้าเปรียบเทียบกับ SQL ก็คือ table)

Aliases

ชื่อที่ 2 ของ index (เป็นเหมือนชื่อเล่นที่เราเอาไว้เรียก index)

Document

ข้อมูลที่ถูกเก็บใน index (ถ้าเปรียบเทียบกับ SQL ก็คือแถวที่อยู่ใน table)

Terms

Token ของ input ที่ถูกเก็บอยู่ใน index ของ Elasticsearch

สิ่งที่ต้องทำเพื่อใช้การ Search ใน Elasticsearch

กำหนด structure ของ index

ในขั้นแรกเราจะต้องกำหนดโครงสร้างของ index ในรูปแบบ JSON เพื่อนำไปยิง HTTP request สร้าง index ในขั้นตอนต่อไป

สร้าง Index

เมื่อกำหนดโครงสร้างเสร็จแล้วให้นำมาสร้างเป็น index ที่ถูกเก็บใน elasticsearch

เพิ่ม/แก้ไขข้อมูลใส่ใน index

เมื่อเราสร้าง index สำเร็จขั้นตอนต่อมาก็คือนำข้อมูลที่เราต้องการใช้มาเก็บที่ index ซึ่งเราจะเรียกข้อมูลเหล่านี้ว่า document

Query ข้อมูลจาก index

หลังจากที่ข้อมูลเราพร้อมแล้วเราก็จะสามารถ query ข้อมูลจาก index เพื่อมา implement ระบบ search ของเราต่อไป

Structure ของ Index ใน Elasticsearch

การสร้าง index ใน elasticsearch จะประกอบไปด้วย 3 ส่วนหลักคือ aliases, setting และ mapping

Aliases

{
"aliases": {
"course-local": {}
}
// other fields
}

ชื่อที่สองที่เราสามารถตั้งให้กับ index ของเราได้โดยที่ 1 alias จะสามารถเป็นชื่อเรียกให้ได้กับหลาย index

โดยเราสามรถตั้งค่าให้ 1 alias mapping ไปที่หลาย index ได้

  • course-local-1 มี alias เป็น course-local
  • course-local-2 มี alias เป็น course-local ก็ได้
  • โดยการใช้งานคือเราจะสามารถเรียกชื่อใน request เป็น course-local ได้เลยและจะเป็นการ refer ถึง index 2 ตัวนี้

ในการ query ถ้าหากว่า alias มี index จะทำให้เราสามารถค้นหาข้อมูลจาก indices ที่ mapping กับ alias อันนี้

การเขียน/แก้ไขข้อมูลที่ aliases

เราสามารถตั้งค่า is_write_index ให้กับ aliases ได้เพื่อบอกว่า index ที่เรากำลังสร้างจะเป็นตัวที่ถูกแก้ไขข้อมูลเมื่อเราทำการยิง write request

  • จากตัวอย่างครั้งที่แล้ว course-local-1 และ course-local-2 มี aliases เป็น course-local
  • course-local-1 -> is_write_index = true
  • course-local-2 -> is_write_index = false
  • เมื่อเราทำการแก้ไขข้อมูลใน index ชื่อ course-local ข้อมูลจะถูกแก้ที่ course-local-1 แค่เพียงแค่ที่เดียว

การแก้ไขข้อมูลที่ aliases จะแบ่งเป็น 2 รูปแบบเมื่อไม่ได้ตั้ง is_write_index

  1. ถ้า aliases มีการ map ไปที่หลาย index และไม่ได้ตั้งค่า is_write_index elasticsearch จะทำการ reject write request
  2. ถ้า aliases มีการ map ไปที่ index ตัวเดียวและไม่ได้ตั้งค่า is_write_index elasticsearch จะเพิ่ม/แก้ไขข้อมูลใน index ที่ map กับ aliases

สามารถอ่านวิธีการตั้งค่า aliases แบบละเอียดได้ที่ https://www.elastic.co/guide/en/elasticsearch/reference/current/aliases.html

Setting

ส่วนนี้จะเป็นการตั้งค่า setting ของ index ซึ่งมีให้เลือกเยอะมาก ในบทความนี้จะขอพูดถึงแค่ส่วนที่ผมใช้ใน CU Get Reg นะครับ

Index

{
"settings": {
"index": {
"number_of_shards": 1
}
}
// other fields
}

จะเป็นการตั้งค่า index โดยใน elasticsearch จะแบ่งออกเป็น 2 รูปแบบ

  • static ตั้งค่าได้แค่ตอนสร้าง index (ไม่สามารถแก้ config ได้)
  • dynamic สามารถแก้ไขได้หลังจากที่สร้าง index แล้ว

ตัวอย่าง Static Setting

  • number_of_shards จำนวน primary shard ของ index (default = 1)
  • number_of_routing_shards จำนวน routing shard ใช้ในการ route document ไปที่ primary shard อ่านเพิ่มเติมที่ routing field
  • soft_deletes.enabled เปิดใช้งาน soft delete

ตัวอย่าง Dynamic Setting

  • number_of_replicas จำนวน replicas shard ของ index (default = 1)
  • max_result_window จำนวนมากที่สุดของ search result (from + size) ที่แสดงได้ (default = 10000)
  • hidden ซ่อนการมองเห็น index (default = false)

สามารถดู setting ทั้งหมดได้ที่ https://www.elastic.co/guide/en/elasticsearch/reference/current/index-modules.html

Analysis

{
"settings": {
"analysis": {
"filter": {
"thai_stop": {
"type": "stop",
"stopwords": "_thai_"
}
},
"analyzer": {
"thai": {
"tokenizer": "thai",
"filter": [
"lowercase",
"decimal_digit",
"thai_stop"
]
}
}
}
}
"mappings": {
"properties": {
"courseNameTh": {
"type": "text",
"analyzer": "thai"
},
"courseDescTh": {
"type": "text",
"analyzer": "thai"
}
}
}
// other fields
}

โดยปกติแล้ว elasticsearch จะใช้ standard analyzer เป็นค่า default และเราสามารถตั้งค่า analysis ได้ด้วย

  • ตั้งค่า built-in analyzer
  • สร้าง custom analyzer ขึ้นมาใหม่

ตั้งค่า built-in analyzer

{
"settings": {
"analysis": {
"filter": {
"thai_stop": {
"type": "stop",
"stopwords": "_thai_"
}
},
"analyzer": {
"thai": {
"tokenizer": "thai",
"filter": [
"lowercase",
"decimal_digit",
"thai_stop"
]
}
}
}
}
}

ในการเรียกใช้ built-in analyzer สามารถทำได้ตามตัวอย่าง โดยจะแบ่งเป็น 2 ส่วนเพื่อให้ทำความเข้าใจได้ง่ายขึ้น

Filter

  • thai_stop => ชื่อ filter
  • type => ชนิดของ filter (elasticsearch มี built-in filter ให้เลือกเยอะมาก)
  • stopwords => ลบ stopwords (ในตัวอย่างคือลบของภาษาไทย)

Analyzer

  • thai => ชื่อ analyzer
  • tokenizer => ชนิดของ tokenizer ที่จะนำมาใช้ใน analyzer
  • filter => รายชื่อ filter ที่จะนำมาใช้ใน analyzer ตัวนี้

อ่านรายละเอียดการตั้งค่า built-in analyzer เพิ่มเติมได้ที่

https://www.elastic.co/guide/en/elasticsearch/reference/current/configuring-analyzers.html

สร้าง custom analyzer

{
"settings": {
"analysis": {
"analyzer": {
"my_custom_analyzer": {
"type": "custom",
"tokenizer": "standard",
"char_filter": [
"html_strip"
],
"filter": [
"lowercase",
"asciifolding"
]
}
}
}
}
}

ในการสร้าง custom analyzer ทำได้ตามตัวอย่างโดยสิ่งที่แตกต่างไปจาก built-in analyzer คือ type จะมีค่าเป็น custom

  • my_custom_analyzer => ชื่อ analyzer
  • tokenizer => ชนิดของ tokenizer ที่จะนำมาใช้ใน analyzer
  • char_filter => ตั้งค่า character filter
  • filter => รายชื่อ filter ที่จะนำมาใช้ใน analyzer ตัวนี้

อ่านรายละเอียดการตั้งค่า custom analyzer เพิ่มเติมได้ที่

https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-custom-analyzer.html

Index Mapping

Index mapping ใน elasticsearch คือการกำหนดโครงสร้าง datatype ของ field ใน document ที่ถูกเก็บอยู่ใน index (ถ้าเปรียบเทียบกับ SQL ก็คือการทำ table migration)

Index mapping ใน elasticsearch มีอยู่ 2 แบบ

  • Dynamically Mapping คือการที่ elasticsearch จะทำการ mapping ข้อมูลใน document กับ data type ให้อัตโนมัติ
  • Explicit Mapping คือการที่เราตั้งค่า data type ของแต่ละ field ใน index ด้วยตัวเอง

Datatype ใน elasticsearch

elasticsearch support data type หลากหลายรูปแบบมากในบทความนี้จะขอยกตัวอย่างที่เรานำมาใช้จริงใน CU Get Reg เพื่อให้ง่ายต่อการอธิบาย

Common types

  • boolean ข้อมูลประเภท true หรือ false
  • keywords ข้อมูลประเภท keyword เช่น keyword constant_keyword wildcard
  • numbers ข้อมูลประเภท numeric เช่น long double
  • dates ข้อมูลประเภทเวลา
  • alias ตั้งชื่อที่ 2 ให้กับ field อื่นใน index

การเก็บค่า Object

  • object ข้อมูลประเภท JSON เป็น inner object ทำให้มีข้อจำกัดในการ query object ที่เก็บในรูปแบบ array (default dynamic mapping ของ JSON)
  • nested เป็นอีกหนึ่ง version ของ object ซึ่งสามารถ index object ที่เป็น array ได้ทำให้สามารถ query object ที่เก็บอยู่ใน array ได้แต่ก็แลกมาด้วย cost และ performance

Object vs Nested

หลายคนอาจจมีคำถามว่าแล้ว object กับ nested มันต่างกันยังไงบ้าง? เรามาดูข้อดีข้อเสียของแต่ละแบบกัน

Object

  • เป็น default ของ dynamic mapping
  • เหมาะกับการเก็บค่า object ที่ไม่ใช่ array

Nested

  • เหมาะกับการเก็บค่า object ที่เป็น array เมื่อคุณต้องการ query field นี้
type User = {
username: string
}

type BlogComment = {
author: User;
content: string;
}

type Blog = {
title: string;
description: string;
comments: BlogComment[];
author: User;
}

จากใน code ตัวอย่าง Blog จะมี comment ซึ่งเก็บค่าเป็น array มาดู use case กันว่าแบบไหนควรเก็บเป็น object หรือ nested

  • ถ้าคุณต้องการที่จะ search หรือ query comment ใน blog => เก็บค่าเป็น nested เพื่อให้ search ค่าใน array ได้
  • ถ้าคุณไม่จำเป็นที่จะต้อง search หรือ query comment ใน blog => เก็บค่าเป็น object ก็พอ

การตั้งค่าเพื่อทำ Full Text Search

มาถึง highlight สำหรับการใช้ elasticsearch ใน CU Get Reg แล้วนั่นก็คือการทำ full text search นั่นเองง

Full Text Search คืออะไร

ให้คุณลองนึกถึงเวลาที่ search ใน google เวลาที่คุณ search อะไรสักอย่างไป ผลลัพธ์ที่ได้มามันจะไม่ใช่แค่เว็บที่มีชื่อเหมือนกับ keyword เป๊ะๆ แต่จะรวมไปถึงเว็บที่มีความเก็บข้องหรือมี keyword เหล่านี้อยู่ภายในเว็บด้วย

สิ่งเหล่านี้แหละคือ full text search พูดง่ายๆ คือการ search หา document ที่มีข้อความที่คุณต้องอยู่ภายในนั้น

Text search types

  • text ข้อมูลสำหรับที่จะใช้ทำ full text search (analyzer จะถูกเรียกใช้เมื่อ query field นี้)
  • completion ข้อมูลสำหรับการทำ suggestion หรือ auto complete ซึ่งจะถูก optimize ให้ super-fast look up และเก็บข้อมูลใน memory (ถ้าว่างๆ เดี๋ยวมาเขียนบทความเกี่ยวกับการทำ suggestion ใน elasticsearch 😶)

Keyword vs Text

มาถึงตรงนี้หลายคนน่าจะมีคำถามว่าแล้ว keyword ใน common type กับ text ต่างกันยังไงบ้าง?

Keyword

  • exactly match (ต้องเหมือนเท่านั้นถึงจะ hit ใน query)

Text

  • ใช้ทำ full text search (มีแค่บางส่วนใน field ที่เหมือนก็ hit ใน query แล้ว)
  • มีการเรียกใช้ analyzer เมื่อ write/query field นี้ (มี cost มากกว่า keyword)

เลือกใช้ Analysis กับ Text

แน่นอนว่าในการทำ full text search เราต้องใช้ analyzer มาช่วยย่อย input text เป็น token เพื่อนำมา search ใน index ของเราและเราสามารถเลือกใช้ analyzer ในแต่ละ field ได้ด้วยนะ

{
"settings": {
"analysis": {
"filter": {
"thai_stop": {
"type": "stop",
"stopwords": "_thai_"
}
},
"analyzer": {
"thai": {
"tokenizer": "thai",
"filter": [
"lowercase",
"decimal_digit",
"thai_stop"
]
}
}
}
},
"mappings": {
"properties": {
"courseNameEn": {
"type": "text"
},
"courseDescEn": {
"type": "text"
},
"courseNameTh": {
"type": "text",
"analyzer": "thai"
},
"courseDescTh": {
"type": "text",
"analyzer": "thai"
}
}
}
}

จากใน code ตัวอย่างจะเห็นได้ว่ามีการ config analyzer ภาษาไทยขึ้นมาใหม่ใน setting

  • courseNameEn => ชื่อ course ภาษาอังกฤษใช้ standard analyzer (default)
  • courseDescEn => ข้อมูล course ภาษาอังกฤษใช้ standard analyzer (default)
  • courseNameTh => ชื่อ course ภาษาไทยใช้ analyzer ภาษาไทย
  • courseDescTh => ข้อมูล course ภาษาไทยใช้ analyzer ภาษาไทย

เปรียบเทียบระหว่าง Entity กับ Document ใน Elasticsearch

หลังจากพูดถึงเรื่อง data type กันไปเยอะแล้วมาดูกันว่า data ที่เป็น entity ใน database หลักเทียบกับ document ใน elasticsearch จะมีความเหมือนหรือต่างกันยังไงบ้าง

ด้วยความที่ตัว struture ของจริงมีความซับซ้อนมากเพื่อให้ง่ายและกระชับต่อการอธิบายขอยกตัวอย่างมาแค่บาง field นะครับ

// Structure of entity in database (MongoDB)
export interface Course {
studyProgram: StudyProgram
semester: Semester
academicYear: string
courseNo: string
courseDescTh?: string
courseDescEn?: string
abbrName: string
courseNameTh: string
courseNameEn: string
faculty: string
department: string
credit: number
creditHours: string
courseCondition: string
genEdType: GenEdType
rating?: string
midterm?: ExamPeriod
final?: ExamPeriod
sections: Section[]
}

export interface Section {
sectionNo: string
closed: boolean
capacity: {
current: number
max: number
}
note?: string
classes: {
type: ClassType
dayOfWeek?: DayOfWeek
period?: Period
building?: string
room?: string
teachers: string[]
}[]
genEdType: GenEdType
}
// Structure of document in Elasticsearch
{
"mappings": {
"properties": {
"rawData": {
"type": "nested",
"properties": {
"sections": {
"type": "nested",
"properties": {
"classes": {
"type": "nested",
"properties": {
"dayOfWeek": {
"type": "keyword"
},
"period": {
"type": "nested",
"properties": {
"start": {
"type": "keyword"
},
"end": {
"type": "keyword"
}
}
}
}
}
}
}
}
},
"abbrName": {
"type": "text"
},
"courseNo": {
"type": "keyword"
},
"courseNameEn": {
"type": "text"
},
"courseDescEn": {
"type": "text"
},
"courseNameTh": {
"type": "text",
"analyzer": "thai"
},
"courseDescTh": {
"type": "text",
"analyzer": "thai"
},
"genEdType": {
"type": "keyword"
},
"studyProgram": {
"type": "keyword"
},
"semester": {
"type": "keyword"
},
"academicYear": {
"type": "keyword"
}
}
}
}

สังเกตุว่าใน document ของ elasticsearch

  • ผมจะแยก field ที่ใช้ในการทำ full-text search ออกมาเพื่อให้ง่ายต่อการ query
  • ใช้ data type nested สำหรับข้อมูลที่ถูกเก็บไว้เป็น array และต้องใช้ query เป็น filter สำหรับการ search
  • idea คือเมื่อ query hit document เราจะ return ข้อมูล rawData ให้ frontend นำไปใช้ต่อ

การสร้าง Index และ Reindex ใน Elasticsearch

เมื่อเราตั้งค่าทุกส่วนเสร็จแล้วให้นำมารวมเป็น structure เพื่อนำมาสร้างเป็น index ในขั้นต่อไป

{
"aliases": {
"course-local": {}
},
"settings": {
"index": {
"number_of_shards": 1
},
"analysis": {
"filter": {
"thai_stop": {
"type": "stop",
"stopwords": "_thai_"
}
},
"analyzer": {
"thai": {
"tokenizer": "thai",
"filter": [
"lowercase",
"decimal_digit",
"thai_stop"
]
}
}
}
},
"mappings": {
"properties": {
"rawData": {
"type": "nested",
"properties": {
"sections": {
"type": "nested",
"properties": {
"classes": {
"type": "nested",
"properties": {
"dayOfWeek": {
"type": "keyword"
},
"period": {
"type": "nested",
"properties": {
"start": {
"type": "keyword"
},
"end": {
"type": "keyword"
}
}
}
}
}
}
}
}
},
"abbrName": {
"type": "text"
},
"courseNo": {
"type": "keyword"
},
"courseNameEn": {
"type": "text"
},
"courseDescEn": {
"type": "text"
},
"courseNameTh": {
"type": "text",
"analyzer": "thai"
},
"courseDescTh": {
"type": "text",
"analyzer": "thai"
},
"genEdType": {
"type": "keyword"
},
"studyProgram": {
"type": "keyword"
},
"semester": {
"type": "keyword"
},
"academicYear": {
"type": "keyword"
}
}
}
}

การสร้าง Index

การสร้าง index ใน elasticsearch เราจะต้องยิง HTTP request ไปที่ elasticsearch cluster ของเราผ่าน API

PUT /<index name>

{
"aliases": {
// alias
},
"settings": {
// settings
},
"mappings": {
// mappings
}
}

Reindex

workflow การ reindex

ในการ update mapping ของ index จะมีข้อจำกัดอยู่คือ

  • เพิ่ม field ใน index ได้
  • ไม่สามารถแก้ไข data type ของ field ที่มีอยู่แล้ว
  • ไม่สามารถแก้ไข setting บางอย่างได้

ด้วยข้อจำกัดนี้จึงเกิด concept reindex ขึ้นมาก็คือการ copy ข้อมูลจาก index เก่าไปยัง index ใหม่ที่คุณสร้างขึ้นมา

ตัวอย่างการ Reindex

  • คุณต้องการแก้ไข mapping data type ของ field ใน index (index นี้มี document แล้ว)
  • สร้าง index ใหม่ขึ้นมา (mapping และ setting ที่แก้เรียบร้อยแล้ว)
  • ทำการ reindex (copy document จาก index เก่าไปที่ index ใหม่)
  • (optional) ลบ index เก่าจาก alias เพื่อให้ alias refer ไปที่ index ใหม่ตัวเดียว
POST _reindex
{
"source": {
"index": <old index>
},
"dest": {
"index": <new index>
}
}

TIP: ถ้าเราใช้ alias ใน request reindex จะไม่มี downtime ในการ reindex

ลบ Index

DELETE /<index name>

เมื่อคุณต้องการลบ index ที่ไม่ใช้งานแล้วสามารถทำได้ด้วยการยิง DELETE Request

คำศัพท์ที่ควรรู้ก่อนพูดถึง Query ใน Elasticsearch

Fuzzy Matching

การค้นหา string ที่มีความใกล้เคียงกับ search keyword เช่น ถ้าคุณค้นหาคำว่า apple ผลลัพธ์ที่ได้ก็อาจจะมี aplpe หรือ aapple รวมมาด้วยเพราะ string มีความใกล้เคียงกัน

โดยปกติแล้วเรานิยมใช้ fuzzy matching กับการทำ spelling checking หรือ text search

Boolean Query

query ที่มีการรวมหลายๆ query เข้าด้วยกันโดยใช้ boolean logic เช่น AND, OR NOT

โดยปกติแล้วเราจะใช้ boolean query ใน search engine เมื่อมีหลาย criteria

Terms

Token ของ input ที่ถูกเก็บอยู่ใน index ของ Elasticsearch

การใช้ Query ใน Elasticsearch

การ query document ของ elasticsearch มีหลากหลายรูปแบบมากในบทความนี้ขอยกมาเฉพาะ query ที่ได้ใช้ในการ optimize search ของ CU Get Reg นะครับ

ผลลัพธ์ที่ได้จะเรียงลำดับตาม Score

การ query ใน elasticsearch จะมีการเรียงลำดับผลลัพธ์ตาม score (ความเกี่ยวข้องกันระหว่าง document กับ query)

Query Structure

โครงสร้างของ Query ใน elasticsearch จะแบ่งเป็น 2 ส่วน

  • Query Context เป็นส่วนที่บอกว่า document ที่ตรงกับ query เท่าไร หรือพูดง่ายๆ คือ “เป็นส่วนที่ใช้ในการให้คะแนนผลลัพธ์”
  • Filter Context เป็นส่วนที่เอาไว้เช็คว่า document ตรงกับ query ไหม หรือพูดง่ายๆ คือ “เป็นส่วนที่ใช้ในการตัดสินว่า document นี้จะนับเป็น search result ไหม” (ส่วนนี้ไม่นำมาคิดเป็นคะแนนของ query นะ)

ตัวอย่าง Query Structure

{
"query": {
"bool": {
"must": [
{ "match": { "courseNameEn": "Exploring Enginering World" }},
{ "match": { "courseNameDescEn": "First step to engineering world" }}
],
"filter": [
{ "term": { "sememster": "1" }},
{ "term": { "academicYear": "2565" }}
]
}
}
}

Query Context คือส่วนบน (must) ส่วนนี้คือส่วนที่นำมาคิดคะแนนของ search result

"must": [
{ "match": { "courseNameEn": "Exploring Enginering World" }},
{ "match": { "courseNameDescEn": "First step to engineering world" }}
]

Filter Context คือส่วนล่าง (filter) ส่วนนี้จะเอาไว้แค่หาข้อมูลที่ match กับ query แต่ไม่ได้เอามาคิดคะแนนของ search result

"filter": [ 
{ "term": { "sememster": "1" }},
{ "term": { "academicYear": "2565" }}
]

Fulltext Query

Match

GET /_search
{
"query": {
"match": {
"tagline": {
"query": "Thinc Make Impact",
"operator": "or"
}
}
}
}

การค้นหา document ที่ match กับ keyword ใน field ที่ต้องการ (keyword จะถูก analysis ก่อนนำมา matching)

query ในตัวอย่าง

  • ชื่อ field => tagline
  • query => Thinc Make Impact

การทำงานหลังของ match จะเป็นในรูปแบบของ boolean query

โดย input text จะถูก analyzer แยกส่วนออกมาเป็น token และหลังจากนั้นจะสร้างเป็น boolean query เพื่อค้นหา token ใน database และเชื่อมด้วย operator (default operator คือ OR)

NOTE: การ matching สามารถตั้งค่าให้ใช้เป็น fuzzy matching ได้ด้วยนะ

https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-match-query.html

Multimatch

GET /_search
{
"query": {
"multi_match" : {
"query": "Thinc Make Impact",
"fields": [ "name^3", "tagline" ]
}
}
}

เป็นอีกหนึง version ของ match query โดยความพิเศษของ multimatch query คือเราสามารถเลือก field ที่ใช้ในการค้นหาได้มากกว่า 1 field

query ในตัวอย่าง

  • ชื่อ field => name, tagline
  • query => Thinc Make Impact

เราสามารถทำการ boost field เพื่อให้ผลลัพท์เรียงลำดับความสำคัญตาม field ที่ boost ได้ด้วยเครื่องหมาย ^

เช่น ถ้าใช้ query ในตัวอย่างผลลัพธ์ที่ได้

[
{
"name": "Thinc."
"tagline": "Example Tagline"
},
{
"name": "Example Organization"
"tagline": "Thinc. Club"
},
]

สังเกตุว่า document ที่มีชื่อว่า Thinc จะอยู่แรกสุดเพราะ query ให้ความสำคัญกับ field name มากกว่า tagline

https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-multi-match-query.html

Query String

GET /_search
{
"query": {
"query_string": {
"query": "(Thinc) OR (Engineering Club)",
}
}
}

การ query โดยใช้ syntax ของ query string การใช้ query string โดยหลังบ้านของ elasticsearch จะแปลงไปเป็น query ที่มีความซับซ้อนก่อนที่จะ execute

query string จะมีความยืดหยุ่นและสามารถอ่านแล้วเข้าใจได้ง่ายสำหรับ query ที่ไม่ซับซ้อนแต่ถ้า query มีความซับซ้อนมากให้ผลตรงข้าม (ลองนึกถึง query ที่มีวงเล็บซ้อนกันเยอะๆดูสิ)

query ในตัวอย่าง

  • query => หา document ที่มีคำว่า Thinc หรือ Engineering Club

https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html

Term-Level Query

การ query ในระดับ term ซึ่งการ query term จะต่างกับ full text query ที่ term query จะไม่ analysis input text ก่อนนำไป search

โดยปกติแล้วเรานิยมใช้ term query ในการค้นหาข้อมูลแบบ exact match (เหมือนเป๊ะๆ)

Term

GET /_search
{
"query": {
"term": {
"course.name": {
"value": "cloud",
}
}
}
}

การ query แบบ exact match (ค่าของ term ใน field ที่ต้องการค้นหาต้องเหมือนเป๊ะๆ)

query ในตัวอย่าง

  • query => หา document ที่มี term cloud

ในการทำงานหลังบ้านของ term query คือจะนำ keyword ไปค้นหาใน database โดยไม่มีการ analysis keyword ก่อนจึงไม่เหมาะกับการทำ full text search

เราจะใช้ term query กับ field ที่มี data type เป็น keyword และหลีกเลี่ยงไม่ใช้กับ field ที่มี data type เป็น text เพราะจะได้ผลลัพธ์ที่ไม่ดีเท่า match query

https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-term-query.html

Terms

GET /_search
{
"query": {
"terms": {
"course.name": [ "cloud", "computing", "101" ]
}
}
}

เป็นอีกหนึง version ของ term query ความพิเศษคือเราสามารถเลือกค้นหาได้หลาย terms

query ในตัวอย่าง

  • query => หา document ที่มี term cloud, computing หรือ 101

https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-terms-query.html

Nested Query

GET /_search
{
"query": {
"nested": {
"path": "rawData",
"query": {
"nested": {
"path": "rawData.sections",
"query": {
"nested": {
"path": "rawData.sections.classes",
"query": {
"bool": {
"must": {
"terms": {"rawData.sections.classes.dayOfWeek": ["MON", "TUE", "WED"]}
}
}
}
}
}
}
}
}
}
}

การ query ข้อมูลประเภท nested

ถ้ายังจำในหัวข้อ mapping กันได้ index ของเราจะมี field นึงที่เก็บค่า rawData เอาไว้ในรูปแบบ nested

ในการ query ที่ต้องการค้นหาข้อมูลที่ match กับ field เหล่านั้นเราสามารถใช้ nested query ได้

query ในตัวอย่าง

  • query => ต้องการ query dayOfWeek ที่อยู่ใน rawData
  • สังเกตุดูว่าใน query จะลงลึกไปเรื่อยๆ จนถึง rawData.sections.classes แล้วจึงใช้ boolean query ต่อ

https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-nested-query.html

Compound Query

Boolean Query

POST _search
{
"query": {
"bool" : {
"must" : [
{
"multi_match": {
"query": "Cloud Computing",
"fields": [
"abbrName^5",
"courseNo^5",
"courseNameEn^3",
"courseDescEn",
"courseNameTh^3",
"courseDescTh"
]
}
}
],
"filter": [
{
"term": {
"semester": {
"value": "1",
},
},
},
{
"term": {
"studyProgram": {
"value": "T",
},
},
},
{
"term": {
"academicYear": {
"value": "2566",
},
},
},
],
"must_not" : [
{
"nested": {
"path": "rawData.sections.classes.period",
"query": {
"query_string": {
"query": "rawData.sections.classes.period.start:[8 AM TO 11 AM] AND rawData.sections.classes.period.end:[* TO 11 AM]"
}
}
}
}
],
"should" : [
{ "terms" : { "rawData.sections.classes.dayOfWeek" : ["MON", "TUE", "WED"] } },
]
}
}
}

การ query ที่มีหลายๆ query รวมกันโดยใช้ operator แบบ boolean เช่น AND, OR, NOT

พูดง่ายๆ ก็คือ query ที่รวม query อื่นๆ ที่พูดถึงก่อนหน้านี้มาเข้าด้วยกัน

Boolean Syntax

  • must คือสิ่งที่จะต้องมีอยู่ใน document (ใช้ในการให้คะแนนผลลัพธ์)
  • filter คือสิ่งที่จะต้องมีอยู่ใน document (เป็น filter context ไม่ได้ใช้ในการให้คะแนน)
  • should คือสิ่งที่อาจจะมีอยู่ใน document
  • must_not คือสิ่งที่ห้ามมีอยู่ใน document

https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-bool-query.html

Architecture ของ CU Get Reg หลังจากใช้ Elasticsearch

หลายคนอาจจะมีคำถามว่าถ้าเปลี่ยน database ขนาดนี้แล้ว architecture จะออกมาเป็นยังไง ต้องทำ CQRS แยก Read/Write Operation ไหม?

ต้องบอกว่าโชคดีที่ Content หลักของ CU Get Reg คือ Course ที่เราทำ Web Scraping มาจาก Reg Chula แล้วนำมาเก็บใน database จึงทำให้ความยุ่งยากในการ manage operation Write ลดลงไปเยอะมากไม่จำเป็นที่จะต้องทำ CQRS

และด้วยความที่ content ของ course มันไม่ใช่ dynamic content (เพราะ scrape มาจาก Reg Chula) สิ่งที่เปลี่ยนไปใน Architecture ก็เลยมีแค่ database เท่านั้น!!

Before Optimize Search Engine Workflow

Workflow การ Fetch Data จาก Reg Chula (Before)
Workflow การ Search Course ใน CU Get Reg (Before)

After Optimize Search Engine Workflow

Workflow การ Fetch Data จาก Reg Chula (After)
Workflow การ Search Course ใน CU Get Reg (After)

Wrapping Up

แน่นอนว่าสิ่งที่อยู่ใน blog นี้ยังเป็นเพียงแค่การเริ่มต้น Elasticsearch ยังมีสิ่งที่น่าสนใจอีกมากมาย ผมหวังว่าผู้อ่านจะสามารถนำสิ่งที่อยู่ใน blog นี้ไปต่อยอดกับ feature อีกมากมายในอนาคตได้ครับ 😬

ถ้าใครสนใจ Optimize หรือทำ Feature ใหม่ที่สร้างคุณภาพชีวิตที่ดีขึ้นให้กับชาวจุฬาฯ สามารถมาเข้าร่วมกับพวกเราได้เลยในตอนนี้ cugetreg.com เป็น Open Source Project ที่ทุกคนสามารถเข้ามา contribute ร่วมกันได้

--

--