Elasticsearch เป็นหนึ่งในเครื่องมือค้นหาที่ได้รับความนิยมมากที่สุด ในบรรดาบริษัทใหญ่ๆ จำนวนมากที่ชื่นชอบและใช้งานมันอย่างแข็งขันในการผลิต มีบริษัทยักษ์ใหญ่อย่าง Netflix, Medium, GitHub
Elasticsearch มีประสิทธิภาพมาก โดยมีกรณีการใช้งานหลักที่มีการค้นหาข้อความแบบเต็ม บันทึกแบบเรียลไทม์ และการวิเคราะห์ความปลอดภัย
น่าเสียดายที่ Elasticsearch ไม่ค่อยได้รับความสนใจจากชุมชน Rails มากนัก บทความนี้จึงพยายามเปลี่ยนแปลงสิ่งนี้โดยคำนึงถึงสองเป้าหมาย:แนะนำให้ผู้อ่านรู้จักกับแนวคิดของ Elasticsearch และแสดงวิธีใช้งานกับ Ruby on Rails
คุณสามารถค้นหาซอร์สโค้ดของโครงการตัวอย่างที่เราจะสร้างได้ที่นี่ ประวัติการคอมมิตนั้นสอดคล้องกับลำดับของหัวข้อในบทความนี้มากหรือน้อย
แนะนำตัว
จากมุมมองที่กว้างขึ้น Elasticsearch เป็นเครื่องมือค้นหาที่
- ถูกสร้างขึ้นบน Apache Lucene;
- จัดเก็บและจัดทำดัชนีเอกสาร JSON อย่างมีประสิทธิภาพ
- เป็นโอเพ่นซอร์ส
- จัดเตรียมชุดของ REST API สำหรับการโต้ตอบกับมัน
- โดยค่าเริ่มต้นไม่มีการรักษาความปลอดภัย (ทุกคนสามารถสอบถามผ่านปลายทางสาธารณะ)
- สเกลในแนวนอนค่อนข้างดี
มาดูแนวคิดพื้นฐานบางส่วนกัน
ด้วย Elasticsearch เราใส่เอกสารลงในดัชนี ซึ่งจะถูกสืบค้นข้อมูล
ดัชนี คล้ายกับตารางในฐานข้อมูลเชิงสัมพันธ์ เป็นร้านที่เราใส่ เอกสาร (แถว) ที่สามารถสืบค้นภายหลังได้
เอกสาร คือชุดของเขตข้อมูล (คล้ายกับแถวในฐานข้อมูลเชิงสัมพันธ์)
แผนที่ ก็เหมือนนิยามสคีมาในฐานข้อมูลเชิงสัมพันธ์ การทำแผนที่สามารถกำหนดได้อย่างชัดเจนหรือคาดเดาโดย Elasticsearch ในเวลาที่แทรก การกำหนดการแมปดัชนีล่วงหน้าจะดีกว่าเสมอ
ตอนนี้เรามาตั้งค่าสภาพแวดล้อมของเรากันดีกว่า
การติดตั้ง Elasticsearch
วิธีที่ง่ายที่สุดในการติดตั้ง Elasticsearch บน macOS คือการใช้ brew:
brew tap elastic/tap
brew install elastic/tap/elasticsearch-full
อีกทางเลือกหนึ่ง เราสามารถเรียกใช้ผ่าน 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
โปรดดูข้อมูลอ้างอิงอย่างเป็นทางการสำหรับตัวเลือกอื่นๆ
Elasticsearch ยอมรับคำขอบนพอร์ต 9200 โดยค่าเริ่มต้น คุณสามารถตรวจสอบว่ามันทำงานด้วยการร้องขอ curl ง่ายๆ (หรือเปิดในเบราว์เซอร์):
curl https://localhost:9200
API
Elasticsearch จัดเตรียมชุดของ REST API เพื่อโต้ตอบกับงานทุกประเภทที่เป็นไปได้ ตัวอย่างเช่น สมมติว่าเราเรียกใช้คำขอ POST ด้วยประเภทเนื้อหา JSON เพื่อสร้างเอกสาร:
curl -X POST https://localhost:9200/my-index/_doc \
-H 'Content-Type: application/json' \
-d '{"title": "Banana Cake"}'
ในกรณีนี้ my-index
คือชื่อของดัชนี (หากไม่มีอยู่ ดัชนีจะถูกสร้างขึ้นโดยอัตโนมัติ)
_doc
เป็นเส้นทางของระบบ (เส้นทางระบบทั้งหมดเริ่มต้นด้วยขีดล่าง)
มีหลายวิธีในการโต้ตอบกับ API
- การใช้
curl
จากบรรทัดคำสั่ง (คุณอาจพบว่า jq มีประโยชน์) - การเรียกใช้ GET เคียวรีจากเบราว์เซอร์โดยใช้ส่วนขยายบางอย่างสำหรับการพิมพ์ JSON ที่สวยงาม
- การติดตั้ง Kibana และการใช้คอนโซล Dev Tools ซึ่งเป็นวิธีโปรดของฉัน
- ในที่สุดก็มีส่วนขยาย Chrome ที่ยอดเยี่ยมด้วย
เพื่อประโยชน์ของบทความนี้ ไม่สำคัญว่าคุณจะเลือกอันไหน เราจะไม่โต้ตอบกับ API โดยตรงอยู่ดี เราจะใช้อัญมณีซึ่งพูดคุยกับ REST API แทน
การเริ่มต้นแอปใหม่
แนวคิดคือการสร้างแอปพลิเคชั่นเนื้อเพลงโดยใช้ชุดข้อมูลสาธารณะที่มีเพลงมากกว่า 26K+ แต่ละเพลงจะมีช่องชื่อ ศิลปิน ประเภท และข้อความ เราจะใช้ Elasticsearch สำหรับการค้นหาข้อความแบบเต็ม
เริ่มต้นด้วยการสร้างแอปพลิเคชัน Rails อย่างง่าย:
rails new songs_api --api -d postgresql
เนื่องจากเราจะใช้เป็น API เท่านั้น เราจึงจัดเตรียม --api
ตั้งค่าสถานะเพื่อจำกัดชุดมิดเดิลแวร์ที่ใช้
มาสร้างแอปของเรากันเถอะ:
bin/rails generate scaffold Song title:string artist:string genre:string lyrics:text
ตอนนี้ มาเริ่มการย้ายข้อมูลและเริ่มเซิร์ฟเวอร์กัน:
bin/rails db:create db:migrate
bin/rails server
หลังจากนั้น เราตรวจสอบว่าปลายทาง GET ใช้งานได้:
curl https://localhost:3000/songs
นี่จะคืนค่าอาร์เรย์ว่าง ซึ่งไม่น่าแปลกใจเพราะยังไม่มีข้อมูล
แนะนำ Elasticsearch
มาเพิ่ม Elasticsearch ลงในมิกซ์กัน ในการทำเช่นนั้น เราจะต้องใช้ elasticsearch-model gem มันคือ Elasticsearch gem อย่างเป็นทางการที่รวมเข้ากับโมเดล Rails อย่างดี
เพิ่มสิ่งต่อไปนี้ใน Gemfile
. ของคุณ :
gem 'elasticsearch-model'
โดยค่าเริ่มต้น มันจะเชื่อมต่อกับพอร์ต 9200 บน localhost ซึ่งเหมาะกับเราอย่างสมบูรณ์ แต่ถ้าคุณต้องการเปลี่ยนแปลง คุณสามารถเริ่มต้นไคลเอนต์โดย
Song.__elasticsearch__.client = Elasticsearch::Client.new host: 'myserver.com', port: 9876
ต่อไป ในการทำให้โมเดลของเราสามารถจัดทำดัชนีโดย Elasticsearch เราต้องทำสองสิ่ง อันดับแรก เราต้องเตรียมการทำแผนที่ (ซึ่งโดยพื้นฐานแล้วเป็นการบอก Elasticsearch เกี่ยวกับโครงสร้างข้อมูลของเรา) และประการที่สอง เราควรสร้างคำขอค้นหา อัญมณีของเราทำได้ทั้งสองอย่าง ไปดูวิธีใช้งานกันเลย
เป็นความคิดที่ดีเสมอที่จะเก็บโค้ดที่เกี่ยวข้องกับ Elastisearch ไว้ในโมดูลที่แยกจากกัน ดังนั้นเรามาสร้างข้อกังวลที่ app/models/concerns/searchable.rb
และเพิ่ม
# 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
แม้จะเป็นเพียงโครงกระดูก แต่ก็มีของให้แกะที่นี่
สิ่งแรกและที่สำคัญที่สุดคือ Elasticsearch::Model
ซึ่งเพิ่มฟังก์ชันบางอย่างสำหรับการโต้ตอบกับ ES Elasticsearch::Model::Callbacks
โมดูลช่วยให้มั่นใจว่าเมื่อเราอัปเดตบันทึก จะอัปเดตข้อมูลใน Elasticsearch โดยอัตโนมัติ mapping
block คือที่ที่เราใส่การทำแผนที่ดัชนี Elasticsearch ซึ่งกำหนดว่าฟิลด์ใดจะถูกเก็บไว้ใน Elasticsearch และควรมีประเภทใด ในที่สุดก็มี search
วิธีที่เราจะใช้ในการค้นหาเนื้อเพลงของ Elasticsearch อัญมณีที่เราใช้ให้ search
เมธอดที่สามารถใช้กับข้อความค้นหาง่ายๆ เช่น Song.search("genesis”)
แต่เราจะใช้กับคำค้นหาที่ซับซ้อนมากขึ้นซึ่งสร้างโดยใช้ข้อความค้นหา DSL (เพิ่มเติมในภายหลัง)
อย่าลืมใส่ข้อกังวลในคลาสโมเดลของเรา:
# /app/models/song.rb
class Song < ApplicationRecord
include Searchable
end
การจับคู่
ใน Elasticsearch การทำแผนที่เปรียบเสมือนคำจำกัดความของสคีมาในฐานข้อมูลเชิงสัมพันธ์ เราอธิบายโครงสร้างของเอกสารที่เราต้องการจัดเก็บ ต่างจากฐานข้อมูลเชิงสัมพันธ์ทั่วไป เราไม่จำเป็นต้องกำหนดแผนที่ล่วงหน้า:Elasticsearch จะพยายามคาดเดาประเภทให้เราอย่างเต็มที่ อย่างไรก็ตาม เนื่องจากเราไม่ต้องการความประหลาดใจใดๆ เราจะกำหนดแผนที่ของเราไว้ล่วงหน้าอย่างชัดแจ้ง
สามารถอัปเดตการแมปผ่านปลายทาง REST โดยใช้ PUT /my-index/_mapping
และอ่านผ่าน GET /my-index/_mapping
แต่ elasticsearch
gem abstracts สำหรับเรา สิ่งที่เราต้องทำคือจัดเตรียม 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
เรากำลังจะสร้างดัชนี artist
, title
และ lyrics
ฟิลด์ที่ใช้ประเภทข้อความ เป็นประเภทเดียวที่จัดทำดัชนีสำหรับการค้นหาข้อความแบบเต็ม สำหรับ genre
เราจะใช้ประเภทคำหลักซึ่งเป็นการค้นหาในอุดมคติที่กรองด้วยค่าที่แน่นอน
ตอนนี้เรียกใช้คอนโซล rails ด้วย bin/rails console
แล้วรัน
Song.__elasticsearch__.create_index!
สิ่งนี้จะสร้างดัชนีของเราใน Elasticsearch __elasticsearch__
วัตถุคือประตูสู่โลกของ Elasticsearch ซึ่งอัดแน่นไปด้วยวิธีการที่มีประโยชน์มากมายสำหรับการโต้ตอบกับ Elasticsearch
การนำเข้าข้อมูล
ทุกครั้งที่เราสร้างบันทึก มันจะส่งข้อมูลไปยัง Elasticsearch โดยอัตโนมัติ ดังนั้น เราจะดาวน์โหลดชุดข้อมูลที่มีเนื้อเพลงและนำเข้าไปยังแอพของเรา ขั้นแรก ดาวน์โหลดจากลิงค์นี้ (ชุดข้อมูลภายใต้ Creative Commons Attribution 4.0 International license
). ไฟล์ CSV นี้มีบันทึกมากกว่า 26,000 รายการ ซึ่งเราจะนำเข้าไปยังฐานข้อมูลและ Elasticsearch ของเราด้วยรหัสด้านล่าง:
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
เปิดคอนโซลรางและเรียกใช้ Song.import_csv!
(จะใช้เวลาสักครู่) อีกทางหนึ่ง เราสามารถนำเข้าข้อมูลจำนวนมาก ซึ่งเร็วกว่ามาก แต่ในกรณีนี้ เราต้องการให้แน่ใจว่าเราสร้างบันทึกในฐานข้อมูล PostgreSQL และ Elasticsearch
เมื่อการนำเข้าเสร็จสิ้น ตอนนี้เรามีเนื้อเพลงมากมายที่เราสามารถค้นหาได้
การค้นหาข้อมูล
elasticsearch-model
gem เพิ่ม search
วิธีการที่ช่วยให้เราสามารถค้นหาในฟิลด์ที่จัดทำดัชนีทั้งหมด มาใช้ในข้อกังวลที่ค้นหาได้ของเรา:
# app/models/concerns/searchable.rb
# ...
def self.search(query)
self.__elasticsearch__.search(query)
end
# ...
เปิดคอนโซลรางและเรียกใช้ res = Song.search('genesis')
. ออบเจ็กต์การตอบกลับมีข้อมูลเมตาจำนวนมาก:ระยะเวลาในการร้องขอ ใช้โหนดใด ฯลฯ เราสนใจ Hit ที่ res.response["hits"]["hits"]
.
มาเปลี่ยน index
. ของคอนโทรลเลอร์ของเรากันเถอะ วิธีการสอบถาม ES แทน
# app/controllers/songs_controller.rb
def index
query = params["query"] || ""
res = Song.search(query)
render json: res.response["hits"]["hits"]
end
ตอนนี้เราสามารถลองโหลดในเบราว์เซอร์หรือใช้ curl https://localhost:3000/songs?query=genesis
. คำตอบจะมีลักษณะดังนี้:
[
{
"_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": "..."
}
},
...
]
อย่างที่คุณเห็น ข้อมูลจริงถูกส่งกลับภายใต้ _source
คีย์ ส่วนช่องอื่นๆ เป็นข้อมูลเมตา ที่สำคัญที่สุดคือ _score
แสดงให้เห็นว่าเอกสารมีความเกี่ยวข้องกับการค้นหานั้นอย่างไร เราจะดำเนินการในเร็วๆ นี้ แต่ก่อนอื่น เรามาเรียนรู้วิธีสร้างคำค้นหากันก่อน
แบบสอบถาม DSL
DSL ข้อความค้นหาของ Elasticsearch มีวิธีการสร้างการสืบค้นที่ซับซ้อน และเราสามารถใช้ได้จากรหัส ruby เช่นกัน ตัวอย่างเช่น มาแก้ไขวิธีการค้นหาเพื่อค้นหาเฉพาะช่องศิลปิน:
# 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
โครงสร้างการจับคู่ข้อความค้นหาช่วยให้เราสามารถค้นหาเฉพาะฟิลด์เฉพาะ (ในกรณีนี้คือศิลปิน) ตอนนี้ หากเราค้นหาเพลงอีกครั้งด้วย "genesis" (ลองโหลด https://localhost:3000/songs?query=genesis
) เราจะได้เฉพาะเพลงของวง "Genesis" เท่านั้น ไม่ใช่เพลงที่มี "genesis" อยู่ในชื่อ หากเราต้องการสืบค้นข้อมูลหลายช่อง ซึ่งมักจะเป็นกรณีนี้ เราสามารถใช้การสืบค้นแบบหลายรายการได้:
# app/models/concerns/searchable.rb
def self.search(query)
params = {
query: {
multi_match: {
query: query,
fields: [ :title, :artist, :lyrics ]
},
},
}
self.__elasticsearch__.search(params)
end
การกรอง
จะเป็นอย่างไรถ้าเราต้องการค้นหาเฉพาะในเพลงร็อค จากนั้นเราต้องกรองตามประเภท! นี่จะทำให้การค้นหาของเราซับซ้อนขึ้นเล็กน้อย แต่ไม่ต้องกังวล เราจะอธิบายทุกอย่างทีละขั้นตอน!
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
คำหลักใหม่คำแรกคือ bool ซึ่งเป็นเพียงวิธีการรวมข้อความค้นหาหลายคำเข้าเป็นหนึ่งเดียว ในกรณีของเรา เรากำลังรวม must
และ filter
. อันแรก (must
) มีส่วนช่วยในการให้คะแนนและมีข้อความค้นหาเดียวกันกับที่เราเคยใช้มาก่อน อันที่สอง (filter
) ไม่ส่งผลต่อคะแนน แต่ทำในสิ่งที่ระบุ:กรองเอกสารที่ไม่ตรงกับคำค้นหาออก เราต้องการกรองบันทึกของเราตามประเภท ดังนั้นเราจึงใช้คำค้นหา
สิ่งสำคัญคือต้องสังเกตว่า filter-term
ชุดค่าผสมไม่มีส่วนเกี่ยวข้องกับการค้นหาข้อความแบบเต็ม เป็นเพียงตัวกรองปกติตามค่าที่แน่นอน เช่นเดียวกับ WHERE
ประโยคทำงานใน SQL (WHERE genre = 'rock'
). เป็นการดีที่จะรู้วิธีใช้ term
การกรอง แต่เราไม่ต้องการมันที่นี่
การให้คะแนน
ผลการค้นหาเรียงตาม _score
ที่แสดงให้เห็นว่ารายการมีความเกี่ยวข้องกับการค้นหาเฉพาะอย่างไร ยิ่งคะแนนสูง เอกสารก็ยิ่งมีความเกี่ยวข้องมากขึ้นเท่านั้น คุณอาจสังเกตเห็นว่าเมื่อเราค้นหาคำว่า genesis
ผลลัพธ์แรกที่โผล่ขึ้นมาคือเพลงของ Grimes ในขณะที่ฉันสนใจวง Genesis มากกว่า แล้วเราจะปรับเปลี่ยนกลไกการให้คะแนนให้สนใจด้านศิลปินมากขึ้นได้หรือไม่? ใช่ เราทำได้ แต่ในการทำเช่นนั้น เราต้องปรับแต่งการสืบค้นของเราก่อน:
def self.search(query)
params = {
query: {
bool: {
should: [
{ match: { title: query }},
{ match: { artist: query }},
{ match: { lyrics: query }},
],
}
},
}
self.__elasticsearch__.search(params)
end
เคียวรีนี้เทียบเท่ากับอันเดิม ยกเว้นว่าใช้คีย์เวิร์ด bool ซึ่งเป็นเพียงวิธีการรวมการสืบค้นหลายรายการเป็นหนึ่งเดียว เราใช้ should
ซึ่งมีสามข้อความค้นหาแยกกัน (หนึ่งรายการต่อฟิลด์):โดยพื้นฐานแล้วจะรวมกันโดยใช้ตรรกะ OR หากเราใช้ must
แต่จะรวมกันโดยใช้ตรรกะ AND เหตุใดเราจึงต้องมีการแข่งขันแยกกันในแต่ละสนาม? นั่นเป็นเพราะว่าตอนนี้เราสามารถระบุคุณสมบัติบูสต์ ซึ่งเป็นค่าสัมประสิทธิ์ที่คูณคะแนนจากการสืบค้นเฉพาะ:
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
สิ่งอื่นที่เท่าเทียมกัน คะแนนของเราจะสูงขึ้นห้าเท่าหากข้อความค้นหาตรงกับศิลปิน ลอง genesis
สืบค้นอีกครั้งด้วย https://localhost:3000/songs?query=genesis
และคุณจะเห็นเพลงของวง Genesis มาเป็นอันดับแรก หวาน!
ไฮไลต์
คุณสมบัติที่เป็นประโยชน์อีกประการหนึ่งของ Elasticsearch คือสามารถเน้นการจับคู่ภายในเอกสาร ซึ่งทำให้ผู้ใช้เข้าใจได้ดีขึ้นว่าเหตุใดผลลัพธ์จึงปรากฏในการค้นหา
ใน HTML มีแท็ก HTML พิเศษสำหรับสิ่งนั้น และ Elasticsearch สามารถเพิ่มได้โดยอัตโนมัติ
มาเปิด searchable.rb
. กันเถอะ กังวลอีกครั้งและเพิ่มคำสำคัญใหม่:
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
highlight
ใหม่ ฟิลด์ ระบุว่าฟิลด์ใดควรเน้น เราเลือกทั้งหมด ทีนี้ ถ้าเราโหลด https://localhost:3000/query=genesis
เราควรจะเห็นช่องใหม่ชื่อว่า "highlight" ซึ่งมีฟิลด์เอกสารที่มีวลีที่ตรงกันอยู่ใน em
แท็ก
สำหรับข้อมูลเพิ่มเติมเกี่ยวกับการเน้น โปรดดูคู่มืออย่างเป็นทางการ
ความคลุมเครือ
เอาล่ะ หากเราเขียน benesis
. ผิดพลาด แทน genesis
? สิ่งนี้จะไม่ส่งคืนผลลัพธ์ใดๆ แต่เราสามารถบอก Elasticsearch ให้จู้จี้จุกจิกน้อยลงและอนุญาตการค้นหาที่คลุมเครือ ดังนั้นมันจะแสดง genesis
ผลลัพธ์เช่นกัน
นี่คือวิธีการทำ เพียงเปลี่ยนข้อความค้นหาศิลปินจาก { match: { artist: { query: query, boost: 5 } }}
เป็น { match: { artist: { query: query, boost: 5, fuzziness: "AUTO" } }}
. สามารถกำหนดค่ากลไกความคลุมเครือที่แน่นอนได้ โปรดดูรายละเอียดเพิ่มเติมในเอกสารอย่างเป็นทางการ
จะไปต่อที่ไหน
ฉันหวังว่าบทความนี้จะทำให้คุณเชื่อมั่นว่า Elasticsearch เป็นเครื่องมือที่ทรงพลังที่สามารถใช้ได้และควรใช้เมื่อคุณต้องใช้การค้นหาที่ไม่สำคัญ หากคุณพร้อมที่จะเรียนรู้เพิ่มเติม นี่คือลิงค์ที่เป็นประโยชน์:
ทรัพยากร
- ข้อมูลอ้างอิงอย่างเป็นทางการของ Elasticsearch
- อัญมณีทับทิม
- อัญมณี Rails
- หนังสือที่ดีมากที่เต็มไปด้วยความรู้เชิงปฏิบัติ
- สร้างการเติมข้อความอัตโนมัติ
อัญมณีทางเลือก
- Searchkick
- เคี้ยวหนึบ