ข้ามไปยังเนื้อหาหลัก
sinananr.com
กลับไปหน้าบทความทั้งหมด

ออกแบบระบบ search ของบล็อก: keyword วันนี้ + semantic ในวันพรุ่งนี้

บันทึกการออกแบบระบบ search ของ sinananr.com 4 ชั้น ตั้งแต่ Astro static + React modal ไปจนถึง Cloudflare Worker, Sanity GROQ และ SurrealDB HNSW ที่กำลังจะมา

สินอนันต์ รูปแก้ว · 26 เมษายน 2569 · 9 นาที
ระบบค้นหาในบล็อก: keyword + semantic ที่แยกชั้นกันชัดเจน

AI summary

บันทึกการออกแบบระบบ search ของ sinananr.com แบ่งเป็น 4 ชั้น (Astro modal → Cloudflare Worker → Sanity GROQ + SurrealDB HNSW) เริ่มจาก keyword search ด้วย Sanity match operator เพราะเร็วและถูก แล้ววางแผน semantic search ผ่าน SurrealDB HNSW + BGE-m3 embedding ในเฟสถัดไป Worker เป็นชั้น proxy ที่ rate-limit (token bucket 60/min/IP) + edge cache 60s + normalize response shape ทำให้สลับ backend ภายหลังได้โดยไม่กระทบ frontend

ตอนเริ่มทำบล็อก sinananr.com เวอร์ชันแรก ผมไม่ได้คิดเรื่องระบบ search เลย เพราะคิดว่าเนื้อหายังน้อย ใครจะอ่านก็เลื่อนหาเองได้ พอเขียนไปได้สิบกว่าโพสต์ค่อยรู้ว่า ตัวเองเริ่มหาเนื้อหาเก่าของตัวเองไม่เจอ เลยตัดสินใจสร้างระบบ search แบบที่ขยายต่อได้ในอนาคต ไม่ใช่แค่ใส่ form ฟิลเตอร์เร็ว ๆ แล้วจบ

บทความนี้เป็นบันทึกการออกแบบระบบ search ของ sinananr.com ทุกชั้น ตั้งแต่ UI ที่ผู้อ่านเห็นไปจนถึง storage layer ลึก ๆ พร้อมเหตุผลว่าทำไมเลือก Sanity GROQ ทำ keyword search ก่อน และวางแผนใช้ SurrealDB HNSW ทำ semantic search ในเฟสถัดไป

ทำไมต้องสร้างระบบ search เอง ในเมื่อ Sanity GROQ ก็มีอยู่แล้ว

Sanity เปิด Content Lake ให้เรียก GROQ ตรงจาก browser ได้ผ่าน CDN ฟรีอยู่แล้ว ฟังดูเหมือนไม่ต้องทำอะไรเพิ่ม แต่พอลองใช้จริงพบ 4 ปัญหา ที่ทำให้ตัดสินใจวาง Cloudflare Worker เป็นชั้นกลางระหว่างเว็บกับ Sanity

ข้อแรก rate-limit ระดับเว็บ — ถ้าเรียกตรงไป Sanity ทุก keystroke ของผู้อ่าน จะมี scraper หรือ bot สแปม endpoint ได้สบายโดยไม่มีอะไรหยุด ข้อสอง Project ID ของ Sanity จะหลุดออกไปอยู่ในทุกหน้า แม้จะไม่ใช่ secret แต่ก็ไม่จำเป็นต้องเปิด ข้อสาม การ cache ที่ edge ควบคุมเองยากกว่า ข้อสี่ — สำคัญที่สุด — เมื่อเปิด semantic search ในเฟสถัดไป ผม shape response ให้เหมือน keyword search ได้ ฝั่ง frontend จึงไม่ต้องแก้อะไรเลยตอนสลับ backend

การมีชั้น proxy แสนยุ่งเหยิงตอนแรก กลายเป็นชั้นที่ปลดล็อกการเปลี่ยนแปลงในอนาคต — ผมยอมแลกความซับซ้อนวันนี้กับความยืดหยุ่นของพรุ่งนี้

ระบบ search ของบล็อกแบ่งเป็นกี่ชั้น

ตอนแรกที่วางสถาปัตยกรรม ผมแบ่งระบบออกเป็น 4 ชั้น ชั้นบนสุดคือผู้อ่านที่เปิด browser ลงมาเป็นหน้า Astro static ที่ build ไปกับ Cloudflare Pages ภายในมี React island ตัวเล็ก ๆ ชื่อ SearchModal คอยจัดการ state การค้นหาแบบ real-time

ชั้นถัดมาคือ Cloudflare Worker (เขียนด้วย Hono) ที่ทำหน้าที่ proxy + rate-limit + cache รับ request จาก SearchModal แล้วส่งต่อไป Sanity Content Lake ในเฟสถัดไปจะมี SurrealDB Cloud มาเสริมตรงนี้ผ่าน HNSW vector index สำหรับ semantic search ทั้งหมดนี้คุยกันด้วย JSON shape เดียว ทำให้ frontend ไม่ต้องรู้ว่า backend ใช้อะไร

ตัวจริงของแต่ละ component

  • ผู้อ่าน → browser โหลดหน้า static + JS ของ SearchModal
  • /search modal → React island fetch /api/search?q=...
  • Cloudflare Worker (Hono) → token-bucket rate-limit + edge cache + GROQ proxy
  • Sanity Content Lake → keyword search ผ่าน match operator (เฟสปัจจุบัน)
  • SurrealDB Cloud → vector::knn HNSW search (เฟสถัดไป)

Phase 1: full-text keyword ด้วย Sanity GROQ

Endpoint จริงเปิดที่ api.sinananr.com/api/search รับ query string 2 ตัวคือ q (คำค้น ขั้นต่ำ 2 ตัวอักษร) และ limit (1-50, ดีฟอลต์ 10) ตอบกลับเป็น JSON ที่มีฟิลด์ q, limit, results พร้อมข้อมูลโพสต์ได้แก่ _id, title, slug, excerpt, publishedAt — เท่านี้พอสำหรับการ render ใน SearchModal

GROQ ที่ Worker ยิงไป Sanity จะใช้ match operator บน 3 ฟิลด์พร้อมกัน คือ title, excerpt และ pt::text(body) จุดที่สำคัญที่สุดคือ pt::text(body) เพราะมัน flatten Portable Text เป็น plain text ก่อน match ทำให้ค้นในเนื้อหาบทความได้จริง ไม่ใช่แค่หัวข้อ

เคล็ดลับเล็ก ๆ สามอย่างที่ใช้จริง

หนึ่ง — กรอง draft ออกด้วย !(_id in path('drafts.**')) ป้องกันบทความที่ยังไม่ publish หลุดมาให้ผู้อ่าน สอง — order(_score desc, publishedAt desc) ใช้ relevance score ของ Sanity คู่กับวันที่เผยแพร่ ทำให้บทความที่ตรงคำมากและใหม่ขึ้นมาก่อน สาม — ก่อนยิง GROQ ผมเติม * ท้าย keyword เสมอ เช่นพิมพ์ astr Worker ส่งไปเป็น astr* แล้วเจอ astro ได้ทันที

Wildcard ตัวเดียวเปลี่ยน UX จาก 'พิมพ์ครบแล้วค่อยกด' เป็น 'พิมพ์ไปเรื่อย ๆ ผลโผล่ตามทันที'

ทำไมยังไม่ใช้ SurrealDB ฝั่ง full-text

Sanity เป็น source of truth ของเนื้อหา editorial อยู่แล้ว ที่ขนาดบล็อกประมาณ 100-500 บทความ GROQ match ตอบกลับใน 100-200ms จาก edge ก็เพียงพอ การ mirror ข้อมูลไป SurrealDB เพื่อทำ full-text เพิ่มความซับซ้อนของ pipeline โดยไม่ได้ประโยชน์อะไรชัดเจน — เก็บ engine ของ Surreal ไว้ใช้กับงานที่มันถนัดจริง ๆ ดีกว่า

Phase 2: ทำไมต้องเพิ่ม semantic ในเมื่อมี keyword แล้ว

Keyword search ค้นเก่งมากในกรณีที่ผู้อ่านรู้คำที่ต้องค้น แต่บ่อยครั้งคนหา เริ่มทำเว็บฟรี ทั้งที่บทความใช้ชื่อ Cloudflare Pages + D1 — keyword หาไม่เจอเพราะคำไม่ตรง ส่วน semantic search ใช้ embedding (vector ของ ความหมาย) มา match ข้อความสองชิ้นที่ใช้คำต่างกันแต่หมายถึงเรื่องเดียวกันได้

ตัวอย่างที่ใช้ทดสอบจริง: ค้น ใช้ AI ช่วยเขียนโค้ด ระบบ keyword จะเจอเฉพาะบทความที่มีคำว่า AI ตรง ๆ แต่ semantic จะเจอ Copilot, ChatGPT, Cursor ได้พร้อมกัน หรือค้น ทำใจ ปล่อยวาง ที่ keyword หาไม่เจออะไรเลย ฝั่ง semantic จะ surface บทความหมวดธรรมะขึ้นมา

Schema ที่เตรียมไว้ใน packages/db/src/schema.surql

ตอนนี้มี table content_post ที่ mirror ข้อมูลจาก Sanity (sanityId, slug, title, excerpt, body, publishedAt) ส่วน field embedding และ HNSW index จะเปิดในเฟสถัดไป HNSW จะใช้ DIMENSION 1024 (ตามขนาดของ BGE-m3) DIST COSINE TYPE F32 EFC 150 M 12 M0 24 — ค่าที่แนะนำของ SurrealDB สำหรับ corpus ขนาดเล็ก-กลาง

Pipeline ที่จะเพิ่มคือ: Sanity webhook ยิงเข้า Worker → Worker fetch เนื้อหาเต็มผ่าน GROQ → flatten Portable Text → ส่งไป Workers AI (BGE-m3) เพื่อ embed → UPSERT เข้า SurrealDB ทั้งหมดนี้ทำที่ edge ไม่มี server ระหว่างกลาง ค่าใช้จ่ายเกือบเป็นศูนย์เพราะ Workers AI BGE-m3 อยู่ที่ราคา $0.011 ต่อ 1M token

Query semantic ที่จะใช้จริง

เวลาผู้อ่านพิมพ์คำถาม Worker จะ embed คำถามด้วย model เดียวกัน แล้วยิง SurrealQL ที่มีท่าค่อนข้างใหม่ คือ embedding <|10, 40|> $vector ตัวเลข 10 หมายถึง top 10 ผลลัพธ์ ตัวเลข 40 คือ effort value ที่ค่าเริ่มต้นแนะนำของ HNSW จากนั้น filter ผลลัพธ์ที่ score < 0.65 ออก เพื่อตัด noise

ทำไมไม่ทำ hybrid search ตั้งแต่แรก

ในตำราหลายเล่มแนะนำให้ทำ hybrid (รวม keyword + semantic ด้วย weight) ตั้งแต่ต้น เพื่อใช้จุดแข็งของทั้งสองฝั่ง ผมเลือกไม่ทำเพราะอยากเปรียบเทียบผลของทั้งสองโหมดก่อน เวลามี bug หรือ result แปลก ๆ จะดู debug ได้ง่ายกว่าตอนแยก endpoint

เฟสแรกเปิด /api/search (keyword only) อย่างเดียว เฟสสองจะเพิ่ม /api/search/semantic เป็น endpoint คู่ขนาน ผู้อ่านเลือกได้ว่าจะใช้โหมดไหนผ่าน UI tab ในโมดอลค้นหา หลังเก็บ telemetry สัก 1-2 เดือน รู้ว่าโหมดไหนใช้บ่อยและให้ผลดีกว่า ค่อยพิจารณาว่าจะทำ hybrid (combined score) เป็นโหมดที่สามไหม

Frontend: จาก SearchBox สู่ SearchModal

เวอร์ชันแรกของระบบ search เป็นหน้า /search ที่มี SearchBox อยู่กลางจอ ใช้งานได้ดี แต่ผู้อ่านต้องคลิกออกจากหน้าที่กำลังอ่านอยู่เพื่อค้นหา ทำให้บางคนเลิกค้นกลางคันเพราะไม่อยากเสีย scroll position ผมเลยรื้อใหม่เป็น modal ที่ overlay บนทุกหน้าได้

ตอนนี้กดปุ่ม search ในเฮดเดอร์หรือกด ⌘K (Cmd+K) / Ctrl+K หรือเครื่องหมาย / ก็เปิดโมดอลขึ้นมา พิมพ์เสร็จกด Esc ปิด ผลที่ค้นไว้ยังคงอยู่ในระบบจน reload หน้า ทำให้สลับไปอ่าน-กลับมาค้นต่อได้ลื่นมาก

3 patterns ที่ลงทุนเขียนเอง

หนึ่ง debounce 350ms — รอผู้ใช้พิมพ์เสร็จก่อนยิง request เพื่อไม่รบกวน Worker ทุก keystroke สอง AbortController.abort() ก่อนยิง request ใหม่ — กันผลค้นเก่ามาทับผลใหม่ที่ออกหลังกว่า สาม useMemo คำนวณ view state จาก loading + error + hasSearched + results.length กลายเป็น 5 state ที่ render ต่างกันชัดเจน (idle, loading, list, empty, error) ไม่ต้องเขียน if ซ้อนกัน 4 ชั้นในเทมเพลต

Rate-limit + edge cache: ปกป้อง Worker โดยไม่กวนผู้อ่าน

Worker ใช้ middleware token bucket ที่เขียนเอง ความจุ 60 token ต่อ IP เติม 1 token ต่อวินาที กรณี burst สูงสุด 60 request พร้อมกันทำได้ แต่ระยะยาว throughput จะถูกจำกัดที่ 60 request ต่อนาที เกินก็ตอบ 429 — ตัวเลขนี้ปรับจูนแล้วว่าครอบคลุมการพิมพ์ค้นหาของผู้อ่านปกติ ไม่กวนใครแต่หยุด scraper ได้

Edge cache ตั้งหัว Cache-Control: public, max-age=60, s-maxage=60 หมายถึง browser cache 60 วินาที + CDN cache อีก 60 วินาที ภายในนาทีเดียวกันถ้ามีคนค้น astro 100 ครั้ง Sanity จะถูกเรียกแค่ครั้งเดียว — เนื้อหาบล็อกไม่ได้เปลี่ยนทุกวินาที การยอมให้ผลแคชสั้น ๆ ไม่กระทบคุณภาพ

Cache 60 วินาทีฟังดูสั้น แต่ลด origin request ไป 90% ในชั่วโมงที่มีคนเข้าเยอะ — บางครั้งการ optimize ที่ดีที่สุดคือไม่ต้อง optimize อะไรเลย แค่ตั้งหัว HTTP ให้ถูก

Sanity GROQ vs SurrealDB: ใช้ตัวไหนทำอะไร

หลักคิดที่ใช้แบ่งงานระหว่างสองระบบนี้กระชับมาก: Sanity คือ source of truth ของเนื้อหา editorial ส่วน SurrealDB คือ source of truth ของ interaction ผู้ใช้ ถ้าข้อมูลคือ บทความเขียนเรื่องอะไร ใช้ Sanity ถ้าข้อมูลคือ ใครชอบบทความไหน ใช้ SurrealDB ถ้าต้องการ ความหมาย ไม่ใช่คำ ใช้ SurrealDB embedding

ตัวอย่างชัดที่สุด การ list บทความล่าสุดบนหน้าแรก ทำที่ Sanity (build-time SSG) ฟรีและเร็ว แต่การจดว่า user A ชอบบทความ X เคยอ่านบทความ Y ไม่เหมาะที่จะอยู่ Sanity เพราะ Sanity Content Lake ออกแบบมาเพื่อ public read — SurrealDB จัดการ user data + permission ระดับ row ได้ดีกว่ามาก

สิ่งที่ควรจำ (Key Takeaways)

  • Worker เป็นชั้น proxy ที่ไม่ได้แค่ proxy — มันคือจุดเดียวที่ rate-limit, cache, normalize response shape ทำให้สลับ backend ภายหลังได้โดยไม่กระทบ frontend
  • Sanity GROQ + pt::text(body) match * พอสำหรับบล็อกขนาดเล็ก-กลาง latency 100-200ms จาก edge ค่าใช้จ่ายเกือบเป็นศูนย์
  • Semantic search ใน SurrealDB ใช้ HNSW DIMENSION 1024 DIST COSINE — Pipeline embed ผ่าน Workers AI BGE-m3 ค่าใช้จ่ายเกือบเป็นศูนย์เช่นกัน
  • แยก endpoint keyword vs semantic ก่อน hybrid — เพื่อ debug ง่ายและเปรียบเทียบผลในช่วงพัฒนา
  • Modal search > full-page search — เพราะผู้อ่านไม่เสีย scroll position และเปิด-ปิดได้ลื่นด้วย ⌘K / Esc
  • Cache-Control 60 วินาที + token bucket 60/นาที = ป้องกัน abuse โดยไม่รบกวนผู้ใช้จริง

สรุป

การออกแบบระบบ search ของบล็อกส่วนตัวฟังดูง่ายแต่กลับเป็นจุดที่สะท้อนวิธีคิดเรื่อง software architecture เกือบทุกชั้น ตั้งแต่ frontend UX ไปจนถึงการเลือก database ผมเริ่มจาก keyword ที่เพียงพอวันนี้ก่อน แล้วเตรียม schema + pipeline ของ semantic ไว้ขยายต่อในเฟสถัดไป

ส่วนที่ลงทุนเขียนเองอย่าง modal + debounce + AbortController ดูเหมือนของเล็ก ๆ แต่เป็นรายละเอียดที่ทำให้ search รู้สึกเร็วและ predictable มาก ๆ จุดที่อยากแนะนำคนที่กำลังคิดทำระบบคล้ายกัน — อย่ากระโดดไป hybrid ตั้งแต่แรก เริ่มจากชั้นเดียวที่เข้าใจง่ายก่อน แล้วค่อย ๆ ต่อยอด ทุกชั้นที่เพิ่มควรตอบโจทย์ที่เห็นจริง ไม่ใช่ที่จินตนาการขึ้นเอง

Newsletter

บันทึกการเรียนรู้ที่ส่งมาแบบพอดี ๆ

สมัครไว้ก่อนเพื่อรับบทความใหม่เรื่อง AI, การสร้างแอป, เว็บ, ฐานข้อมูล และการเรียนรู้ที่มีสติ — ไม่สแปม