เดโม RAG ส่วนใหญ่มักจะดูยอดเยี่ยมเมื่อรันบนแล็ปท็อป แค่ป้อนไฟล์ PDF ยาวยี่สิบหน้าให้สคริปต์ ถามคำถาม แล้วก็นั่งดูมันอ้างอิงย่อหน้าที่ถูกต้อง แต่การนำ pipeline แบบเดียวกันนั้นไปใช้งานจริง (production) คือจุดที่ความสวยงามสิ้นสุดลง เอกสารทางกฎหมายถูกตัดแบ่งครึ่งในระดับประโยค คู่มือ API หนาเตอะทำให้สัญญาณที่สำคัญถูกกลบด้วย noise ของ boilerplate Latency พุ่งสูงขึ้น ผู้ใช้ต้องรอจนเริ่มกระวนกระวายและเลิกใช้งานไปในที่สุด เราเจอกับกำแพงนี้อย่างจัง เราจึงรื้อเลเยอร์การดึงข้อมูล (retrieval layer) ออกมาใหม่ทั้งหมด และสร้างมันขึ้นมาใหม่ให้เป็นระบบที่วัดผลได้และปรับแต่งได้ ผลลัพธ์ที่ได้คือ pipeline ที่มีค่า recall สูงถึง 95% โดยไม่ทำให้ประสบการณ์การใช้งานของผู้ใช้ช้าเหมือนการดูสไลด์โชว์
ทำไม RAG ในเดโมถึงไปไม่รอดเมื่อใช้งานจริง
Stack มาตรฐานนั้นมีความคล้ายคลึงกันอย่างน่าประหลาดใจ ทั้งในโปรเจกต์งานอดิเรกและผลิตภัณฑ์ระยะเริ่มต้น นั่นคือการใช้ fixed token chunks, embeddings สำเร็จรูป และการเรียกใช้ vector search เพียงครั้งเดียว ความเรียบง่ายนั้นช่างเย้ายวน และมันใช้งานได้ดีเมื่อ corpus ของคุณสะอาด มีขนาดเล็ก และมีโครงสร้างไวยากรณ์ที่คาดเดาได้ แต่ข้อมูลใน production ไม่ใช่แบบนั้นเลย การใช้ chunk ขนาดคงที่ 512 tokens อาจจะไปตัดแบ่งกลางข้อกำหนดการชดใช้ค่าเสียหาย (indemnification clause) ในสัญญา SaaS ได้อย่างง่ายดาย ทันใดนั้น retrieval layer ของคุณก็กำลังป้อนภาระผูกพันทางกฎหมายเพียงครึ่งเดียวให้กับ language model และสั่งให้มันตอบคำถามเรื่องความรับผิดชอบ (liability) โมเดลจึงเกิดอาการ hallucinate เพราะบริบทขาดตอน
เอกสารทางเทคนิคขนาดใหญ่ยิ่งทำให้ปัญหานี้หนักขึ้น เอกสาร API เต็มไปด้วย function signatures, ตาราง และ code blocks การใช้ window ขนาดคงที่อาจจะจับเอาส่วนกลางของ TypeScript interface มาได้ แต่กลับพลาดชื่อฟังก์ชันที่อยู่ด้านบนและตัวอย่างการใช้งานที่อยู่ด้านล่าง embedding vector จึงกลายเป็นการแทนค่าเศษเสี้ยวของไวยากรณ์และ noise แทนที่จะเป็นความสามารถที่แท้จริงที่ผู้ใช้กำลังถามถึง ข้อมูลขยะเข้า โมเดลก็ตอบแบบมั่วๆ ออกมา (Garbage in, hallucination out)
แบ่ง Chunk ตามโครงสร้าง ไม่ใช่ตามจำนวน Token
การเปลี่ยนแปลงแรกที่เราทำคือการเลิกมองว่า chunk เป็นแค่ถุงที่บรรจุ token แต่ chunk คือหน่วยทางความหมาย (semantic units) กลยุทธ์ที่ถูกต้องจึงขึ้นอยู่กับสิ่งที่คุณกำลังทำ indexing อยู่ทั้งหมด
สำหรับเอกสารทางกฎหมาย เราเปลี่ยนไปใช้ recursive chunking ที่เคารพลำดับชั้นของเอกสาร โดยมองว่าส่วน (sections), ส่วนย่อย (subsections) และข้อกำหนด (clauses) คือขอบเขต ข้อกำหนดหนึ่งจะยังคงอยู่ครบถ้วนเพราะมันคือหน่วยของความหมาย หากคุณตัดแบ่งมัน ตรรกะทางกฎหมายก็จะขาดหายไป
สำหรับเอกสาร API การทำ structure-aware chunking จะมองว่า functions, classes และ endpoints เป็นหน่วยอะตอม (atomic) หนึ่ง chunk อาจประกอบด้วย function signature, arguments และ docstring โดยจะไม่ไหลไปรวมกับ utility function ถัดไปเพียงเพราะตัวนับ token ครบกำหนด วิธีนี้ช่วยให้ embedding โฟกัสไปที่ความสามารถเฉพาะอย่างได้อย่างชัดเจน
ตั๋วสนับสนุน (Support tickets) นั้นยุ่งเหยิงกว่า เพราะมันเป็นการสนทนา มีการตอบโต้เป็นสาย (threaded) และไม่มีลำดับที่แน่นอน การใช้ fixed chunks จะไปดึงเอาการอัปเดตสถานะจากวิศวกรและคำร้องเรียนจากลูกค้าในเธรดเดียวกันมาปนกัน แล้วทำเหมือนว่ามันเป็นหน่วยข้อมูลที่สอดคล้องกัน เราจึงเปลี่ยนไปใช้ semantic chunking ซึ่งจะแบ่ง chunk เมื่อหัวข้อหรือผู้พูดเปลี่ยน แทนที่จะแบ่งเมื่อโควตา token หมด
วิลลี่ภายใน (Internal wikis) มักจะเป็นข้อมูลที่ไร้ระเบียบที่สุดในองค์กร ทั้งการจัดรูปแบบที่ไม่สม่ำเสมอ หัวข้อที่หายไป และเนื้อหาที่ไหลรวมกัน สำหรับข้อมูลเหล่านี้ เราใช้ LLM-based chunking โดยใช้โมเดลขนาดเล็กอ่านล่วงหน้าเพื่อระบุขอบเขตทางตรรกะก่อนที่เราจะสร้าง embedding แม้ว่าจะมีต้นทุนเริ่มต้นสูงกว่าการแบ่งตามจำนวนตัวอักษร แต่คุณภาพของการดึงข้อมูลที่ได้นั้นคุ้มค่าในทันที
Hybrid Retrieval: การรวมสัญญาณเข้าด้วยกัน
Vector search นั้นทรงพลังแต่ก็มีจุดบอด หากคุณวางรหัสข้อผิดพลาดที่แม่นยำอย่าง ERR_CONNECTION_REFUSED_0x800 การค้นหาความคล้ายคลึง (similarity search) อาจจะคืนค่าคู่มือการแก้ไขปัญหาของโมดูลอื่นที่ไม่เกี่ยวข้องมาให้ เพราะใน embedding space พวกมันถูกจัดกลุ่มให้อยู่ใกล้กัน การจับคู่ที่แม่นยำ (exact matches) นั้นสำคัญ และการใช้ vector search เพียงอย่างเดียวอาจทำให้ความแม่นยำนั้นหายไป
การค้นหาด้วย Keyword โดยใช้ BM25 สามารถแก้ปัญหาเรื่องการจับคู่ที่แม่นยำได้อย่างยอดเยี่ยม แต่กลับมีปัญหาเมื่อต้องรับมือกับความหมายเชิงแนวคิด (conceptual distance) หากผู้ใช้ถามเกี่ยวกับ "performance degradation under heavy load" BM25 อาจจะพลาดบันทึกการวินิจฉัยที่อธิบายว่า "slow throughput during traffic spikes" เนื่องจากไม่มีคำสำคัญที่ตรงกันมากพอ
เราเลิกเลือกข้างและเริ่มรันทั้งสองระบบควบคู่กันไป ทั้ง vector search และ keyword search จะคืนรายการจัดลำดับของตัวเองออกมา จากนั้นเราจะนำมารวมกันด้วย Reciprocal Rank Fusion (RRF) ซึ่ง RRF นั้นเรียบง่ายแต่มีประสิทธิภาพอย่างร้ายกาจ มันจะให้คะแนนแต่ละเอกสารตามตำแหน่งที่อยู่ในแต่ละรายการ เอกสารที่อยู่ในลำดับต้นๆ ของทั้งสองระบบจะได้รับการดันอันดับขึ้นอย่างมหาศาล ส่วนเอกสารที่ระบบใดระบบหนึ่งให้ความสำคัญ ก็ยังคงได้รับโอกาสในการเป็นตัวเลือกในชุดข้อมูลสุดท้าย
หลังจากทำ fusion แล้ว เราจะนำผู้สมัคร (candidates) ที่ติดอันดับต้นๆ มาผ่าน cross-encoder reranker สิ่งนี้ไม่ได้มาฟรีๆ เพราะมันเพิ่มเวลาในการประมวลผล (compute) ประมาณ 50 มิลลิวินาที แต่มันช่วยเพิ่ม recall ได้ถึง 15% โดย cross-encoder จะประเมิน query ทั้งหมดพร้อมกับแต่ละ chunk ของผู้สมัครไปพร้อมกัน ทำให้ได้คะแนนความเกี่ยวข้อง (relevance score) ที่ละเอียดและแม่นยำกว่าที่ bi-encoder embedding จะทำได้มาก เวลาที่เพิ่มขึ้นมา 50 มิลลิวินาทีนั้นถือว่าคุ้มค่ามาก เพราะมันช่วยป้องกันไม่ให้คุณส่ง context window ที่ไร้คุณภาพไปยัง LLM และต้องเสียเวลาถึงสองวินาทีเพื่อรอคำตอบที่สับสนหรือเกิดอาการ hallucination
แก้ไข Query ก่อนเริ่มการค้นหา
ผู้ใช้ไม่ได้เขียน query เหมือนวิศวกรการค้นหา พวกเขาพิมพ์แค่ "app broken" วางเศษเสี้ยวของ log ที่อ่านยาก หรือถามคำถามที่คลุมเครือและกำกวม หากคุณส่งข้อความดิบ (raw strings) เหล่านั้นไปยัง index โดยตรง คุณก็จะได้รับผลลัพธ์ที่ไร้คุณภาพกลับมา
เราทำการแปลง (transform) ทุก query ก่อนที่มันจะเข้าสู่ retrieval engine
อย่างแรกคือ query expansion ระบบจะสร้างคำค้นหาหลายๆ คำจากคำถามสั้นๆ เพียงคำถามเดียว เช่น เมื่อผู้ใช้ถามว่า "How do I fix the timeout?" ตัว engine จะขยายขอบเขตให้ครอบคลุมทั้ง connection timeouts, read timeouts, gateway timeouts และ retry logic ซึ่งแค่แนวทางนี้อย่างเดียวก็ช่วยเพิ่ม recall ของเราจาก 78% เป็น 96% แล้ว
อย่างที่สองคือ query decomposition คำถามที่ซับซ้อนจะถูกย่อยเป็นคำถามย่อย (sub-questions) ที่เล็กลง เช่น query อย่าง "What's the refund policy for enterprise customers past 90 days and how does it differ from monthly plans?" จะถูกเปลี่ยนเป็นการค้นหาที่เจาะจงสองครั้ง แทนที่จะเป็นการทำ embedding lookup ที่เทอะทะเพียงครั้งเดียว โดยแต่ละคำถามย่อยจะค้นหาใน index แยกกัน แล้วค่อยนำผลลัพธ์มาประกอบกันในขั้นตอนถัดไป (downstream) วิธีนี้ช่วยให้การค้นหา (retrieval) มีความแคบและแม่นยำ ซึ่งช่วยป้องกันการเจือจาง (dilution) ของข้อมูลที่เกิดขึ้นเมื่อ embedding เพียงตัวเดียวพยายามจะจับคู่กับแนวคิดนับสิบอย่างพร้อมกัน
ให้ Bayesian Search ปรับจูน Pipeline ของคุณ
หากคุณยังคงต้องมานั่งปรับจูน chunk size, overlap ratios และ retrieval weights ด้วยตัวเอง แสดงว่าคุณกำลังทิ้งประสิทธิภาพไปอย่างน่าเสียดาย เราเลิกใช้วิธีการเดาสุ่มแล้ว
เรากำหนด search space ที่มีทั้ง chunk size, overlap percentage, น้ำหนักระหว่าง vector-versus-BM25 และ reranker thresholds เป็นตัวแปร จากนั้นเราจึงใช้ Bayesian optimization แทนที่จะทำ grid-searching ผ่านการตั้งค่าแบบสุ่มนับร้อยรูปแบบ Bayesian search จะสร้างโมเดลความน่าจะเป็น (probabilistic model) ของสิ่งที่ใช้งานได้จริง มันจะเสนอการตั้งค่าหนึ่งขึ้นมา สังเกตค่า recall และ latency จากนั้นอัปเดตความเชื่อ (beliefs) ของมัน และเสนอการตั้งค่าถัดไป เมื่อเวลาผ่านไป มันจะเข้าสู่จุดสมดุลที่มนุษย์ไม่มีทางสุ่มเจอได้ด้วยตัวเอง
มันค้นพบการผสมผสานที่เราไม่เคยลอง เช่น chunk ที่เล็กลงแต่มี overlap ที่มากขึ้น หรือการให้น้ำหนักการค้นหาแบบ dense vector น้อยลงเล็กน้อยควบคู่ไปกับการใช้ reranker threshold ที่เข้มงวดขึ้น การแลกเปลี่ยน (tradeoffs) ที่ดูไม่ชัดเจนเหล่านี้กลับให้ทั้ง recall ที่สูงขึ้นและ latency ที่ต่ำลง
นี่ไม่ใช่ภารกิจที่ทำเพียงครั้งเดียวแล้วจบไป เราทำการรัน hyperparameter optimization ใหม่ทุกเดือน เพราะ corpus ของคุณมีการเปลี่ยนแปลง (drift) พฤติกรรมผู้ใช้มีการขยับเขยื้อน Pipeline ของคุณควรจะปรับตัวตาม ไม่ใช่ปล่อยให้มันล้าสมัยอยู่กับที่
ผลลัพธ์ที่ได้รับ
ผลลัพธ์ดิบๆ จากการปรับปรุงครั้งนั้นเป็นสิ่งที่ปฏิเสธไม่ได้เลย
Recall ที่ตำแหน่งที่สิบเพิ่มจาก 78% เป็น 95% เมื่อคำตอบที่ถูกต้องอยู่ใน knowledge base ของเรา เราจะดึงมันขึ้นมาได้ถึง 19 ใน 20 ครั้ง ส่วน latency ที่ percentile ที่ 95 ลดลงจาก 850 มิลลิวินาที เหลือเพียง 320 มิลลิวินาที ทำให้การแชทรู้สึกรวดเร็วทันใจแทนที่จะดูอืดอาด
การค้นหา (retrieval) ที่ดีขึ้นช่วยให้โมเดลภาษา (language model) มี grounding ที่ดีขึ้น อัตราการเกิด hallucination ลดลงจาก 12% เหลือเพียง 3% เมื่อโมเดลมี context ที่ถูกต้องอยู่ตรงหน้า มันก็จะไม่สร้างข้อเท็จจริงขึ้นมาเอง ต้นทุนต่อ query ลดลง 38% การค้นหาที่เร็วขึ้นและแม่นยำขึ้นหมายถึงการสูญเสีย token ไปกับ context ที่ไม่เกี่ยวข้อง, การวนลูปลองใหม่ (retry loops) และ prompt ที่เยิ่นเย้อแต่ไร้ประโยชน์น้อยลง
สร้างมันให้เหมือนกับโครงสร้างพื้นฐาน
หากคุณกำลังเปลี่ยนจากตัวต้นแบบ (prototype) ไปสู่การใช้งานจริง (production) ให้ปฏิบัติกับการทำ retrieval เสมือนเป็นโค้ดโครงสร้างพื้นฐาน (infrastructure code) มากกว่าจะเป็นแค่การตั้งค่า (configuration)
