ค้นด้วย "ความหมาย" ไม่ใช่ "ตัวอักษร" — ทำ semantic search โดยไม่เพิ่มฐานข้อมูลเวกเตอร์เฉพาะทาง
บทนำ
ผมเพิ่มฟีเจอร์ค้นหาผลงานลงในแพลตฟอร์มรวมที่สร้างเองซึ่งจัดการทั้งวิดีโอและเพลง การค้นหาด้วยคำสำคัญแบบปกติมีอยู่แล้ว แต่ผมเพิ่ม “ค้นด้วยความหมาย” เข้าไปด้วย โดยไม่เพิ่มฐานข้อมูลเวกเตอร์เฉพาะทาง
เรื่องการยกระดับความแม่นยำของการค้นหาด้วยตัวเลขโดยตรง อยู่ใน ปรับความแม่นยำการค้นหาของ RAG แบบขับเคลื่อนด้วยการวัดผล บทความนี้เป็นเรื่องว่าผมทำ “ค้นด้วยความหมาย” ให้เบาได้อย่างไร
หน้าจอจริงเป็นแบบนี้ สลับไปที่ “ค้นด้วยความหมาย (AI)” แล้วใส่อารมณ์ที่อยากค้นหาเป็นประโยคตรงๆ — เช่น “หนังที่อยากดูคนเดียวในวันฝนตก” สิ่งที่ได้กลับมาไม่ใช่ผลงานที่มีคำนั้นอยู่จริง แต่เป็นผลงานที่มีความหมายใกล้เคียงกัน

การค้นหาด้วยคำสำคัญ ค้นได้แค่ “ตัวอักษร”
การค้นหาด้วยคำสำคัญจะคืนผลงานที่ มี คำที่ป้อนเข้าไปอยู่จริง ดังนั้นถ้าค้นด้วย “เรื่องราวการจากลาที่เศร้าสร้อย” ผลงานชิ้นเอกที่เนื้อหาอธิบายไม่ได้เขียนคำนั้นไว้ก็จะไม่ปรากฏขึ้นมา ไม่ว่าเนื้อหาจริงจะตรงกันแค่ไหนก็ตาม
【ค้นด้วยคำสำคัญ】"การจากลาที่เศร้าสร้อย" ─▶ hit เฉพาะผลงานที่ "มี" คำนั้นอยู่จริง (ตรงกันเชิงตัวอักษร)
→ ผลงานที่ไม่ได้เขียนคำว่า "เศร้าสร้อย" ไว้จะหลุดไป
สิ่งที่อยากค้นหาไม่ใช่ตัวอักษร แต่คือ ความหมาย
ทำให้ทั้งผลงานและคิวรีเป็น “เวกเตอร์ความหมาย”
ตรงนี้เองที่ embedding เข้ามามีบทบาท embedding คือ การแปลง “ความหมาย” ของประโยคหรือผลงานให้เป็นชุดตัวเลขหลายร้อยมิติ (เวกเตอร์) ประโยคที่มีความหมายใกล้เคียงกันจะมาอยู่ในตำแหน่งใกล้กันในฐานะเวกเตอร์ด้วยเช่นกัน
แปลงข้อความอธิบายของผลงานให้เป็นเวกเตอร์ แปลงคิวรีค้นหาให้เป็นเวกเตอร์เช่นกัน แล้ว คืนผลลัพธ์เรียงตามความใกล้ของเวกเตอร์ (= ความใกล้ของความหมาย) นี่คือการค้นหาเชิงความหมาย สำหรับการแปลงเป็นเวกเตอร์ ผมใช้โมเดลที่แปลงข้อความเป็น embedding (bge-m3) รันแบบโลคัลด้วย Ollama บนเครื่องของผมเอง
【ค้นหาเชิงความหมาย】"การจากลาที่เศร้าสร้อย" ─▶ แปลงเป็นเวกเตอร์ ─▶ คืนผลงานที่ใกล้เชิงความหมาย (ไม่ใช่ตัวอักษร แต่คือความหมาย)
สำหรับการวัด “ความใกล้” ผมใช้ cosine similarity ซึ่งเป็นวิธีวัดความใกล้จาก ทิศทางของเวกเตอร์สองตัวสอดคล้องกันแค่ไหน ยิ่งสอดคล้องกันมากเท่าไร ก็ยิ่งถือว่า “ใกล้เชิงความหมาย” มากเท่านั้น
ในสเกลนี้ ไม่จำเป็นต้องมีฐานข้อมูลเวกเตอร์เฉพาะทาง
พอพูดถึงการค้นหาเชิงความหมาย คนส่วนใหญ่มักจะเพิ่ม ฐานข้อมูลเวกเตอร์เฉพาะทาง เข้าไป ถ้าเป็น PostgreSQL ก็จะติดตั้ง pgvector (ส่วนขยายที่เพิ่มความสามารถค้นหาเวกเตอร์ให้ฐานข้อมูล) เป็นทางเลือกมาตรฐาน แต่ก็เพิ่ม dependency และภาระการดูแลอีกหนึ่งอย่าง
แต่ผลงานที่จัดการมีอยู่ประมาณ 1,000 รายการ ในสเกลนี้ ไม่จำเป็นต้องมีฐานข้อมูลเฉพาะทาง แค่คำนวณเวกเตอร์ของผลงานทั้งหมดครั้งเดียวตอนเริ่มระบบ เก็บไว้ในหน่วยความจำ แล้วคำนวณ cosine similarity ภายในแอปทุกครั้งที่ค้นหา ก็เร็วเพียงพอแล้ว
ตอนเริ่มระบบ: แปลงผลงานทั้งหมดเป็นเวกเตอร์ครั้งเดียว เก็บไว้ในหน่วยความจำเป็น "ดัชนี" (ประมาณ 1,000 รายการ・bge-m3)
ประโยคคิวรี ─▶ แปลงเป็นเวกเตอร์ ─┐
├─▶ cosine similarity ในหน่วยความจำ ─▶ คืนเรียงตามความใกล้
ดัชนี (เวกเตอร์ของผลงานทั้งหมด)─┘
│
└─ ใช้ดัชนีเดียวกันซ้ำ ─┬─ ค้นหา (คิวรี → ผลงาน)
├─ ความคล้าย (ผลงาน → ผลงานที่คล้ายกัน)
└─ คำแนะนำ (เวกเตอร์เฉลี่ยของประวัติ → ผลงาน)
※ ไม่ได้เพิ่มฐานข้อมูลเวกเตอร์เฉพาะทาง (pgvector ฯลฯ)
ในโค้ด เนื้อหาของดัชนีเรียบง่ายมาก
// โหลดเวกเตอร์ของผลงานทั้งหมดเข้าหน่วยความจำตอนเริ่มระบบ ไม่ใช้ฐานข้อมูลเวกเตอร์เฉพาะทาง
class EmbeddingIndexService {
private Map<Integer, float[]> vectors; // ID ผลงาน → เวกเตอร์ความหมาย
// คืนผลงานที่ใกล้กับเวกเตอร์คิวรี เรียงตามลำดับความใกล้
List<Scored> topSimilar(float[] query, int limit) {
return vectors.entrySet().stream()
.map(e -> new Scored(e.getKey(), cosine(query, e.getValue()))) // ความใกล้ของทิศทาง = ความใกล้ของความหมาย
.sorted(comparingDouble(Scored::score).reversed())
.limit(limit)
.toList();
}
}
และสิ่งที่น่าดีใจคือ ดัชนีตัวเดียวนี้นำไปใช้ซ้ำได้หมด ใส่คิวรี → “ค้นหา” ใส่ผลงานหนึ่งเรื่อง → “ผลงานที่คล้ายกับเรื่องนี้” ใส่เวกเตอร์เฉลี่ยของประวัติการรับชมของผู้ใช้ → “แนะนำสำหรับคุณ” — ใช้ topSimilar ตัวเดียวกันครอบคลุมได้ทั้งหมด
ใกล้เชิงความหมาย ไม่ได้แปลว่า แสดงได้
มีจุดหนึ่งที่รู้อยู่แล้วตั้งแต่แรกว่าเป็นช่องโหว่ของการค้นหาเชิงความหมาย embedding รู้แค่ “ความใกล้เชิงความหมาย” เท่านั้น ไม่รู้เรื่องความแตกต่างของประเภท (วิดีโอกับเพลง) หรือกฎทางธุรกิจอย่าง “แสดงได้หรือไม่” ดังนั้นแค่ใกล้กันก็อาจมีผลงานต่างหมวดหมู่ปนเข้ามาได้
นี่เป็นปัญหาที่มาพร้อมกันตั้งแต่จุดที่เพิ่มการค้นหาเชิงความหมายเข้าไป และจำเป็นต้อง กรองผลลัพธ์ที่ดึงมาที่ปลายทาง เรื่องนั้นอยู่ใน กับดักที่หมวดหมู่อื่นปนเข้ามาในการค้นหาด้วยเวกเตอร์ ส่วนเรื่องการคัดกรองผลลัพธ์ที่ดึงมาด้วยความเกี่ยวข้องก่อนส่งต่อ อยู่ใน วางตัวตัดสินความเกี่ยวข้องขนาดเล็กไว้หน้า RAG
การเริ่มต้นแบบเบาๆ ได้นี่เอง คือจุดสำคัญของการออกแบบนี้ ก่อนจะสร้างฐานข้อมูลเฉพาะทาง “ค้นด้วยความหมาย” ก็ตั้งขึ้นมาได้เพียงพอด้วยหน่วยความจำในเครื่องและ cosine similarity แบบง่ายๆ ที่มีอยู่ในมือ ค่อยย้ายไปฐานข้อมูลเฉพาะทางทีหลังเมื่อสเกลโตขึ้นและจำเป็นจริงๆ ไม่วางเครื่องมือหนักตั้งแต่แรก — นั่นคือทางที่เลือกสำหรับฟีเจอร์นี้
บทความที่เกี่ยวข้อง
- เรื่องการยกระดับความแม่นยำของการค้นหาด้วยตัวเลข อยู่ใน ปรับความแม่นยำการค้นหาของ RAG แบบขับเคลื่อนด้วยการวัดผล
- กับดักที่หมวดหมู่อื่นซึ่งใกล้เพียงเชิงความหมายปนเข้ามา อยู่ใน กับดักที่หมวดหมู่อื่นปนเข้ามาในการค้นหาด้วยเวกเตอร์
- เรื่องการนำดัชนีนี้ไปใช้ซ้ำเป็น “คำแนะนำ” โดยไม่ปล่อยให้สมาชิกที่ไม่มีประวัติเจอหน้าจอว่างเปล่า อยู่ใน สร้างคำแนะนำจาก “เวกเตอร์เซนทรอยด์” ของประวัติการรับชม
- เรื่องการดึงดัชนีนี้มาใช้ในบทบาท “ผลงานที่คล้ายกับเรื่องนี้” เพื่อใช้ในการเวียนชมบนหน้ารายละเอียด อยู่ใน อย่าปล่อยให้หน้ารายละเอียดผลงานเป็น “ทางตัน” — ส่งผู้ชมไปยังเรื่องถัดไปด้วย “สำหรับคนที่ชอบเรื่องนี้”