14/07/2026 03:00น.

Golang The Series EP.160: วิธีสร้าง Enterprise Internal AI Tool (RAG) ด้วย Qdrant และ Gin
#Go
#Golang
#RAG
#Qdrant
#Gin Gonic
#Server-Sent Events
# Design System
#SSE Streaming
#Context Injection
#Enterprise AI Tool
#Superdev Academy
ยินดีต้อนรับชาว Superdev Academy เข้าสู่ EP.160 ซึ่งเป็น Workshop ใหญ่ประจำซีซันนี้ครับ! ตลอดเส้นทางซีรีส์ที่ผ่านมาเราได้ลุยกันตั้งแต่การติดตั้ง Environment, การแปลงข้อความเป็นเวกเตอร์ (Embeddings), การหั่นข้อมูลอย่างมีกลยุทธ์ (Data Chunking), ไปจนถึงเทคนิคสำคัญของระบบ RAG (Retrieval-Augmented Generation) อย่าง Hybrid Search และ Context Injection
วันนี้ได้เวลาที่เราจะนำองค์ความรู้ทั้งหมดมารวมร่างสร้าง Internal AI Tool ประจำองค์กรด้วยภาษา Go (Golang) นั่นคือเซิร์ฟเวอร์ระบบถาม-ตอบอัจฉริยะที่สามารถสืบค้นความรู้จากเอกสารภายใน มีระบบกรองสิทธิ์เข้าถึงข้อมูลตามแผนกด้วย Hybrid Search เพื่อความปลอดภัย และสตรีมคำตอบ (Streaming) ส่งกลับหาผู้ใช้งานแบบ Real-time ผ่านเทคนิค SSE ครับ!
โครงสร้างโปรเจกต์ (Project Structure)
เพื่อให้โค้ดของเรามีความยืดหยุ่น ดูแลรักษาง่าย และแยกหน้าที่การทำงานอย่างชัดเจน เราจะจัดโครงสร้างโปรเจกต์ตามสถาปัตยกรรมแบบแยกเลเยอร์ (Layered Architecture) ดังนี้ครับ:
Plaintext
internal-ai-tool/
├── config/
│ └── qdrant.go # จัดการ Connection ของ Qdrant Vector DB
├── handlers/
│ └── qa.go # HTTP Handlers สำหรับรับ Request และจัดการ SSE Streaming
├── services/
│ ├── ai.go # Wrapper สำหรับเรียกใช้ OpenAI API (Embeddings & Chat Stream)
│ └── vector.go # จัดการฟังก์ชันสืบค้นข้อมูล (Query) แบบ Hybrid Search บน Qdrant
├── go.mod
├── go.sum
└── main.go # Entry point ของโปรเจกต์ และตั้งค่า Dependency Injection
เริ่มต้นด้วยการสร้าง Go Module และติดตั้ง Dependencies หลักของโปรเจกต์:
Bash
go mod init internal-ai-tool
go get github.com/gin-gonic/gin
go get github.com/sashabaranov/go-openai
go get github.com/qdrant/go-client/qdrant
go get github.com/google/uuid
เลเยอร์จัดเตรียมโครงสร้างและการเชื่อมต่อ (Configuration & Services)
ในส่วนนี้เราจะแยกการทำงานออกเป็น 3 ส่วนหลัก ได้แก่ การเชื่อมต่อฐานข้อมูลเวกเตอร์ (Vector Database), บริการจัดการโมเดล AI (OpenAI Service) และบริการสืบค้นข้อมูลเวกเตอร์ (Vector Service) เพื่อให้โค้ดแยกขาดจากกันตามหลักการ Clean Architecture ครับ
1. จัดการ Connection ของฐานข้อมูลเวกเตอร์ (config/qdrant.go)
เริ่มต้นด้วยการเขียนฟังก์ชันเพื่อเปิดการเชื่อมต่อ (Connection) ไปยัง Qdrant Vector Database โดยเราจะเตรียม Client ตัวนี้ไว้เพื่อส่งต่อทำ Dependency Injection ในหน้า main.go ต่อไป
Go
package config
import (
"log"
"github.com/qdrant/go-client/qdrant"
)
// NewQdrantClient ทำหน้าที่สร้างและส่งคืน Client สำหรับเชื่อมต่อกับ Qdrant Vector DB
func NewQdrantClient(host string, port int) *qdrant.Client {
client, err := qdrant.NewClient(&qdrant.Config{
Host: host,
Port: port,
})
if err != nil {
log.Fatalf("❌ ไม่สามารถเชื่อมต่อ Qdrant ได้: %v", err)
}
return client
}
2. บริการเชื่อมต่อ OpenAI API (services/ai.go)
ถัดมาคือเลเยอร์ที่คอยติดต่อกับ OpenAI API โดยทำหน้าที่หลัก 2 อย่างคือ การทำ Text Embedding (แปลงคำถามของ User เป็นเวกเตอร์) และ การทำ Chat Completion Stream เพื่อดึงคำตอบจากโมเดล GPT-4o ออกมาแบบสตรีมมิ่งทีละตัวอักษร
Go
package services
import (
"context"
"github.com/sashabaranov/go-openai"
)
type AIService struct {
client *openai.Client
}
func NewAIService(apiKey string) *AIService {
return &AIService{client: openai.NewClient(apiKey)}
}
// CreateEmbedding แปลงข้อความดิบให้กลายเป็น Vector (ค่านัยยะทางภาษา)
func (s *AIService) CreateEmbedding(ctx context.Context, text string) ([]float32, error) {
req := openai.EmbeddingRequest{
Input: []string{text},
Model: openai.SmallEmbedding3Small, // ใช้โมเดลประสิทธิภาพสูงและประหยัดค่าใช้จ่าย
}
resp, err := s.client.CreateEmbeddings(ctx, req)
if err != nil {
return nil, err
}
return resp.Data[0].Embedding, nil
}
// GetChatStream ส่งข้อความไปยัง OpenAI และเปิดการรับข้อมูลแบบ Stream
func (s *AIService) GetChatStream(ctx context.Context, systemPrompt, userPrompt string) (*openai.ChatCompletionStream, error) {
return s.client.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{
Model: openai.GPT4o,
Messages: []openai.ChatCompletionMessage{
{Role: openai.ChatMessageRoleSystem, Content: systemPrompt},
{Role: openai.ChatMessageRoleUser, Content: userPrompt},
},
})
}
3. บริการสืบค้นข้อมูลเวกเตอร์แบบกำหนดสิทธิ์ (services/vector.go)
ส่วนสำคัญที่สุดของระบบ RAG คือส่วนนี้ครับ เราจะใช้ฟีเจอร์การ Query ของ Qdrant มาทำ Hybrid Search โดยมีจุดเด่นคือการใส่ Filter สิทธิ์การเข้าถึงข้อมูลตามแผนก (Department) และกรองเอาเฉพาะเอกสารที่มีสถานะ active เท่านั้นพนักงานจึงจะสามารถค้นเจอข้อมูลชิ้นนั้นได้
Go
package services
import (
"context"
"github.com/qdrant/go-client/qdrant"
)
type VectorService struct {
client *qdrant.Client
}
func NewVectorService(client *qdrant.Client) *VectorService {
return &VectorService{client: client}
}
// SearchRelevantContext ค้นหาข้อมูลจาก Qdrant โดยมีเงื่อนไขกรองสิทธิ์ตามแผนกของผู้ใช้งาน
func (s *VectorService) SearchRelevantContext(ctx context.Context, vector []float32, department string) ([]string, error) {
searchLimit := uint64(3) // ดึงเอกสารที่มีความคล้ายคลึงที่สุด 3 ชิ้น
// กรองข้อมูล (Security Filter): ดึงเฉพาะเอกสารที่ตรงตามแผนกและมีสถานะ active เท่านั้น
searchFilters := &qdrant.Filter{
Must: []*qdrant.Condition{
qdrant.NewMatchKeyword("department", department),
qdrant.NewMatchKeyword("status", "active"),
},
}
resp, err := s.client.Query(ctx, &qdrant.QueryPoints{
CollectionName: "ai_knowledge_base",
Query: qdrant.NewQuery(vector...),
Filter: searchFilters,
Limit: &searchLimit,
})
if err != nil {
return nil, err
}
var chunks []string
for _, point := range resp {
if contentVal, exists := point.Payload["content"]; exists {
chunks = append(chunks, contentVal.GetStringValue())
}
}
return chunks, nil
}
เลเยอร์ควบคุม API Endpoint และระบบ Server-Sent Events (SSE) Streaming
ในเลเยอร์นี้เราจะเข้าสู่ไฟล์ handlers/qa.go ซึ่งทำหน้าที่รับ Request จาก Frontend นำข้อมูลไปประมวลผลร่วมกับเลเยอร์ Service และส่งคำตอบกลับแบบ Real-time เพื่อให้เห็นภาพสถาปัตยกรรมภายในชัดเจน เราจะแบ่งโค้ดออกเป็น 3 ส่วนตามหน้าที่ของมันครับ
1. โครงสร้างข้อมูลและการรับ Request (Struct & Validation)
เริ่มต้นด้วยการประกาศ Package, นำเข้า Dependencies และสร้างเลเยอร์รับข้อมูล (Data Transfer Object) ในรูปแบบ Struct เพื่อรองรับคำถามรวมถึงสิทธิ์การเข้าถึงข้อมูลตามแผนกครับ
Go
package handlers
import (
"errors"
"fmt"
"io"
"net/http"
"strings"
"internal-ai-tool/services"
"github.com/gin-gonic/gin"
)
// QARequest โครงสร้างข้อมูลสำหรับรับ Payload JSON จากหน้าบ้าน
type QARequest struct {
Question string `json:"question" binding:"required"`
Department string `json:"department" binding:"required"` // ใช้สำหรับทำ Security Filter
}
2. กระบวนการสืบค้นข้อมูลและการทำ Context Injection
ถัดมาเราจะเขียนฟังก์ชัน HandleQAStream โดยส่วนแรกนี้จะทำหน้าที่ผูกข้อมูล (JSON Binding), ส่งคำถามไปทำ Text Embedding และทำการค้นหาข้อมูลอ้างอิงด้วย Hybrid Search ก่อนจะนำเนื้อหาที่ได้มาประกอบร่างเป็น Prompt (Context Injection) เพื่อเตรียมส่งให้ LLM ครับ
Go
// HandleQAStream ควบคุมโฟลว์การทำงานของระบบ RAG และสตรีมคำตอบกลับผ่าน SSE
func HandleQAStream(ai *services.AIService, vector *services.VectorService) gin.HandlerFunc {
return func(c *gin.Context) {
var req QARequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "พารามิเตอร์ไม่ถูกต้อง"})
return
}
ctx := c.Request.Context()
// สเต็ปที่ 1: แปลงคำถามของ User เป็น Vector
queryVector, err := ai.CreateEmbedding(ctx, req.Question)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "เกิดข้อผิดพลาดในการทำ Embedding"})
return
}
// สเต็ปที่ 2: ค้นหาเอกสารในคลังความรู้พร้อมตรวจสอบสิทธิ์แผนก
chunks, err := vector.SearchRelevantContext(ctx, queryVector, req.Department)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "ไม่สามารถสืบค้นคลังข้อมูลความรู้ได้"})
return
}
// สเต็ปที่ 3: ทำ Context Injection เพื่อจำกัดกรอบคำตอบให้ AI
contextText := ""
if len(chunks) > 0 {
contextText = strings.Join(chunks, "\n\n")
} else {
contextText = "ไม่มีข้อมูลอ้างอิงในระบบที่เกี่ยวข้องกับแผนกนี้"
}
systemPrompt := "คุณคือ AI ประจำองค์กร หน้าที่ของคุณคือตอบคำถามพนักงานโดยใช้ข้อมูลอ้างอิง (Context) ที่กำหนดให้เท่านั้น หากไม่มีคำตอบใน Context ให้ตอบตรงๆ ว่าไม่พบข้อมูลในระบบอ้างอิง ห้ามเดา"
userPrompt := fmt.Sprintf("ข้อมูลอ้างอิง (Context):\n%s\n\nคำถาม: %s\nคำตอบ:", contextText, req.Question)
3. เปิดท่อส่งข้อมูลแบบ Server-Sent Events (SSE) Streaming
ส่วนสุดท้ายของฟังก์ชันคือการตั้งค่า HTTP Header ให้รองรับโปรโตคอล text/event-stream และใช้ฟีเจอร์ c.Stream ของ Gin Gonic ในการวนลูปรับข้อมูล Chunk จาก OpenAI API เพื่อดันข้อมูลออกไปหาฝั่งหน้าบ้านทันทีที่มีคำตัวอักษรใหม่ออกมาครับ
Go
// สเต็ปที่ 4: เรียกใช้ Chat Completion Stream และตั้งค่าเซิร์ฟเวอร์ส่งข้อมูลแบบ SSE
stream, err := ai.GetChatStream(ctx, systemPrompt, userPrompt)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "การเปิดท่อสตรีมข้อมูลล้มเหลว"})
return
}
defer stream.Close()
// ตั้งค่า HTTP Header ให้เปิดการเชื่อมต่อแบบข้ามฝั่งค้างไว้ (Keep-Alive)
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
// วนลูปสตรีมคำตอบออกไปทีละอักษรแบบ Real-time
c.Stream(func(w io.Writer) bool {
resp, err := stream.Recv()
if errors.Is(err, io.EOF) {
c.SSEvent("message", "[DONE]") // ส่งสัญญาณแจ้งหน้าบ้านเมื่อคำตอบจบลง
return false
}
if err != nil {
return false
}
if len(resp.Choices) > 0 {
content := resp.Choices[0].Delta.Content
if content != "" {
c.SSEvent("message", content)
}
}
return true
})
}
}
ประกอบร่างแอปพลิเคชันและทำ Dependency Injection (main.go)
มาถึงขั้นตอนสุดท้ายในการประกอบชิ้นส่วนทั้งหมดเข้าด้วยกันในไฟล์ main.go ครับ เลเยอร์นี้เปรียบเสมือนศูนย์บัญชาการหลักที่จะทำหน้าที่โหลดค่าคอนฟิกูเรชัน, เปิดการเชื่อมต่อฐานข้อมูลเวกเตอร์, ทำ Dependency Injection (DI) เพื่อมัดรวม Service และ Handler เข้าด้วยกัน ก่อนจะเปิดสตรีมมิ่งเซิร์ฟเวอร์ด้วย Gin Gonic ครับ
เพื่อให้บทความอ่านง่ายและเป็นระเบียบ เรามาเจาะลึกส่วนประกอบภายใน main.go กันครับ
1. การเตรียมระบบและการทำ Dependency Injection
ในส่วนแรกของฟังก์ชัน main จะเน้นไปที่การเตรียมระบบเพื่อความปลอดภัยและความยืดหยุ่นในการขยายสเกลระบบในอนาคต
Go
package main
import (
"log"
"os"
"internal-ai-tool/config"
"internal-ai-tool/handlers"
"internal-ai-tool/services"
"github.com/gin-gonic/gin"
)
func main() {
// 1. โหลด API Key จาก Environment Variables เพื่อความปลอดภัยตามมาตรฐาน Enterprise
openAIKey := os.Getenv("OPENAI_API_KEY")
if openAIKey == "" {
log.Fatal("ERROR: กรุณาตั้งค่า Environment Variable สำหรับ OPENAI_API_KEY")
}
// 2. เริ่มต้นเปิดการเชื่อมต่อกับ Qdrant Vector DB
qdrantClient := config.NewQdrantClient("localhost", 6334)
defer qdrantClient.Close() // ตรวจสอบให้มั่นใจว่าปิด Connection เมื่อเซิร์ฟเวอร์หยุดทำงาน
// 3. ทำ Dependency Injection (DI) ส่งมอบออบเจกต์ไปใช้งานในเลเยอร์ที่เกี่ยวข้อง
aiService := services.NewAIService(openAIKey)
vectorService := services.NewVectorService(qdrantClient)
2. การจัดทำ Routing และการเปิดเซิร์ฟเวอร์ (Routing & Initialize)
ส่วนสุดท้ายคือการเปิดจุดรับส่งข้อมูล (Endpoint) เพื่อเชื่อมต่อกับระบบหน้าบ้าน (Frontend)
Go
// 4. สร้างเซิร์ฟเวอร์ Routing ด้วย Gin Gonic
r := gin.Default()
// ลงทะเบียน Endpoint สำหรับระบุสิทธิ์แผนกและสตรีมคำตอบผ่าน POST Method
r.POST("/api/v1/qa/stream", handlers.HandleQAStream(aiService, vectorService))
// สตาร์ทระบบพร้อมทำงานบนพอร์ตที่กำหนด
log.Println("🚀 เซิร์ฟเวอร์ Internal AI Tool เริ่มทำงานบนพอร์ต :8080...")
r.Run(":8080")
}
นี่คือเนื้อหาส่วนสุดท้ายของบทความ (Daily Mission, FAQ, สรุป และบทนำของ EP ถัดไป) ที่ปรับปรุงเรื่องคำผิด (แก้จาก ยิงไอพี -> ยิง API) ลบสำนวน AI ออกทั้งหมด และเติมเนื้อหาในส่วน FAQ กับสรุป เพื่อให้บทความมีความสมบูรณ์และได้คะแนน SEO สูงสุดครับ:
🎯 ท้าให้ลอง (Daily Mission)
ยินดีด้วยครับ! ตอนนี้คุณมีสถาปัตยกรรมระบบถาม-ตอบจากเอกสาร (RAG System) ที่สะอาด ถูกต้องตามหลักการเขียนโปรแกรม และพร้อมให้ทีม Frontend นำไปเชื่อมต่อกับหน้าจอ UI เพื่อใช้งานจริงในองค์กรแล้วครับ
การบ้านใหญ่ส่งท้าย Workshop: ให้คุณทดลองสั่งรันเซิร์ฟเวอร์ตัวนี้ขึ้นมา แล้วลองใช้คำสั่ง curl หรือส่ง Request ผ่านโปรแกรม Postman โดยลองแกล้งเปลี่ยนข้อมูลในฟิลด์ "department" ใน JSON Body สลับไปมาระหว่างแผนกต่าง ๆ เช่น สลับจาก "Human Resources" ไปเป็น "Engineering" โดยใช้คำถามเดิม
ลองสังเกตดูครับว่า Security Filter หลังบ้านทำงานได้ตรงตามเป้าหมายไหม ระบบยอมเปิดเผยข้อมูลหรือเลือกหยิบคำตอบออกมาได้แตกต่างกันตามสิทธิ์ที่เราล็อกไว้ในคำสั่งสืบค้นข้อมูลเวกเตอร์บน Qdrant หรือไม่? ลองไปพิสูจน์ฝีมือการเขียนระบบ Backend นี้กันดูนะครับ!
FAQ (คำถามที่พบบ่อย)
ทำไมระบบถาม-ตอบ AI แบบ Streaming ถึงนิยมใช้ Server-Sent Events (SSE) มากกว่า WebSockets?
เพราะระบบนี้เป็นการส่งข้อมูลแบบทิศทางเดียว (One-way Streaming) จากเซิร์ฟเวอร์กลับไปหาคลื่นต์เท่านั้น SSE ทำงานบนโปรโตคอล HTTP มาตรฐาน จึงประหยัดทรัพยากรเซิร์ฟเวอร์และติดตั้งระบบง่ายกว่า WebSockets ที่ออกแบบมาสำหรับการสื่อสารแบบสองทาง (Bi-directional) ครับ
หากคลังข้อมูลบน Qdrant มีขนาดใหญ่ขึ้นมาก จะส่งผลให้ระบบค้นหาช้าลงไหม?
หากไม่ได้ทำ Index อาจช้าลงครับ แนะนำให้ทำ Payload Indexing บนฟิลด์ที่ใช้เป็นเงื่อนไขในการกรอง เช่น ฟิลด์ department และ status เพื่อช่วยให้ Qdrant ค้นหาข้อมูลเวกเตอร์ร่วมกับ Filter ได้อย่างรวดเร็วในระดับ Millisecond
สรุปเนื้อหาในตอนนี้
ในบทเรียนนี้เราได้เรียนรู้และลงมือทำในหัวข้อสำคัญประกอบด้วย:
การออกแบบสถาปัตยกรรมระบบ RAG (Retrieval-Augmented Generation) แยกเลเยอร์ด้วยภาษา Go (Golang)
การจัดการ Connection และการค้นหาข้อมูลเวกเตอร์พร้อมระบุเงื่อนไขกรองสิทธิ์ด้วย Qdrant Vector DB
เทคนิคการทำ Context Injection เพื่อควบคุมกรอบและบริบทการตอบคำถามของโมเดล OpenAI GPT-4o
การสร้าง HTTP API Endpoint และสตรีมคำตอบแบบ Real-time ด้วย Server-Sent Events (SSE) บน Gin Gonic
ในตอนต่อไป (EP.161): เราปิดกล่อง Workshop ระบบ RAG พื้นฐานได้อย่างสวยงามแล้วครับ แต่ในหน้างานจริงระดับ Enterprise ถ้าเกิดวันดีคืนดีพนักงานพร้อมใจกันอัปโหลดไฟล์คู่มือและเอกสารจัดซื้อนับร้อยไฟล์เข้ามาในระบบพร้อมกันล่ะ? ระบบสกัดข้อมูลที่ต้องรอยิง API ทีละคำสั่ง ย่อมเสี่ยงต่ออาการเซิร์ฟเวอร์ Timeout และระบบล่มแน่นอน
เราจะขึ้นบทเรียนสถาปัตยกรรมขั้นสูงในชื่อตอน "Async AI Tasks การประยุกต์ใช้ Worker Pools จัดการคิวงานสกัด AI ขนาดใหญ่หลังบ้าน" บอกเลยว่าสาย Backend ที่ต้องการรีดประสิทธิภาพระบบสเกลสูงห้ามพลาดเด็ดขาดครับ Gophers ของ Superdev Academy ทุกคน!
ฝากกดติดตามพวกเราได้ที่ Superdev Academy ในทุกช่องทางนะครับ!
🔵 Facebook: Superdev Academy Thailand (อัปเดตข่าวสารและบทความใหม่)
🎬 YouTube: Superdev Academy Channel (ติวเข้มแบบวิดีโอ)
📸 Instagram: @superdevacademy (เกร็ดความรู้สั้นๆ และเบื้องหลังการทำงาน)
🎬 TikTok: @superdevacademy (Tips & Tricks ฉบับย่อยง่าย)
🌐 Website: superdevacademy.com (คลังบทความและคอร์สเรียนฉบับเต็ม)