Tech Blog

ค้นด้วย "ความหมาย" ไม่ใช่ "ตัวอักษร" — ทำ semantic search โดยไม่เพิ่มฐานข้อมูลเวกเตอร์เฉพาะทาง

RAG 意味検索 embedding セマンティック検索

บทนำ

ผมเพิ่มฟีเจอร์ค้นหาผลงานลงในแพลตฟอร์มรวมที่สร้างเองซึ่งจัดการทั้งวิดีโอและเพลง การค้นหาด้วยคำสำคัญแบบปกติมีอยู่แล้ว แต่ผมเพิ่ม “ค้นด้วยความหมาย” เข้าไปด้วย โดยไม่เพิ่มฐานข้อมูลเวกเตอร์เฉพาะทาง

เรื่องการยกระดับความแม่นยำของการค้นหาด้วยตัวเลขโดยตรง อยู่ใน ปรับความแม่นยำการค้นหาของ RAG แบบขับเคลื่อนด้วยการวัดผล บทความนี้เป็นเรื่องว่าผมทำ “ค้นด้วยความหมาย” ให้เบาได้อย่างไร

หน้าจอจริงเป็นแบบนี้ สลับไปที่ “ค้นด้วยความหมาย (AI)” แล้วใส่อารมณ์ที่อยากค้นหาเป็นประโยคตรงๆ — เช่น “หนังที่อยากดูคนเดียวในวันฝนตก” สิ่งที่ได้กลับมาไม่ใช่ผลงานที่มีคำนั้นอยู่จริง แต่เป็นผลงานที่มีความหมายใกล้เคียงกัน

โหมดค้นหาเชิงความหมายของ CINEMA DAYS ผลลัพธ์จากการค้นด้วยประโยค "หนังที่อยากดูคนเดียวในวันฝนตก" (เพื่อวัตถุประสงค์พอร์ตโฟลิโอ ผลงานเป็นข้อมูลตัวอย่าง)


การค้นหาด้วยคำสำคัญ ค้นได้แค่ “ตัวอักษร”

การค้นหาด้วยคำสำคัญจะคืนผลงานที่ มี คำที่ป้อนเข้าไปอยู่จริง ดังนั้นถ้าค้นด้วย “เรื่องราวการจากลาที่เศร้าสร้อย” ผลงานชิ้นเอกที่เนื้อหาอธิบายไม่ได้เขียนคำนั้นไว้ก็จะไม่ปรากฏขึ้นมา ไม่ว่าเนื้อหาจริงจะตรงกันแค่ไหนก็ตาม

【ค้นด้วยคำสำคัญ】"การจากลาที่เศร้าสร้อย" ─▶ 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 แบบง่ายๆ ที่มีอยู่ในมือ ค่อยย้ายไปฐานข้อมูลเฉพาะทางทีหลังเมื่อสเกลโตขึ้นและจำเป็นจริงๆ ไม่วางเครื่องมือหนักตั้งแต่แรก — นั่นคือทางที่เลือกสำหรับฟีเจอร์นี้


บทความที่เกี่ยวข้อง

ส่งข้อความได้ตามสบาย

ไม่ว่าจะเป็นการว่าจ้างงาน แนะนำโปรเจกต์ ความคิดเห็น หรือคำถาม ยินดีรับทั้งหมด ผมหวังเป็นอย่างยิ่งว่าจะได้เชื่อมต่อกับผู้ที่มีอุดมการณ์อันสูงส่งเช่นเดียวกัน ผมจะมุ่งมั่นท้าทายในสิ่งที่ทุ่มเททั้งชีวิตต่อไป ขอขอบคุณและฝากเนื้อฝากตัวด้วยครับ