Tech Blog

สร้างคำแนะนำจาก "เวกเตอร์เซนทรอยด์" ของประวัติการรับชม — โครงสร้างสำรอง 3 ชั้นที่ไม่ปล่อยให้หน้าจอว่างเปล่าแม้กับสมาชิกที่ไม่มีประวัติเลย

Recommendation embedding パーソナライズ Cold Start

บทนำ

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

และมันใช้ดัชนีที่สร้างไว้สำหรับการค้นหาเชิงความหมายซ้ำโดยตรง เรื่องราวการทำ semantic search โดยไม่เพิ่มฐานข้อมูลเวกเตอร์เฉพาะทาง อยู่ใน ค้นด้วย “ความหมาย” ไม่ใช่ “ตัวอักษร” บทความนี้เป็นเรื่องของ การนำดัชนีเดียวกันมาใช้ซ้ำเป็น “คำแนะนำ” และการทำให้หน้าจอไม่ว่างเปล่าแม้กับสมาชิกที่ไม่มีประวัติ ด้วยโครงสร้าง 3 ชั้น

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

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


“คำแนะนำ” ใช้อะไรเป็นเหตุผล

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

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

【อันดับความนิยม】     ชั้นเดียวกันสำหรับทุกคน         … ง่าย แต่ไม่ได้มองที่ "คุณ"
【เฉพาะบุคคล】          เลือกจากประวัติของคนคนนั้น       … มองที่ "คุณ" but จะแปลงรสนิยมเป็นตัวเลขอย่างไร?

”เวกเตอร์เฉลี่ย” ของผลงานที่เคยดู กลายเป็นรสนิยมของคนคนนั้น

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

วิธีแทนรสนิยมนั้นเรียบง่าย นำเวกเตอร์ของผลงานทั้งหมดที่คนคนนั้นเคยดูมารวมกัน แล้วหาค่าเฉลี่ย เรียกเวกเตอร์เฉลี่ยนี้ว่า “เซนทรอยด์” เซนทรอยด์คือจุดหนึ่งที่อยู่ “ตรงกลาง” ของผลงานที่คนคนนั้นเคยดูมา ซึ่งหมายถึง ศูนย์กลางของรสนิยมคนคนนั้น จากนั้นก็แค่คืนผลงานที่ยังไม่เคยดูซึ่งใกล้กับเซนทรอยด์นี้ เพียงถามดัชนีว่า “เรียงตามความใกล้กับเซนทรอยด์”

เฉลี่ยเวกเตอร์ความหมายของผลงานที่เคยดู = เซนทรอยด์ของรสนิยม

   ผลงาน A ●
   ผลงาน B ●   ─▶ ค่าเฉลี่ย (เซนทรอยด์) ─▶ ★ ศูนย์กลางรสนิยมของคุณ
   ผลงาน C ●

                     └─▶ คืนผลงานที่ "ยังไม่เคยดู" ซึ่งใกล้กับเซนทรอยด์ = คำแนะนำ

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

// ค่าเฉลี่ยของ embedding ของกลุ่มผลงานที่เคยดู (เซนทรอยด์) = เวกเตอร์รสนิยมของคนคนนั้น
List<Double> profileVector(List<Integer> filmIds) {
    double[] sum = null;
    int count = 0;
    for (int filmId : filmIds) {
        List<Double> v = getVector(filmId);
        if (v == null) continue;          // ข้ามผลงานที่ยังไม่ได้สร้างเวกเตอร์
        if (sum == null) sum = new double[v.size()];
        for (int i = 0; i < sum.length; i++) sum[i] += v.get(i);
        count++;
    }
    if (count == 0) return List.of();     // ไม่มีแม้แต่รายการเดียว → ค่าว่าง (= สัญญาณให้ถดถอยไปขั้นถัดไป)
    List<Double> centroid = new ArrayList<>();
    for (double s : sum) centroid.add(s / count);
    return centroid;
}

เมื่อสร้างเซนทรอยด์ได้แล้ว ที่เหลือก็แค่ส่งเข้า topSimilar (คืนผลลัพธ์เรียงตามความใกล้กับเวกเตอร์คิวรี) ตัวเดียวกับที่ใช้ในการค้นหาเชิงความหมาย ใส่ “เซนทรอยด์ของคุณ” แทนข้อความคิวรี — นั่นคือการทำให้เฉพาะบุคคล


จะมีสมาชิกที่สร้างเซนทรอยด์ไม่ได้อยู่เสมอ

ตรงนี้คือกับดัก เซนทรอยด์สร้างได้เฉพาะคนที่มีประวัติเท่านั้น

  • สมาชิกใหม่ที่เพิ่งลงทะเบียนมีประวัติเป็นศูนย์ (cold start = ไม่มีประวัติ ยังไม่มีเบาะแสสำหรับคนคนนั้น)
  • แม้จะมีประวัติ แต่เวกเตอร์ของผลงานนั้นอาจยังไม่ถูกสร้างขึ้น

สำหรับสมาชิกแบบนี้ การทำคำแนะนำเฉพาะบุคคลด้วย embedding จะไม่คืนอะไรเลย แล้วจะแสดง “คำแนะนำ” ที่ว่างเปล่าหรือ? นั่นคือสิ่งที่แย่ที่สุด ถ้าชั้นวางแรกที่เปิดหลังลงทะเบียนว่างเปล่า สมาชิกคนนั้นจะไม่กลับมาเปิดอีกเลย

จึงทำเป็น “ถ้าให้ไม่ได้ คุณภาพลดลงก็ต้องให้อะไรบางอย่างด้วยวิธีอื่นเสมอ” — เป็น ระบบสำรอง 3 ชั้น ระบบสำรองในที่นี้หมายถึง เมื่อวิธีชั้นบนใช้ไม่ได้ ก็ ถดถอย ลงไปสลับใช้วิธีชั้นล่าง ลองไล่จากบนลงล่าง แล้วหยุดทันทีที่ได้ผล

GET /api/me/recommendations


 ① embedding: ผลงานที่ยังไม่เคยดูซึ่งใกล้กับเซนทรอยด์ของประวัติ
        │  ได้ผล ─────────────▶ คืนค่า (method="embedding")

        └─ ประวัติเป็นศูนย์/embedding ยังไม่ถูกสร้าง → ถดถอย


        ② taste_tag: เลือกด้วยกฎแท็กรสนิยม
        │   (เรียงตามหมวดหมู่เดียวกัน + จำนวนแท็กที่มีร่วมกัน)
        │  ได้ผล ─────────────▶ คืนค่า (method="taste_tag")

        └─ ไม่มีประวัติเลย → ถดถอย


        ③ popularity: อันดับความนิยม (ป้อมปราการสุดท้ายสำหรับทุกคน)
              เสมอ ────────────▶ คืนค่า (method="popularity")

ชั้นที่ 3 อันดับความนิยม ไม่ได้มองที่ “คุณ” แต่ ดีกว่าหน้าจอว่างเปล่ามาก อุดมคติด้านคุณภาพ (①) กับความรับผิดชอบที่ต้องคืนอะไรบางอย่างเสมอ (③) เชื่อมกันด้วยบันไดแห่งการถดถอย

อีกอย่างที่ใส่เข้าไป ดูเรียบง่ายแต่ได้ผล คือใส่ตัวชี้วัดภายในชื่อ method (“embedding” / “taste_tag” / “popularity”) ไว้ในการตอบกลับ ทำให้ วัดย้อนหลังได้ว่าตอนนี้คำแนะนำถูกให้จากชั้นไหน ว่า ① ทำงานมากแค่ไหน = สัดส่วนสมาชิกที่ได้รับคำแนะนำเฉพาะบุคคลที่ใช้งานได้จริง เป็นฐานสำหรับเฝ้าดูคุณภาพด้วยตัวเลข ไม่ใช่สร้างแล้วปล่อยทิ้งไว้

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


ดัชนีเดียว มีสามใบหน้า

สิ่งที่ดีที่สุดจากการสร้างครั้งนี้คือ ดัชนีที่เตรียมไว้สำหรับการค้นหา กลายเป็น “คำแนะนำ” ได้เลยโดยไม่ต้องเปลี่ยนอะไร

  • ใส่ข้อความคิวรี → การค้นหาเชิงความหมาย
  • ใส่ผลงานหนึ่งเรื่อง → “ผลงานที่คล้ายกับเรื่องนี้”
  • ใส่เซนทรอยด์ของประวัติ → “แนะนำสำหรับคุณ”

ต่างกันแค่ทางเข้า แต่ข้างในทำงานด้วย topSimilar ตัวเดียวกัน ดัชนีที่สร้างขึ้นอย่างเบาสำหรับการค้นหาเชิงความหมาย แสดงใบหน้าที่สามออกมาได้โดยไม่ต้องเพิ่มฐานข้อมูลหรือโมเดลใดๆ เพิ่มเติม พอพูดถึง “การทำเฉพาะบุคคล” มักนึกถึงโครงสร้างพื้นฐานขนาดใหญ่ แต่ มันตั้งขึ้นมาได้แค่เติมท่าเดียว คือการเฉลี่ยประวัติ ลงบนกลไกวัด “ความใกล้เชิงความหมาย” ที่มีอยู่ในมือแล้ว

และอีกเรื่อง อย่าจบงานด้วยชั้นคุณภาพสูงเพียงชั้นเดียว วางชั้น ② ③ ไว้สำหรับสมาชิกที่ ① ทำไม่ได้ การมีทั้งคำแนะนำในอุดมคติ และคำมั่นว่าหน้าจอของใครก็จะไม่ว่างเปล่า พร้อมกันในเวลาเดียวกัน — นั่นคือรูปแบบที่เลือกใช้สำหรับฟีเจอร์นี้


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

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

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