AI Engineer › Embeddings และ RAG

เวกเตอร์, cosine similarity และ embeddings

ประมาณ 40 นาที

เป้าหมายของบท

  • สร้างเวกเตอร์ TF-IDF ของเอกสารและคำถาม และคำนวณ cosine similarity ทั้งด้วยมือและด้วย sklearn
  • จัดอันดับเอกสารด้วยความคล้ายและอ่านผล (top-k, อันดับของเฉลย, MRR)
  • เข้าใจว่า embeddings แก้ปัญหา paraphrase อย่างไร และเห็นข้อจำกัดของ TF-IDF จากข้อมูลจริง

แปลงข้อความเป็นตัวเลข เพื่อวัด “ความใกล้กัน”

คอมพิวเตอร์เปรียบเทียบข้อความโดยตรงไม่ได้ ต้องแปลงเป็น เวกเตอร์ แล้ววัดว่าเวกเตอร์ชี้ไปทางเดียวกันแค่ไหนด้วย cosine similarity (cos ของมุมระหว่างเวกเตอร์; 1 = ทิศเดียวกัน, 0 = ไม่เกี่ยวกัน) ซึ่งไม่ขึ้นกับความยาวของข้อความ

TF-IDF: เวกเตอร์แบบนับคำอย่างฉลาด

TfidfVectorizer สร้างคอลัมน์ 1 คอลัมน์ต่อ 1 คำในคลังคำ (vocabulary) ค่าของแต่ละช่อง = ความถี่ของคำในเอกสาร (TF) × ความหายากของคำในทุกเอกสาร (IDF) — คำที่พบทุกเอกสารไม่ช่วยแยก, คำหายากมีน้ำหนักสูง

vec = TfidfVectorizer(stop_words="english")   # ตัดคำทั่วไป (the, is, …)
D = vec.fit_transform(docs["text"])           # 24 เอกสาร × 301 คำ (sparse — มีค่าไม่เป็นศูนย์แค่ ~6.6%)
Q = vec.transform(qs["question"])             # ต้อง transform ด้วยคลังคำเดียวกัน
sims = cosine_similarity(Q, D)                # คำถาม × เอกสาร
  • แต่ละแถวของ D ถูก normalize ให้ความยาว 1 อัตโนมัติ
  • IDF จริงในฐานความรู้ของเรา: “refund” อยู่ 1 เอกสาร ได้ 3.5257, “shipping” อยู่ 3 เอกสาร ได้ 2.8326, “order” อยู่ 12 เอกสาร ได้ 1.6539 (ยิ่งพบน้อยยิ่งมีน้ำหนักมาก)
  • cosine ด้วยมือ = a @ b / (‖a‖ × ‖b‖) — คำถาม “How long does standard delivery take?” มีค่าไม่เป็นศูนย์แค่ 2 คำ (delivery, standard) ได้ 0.1787 กับ d01 ตรงกับ cosine_similarity

ข้อจำกัด: ต้องใช้คำเดียวกัน

TF-IDF เทียบ “คำที่ตรงกัน” เท่านั้น คำถาม “My parcel arrived broken, what should I do?” (เฉลย d08 ที่เขียนว่า damaged) — คำ “broken” ไม่อยู่ในคลังคำเลย ระบบจึงจัด d13 (เรื่องเปลี่ยนที่อยู่ พูดถึง parcel หลายครั้ง) ไว้อันดับ 1 (0.5344) ส่วนเฉลยอยู่อันดับ 4 (0.1695) นี่คือเหตุที่ hit@1 ของเรามีเพียง 0.6333 ทั้งที่ hit@3 ได้ 0.8333

Embeddings: เวกเตอร์ที่จับ “ความหมาย”

Embedding model (จากผู้ให้บริการ เช่น Voyage, OpenAI, Cohere หรือโมเดลเปิดอย่าง sentence-transformers) แปลงข้อความเป็นเวกเตอร์หนาแน่นราว 384–3,072 มิติ ที่ข้อความความหมายใกล้กันอยู่ใกล้กัน แม้ไม่ใช้คำเดียวกัน (“broken” ใกล้ “damaged”) ขั้นตอนอื่นๆ เหมือนกัน: ทำเวกเตอร์ทุก chunk ครั้งเดียวเก็บไว้ (vector store) → ทำเวกเตอร์คำถาม → cosine → เอา top-k ในเบราว์เซอร์นี้เรียกโมเดลจริงไม่ได้ ตัวอย่างต่อไปใช้ LSA (TruncatedSVD บน TF-IDF) เป็นเวกเตอร์หนาแน่นที่คำนวณเองได้ เพื่อเห็นรูปแบบ “เวกเตอร์หนาแน่น + cosine”:

svd = TruncatedSVD(10, random_state=42).fit(D)
Dd, Qd = svd.transform(D), svd.transform(Q)          # 24 × 10 และ 30 × 10
cosine_similarity(Qd, Dd)

ผลบน 30 คำถามของเรา: LSA 10 มิติ ได้ hit@1 0.5667, hit@3 0.8667 เทียบกับ TF-IDF 0.6333 / 0.8333 — ไม่ดีกว่า เพราะเอกสารมีแค่ 24 ชิ้นเรียนรู้ความหมายได้น้อย (10 มิติอธิบายความแปรปรวนได้ ~49%) และ “ความหมาย” ที่แท้จริงต้องมาจากโมเดลที่เทรนด้วยข้อความมหาศาล บทเรียนที่ซื่อตรง: อย่าเดาว่าเทคนิคที่ “ดูทันสมัยกว่า” จะดีกว่า — วัดบนชุดคำถามของเราเสมอ (บทที่ 4)

ตัวอย่างแก้โจทย์

กด "ขั้นต่อไป" ทีละขั้น ลองรันและแก้โค้ดในแต่ละขั้นได้

TF-IDF และ cosine

สร้างเวกเตอร์ของเอกสารและคำถาม แล้ววัดความคล้าย

  1. เมทริกซ์เอกสารมี 24 แถว × 301 คำ และคำถามแรกมีค่าไม่เป็นศูนย์แค่ 2 คำ

    Python

เมื่อคำไม่ตรงกัน

ดูคำถามที่ใช้คำต่างจากเอกสาร

  1. "broken" ไม่อยู่ในคลังคำ แต่ "damaged" อยู่

    Python

เติมช่องว่าง

แทน ___ ด้วยโค้ดที่ถูกต้อง แล้วกดตรวจคำตอบ

ยังไม่ได้ทำ

คำนวณ cosine similarity ระหว่างเวกเตอร์ a และ b ด้วยมือ เก็บไว้ในตัวแปร cos เติม ___ ให้ครบ

แบบฝึกท้ายบท

บทนี้จะนับว่าเสร็จเมื่อผ่านข้อพื้นฐานและประยุกต์ครบ ข้อท้าทายได้ดาวเพิ่ม

🟢 พื้นฐาน

🟢 พื้นฐานยังไม่ได้ทำ

TfidfVectorizer(stop_words="english") ที่ fit บน docs["text"]: จำนวนคำในคลังคำ (n_terms) และจำนวนเอกสาร (n_docs) เก็บไว้ในตัวแปรตามชื่อ

🟢 พื้นฐานยังไม่ได้ทำ

คำว่า "broken" และ "damaged" อยู่ในคลังคำ (vec.vocabulary_) หรือไม่ เก็บ True/False ไว้ใน has_broken, has_damaged

🟢 พื้นฐานยังไม่ได้ทำ

คำถามแรก (Q[0]) มีค่าไม่เป็นศูนย์ที่คำใดบ้าง (list ของคำ เรียงตามลำดับในคลังคำ) เก็บไว้ในตัวแปร q01_terms

🟢 พื้นฐานยังไม่ได้ทำ

คำนวณ cosine similarity ด้วยมือ (a @ b ÷ (‖a‖‖b‖)) ระหว่างคำถามแรกกับเอกสาร d01 ปัด 4 ตำแหน่ง เก็บไว้ในตัวแปร cos_q01_d01

🟡 ประยุกต์

🟡 ประยุกต์ยังไม่ได้ทำ

คำถามที่ 11 (index 10): doc_id ของ 3 เอกสารที่คล้ายที่สุด (มากไปน้อย) เก็บเป็น list ไว้ใน top3_q11 และอันดับ (เริ่มที่ 1) ของเอกสารเฉลย gold_doc_id ไว้ใน gold_rank_q11

🟡 ประยุกต์ยังไม่ได้ทำ

ค่า IDF ของคำ "refund" และ "order" (vec.idf_ อ้างตำแหน่งจาก vec.vocabulary_) ปัด 4 ตำแหน่ง เก็บไว้ในตัวแปร idf_refund, idf_order

🟡 ประยุกต์ยังไม่ได้ทำ

คู่เอกสารที่คล้ายกันที่สุด (ไม่นับเทียบกับตัวเอง): คำนวณ cosine_similarity(D) แล้วใส่ 0 ที่เส้นทแยง เก็บ "doc_id แรก+doc_id หลัง" (แถวมาก่อนคอลัมน์ ตามลำดับที่ argmax คืน) ไว้ใน most_similar_pair และค่า (ปัด 4 ตำแหน่ง) ไว้ใน pair_score

🔴 ท้าทาย

🔴 ท้าทายยังไม่ได้ทำ

ประเมิน retrieval ด้วย sims: อันดับ (เริ่มที่ 1) ของเอกสารเฉลยของทุกคำถาม แล้วเก็บ hit@1, hit@3 และ MRR (ค่าเฉลี่ยของ 1 ÷ อันดับ) ปัด 4 ตำแหน่ง ไว้ใน hit1, hit3, mrr

🔴 ท้าทายยังไม่ได้ทำ

ทำ LSA: TruncatedSVD(10, random_state=42) fit บน D แล้ว transform ทั้ง D และ Q เป็นเวกเตอร์หนาแน่น จัดอันดับด้วย cosine_similarity แล้วเก็บ hit@1 และ hit@3 ของ LSA (ปัด 4 ตำแหน่ง) ไว้ใน lsa_hit1, lsa_hit3 และ True/False ว่า hit@1 ของ LSA สูงกว่าของ TF-IDF (คำนวณจาก sims) หรือไม่ ไว้ใน lsa_beats_tfidf

Quiz

1. cosine similarity วัดอะไร
2. ทำไมคำถาม "parcel arrived broken" จึงหาเอกสาร d08 (ใช้คำว่า damaged) ไม่เจอด้วย TF-IDF
3. embedding model ช่วยแก้ปัญหาข้างต้นอย่างไร
4. ในบทนี้ LSA 10 มิติ hit@1 0.5667 ต่ำกว่า TF-IDF 0.6333 บอกอะไร

สรุป

  • TF-IDF แปลงข้อความเป็นเวกเตอร์ (24 × 301, sparse) แล้ววัดด้วย cosine; cosine = a·b ÷ (‖a‖‖b‖)
  • TF-IDF เทียบเฉพาะคำที่ตรงกัน: "broken" ไม่อยู่ในคลังคำ เฉลย d08 (damaged) จึงอยู่อันดับ 4 (0.1695) ขณะ d13 ได้ 0.5344
  • Embedding model จับความหมายจึงรับมือ paraphrase ได้ (เรียกในเบราว์เซอร์ไม่ได้); LSA 10 มิติบนข้อมูล 24 เอกสารไม่ดีกว่า TF-IDF (hit@1 0.5667 vs 0.6333)

คำถามสัมภาษณ์

อธิบายว่า vector search ทำงานอย่างไรในระบบ RAG

ทำเวกเตอร์ของทุก chunk ล่วงหน้าเก็บไว้ (vector store) เมื่อมีคำถามก็แปลงเป็นเวกเตอร์ด้วยโมเดลเดียวกัน แล้วคำนวณความคล้าย (มักใช้ cosine) กับทุก chunk และเลือก top-k ที่คล้ายที่สุดมาใส่ใน prompt สำหรับข้อมูลขนาดใหญ่ใช้ดัชนีแบบประมาณ (ANN) เพื่อให้ค้นหาเร็ว

TF-IDF กับ embeddings ต่างกันอย่างไร และเมื่อไรใช้อย่างไหน

TF-IDF เป็นเวกเตอร์แบบ sparse ตามคำที่ตรงกัน อธิบายง่าย เร็ว ไม่ต้องเรียกโมเดล เหมาะกับคำเฉพาะ/รหัส แต่รับมือ paraphrase ไม่ได้ ส่วน embeddings เป็นเวกเตอร์หนาแน่นที่จับความหมาย รับมือการใช้คำต่างกันได้ดีกว่า แต่ต้องมีโมเดลและต้นทุน ในทางปฏิบัติมักใช้ผสมกัน (hybrid) และวัดบนชุดคำถามของตัวเอง