ทีมวิศวกรรมส่วนใหญ่มักจะติดหล่มเดิมๆ กับการทำ retrieval-augmented generation (RAG) พวกเขามักจะทำตามตำราแบบเดิมๆ คือ: แบ่งเอกสารเป็น chunk ขนาดตายตัวที่ 512 หรือ 1,024 tokens, ส่งผ่าน embedding model เพียงตัวเดียว, และเรียกใช้ vector database ด้วยการทำ top-k lookup แบบง่ายๆ หากดูในสไลด์นำเสนอ สิ่งนี้อาจดูดี แต่เมื่อนำไปใช้จริงในระบบ production มันกลับพังไม่เป็นท่า
การแบ่ง chunk แบบตายตัวไม่ได้คำนึงถึงเนื้อหา มันอาจจะแบ่งสัญญาทางกฎหมายกลางประโยค ทิ้งให้ข้อกำหนดเรื่องความรับผิด (liability clauses) กระจัดกระจายอยู่ระหว่างข้อความสองส่วนที่ไม่เกี่ยวข้องกัน หรือมันอาจจะเทคำอธิบาย API endpoint ทั้งหมดลงใน chunk เดียวที่บวมจนใหญ่เกินไป จนทำให้พารามิเตอร์เฉพาะที่ผู้ใช้ถามถึงจมหายไปในกองข้อมูล และเมื่อการดึงข้อมูล (retrieval) ช้าลง ทุกมิลลิวินาทีของ latency จะส่งผลกระทบโดยตรงต่อประสบการณ์ของผู้ใช้ เราเรียนรู้เรื่องนี้ด้วยวิธีที่ยากลำบาก จากนั้นเราจึงรื้อเลเยอร์การดึงข้อมูลของเราออกแล้วสร้างมันขึ้นมาใหม่ ผลปรากฏว่าค่า Recall ที่ 10 ของเราพุ่งจาก 78% เป็น 95% ในขณะที่ latency ไม่ได้เพิ่มขึ้นเลย แต่มันกลับลดลงอย่างมหาศาล
ปัญหาของการทำ RAG แบบ Copy-Paste
Stack ของ RAG มาตรฐานได้กลายเป็นค่าเริ่มต้นไปแล้ว: chunk ขนาดเล็ก, embedding model ตัวเดียว, vector search แล้วก็จบ วิธีการนั้นอาจจะรอดในการทำ demo เพราะ demo มักใช้คำถามที่สะอาดและเอกสารที่เป็นระเบียบ แต่ข้อมูลในระบบ production ไม่เคยเป็นระเบียบแบบนั้น
เอกสารทางกฎหมายมีโครงสร้างแบบลำดับชั้น (hierarchical structure) ส่วนต่างๆ ประกอบด้วยหัวข้อย่อย และหัวข้อย่อยก็ประกอบด้วยข้อกำหนดต่างๆ หากคุณใช้ตัวนับ token แบบหยาบๆ ตัดแบ่งพวกมัน คุณกำลังทำลายความสัมพันธ์ที่โมเดลจำเป็นต้องใช้ในการให้เหตุผล เอกสาร API ก็มีโครงสร้างเช่นกันแต่แตกต่างออกไป ทั้ง signature ของฟังก์ชัน, พารามิเตอร์, ค่าที่ส่งกลับ (return value) และตัวอย่างการใช้งาน ล้วนประกอบกันเป็นหน่วยทางตรรกะ หากคุณบังคับให้สิ่งเหล่านี้อยู่ในหน้าต่าง token ที่ตายตัว คุณก็ต้องตัดตัวอย่างทิ้ง หรือไม่ก็ต้องเติมฟังก์ชันที่ไม่เกี่ยวข้องลงไปใน chunk เพื่อให้เต็ม ส่วนตั๋วสนับสนุน (support tickets) นั้นยุ่งเหยิง เป็นการสนทนา และมีการเปลี่ยนหัวข้ออย่างกะทันหัน ส่วน wikis ก็มีเนื้อหาที่แผ่ขยายและมีการอ้างอิงข้ามกันไปมา กลยุทธ์การแบ่ง chunk เพียงแบบเดียวไม่สามารถตอบโจทย์ทั้งหมดนี้ได้ แต่ทีมต่างๆ มักจะเลือกใช้แบบนั้นเป็นประจำ เราจึงเลิกแสร้งทำเป็นว่ามันทำได้
Strategic Chunking: เลือกวิธีการให้เหมาะกับเนื้อหา
เราเปลี่ยนมาใช้ content-aware chunking สำหรับเอกสารทางกฎหมาย เราใช้ recursive chunking ที่เคารพโครงสร้างลำดับชั้นของเอกสาร เพื่อรักษาข้อกำหนดต่างๆ ให้ครบถ้วนและคงความสัมพันธ์แบบ parent-child ระหว่างส่วนต่างๆ ไว้ สำหรับเอกสาร API เราสร้าง function-aware chunking ที่ปฏิบัติกับแต่ละฟังก์ชันหรือ endpoint เสมือนเป็นขอบเขต หากคำอธิบายพารามิเตอร์ยาวเกินไป chunk จะขยายออกรอบๆ ฟังก์ชันนั้น แทนที่จะขยายตามขีดจำกัดของ token สำหรับ support tickets เราใช้ semantic chunking ที่ตรวจจับขอบเขตของหัวข้อตามธรรมชาติ เมื่อลูกค้าเปลี่ยนจากการร้องเรียนเรื่องการเรียกเก็บเงินไปเป็นบั๊กทางเทคนิคอย่างกะทันหัน การแบ่ง chunk จะเกิดขึ้น ณ จุดที่มีการเปลี่ยนเรื่องนั้น สำหรับ wikis และฐานความรู้ที่ไม่มีโครงสร้าง เราใช้ agentic chunking โดยใช้ LLM ขนาดเล็กมาประเมินข้อความและตัดสินใจว่าขอบเขตที่มีความหมายควรอยู่ตรงไหน วิธีนี้อาจจะตั้งค่าได้ช้ากว่าการแบ่งตามจำนวนตัวอักษร แต่มันคือความแตกต่างระหว่างการดึงข้อมูลที่ใช้งานได้จริง กับการดึงข้อมูลแบบสุ่มเดา
Hybrid Retrieval: ทำไมแค่ Vector Search ถึงไม่เพียงพอ
Vector search เข้าใจความหมาย แต่ก็อาจพลาดการจับคู่ที่ตรงตัว (exact matches) หากผู้ใช้พิมพ์รหัสข้อผิดพลาดอย่าง ERR_CONNECTION_RESET_0x5F3 ความคล้ายคลึงเชิงความหมาย (semantic similarity) อาจจัดลำดับให้มันอยู่ต่ำกว่าย่อหน้าที่พูดถึงเรื่องข้อผิดพลาดเครือข่ายโดยทั่วไป ในทางกลับกัน BM25 สามารถหาข้อความที่ตรงกันได้เป๊ะๆ แต่พลาดความเกี่ยวเนื่องเชิงแนวคิด ดังนั้นคุณจึงต้องการทั้งสองอย่าง
เราใช้ vector search และ BM25 ควบคู่กันไป จากนั้นจึงรวมผลลัพธ์ด้วย Reciprocal Rank Fusion หรือ RRF ซึ่งจะทำการปรับค่าคะแนน (normalize) จากพื้นที่การค้นหาที่แตกต่างกันสองแห่งโดยไม่ต้องบังคับให้พวกมันอยู่ในสเกลเดียวกัน หลังจากรวมผลแล้ว เราจะส่งผู้สมัคร (candidates) อันดับต้นๆ ผ่าน cross-encoder reranker วิธีนี้จะเพิ่ม latency เพียงเล็กน้อย แต่สิ่งที่ได้กลับมาคือความแม่นยำ (precision) ที่เพิ่มขึ้นอย่างมีนัยสำคัญ ตัว reranker จะอ่านทั้งคำค้นหาและผู้สมัครแต่ละรายไปพร้อมกัน และมอบคะแนนความเกี่ยวข้องที่แม่นยำกว่าการใช้ cosine similarity ของ embedding เริ่มต้นมาก ในทางปฏิบัติ การผสมผสานนี้สามารถจับรหัสข้อผิดพลาดที่ตรงตัวซึ่ง vector search บริสุทธิ์พลาดไป ในขณะที่ยังสามารถดึงขั้นตอนการแก้ไขปัญหาที่เกี่ยวเนื่องกันเชิงแนวคิดซึ่งการค้นหาด้วย keyword อาจจะมองข้ามไปได้
Query Expansion: การปรับปรุงคำค้นหาก่อนเข้าสู่ Index
ผู้ใช้ไม่ได้เขียนคำค้นหาที่สมบูรณ์แบบ พวกเขามักถามคำถามแบบ multi-hop เช่น "ทำไมการ deploy ครั้งล่าสุดถึงล้มเหลว และฉันจะ roll it back ได้อย่างไร" ซึ่งต้องใช้การค้นหาความรู้จากสองแหล่งที่แยกกันแล้วนำมาเชื่อมโยงกัน หรือบางครั้งพวกเขาก็ถามคำถามที่คลุมเครือซึ่งจับคู่กับ index ได้ยาก
เราทำการแปลงคำค้นหาก่อนการค้นหา คำถามแบบ multi-hop จะถูกย่อยออกเป็นคำถามย่อย เจตนาที่คลุมเครือจะถูกขยายออกเป็นคำค้นหาเฉพาะเจาะจงหลายรายการ เราพบว่าการขยายคำค้นหาของผู้ใช้หนึ่งรายการให้เป็นคำค้นหาที่แตกต่างกันห้ารายการ สามารถเพิ่มค่า recall จาก 78% เป็น 96% ได้ นี่ไม่ใช่เรื่องของการพยายามเขียน prompt ให้ LLM หนักขึ้น แต่เป็นเรื่องของการเพิ่มโอกาสให้ระบบการดึงข้อมูล (retrieval system) สามารถค้นหาบริบทที่ถูกต้องได้มากขึ้น คำค้นหาแต่ละรายการที่ถูกสร้างขึ้นจะครอบคลุมมุมมองหรือคำศัพท์ที่แตกต่างกัน และผลลัพธ์ที่นำมารวมกันจะช่วยสร้างภาพรวมที่สมบูรณ์
Bayesian Optimization: เลิกการคาดเดา
เมื่อคุณมีทั้งกลยุทธ์การแบ่ง chunk ที่หลากหลาย, การดึงข้อมูลแบบไฮบริด (hybrid retrieval) และการขยายคำค้นหา (query expansion) แล้ว คุณจะพบกับปัญหาใหม่ นั่นคือมีตัวปรับแต่ง (knobs) มากเกินไป ทั้งขนาด chunk, เปอร์เซ็นต์การซ้อนทับ (overlap), น้ำหนักของ vector เทียบกับน้ำหนักของ BM25, เกณฑ์การจัดลำดับใหม่ (reranking thresholds) และค่า top-k ทั้งหมดนี้ล้วนมีปฏิสัมพันธ์กันในรูปแบบที่ไม่เป็นเส้นตรง (nonlinear) การปรับจูนด้วยมือจึงกลายเป็นการเล่นเกมคาดเดา
เราเลิกคาดเดา เราปฏิบัติกับ
