มาลองใช้ Elasticsearch ทำระบบ full text search กันเถอะ
รีวิวการ optimize search engine ของ CU Get Reg ด้วย Elasticsearch
เกริ่นนำ
ในช่วงไม่กี่เดือนมานี้ผมได้มีโอกาสช่วยทีม 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 ข้อความที่ 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 คือ 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
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
ในการ 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
- ถ้า aliases มีการ map ไปที่หลาย index และไม่ได้ตั้งค่า
is_write_index
elasticsearch จะทำการ reject write request - ถ้า 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
ในการ 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
After Optimize Search Engine Workflow
Wrapping Up
แน่นอนว่าสิ่งที่อยู่ใน blog นี้ยังเป็นเพียงแค่การเริ่มต้น Elasticsearch ยังมีสิ่งที่น่าสนใจอีกมากมาย ผมหวังว่าผู้อ่านจะสามารถนำสิ่งที่อยู่ใน blog นี้ไปต่อยอดกับ feature อีกมากมายในอนาคตได้ครับ 😬
ถ้าใครสนใจ Optimize หรือทำ Feature ใหม่ที่สร้างคุณภาพชีวิตที่ดีขึ้นให้กับชาวจุฬาฯ สามารถมาเข้าร่วมกับพวกเราได้เลยในตอนนี้ cugetreg.com เป็น Open Source Project ที่ทุกคนสามารถเข้ามา contribute ร่วมกันได้
Thinc. — Think Make Impact 💡
ติดตามพวกเราได้ที่
- Medium: @thinc-org
- facebook: Thinc
- instagram: @thinc.in.th