Tech Blog

RAG ที่มีแค่ vector search "ลืมในตอนที่สำคัญที่สุด" — ทำให้ recall ขึ้นจาก 0.2 เป็น 1.0 ด้วย hybrid search + การวัดผล

RAG ChromaDB Ollama ハイブリッド検索 BM25 リランカー 測定駆動 recall Claude Code 個人ナレッジ管理 GPU

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

สิ่งที่คุณจะได้เรียนรู้

  • ธรรมชาติที่แท้จริงของปัญหาที่ RAG ซึ่งคุณ “ออกแบบให้เป็นคู่หู” กลับ ไม่จดจำ เมื่อใช้งานจริง
  • วิธีแยกแยะว่ามันคือ retrieval ที่อ่อนแอ ไม่ใช่ข้อบกพร่องของการออกแบบ (เพื่อไม่ตกหลุมพรางการออกแบบใหม่)
  • “การกู้ข้อมูลที่ยังไม่ถูก ingest” ที่ควรทำก่อนขัดเกลา search
  • วิธีแก้ search ขณะวัด recall ด้วย golden set (ไม่เดา)
  • recall ขยับอย่างไรด้วย hybrid search, การกระจายความหลากหลาย และการแก้อคติของลำดับความสำคัญ
  • คุณค่าของการ ตัดสินใจ “ไม่รับ” ด้วยการวัดผล (ความกล้าที่จะทิ้งเมื่อวัดแล้วพบว่ามันแย่ลง)
  • “กำแพงคำศัพท์” ที่เหลืออยู่ในตอนท้าย เรื่องของ GPU และ มุกปิดท้าย
  • การปรับปรุง search ที่จริงแล้วต่อเนื่องไปถึง การ ingest ข้อมูล, การออกแบบการประเมิน, สถาปัตยกรรมการใช้งาน และการออกแบบโครงสร้างพื้นฐาน (การกระจาย GPU)

บทความนี้เหมาะกับใคร

  • คนที่ ใช้ RAG / PKM ส่วนตัวจริง ๆ และรู้สึกว่ามัน ดึงข้อมูลได้ไม่ค่อยดี
  • คนที่สร้าง RAG ด้วย “vector search ไปก่อน” แล้วทิ้งไว้แบบนั้น
  • คนที่เคย ตกหล่มเพราะจูน search ด้วยความรู้สึก
  • คนที่วางแผนจะกระจายการประมวลผลของ RAG แต่ต้องสะดุดอยู่กับที่เพราะฮาร์ดแวร์ที่สำคัญไม่ทำงาน (ครั้งนี้คือฉันเอง)

บทนำ: คู่หูที่ควรจะเป็น กลับเงียบในตอนสำคัญ

ครั้งที่แล้ว ฉันมาถึงหลักการออกแบบเพื่อเปลี่ยน RAG ส่วนตัวที่สร้างเองให้เป็น “คู่หูที่อยู่ด้วยกันได้ 5 ปี” ได้แก่ เก็บโน้ตดิบไว้โดยไม่ทิ้ง, การกลั่น (distillation) คือการเสริมไม่ใช่การแทนที่ และเลเยอร์ Activation ที่ฉีดความทรงจำที่เกี่ยวข้องเข้ามาทุกเทิร์น

🔗 บทความก่อนหน้า: คืนที่ AI แก้ไขฉัน 5 ครั้ง - ปรัชญาการออกแบบเพื่อให้ RAG ส่วนตัวเป็นเพื่อนคุณเป็นเวลา 5 ปี

ในแง่การออกแบบ ฉันพอใจกับมัน

แต่พอใช้งานจริงทุกวัน วันหนึ่งฉันก็คิดขึ้นมาว่า:

“นายไม่จำในตอนที่สำคัญเลยนะ?”

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

การออกแบบควรจะดี แต่ประสบการณ์กลับเป็น “คู่หูความจำไม่ดี”

ตรงนี้มีความผิดพลาดที่หลายคนทำ นั่นคือ คิดว่า “การออกแบบมันแย่” แล้วเริ่มสร้างใหม่ ในบทความก่อนหน้า ฉันกับ AI เหยียบ “หลุมพรางการออกแบบใหม่” ซ้ำแล้วซ้ำเล่า ดังนั้นครั้งนี้ ฉันจึงสงสัยสิ่งนั้นก่อน

นี่มันคือ retrieval (การค้นหา) ที่อ่อนแอ ไม่ใช่ข้อบกพร่องของสถาปัตยกรรม หรือเปล่า?

โครงสร้างสองชั้น, Capture-first, Activation — โครงกระดูกนั้นถูกต้องตามที่ตกผลึกไว้ครั้งที่แล้ว สิ่งที่ต้องแก้คือคุณภาพของ search ไม่ใช่การออกแบบใหม่ การแยกแยะนี้คือจุดเริ่มต้นในครั้งนี้

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


องก์ที่ 1: ก่อนขัดเกลา search — สงสัยว่า “มันไม่เคยอยู่ในนั้นตั้งแต่แรก”

พอฮึกเหิมจะปรับปรุง search ฉันก็สังเกตเห็นความเป็นไปได้ที่ไม่น่าอภิรมย์

ที่ดึงไม่ได้ อาจไม่ใช่เพราะ search แย่ แต่เพราะข้อมูลไม่เคยถูกใส่เข้าไปตั้งแต่แรก?

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

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

สิ่งที่เกิดขึ้นตอน ingest (ภาพประกอบ)

ข้อความยาว:  ┌──────────────────────────────────────────┐
             │ ●●●●●●●●  แก่นของการออกแบบอยู่ตรงครึ่งหลังนี้ →│
             └──────────────────────────────────────────┘
  ก่อนแก้:   [●●●●●●●●] ✂   ← ตัดตรงนี้ ครึ่งหลังหายไป
  หลังแก้:   [●●●●][●●●●]    ← แบ่งแล้วใส่เข้าดัชนีทั้งหมด

AI บางทีตอบเป็นชิ้น ๆ แบบ "ตอบ → รันเครื่องมือ → ตอบต่อ":

  ข้อความผู้ใช้จริง
   ├ AI ตอบ (1)
   ├ รันเครื่องมือ (search หรือ code) → ผลลัพธ์ของมัน
   ├ AI ตอบ (2)   ← ตอบต่อหลังได้ผลลัพธ์
   └ AI ตอบ (3)   ← ตอบต่ออีก
  ข้อความผู้ใช้จริงถัดไป     ← "หนึ่งคำตอบ" จบตรงนี้

  ก่อนแก้: ingest แค่ตอบ (1); ทิ้ง (2)(3)
           (เข้าใจผิดว่าผลลัพธ์เครื่องมือคือ "ข้อความผู้ใช้ถัดไป" แล้วตัดจบ)
  หลังแก้: เชื่อม (1)+(2)+(3) จนถึงข้อความผู้ใช้จริงถัดไป แล้ว ingest ทั้งหมด

มันดูไม่หวือหวา แต่เป็นบทเรียนที่ชี้ขาด

ต่อให้ขัดเกลา search แค่ไหน ก็ดึงสิ่งที่ไม่มีในข้อมูลต้นทางออกมาไม่ได้ จงสงสัย capture ก่อน retrieval

ฉันแก้การตัดสินขอบเขตในการ ingest, เลิกตัดทิ้ง, และใส่บทสนทนาทั้งหมดกลับเข้าไปใหม่ บทสนทนาในอดีตที่หายไปกลับคืนมาอย่างเห็นได้ชัด เรื่องของ search เริ่มเข้าสู่ของจริงจากตรงนี้


องก์ที่ 2: ไม่แก้ด้วยความรู้สึก — แก้ “ขณะวัด” ด้วย golden set

นี่คือส่วนที่ฉันอยากสื่อมากที่สุดในครั้งนี้

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

ดังนั้นฉันจึงสร้าง golden set เล็ก ๆ ขึ้นเอง (ชุดของคู่คำตอบที่ถูกต้องในรูปแบบ “สำหรับคำถามนี้ ควรมีบันทึกนี้ขึ้นมา”) แล้วทุกครั้งที่เปลี่ยน ก็วัด recall (สัดส่วนที่คำตอบถูกต้องอยู่ใน N อันดับแรก) ดู before/after เป็นตัวเลข แค่นี้โลกก็เปลี่ยน

คะแนนแรกที่วัดของ vector search ล้วน ๆ พูดตรง ๆ คือแย่มาก ดึงสิ่งที่ความหมายใกล้ได้ก็จริง แต่ พอสำนวนต่างนิดเดียวก็หลุด recall@10 อยู่ที่ 0.2 หมายความว่าให้มา 10 รายการ มีแค่ 2 ที่เฉียดคำตอบ

หมัดที่ลงเป็นไปตามตำรา RAG แต่ ฉันใส่ทีละอย่าง วัดทีละอย่าง

ไปป์ไลน์ search (ภาพรูปแบบสุดท้าย)

  คำถาม

   ├─▶ vector search (ความหมายใกล้) ──┐
   │                                  ├─▶ หลอมลำดับ (RRF) ─▶ กระจายความหลากหลาย ─▶ ปรับลำดับความสำคัญ ─▶ ผลลัพธ์
   └─▶ lexical search BM25 (ผิวคำใกล้) ─┘

        └ ความแปรผันของรูปคำในภาษาญี่ปุ่น จับโดยหั่นเป็นหน่วยตัวอักษร 1–2 ตัว
  • Hybrid search: ให้ vector search ที่อิงความหมาย วิ่งคู่ไปกับการแมตช์คำศัพท์ที่อิงผิวคำ (แบบ BM25) แล้วหลอมลำดับของทั้งสอง ฉันทำให้ความแปรผันของรูปคำในภาษาญี่ปุ่น (เช่น 「遅い」osoi “ช้า” กับ 「遅すぎ」ososugi “ช้าเกินไป”) ถูกจับได้ฝั่งผิวคำ อันนี้ได้ผลที่สุด
  • การกระจายความหลากหลาย: เพื่อไม่ให้อันดับต้น ๆ เต็มไปด้วย “เนื้อหาที่แทบเหมือนกัน” ก็ผสมผู้สมัครที่ห่างออกไปเล็กน้อยเข้ามาในโควตาหนึ่ง นี่คือการปกป้อง “การออกแบบที่บอกบริเวณตีนภูเขาไฟฟูจิให้” ที่เขียนไว้ในบทความก่อนหน้า ให้คงอยู่ฝั่ง search ด้วย
  • การแก้อคติของลำดับความสำคัญ: ฉันเคยปักหมุดบันทึกที่ทำเครื่องหมาย “ห้ามลืมเด็ดขาด” ไว้บนสุด แต่พอมันเพิ่มขึ้น ก็ไปเบียดบันทึกที่ใกล้คำถามปัจจุบันจริง ๆ ออกไป ฉันจึงเลิกปักหมุด แล้ว ปรับสมดุลให้แค่ดันลำดับขึ้นนิดหน่อย

ถ้าจะทำให้ฝั่ง “จับด้วยผิวคำ” ของภาษาญี่ปุ่นเป็นรูปธรรมขึ้นนิด: แทนที่จะดูคำตรง ๆ ก็หั่นตัวอักษรเป็นลำดับ 1–2 ตัว (n-gram) ก่อนแล้วค่อยแมตช์ ทำแบบนี้แล้วถึงรูปคำจะเปลี่ยน ผิวคำก็ทับซ้อนกันบางส่วน ฝั่ง BM25 จึงจับได้

# จับความแปรผันของรูปคำภาษาญี่ปุ่นฝั่งผิวคำ: หั่นตัวอักษรเป็น 1–2 gram ป้อนให้ BM25
def char_ngrams(text, n_min=1, n_max=2):
    grams = []
    for n in range(n_min, n_max + 1):
        grams += [text[i:i + n] for i in range(len(text) - n + 1)]
    return grams
# 「遅い」  (osoi)    → 遅, い, 遅い
# 「遅すぎ」(ososugi) → 遅, す, ぎ, 遅す, すぎ
# ตัว 「遅」 ที่ใช้ร่วมกันเฉียดผิวคำ จึงจับฮิตได้แม้รูปคำต่างกัน

ผลคือ recall@10 ของ golden set ขึ้น จาก 0.2 เป็น 1.0 ส่วน crowding (ปรากฏการณ์ที่บันทึกซึ่งถูกทำเครื่องหมายไปยึดอันดับต้น) ก็ดีขึ้นมากในการวัดผลจริง

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

# RRF: หลอมสองอันดับด้วย "ลำดับ" (ฉบับย่อ; ค่าเป็นตัวอย่าง)
def rrf(rankings, k=60):                 # k คือค่าคงที่เพื่อความเสถียร
    score = {}
    for ranking in rankings:             # ranking = ลิสต์ของ id เรียงจากบนลงล่าง
        for rank, doc_id in enumerate(ranking):
            score[doc_id] = score.get(doc_id, 0) + 1 / (k + rank + 1)
    return score                         # id -> คะแนน (ยิ่งมากยิ่งสูง)

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

# วัด recall@N ด้วย golden set (ฉบับย่อ)
def recall_at_n(golden, search, n=10):
    hit = 0
    for query, expected_id in golden:    # (คำถาม, บันทึกที่ควรขึ้นมา)
        top = [doc.id for doc in search(query)][:n]
        hit += 1 if expected_id in top else 0
    return hit / len(golden)

print(recall_at_n(golden, baseline_search))  # เช่น 0.2  (vector search ล้วน)
print(recall_at_n(golden, hybrid_search))    # เช่น 1.0  (hybrid + กระจายความหลากหลาย)

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

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

พอมีตัวเลข การถกเถียงก็เปลี่ยนจาก “ความชอบ” เป็น “ข้อเท็จจริง” อันนี้ในการรีวิวการออกแบบก็เหมือนกัน


องก์ที่ 3: ความกล้าที่จะตัดสินใจ “ไม่รับ” ด้วยการวัดผล

ส่วนที่อร่อยที่สุดของการขับเคลื่อนด้วยการวัดผล คือ การยืนยันได้ว่าหมัดที่แย่นั้นแย่จริง

หนึ่งในหมัดที่ฉันคาดหวังคือ “query expansion” ก่อน search ใช้ LLM เขียนคำถามใหม่เป็นสำนวนอื่น แล้ว search ด้วยหลายแบบ น่าจะทนต่อความเหลื่อมของคำศัพท์ได้ดีขึ้น — นั่นคือแผน

ลองแล้ว วัดแล้ว มันแย่ลง การเขียนใหม่ทำให้ความหมายเคลื่อนเล็กน้อย (drift) กลับทำให้หลุดคำตอบที่ถูก แถมยังช้า ด้วย LLM เบา ๆ ในเครื่อง ทั้งคุณภาพและความเร็วล้วนไม่คุ้ม

อีกอันคือ “เปลี่ยนไปใช้ embedding model ที่แรงกว่า” ฉันลองสร้างและรันจริง แต่บน CPU ในเครื่อง 4 ใน 5 เคสประมวลผลล้มเหลว และที่ผ่านก็ใช้เวลาหลายวินาทีต่อรายการ ถ้าต้นทุนนั้นทับลงไปทุกครั้งที่ search ก็ไม่สมจริงสำหรับคู่หูที่ใช้แบบเรียลไทม์

ทั้งสองอย่าง ฉัน ไม่รับ หลังจากวัดผลแล้ว โค้ดยังเก็บไว้ พร้อมระบุชัดว่า “วัดแล้วเป็น net-negative, ยังไม่ต่อเข้า production” (กระบวนการของความล้มเหลวแบบนี้แหละที่ควรค่าแก่การกลับมาดูภายหลัง)

ตรงนี้ มีหลักการเชิงการใช้งานหนึ่งที่ตกผลึก ไม่ใช่เรื่องว่าฟังก์ชันดีหรือไม่ดี แต่เป็น การตัดสินใจเพื่อปกป้องความเร็วและประสบการณ์ — ที่เรียกว่าการออกแบบ non-functional requirements

ข้อค้นพบ: อย่าวางงานหนักไว้บนเส้นทางของ search (การอ่าน) ที่วิ่งแบบเรียลไทม์ ของหนักไปไว้ batch หลังบ้าน ถ้าฝ่าฝืน ต่อให้คุณภาพขึ้น ประสบการณ์ก็ตาย

จะวางงานหนักไว้บนเส้นทางไหน?

  ┌─ เส้นทาง Live (อ่าน / เรียลไทม์) ─────────────────────────┐
  │  คำถาม → search เบา → ตอบเร็ว  ← ห้ามวางงานหนักตรงนี้     │
  └──────────────────────────────────────────────────────────┘
  ┌─ เส้นทาง Batch (เขียน / หลังบ้าน) ───────────────────────┐
  │  ingest / distillation / preprocessing หนัก ← ใช้เวลานานได้ │
  └──────────────────────────────────────────────────────────┘

  ทั้ง query expansion (LLM เขียนใหม่) และ embedding หนัก ขึ้นเส้นทาง live ไม่ได้ → ไม่รับ

เหตุที่พูดได้เต็มปากว่า “ลองแล้ว → แย่ลง → จึงทิ้ง” ก็เพราะวัด ถ้าใช้ความรู้สึก “ใส่อะไรเข้าไปแล้วรู้สึกว่ามันได้ผล” จะค้างอยู่ตลอดไป


องก์ที่ 4: ความหมายเดียวกัน แต่คำต่างกันก็ดึงไม่ได้ — กำแพงคำศัพท์

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

ตัวอย่างเช่น:

  • ได้ยิน “yaru-ki ga tsuzuku” (มีไฟอยากทำต่อเนื่อง) อยากดึงบันทึก “mochibēshon iji” (รักษาแรงจูงใจ)
  • ได้ยิน “nakama to chikara wo awaseru” (ร่วมแรงกับเพื่อนร่วมทีม) อยากดึงบันทึก “chīmuwāku” (teamwork)

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

กำแพงคำศัพท์: ความหมายเหมือนกัน แต่คำไม่ใช้ตัวอักษรร่วมกันเลย

  คำถาม:  "yaru-ki ga tsuzuku" (มีไฟอยากทำต่อเนื่อง)
            │   ผิวคำทับซ้อน = ศูนย์

  บันทึก: "mochibēshon iji" (รักษาแรงจูงใจ)

  vector search … ควรจะความหมายใกล้ แต่ embedding เบาเอื้อมไม่ถึง   ✗
  BM25 (ผิวคำ)   … ไม่มีตัวอักษรร่วม จึงหยิบไม่ได้เลย                ✗
  ──────────────────────────────────────────────────────────────
  → ต้องมี cross-encoder reranker / embedding หลายภาษาที่แรง ถึงจะสร้างสะพานเชื่อมได้

วิธีรับมือฉันรู้ จัดลำดับใหม่ด้วย cross-encoder reranker หรือเปลี่ยนไปใช้ embedding หลายภาษาที่แรง ทั้งสองรู้ว่าได้ผล

แต่ — ทั้งคู่ ต้องใช้ GPU สมรรถนะสูงเพื่อให้ทำงานที่ความเร็วใช้งานได้จริง บน CPU อย่างที่วัดจริงในองก์ที่ 3 ใช้เวลาหลายวินาทีต่อรายการ ถ้าทุกครั้งที่ search ต้องรอหลายวินาที ก็เอาขึ้นเส้นทางของคู่หูที่ตอบแบบเรียลไทม์ไม่ได้

และจากตรงนี้ เรื่องราวก็กลิ้งไปในทิศทางที่แปลกขึ้นนิด


องก์ที่ 5: แผนกระจายการประมวลผลของ RAG กับความจริงที่เดสก์ท็อปไม่ทำงาน

ต้องใช้ GPU สมรรถนะสูง แต่ให้เครื่องเดียวแบกทั้งหมดก็หนัก งั้นก็แบ่งบทบาทไปยังเครื่อง GPU หลายตัวที่บ้านสิ — พอคิดได้แบบนั้น ฉันก็เริ่มร่าง โครงสร้างกระจายนั้น อย่างกระตือรือร้น

กระจายงานหนักอย่าง embedding และ reranking ไปยังเครื่อง GPU หลายตัวที่บ้าน และให้แล็ปท็อปทำหน้าที่เป็นเครื่องปฏิบัติการล้วน ๆ เพราะสิ่งที่ไหลผ่าน LAN มีแค่ไม่กี่ KB ของคำถามและผลลัพธ์ จึงไม่เป็นคอขวด — ฉันถึงขั้นวาดแผนภาพแล้วก็ปลื้มใจกับตัวเอง

ฉันกดปุ่มเปิดเครื่องเดสก์ท็อปอย่างฮึกเหิม

มันไม่ทำงาน

อีกเครื่องก็ผลเหมือนกัน เดสก์ท็อปทั้งสองที่ไม่ได้แตะมานาน พอเปิดเครื่องก็ไม่บูต มีแค่เสียงบี๊บ 5 ครั้ง ฉันเดาว่าน่าจะเป็นเรื่องความร้อนของ CPU และกะจะเปลี่ยนชุดน้ำปิดและ PSU เป็นของใหม่ แต่การวินิจฉัยที่แน่นอนยังค้างอยู่ ไม่ว่าจะอย่างไร แผนกระจายการประมวลผลของ RAG ก็หยุดอยู่ตรงหน้าความจริงน่าขำที่ว่า เครื่องที่สำคัญทั้งสองไม่บูต

ตรงนี้ ชั่วขณะหนึ่งฉันฝากความหวังไว้กับแล็ปท็อปที่อยู่ในมือ

จริงอยู่ แล็ปท็อปเครื่องนี้ก็มี RTX 2070 อยู่ ลองตรงนี้เลยก็ได้นี่ — ชั่วขณะหนึ่งฉันคิดแบบนั้น

แต่ฉันรีบทบทวนใหม่ แล็ปท็อปเครื่องนี้คือเครื่องทำงานที่ฉันรัน VSCode และ Claude Code อยู่ทุกวันนั่นเอง บทบาทที่ฉันมอบให้แล็ปท็อปในโครงสร้างกระจายดั้งเดิม คือฝั่งที่โยนคำถามและรับผลลัพธ์ — นั่นคือเครื่องปฏิบัติการ งานหนักฝากไว้กับ GPU ฝั่งเดสก์ท็อป ส่วนเครื่องในมือเก็บให้เบาไว้ นี่ไม่ใช่การประนีประนอม แต่เป็นการจัดวางที่ฉันตั้งใจวางไว้แบบนั้น

ถ้าให้เครื่องมือทำงานประจำวันแบกเซิร์ฟเวอร์ embedding และ reranker ไว้ตลอดเวลา งานหลักก็จะหนักขึ้น ดังนั้นการจะลุยกำแพงนี้อย่างจริงจัง ก็ยังต้องซ่อมเดสก์ท็อปและสร้างการกระจายดั้งเดิมก่อน บนแล็ปท็อปมีขอบเขตที่ลองได้อยู่ แต่การ implement นั้น — การใส่ reranker เข้าไป เป็นต้น — ยังอยู่ข้างหน้า

แผนกระจายการประมวลผลในหัว (แบ่งบทบาทไปหลายเครื่องที่บ้าน)

  [เครื่อง A: Ollama (embedding + LLM)]
  ┌──────────────────────────────────┐
  │  Ryzen 7 2700X   +   RTX 2060     │
  └──────────────────────────────────┘
   ※ ทั้ง embedding และ LLM โมเดลเบา 6GB จึงพอ เลือกที่ความเร็วมากกว่า VRAM:
     RTX 2060 สถาปัตยกรรม Turing แทนที่จะเป็น GTX สถาปัตยกรรม Pascal
                 │ LAN

  [เครื่อง B: ตัวหลัก / ChromaDB (เน้น CPU)]
  ┌──────────────────────────────────┐
  │  Core i7 9900K   +   GTX 1080Ti   │
  └──────────────────────────────────┘
   ※ งานของ ChromaDB เน้น CPU ส่วน GPU ที่ว่างก็มอบ reranker
     ที่จัดลำดับผลลัพธ์ใหม่เพื่อยกความแม่นยำ
                 │ LAN

  [แล็ปท็อป: client]
  ┌──────────────────────────────────┐
  │  Core i7-9750H   +   RTX 2070     │
  └──────────────────────────────────┘

      ✗ เดสก์ท็อปทั้ง 2 เครื่องไม่บูตด้วยอาการเดียวกัน


  การกระจายต้องพักไว้ก่อน ตอนนี้เริ่มจากขอบเขตที่ลองได้บนแล็ปท็อป (เครื่องทำงาน)
  การกระจายจริงจะมาหลังซ่อมเดสก์ท็อป (การ implement ยังอยู่ข้างหน้า)

ขอเสริมไว้หน่อย การจัดวางนี้ไม่ใช่การมั่ว ๆ มาประกอบกัน embedding และ LLM โมเดลเบา จึงไปที่ RTX 2060 — เร็วบน Turing แม้แค่ 6GB ส่วนงานหลักของ ChromaDB เน้น CPU จึงไปที่เครื่อง 1080Ti ที่ GPU ว่างสามารถรับ reranker ได้ ส่วนแล็ปท็อปทำหน้าที่ปฏิบัติการล้วน ๆ และสิ่งที่ไหลผ่าน LAN มีแค่ไม่กี่ KB ของคำถามและผลลัพธ์ — เป็นการตัดสินใจออกแบบที่แท้จริง ที่จับคู่บทบาทเข้ากับจุดเด่นจุดด้อยของฮาร์ดแวร์แต่ละชิ้น ตั้งใจจะแก้ search ของ RAG แท้ ๆ แต่ไม่ทันรู้ตัวมันก็กลายเป็น การออกแบบโครงสร้างพื้นฐาน ที่คิดเรื่องการวางเครือข่ายและฮาร์ดแวร์

และวิธีคิด “แบ่งบทบาทหนัก แล้ววางบนฮาร์ดแวร์ที่ถนัด” นี้ ยิ่งเรียกร้องสมรรถนะจาก AI มากเท่าไร ก็ยิ่งได้ผลมากเท่านั้น เมื่อสเกลเปลี่ยน เครื่องมือสำหรับการสเกลและ orchestration จะเปลี่ยน แต่วิธีคิดในการออกแบบยกข้ามไปได้ตรง ๆ — การตัดสินใจที่เหยียบผ่านบนโต๊ะ ต่อเนื่องไปถึงการกระจายการประมวลผลของระบบขนาดใหญ่

แม้แต่ก้าวแรกของมัน — การ abstract ปลายทางการเชื่อมต่อของ embedding ด้วย environment variable — ก็ออกแบบได้โดยไม่ต้องรอให้ฮาร์ดแวร์ฟื้น ถ้าไม่ hardcode ปลายทาง “โค้ดชุดเดียวกัน” ก็ทำงานได้ทั้งเครื่องเดียวและแบบกระจาย แค่สลับว่าชี้ไปที่ไหน (การ implement ยังอยู่ข้างหน้า)

# abstract ปลายทางการเชื่อมต่อของ embedding ด้วย environment variable (ดีไซน์ ยังไม่ implement)
# ด้วย "โค้ดชุดเดียวกัน" ทั้งเครื่องเดียวและแบบกระจาย แค่สลับปลายทาง
import os

OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
# แล็ปท็อปเครื่องเดียว → localhost
# แบบกระจาย          → แค่ชี้ไปที่ http://<เครื่อง RTX 2060>:11434

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


นอกเรื่อง: ทำไมฉันถึงมี GPU?

สุดท้าย ขอเล่าเรื่องสภาพแวดล้อมสักนิด

การจูน RAG ด้วย GPU แบบโลคัลนั้นทรงพลัง ไม่ต้องส่งไปคลาวด์ วัดและวนซ้ำที่เครื่องตัวเองกี่ครั้งก็ได้ องก์ที่ 2–4 ทำได้ เพราะมี GPU ที่พอใช้ได้อยู่ในเครื่อง

GPU เหล่านี้ไม่ได้ซื้อมาเพื่อ RAG ฉันชอบรถยนต์เลยประกอบ racing simulator จริงจัง และด้วยความเป็นคนชอบของใหม่ก็เลยเอื้อมไปถึง VR แบบต่อ PC (Oculus Rift 2) ด้วย — ของที่กองไว้เพื่องานอดิเรกแบบนั้น บังเอิญมาได้ผลกับการจูนครั้งนี้ แค่นั้นเอง ทั้งสองอย่างถ้าไม่มี GPU ที่พอใช้ได้ก็เล่นได้ไม่ลื่น

ถ้าคุณมีเกมมิ่งพีซีนอนหลับอยู่ในมือ นั่นคือสภาพแวดล้อมทดลองชั้นดี มี GPU แล้ว local LLM/RAG ก็ลองได้แบบสบาย ๆ กี่ครั้งก็ได้ อันที่จริง การจูนองก์ที่ 2–4 รันบนแล็ปท็อปเครื่องเดียวนั่นแหละ


บทส่งท้าย: ความแข็งแกร่งเงียบ ๆ ของการแก้ด้วยการวัด

ไม่มีเรื่องหวือหวาสักอย่าง เรียงสิ่งที่ทำออกมาก็เป็นแบบนี้:

  • ก่อน search ฉันสงสัยการ ingest (ดึงสิ่งที่ไม่มีในข้อมูลต้นทางไม่ได้)
  • สร้าง golden set แล้วแก้พลางวัด recall พลาง (ไม่เดา)
  • ใส่หมัดที่ได้ผล และวัดแล้วทิ้งหมัดที่ไม่ได้ผล (ความกล้าที่จะยืนยันการไม่รับ)
  • ไม่วางงานหนักไว้บนเส้นทางเรียลไทม์ (อย่าฆ่าประสบการณ์เพื่อคุณภาพ)
  • สุดท้ายชนกำแพง GPU วาดการกระจายที่จำเป็นจริง ๆ แต่เดสก์ท็อปที่สำคัญไม่บูตเลยสะดุดอยู่กับที่ (พร้อมมุกปิดท้าย)

สิ่งเหล่านี้ ฉันคิดว่าเป็นหลักการที่ไม่ล้าสมัยไม่ว่าเครื่องมือจะเป็นอะไร ต่อให้ ChromaDB เปลี่ยนเป็น vector DB ตัวอื่น หรือ Ollama เปลี่ยนเป็น runtime อื่น “แก้พลางวัดพลาง”, “อย่าวางของหนักบนเส้นทาง live”, “มอบงานหนักให้ฮาร์ดแวร์ที่เหมาะ และ abstract ปลายทางเชื่อมต่อเพื่อให้โค้ดชุดเดียวรันได้ทั้งเครื่องเดียวและแบบกระจาย” และ “ทำเท่าที่ทำได้ที่เครื่องตัวเอง พอไม่พอก็ขยายอย่างตรงไปตรงมา” ก็น่าจะได้ผลไปอีกนาน

คู่หูไม่ได้ฉลาดมาตั้งแต่แรก วัด แก้ แล้ววัดอีก มีเพียงการไป-กลับเงียบ ๆ นั้นเท่านั้น ที่ค่อย ๆ เปลี่ยนคู่หูความจำไม่ดี ให้กลายเป็นคู่หูที่ “จำให้คุณได้”

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

Roadmap — ตำแหน่งปัจจุบัน

  ✅ เสร็จแล้ว
     ├ แก้ช่องโหว่การ ingest (D1/D5) → ingest บทสนทนาทั้งหมดใหม่
     ├ Hybrid search (vector + BM25 + RRF)
     ├ การกระจายความหลากหลาย (MMR) / แก้อคติลำดับความสำคัญ
     ├ วัด recall ด้วย golden set (0.2 → 1.0)
     └ implement query expansion / embedding ตัวแรง (CPU) → วัด → ปฏิเสธ

  ⬜ ยังไม่เสร็จ (สิ่งที่เหลือเพื่อข้ามกำแพงคำศัพท์)
     ├ ทำปลายทางเชื่อมต่อ embedding เป็น env var (เตรียมชี้ไป GPU)
     ├ ใส่ reranker (cross-encoder) เข้าไป
     ├ วัด embedding ตัวแรง + reranker บน GPU ใหม่ → ข้ามกำแพงคำศัพท์
     └ โครงสร้างกระจายจริง (ซ่อมเดสก์ท็อป → แบ่งบทบาท)

ไม่ใช่ “สร้างเสร็จแล้วจบ”

RAG ไม่ใช่ “จูนครั้งเดียวแล้วสมบูรณ์” เมื่อเจอฉากที่มันดึงข้อมูลได้ไม่ดี อย่างแรกคือสืบว่าเกิดอะไรขึ้น วิเคราะห์สาเหตุ จากนั้นซ่อมจุดที่ต้องแก้ — บางครั้งก้าวเข้าไปถึงการออกแบบ — แล้ววัด recall อีกครั้ง

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

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

กรุณาส่งข้อความ หากมีความคิดเห็น หรือคำถาม