06/07/2026 04:39น.

Golang The Series EP.157: สร้าง Document Ingestion Pipeline สกัด PDF/Word ทำ RAG
#Golang
#Go
#Document Ingestion Pipeline
#RAG
#Backend
ยินดีต้อนรับเข้าสู่ EP.157 ครับ! จากตอนที่แล้วที่เราได้สัมผัสพลังของ Semantic Search ในการค้นหาข้อมูลตามความหมายกันไปแล้ว แต่ในชีวิตจริง คลังความรู้ขององค์กรคงไม่ได้มาเป็น Text สั้นๆ ที่พร้อมใช้งานเสมอไป แต่มักจะซ่อนอยู่ในไฟล์เอกสารอย่าง PDF หรือ Microsoft Word (.docx) ที่มีความยาวหลายสิบหน้า
ดังนั้น ภารกิจของชาว Gopher สาย Backend ในวันนี้ คือการสร้าง Document Ingestion Pipeline หรือระบบท่อส่งข้อมูลอัตโนมัติ เพื่อจัดการกระบวนการทั้งหมดตั้งแต่ "อ่านไฟล์เอกสาร ➡️ สกัดข้อความดิบ ➡️ ตัดแบ่งเป็น Chunk ➡️ แปลงเป็น Vector ➡️ และบันทึกลงฐานข้อมูล Qdrant" ให้ทำงานร่วมกันอย่างไร้รอยต่อ เตรียม Editor ให้พร้อม แล้วมาลุยกันเลยครับ!
สถาปัตยกรรมของ Document Ingestion Pipeline
เพื่อความยืดหยุ่นในการรองรับไฟล์หลากหลายประเภท เราจะออกแบบระบบให้แยกออกเป็น 4 ส่วนหลักๆ (Modular Architecture) เพื่อให้ง่ายต่อการบำรุงรักษาและการขยายระบบในอนาคต:
Plaintext
[ไฟล์ PDF / Word] ──> 1. Text Extractor (สกัดเฉพาะข้อความดิบออกมา)
│
▼
2. Chunking Engine (หั่นเป็นชิ้นย่อย พร้อมกำหนดระยะคาบเกี่ยว หรือ Overlap)
│
▼
3. Embedding Client (ส่ง API ไปแปลงความหมายเป็น []float32)
│
▼
4. Vector DB Client (บันทึกพิกัด Vector และ Metadata ลง Qdrant)
การเลือกใช้ Library ในภาษา Go สำหรับอ่าน PDF และ Word
สำหรับไฟล์ PDF: แนะนำไลบรารีอย่าง github.com/dslipak/pdf ซึ่งทำงานได้เร็ว น้ำหนักเบา และสามารถสกัดข้อความภาษาไทย (รวมถึงสระและวรรณยุกต์ลอย) ออกมาได้ค่อนข้างดีเมื่อเทียบกับตัวอื่น (ข้อควรระวัง: ความแม่นยำอาจขึ้นอยู่กับฟอนต์และวิธีการ Export ไฟล์ PDF นั้นๆ ด้วย)
สำหรับไฟล์ Word (.docx): แนะนำ github.com/nguyenthenguyen/docx ซึ่งใช้อ่านโครงสร้าง XML ของไฟล์ Word ได้อย่างรวดเร็ว โดยไม่ต้องพึ่งพาซอฟต์แวร์ภายนอกหรือ Microsoft Office เลย
ตัวอย่างการสร้าง Ingestion Pipeline ด้วย Go
มาดูตัวอย่างการเขียน Pipeline สำหรับสกัดข้อมูลจากไฟล์ PDF เพื่อนำเข้าสู่กระบวนการเตรียมทำระบบ RAG (Retrieval-Augmented Generation) กันครับ
Go
package main
import (
"bytes"
"context"
"fmt"
"log"
"os"
"github.com/dslipak/pdf"
"github.com/google/uuid"
"github.com/qdrant/go-client/qdrant"
"github.com/sashabaranov/go-openai"
)
func main() {
ctx := context.Background()
pdfPath := "company_policy.pdf"
// 1. Text Extraction: สกัดข้อความจาก PDF
fmt.Println("⏳ กำลังสกัดข้อความจากไฟล์ PDF...")
text, err := extractTextFromPDF(pdfPath)
if err != nil {
log.Fatalf("Failed to extract text: %v", err)
}
// 2. Chunking: หั่นข้อความ (ใช้ฟังก์ชันตัดแบ่งที่เราเคยเขียนไว้ใน EP.155)
// หั่นชิ้นละ 500 ตัวอักษร และตั้ง Overlap ไว้ที่ 100 ตัวอักษร
chunks := ChunkText(text, 500, 100)
fmt.Printf("🧩 หั่นข้อความออกมาได้ทั้งหมด %d Chunks\n", len(chunks))
// เริ่มต้น Client สำหรับยิง API (ดึงค่าจาก Environment Variables เพื่อความปลอดภัย)
apiKey := os.Getenv("OPENAI_API_KEY")
if apiKey == "" {
log.Fatal("Please set your OPENAI_API_KEY environment variable")
}
openaiClient := openai.NewClient(apiKey)
// เชื่อมต่อฐานข้อมูล Qdrant
qdrantClient, err := qdrant.NewClient(&qdrant.Config{
Host: "localhost",
Port: 6334, // ใช้พอร์ต 6334 สำหรับการสื่อสารผ่าน gRPC ของ Go Client
})
if err != nil {
log.Fatalf("Qdrant connection failed: %v", err)
}
defer qdrantClient.Close()
// 3 & 4. Loop ทำ Embedding และ Upsert ลง Qdrant
fmt.Println("🚀 กำลังแปลงเป็นเวกเตอร์ และบันทึกลงฐานข้อมูล Qdrant...")
for i, chunk := range chunks {
// แปลงข้อความของ Chunk นี้ให้เป็น Vector
embReq := openai.EmbeddingRequest{
Input: []string{chunk},
Model: openai.SmallEmbedding3Small, // ใช้โมเดลมาตรฐาน 1,536 มิติ
}
embResp, err := openaiClient.CreateEmbeddings(ctx, embReq)
if err != nil {
log.Printf("Error embedding chunk %d: %v", i, err)
continue
}
vector := embResp.Data[0].Embedding
// เตรียมสร้าง Point สำหรับบันทึกลง Qdrant พร้อมกำหนด Metadata Payload
pointID := uuid.New().String()
payload := map[string]interface{}{
"content": chunk,
"source": pdfPath,
"chunk_idx": i,
}
// สั่ง Upsert ข้อมูลเข้า Collection
_, err = qdrantClient.Upsert(ctx, &qdrant.UpsertPoints{
CollectionName: "ai_knowledge_base",
Points: []*qdrant.PointStruct{
{
Id: qdrant.NewIDUUID(pointID),
Vectors: qdrant.NewVectorsDense(vector),
Payload: qdrant.NewValueMap(payload),
},
},
})
if err != nil {
log.Printf("Error upserting chunk %d to Qdrant: %v", i, err)
}
}
fmt.Println("✅ สิ้นสุดกระบวนการ! คลังข้อมูล AI จาก PDF พร้อมใช้งานแล้ว")
}
// ฟังก์ชันช่วยอ่านข้อความดิบจากไฟล์ PDF
func extractTextFromPDF(path string) (string, error) {
r, err := pdf.Open(path)
if err != nil {
return "", err
}
var buf bytes.Buffer
b, err := r.GetPlainText()
if err != nil {
return "", err
}
_, err = buf.ReadFrom(b)
if err != nil {
return "", err
}
return buf.String(), nil
}
// ChunkText (อ้างอิงจาก EP.155) - หั่นข้อความโดยอิงตามรูน (Rune) เพื่อรองรับภาษาไทย
func ChunkText(text string, chunkSize int, overlap int) []string {
runes := []rune(text)
var chunks []string
if len(runes) == 0 {
return chunks
}
for i := 0; i < len(runes); {
end := i + chunkSize
if end > len(runes) {
end = len(runes)
}
chunks = append(chunks, string(runes[i:end]))
if end == len(runes) {
break
}
i += (chunkSize - overlap)
}
return chunks
}
พลัง Concurrency ของ Go กับการสเกล Ingestion Pipeline
เมื่อระบบของคุณขยายตัวไปจนถึงจุดที่มีการอัปโหลดไฟล์พร้อมกันจำนวนมาก (เช่น เอกสารคู่มือองค์กรหรือไฟล์บัญชีนับพันไฟล์) โค้ดแบบวนลูป for ธรรมดาจะกลายเป็นคอขวดทันที เพราะต้องรอให้ API ของ OpenAI และ Qdrant ตอบกลับทีละตัวเรียงกันไป
นี่คือจุดที่ภาษา Go เฉิดฉายที่สุดครับ! เราสามารถปรับปรุงโค้ดด้านบนให้ทำงานแบบ Worker Pool Pattern โดยใช้ Goroutines และ Channels เพื่อสั่งให้เครื่องสกัด Text, ยิงทำ Embedding และ Upsert ลงฐานข้อมูลขนานกัน (Parallel) ได้หลายๆ ไฟล์หรือหลายๆ Chunk พร้อมกัน ช่วยประหยัดเวลาการทำ Data Pipeline จากระดับนาทีให้เหลือเพียงไม่กี่วินาทีเท่านั้น!
🎯 ท้าให้ลอง (Daily Mission)
ลองสร้างไฟล์ PDF ขึ้นมาหนึ่งไฟล์ (อาจจะเป็นข้อความกฎระเบียบบริษัทหรือคู่มือการทำงานสั้นๆ) จากนั้นนำมาวางในโปรเจกต์และรันโค้ดตัวอย่างด้านบนดูครับ
การบ้านชวนอัปเกรดโค้ด: ลองแก้ไขโค้ดด้านบนโดยนำกลไก Goroutine มาแยกส่วนงานแปลง Embedding ให้ทำงานแบบ Concurrency (เช่น ใช้ sync.WaitGroup ช่วยควบคุม) เพื่อให้ระบบทำงานได้เร็วขึ้น และเมื่อรันเสร็จแล้ว อย่าลืมเปิดเบราว์เซอร์ไปที่ Qdrant Web UI (ผ่านพอร์ต HTTP: http://localhost:6333/dashboard) เพื่อตรวจสอบดูว่า Points และ Payload จากไฟล์ PDF ของเราไหลเข้าไปจัดเก็บได้อย่างถูกต้องหรือไม่!
FAQ (คำถามที่พบบ่อยประจำตอน)
ถ้าไฟล์ PDF มีรูปภาพหรือตารางอยู่ข้างใน ไลบรารีที่สกัด Text ธรรมดาจะอ่านรู้เรื่องไหม?
ตัวสกัด Text ทั่วไปจะมองเห็นเฉพาะตัวอักษรดิบครับ หากเจอโครงสร้างตาราง ข้อมูลอาจจะเบี้ยวหรือสลับแถวได้ และจะไม่เห็นข้อมูลในรูปภาพเลย หากระบบบน Production จำเป็นต้องอ่านรูปภาพหรือตารางที่ซับซ้อนอย่างแม่นยำ แนะนำให้เปลี่ยนไปใช้บริการกลุ่ม Document Intelligence API (เช่น Azure Document Intelligence หรือ Google Document AI) หรือใช้โมเดลประเภท Vision ช่วยอ่านแทนครับ
ทำไมเราต้องเก็บคีย์ "source" และ "chunk_idx" ไว้ใน Payload ของ Qdrant ด้วย?
สำคัญมากครับ! เพราะเวลาที่ AI ดึงคำตอบมาแสดงผลให้ผู้ใช้ (หรือส่งให้ LLM สรุป) เรามักจะต้องการทำ Citation (การอ้างอิงแหล่งที่มา) เพื่อบอกผู้ใช้ว่าข้อมูลนี้มาจากหน้าไหน เล่มใด การเก็บข้อมูล source และลำดับ idx ไว้ จะช่วยให้เราย้อนกลับไปตรวจสอบ หรือแสดงลิงก์เอกสารต้นฉบับให้ผู้ใช้เช็กความถูกต้องได้ง่ายขึ้นครับ
สรุปและเตรียมพบกับ EP.158
ยอดเยี่ยมมากครับ! ตอนนี้เราสามารถสกัดความรู้จากเอกสารในโลกภายนอก เข้ามาเก็บเป็น "สมองจำ" ในเครื่องได้แบบอัตโนมัติแล้ว
สเต็ปต่อไปคือการปิดลูปกระบวนการ RAG ที่สมบูรณ์แบบ นั่นคือการทำ "Context Injection: การนำข้อมูลที่ค้นหาได้มาสร้างเป็นบริบทให้ AI" เราจะเอาชิ้นส่วนความรู้ที่สกัดและค้นเจอจาก Vector DB ไปป้อนใส่ปาก LLM อย่างไร เพื่อบังคับให้มันตอบคำถามลูกค้าได้อย่างแม่นยำและไม่มั่ว (Hallucination)?
เตรียมตัวให้พร้อม แล้วเจอกันตอนหน้าครับ Gophers!
ฝากกดติดตามพวกเราได้ที่ Superdev Academy ในทุกช่องทางนะครับ!
🔵 Facebook: Superdev Academy Thailand (อัปเดตข่าวสารและบทความใหม่)
🎬 YouTube: Superdev Academy Channel (ติวเข้มแบบวิดีโอ)
📸 Instagram: @superdevacademy (เกร็ดความรู้สั้นๆ และเบื้องหลังการทำงาน)
🎬 TikTok: @superdevacademy (Tips & Tricks ฉบับย่อยง่าย)
🌐 Website: superdevacademy.com (คลังบทความและคอร์สเรียนฉบับเต็ม)