Chatbot ที่ตอบคำถามได้แค่จากสิ่งที่ model เรียนรู้มา มักจะ ตอบไม่ตรง กับข้อมูลของเรา — ไม่รู้จักสินค้า, ไม่รู้ policy บริษัท, ไม่รู้ FAQ ที่เราเตรียมไว้ วิธีแก้คือ RAG (Retrieval-Augmented Generation) — ดึงข้อมูลที่เกี่ยวข้องจาก knowledge base มาให้ LLM อ่านก่อนตอบ
บทความนี้จะพาไปดูว่าผมสร้าง RAG pipeline ด้วย Go ตั้งแต่เตรียมข้อมูล, สร้าง embedding, เก็บ vector ใน PostgreSQL (pgvector), ค้นหาด้วย cosine similarity, จนถึงส่ง context เข้า LLM — ทั้งหมดบน hexagonal architecture
RAG แบ่งเป็น 3 ขั้นตอน:
User Query↓[Embed Query] → vector↓[Vector Search] → ค้นหา chunks ที่ใกล้เคียง↓[Build Prompt] → context + system prompt + query↓[LLM] → คำตอบที่อิงจากข้อมูลจริง
ใช้ Hexagonal Architecture (Ports & Adapters) แบ่งชัดเจน:
┌─────────────────────────────────────────┐│ Primary Adapter ││ (HTTP Handler / API) │└──────────────┬──────────────────────────┘↓┌─────────────────────────────────────────┐│ Core Service ││ RAGService (Business Logic) ││ - chunkText() ││ - CreateDocument() ││ - GetContext() │└──────┬──────────────────┬───────────────┘↓ ↓┌──────────────┐ ┌───────────────────┐│ Secondary │ │ Secondary ││ Adapter │ │ Adapter ││ (Postgres │ │ (Embedding ││ pgvector) │ │ Service) │└──────────────┘ └───────────────────┘
เอกสารยาวๆ ต้องแบ่งเป็น chunks ก่อนสร้าง embedding เพราะ:
const (ChunkSize = 500 // จำนวน rune ต่อ chunkChunkOverlap = 50 // overlap ระหว่าง chunk)// chunkText แบ่งข้อความเป็น chunks ด้วย sliding window// ใช้ []rune เพื่อรองรับภาษาไทยและ Unicodefunc chunkText(text string) []string {if len(text) <= ChunkSize {return []string{text}}var chunks []stringtextRunes := []rune(text) // สำคัญ: ใช้ rune ไม่ใช่ bytefor i := 0; i < len(textRunes); i += ChunkSize - ChunkOverlap {end := i + ChunkSizeif end > len(textRunes) {end = len(textRunes)}chunk := string(textRunes[i:end])chunk = strings.TrimSpace(chunk)if chunk != "" {chunks = append(chunks, chunk)}}return chunks}
ทำไมใช้ []rune? — ภาษาไทยตัวอักษรหนึ่งตัวอาจยาวหลาย byte ถ้าใช้ len(text) (นับ byte) จะตัดกลางตัวอักษรได้ []rune นับเป็น “ตัวอักษร” จริงๆ
Overlap — chunk ที่ 1 จบที่ตำแหน่ง 500, chunk ที่ 2 เริ่มที่ 450 ทำให้ข้อมูลตรงรอยต่อไม่หายไป
Embedding คือการแปลงข้อความเป็น vector ตัวเลข ที่แทนความหมาย — ข้อความที่ความหมายใกล้กัน vector จะอยู่ใกล้กันในมิติสูง
รองรับ 2 providers: Gemini (default) และ OpenAI-compatible (รวม Groq, OpenRouter)
const DefaultEmbeddingDimension = 3072type EmbeddingService struct {provider string // "gemini" หรือ "openai"apiKey stringmodel stringbaseURL stringclient *http.Client}func NewEmbeddingService() *EmbeddingService {provider := os.Getenv("EMBEDDING_PROVIDER") // default: "gemini"apiKey := os.Getenv("EMBEDDING_API_KEY")model := os.Getenv("EMBEDDING_MODEL")baseURL := os.Getenv("EMBEDDING_URL")if provider == "gemini" && model == "" {model = "gemini-embedding-001"}if provider == "openai" && model == "" {model = "text-embedding-3-small"}return &EmbeddingService{provider: provider,apiKey: apiKey,model: model,baseURL: strings.TrimRight(baseURL, "/"),client: &http.Client{Timeout: 30 * time.Second},}}// Embed แปลงข้อความเป็น vectorfunc (s *EmbeddingService) Embed(ctx context.Context, text string) ([]float32, error) {switch s.provider {case "gemini":return s.embedGemini(ctx, text)default:return s.embedOpenAI(ctx, text)}}
func (s *EmbeddingService) embedGemini(ctx context.Context, text string) ([]float32, error) {apiURL := fmt.Sprintf("https://generativelanguage.googleapis.com/v1beta/models/%s:embedContent?key=%s",s.model, s.apiKey,)reqBody := map[string]interface{}{"model": "models/" + s.model,"content": map[string]interface{}{"parts": []map[string]string{{"text": text}},},}jsonBody, _ := json.Marshal(reqBody)req, _ := http.NewRequestWithContext(ctx, "POST", apiURL, strings.NewReader(string(jsonBody)))req.Header.Set("Content-Type", "application/json")resp, err := s.client.Do(req)if err != nil {return nil, err}defer resp.Body.Close()var result struct {Embedding struct {Values []float32 `json:"values"`} `json:"embedding"`}json.NewDecoder(resp.Body).Decode(&result)return result.Embedding.Values, nil}
func (s *EmbeddingService) embedOpenAI(ctx context.Context, text string) ([]float32, error) {apiURL := s.baseURL + "/embeddings"if s.baseURL == "" {apiURL = "https://api.openai.com/v1/embeddings"}reqBody := map[string]string{"model": s.model, "input": text}jsonBody, _ := json.Marshal(reqBody)req, _ := http.NewRequestWithContext(ctx, "POST", apiURL, strings.NewReader(string(jsonBody)))req.Header.Set("Content-Type", "application/json")req.Header.Set("Authorization", "Bearer "+s.apiKey)resp, err := s.client.Do(req)if err != nil {return nil, err}defer resp.Body.Close()var result struct {Data []struct {Embedding []float32 `json:"embedding"`} `json:"data"`}json.NewDecoder(resp.Body).Decode(&result)return result.Data[0].Embedding, nil}
# Gemini (default)EMBEDDING_PROVIDER=geminiEMBEDDING_API_KEY=your-gemini-api-keyEMBEDDING_MODEL=gemini-embedding-001 # 3072 dimensions# หรือ OpenAIEMBEDDING_PROVIDER=openaiEMBEDDING_API_KEY=your-openai-keyEMBEDDING_MODEL=text-embedding-3-small # 1536 dimensions# หรือ Groq / OpenRouter (OpenAI-compatible endpoint)EMBEDDING_PROVIDER=openaiEMBEDDING_URL=https://api.groq.com/openai/v1
ใช้ pgvector extension ของ PostgreSQL — ไม่ต้องตั้ง vector database แยก ใช้ Postgres ตัวเดียวที่มีอยู่แล้วได้เลย
-- เปิด pgvector extensionCREATE EXTENSION IF NOT EXISTS vector;-- ตาราง documents (เก็บเอกสารต้นฉบับ)CREATE TABLE rag_documents (id UUID PRIMARY KEY DEFAULT gen_random_uuid(),channel_id UUID NOT NULL,name VARCHAR(255) NOT NULL,content TEXT NOT NULL,chunk_count INTEGER DEFAULT 0,created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW());-- ตาราง chunks (เก็บ chunk + embedding vector)CREATE TABLE rag_chunks (id UUID PRIMARY KEY DEFAULT gen_random_uuid(),document_id UUID NOT NULL REFERENCES rag_documents(id) ON DELETE CASCADE,chunk_text TEXT NOT NULL,chunk_index INTEGER NOT NULL,embedding vector(3072), -- Gemini: 3072 dims, OpenAI: 1536 dimscreated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW());-- IVFFlat index สำหรับ cosine similarity searchCREATE INDEX idx_rag_chunks_embeddingON rag_chunks USING ivfflat (embedding vector_cosine_ops)WITH (lists = 10);
vector(3072) — column type จาก pgvector เก็บ float array ขนาด 3,072 มิติ
IVFFlat index — แบ่ง vector ออกเป็น 10 กลุ่ม (lists) เพื่อให้ค้นหาเร็วขึ้น ไม่ต้อง scan ทุก row
// vectorToString แปลง []float32 เป็น format ที่ pgvector รับ "[0.1,0.2,...]"func vectorToString(v []float32) string {parts := make([]string, len(v))for i, val := range v {parts[i] = fmt.Sprintf("%f", val)}return "[" + strings.Join(parts, ",") + "]"}// CreateChunk บันทึก chunk พร้อม embedding vectorfunc CreateChunk(db *sql.DB, documentID, text string, index int, embedding []float32) error {var embeddingStr *stringif len(embedding) > 0 {s := vectorToString(embedding)embeddingStr = &s}_, err := db.Exec(`INSERT INTO rag_chunks (id, document_id, chunk_text, chunk_index, embedding, created_at)VALUES (gen_random_uuid(), $1, $2, $3, $4, NOW())`, documentID, text, index, embeddingStr)return err}
ขั้นตอนเมื่อผู้ใช้อัปโหลดเอกสาร:
[Upload Document]↓[1. Save Document] → rag_documents↓[2. Chunk Text] → แบ่งเป็น chunks 500 runes↓[3. Embed Each Chunk] → เรียก Gemini/OpenAI API↓[4. Store Chunks + Vectors] → rag_chunks + embedding column
type CreateDocumentInput struct {Name string `json:"name"`Content string `json:"content"`ChannelID string `json:"channel_id"`}func (s *RAGService) CreateDocument(input CreateDocumentInput) error {// 1. แบ่ง chunkschunks := chunkText(input.Content)// 2. สร้าง document recorddoc := &RAGDocument{ChannelID: input.ChannelID,Name: input.Name,Content: input.Content,ChunkCount: len(chunks),}if err := s.repo.CreateDocument(doc); err != nil {return err}// 3. สร้าง embedding + บันทึกทีละ chunkctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)defer cancel()for i, chunk := range chunks {var embedding []float32if s.embeddingService.IsAvailable() {emb, err := s.embeddingService.Embed(ctx, chunk)if err != nil {log.Printf("[RAG] Warning: embed chunk %d failed: %v", i, err)// ไม่ return error — ยังเก็บ chunk ไว้ได้ ค้นหาด้วย keyword แทน} else {embedding = emb}}if err := s.repo.CreateChunk(doc.ID, chunk, i, embedding); err != nil {return err}}return nil}
สังเกต: ถ้า embedding ล้มเหลว ระบบยังบันทึก chunk ไว้ — สามารถค้นหาด้วย keyword ได้ เป็น graceful degradation
เมื่อผู้ใช้ถามคำถาม ระบบจะ:
// SearchChunksByVector ค้นหา chunks ด้วย cosine similarity ผ่าน pgvectorfunc SearchChunksByVector(db *sql.DB, channelID string, queryEmbedding []float32, limit int) ([]string, error) {embeddingStr := vectorToString(queryEmbedding)rows, err := db.Query(`SELECT c.chunk_text FROM rag_chunks cJOIN rag_documents d ON c.document_id = d.idWHERE d.channel_id = $1AND c.embedding IS NOT NULLORDER BY c.embedding <=> $2::vectorLIMIT $3`, channelID, embeddingStr, limit)if err != nil {return nil, err}defer rows.Close()var chunks []stringfor rows.Next() {var text stringrows.Scan(&text)chunks = append(chunks, text)}return chunks, nil}
<=> — operator ของ pgvector สำหรับ cosine distance ยิ่งค่าน้อยยิ่งใกล้ (ORDER BY ... <=> = เรียงจากใกล้สุด)
// SearchChunks ค้นหาด้วย keyword (fallback เมื่อไม่มี embedding)func SearchChunks(db *sql.DB, channelID, query string, limit int) ([]string, error) {rows, err := db.Query(`SELECT c.chunk_text FROM rag_chunks cJOIN rag_documents d ON c.document_id = d.idWHERE d.channel_id = $1AND c.chunk_text ILIKE '%' || $2 || '%'LIMIT $3`, channelID, query, limit)if err != nil {return nil, err}defer rows.Close()var chunks []stringfor rows.Next() {var text stringrows.Scan(&text)chunks = append(chunks, text)}return chunks, nil}
ขั้นตอนสุดท้าย — เอา chunks ที่ค้นเจอมาสร้างเป็น context แล้วส่งเข้า LLM พร้อมคำถามผู้ใช้
// GetContext ดึง context จาก knowledge base// ลอง vector search ก่อน ถ้าไม่ได้ fallback เป็น keywordfunc (s *RAGService) GetContext(channelID, query string, limit int) (string, error) {if limit <= 0 {limit = 3}var chunks []string// 1. ลอง vector search ก่อนif s.embeddingService.IsAvailable() {ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)defer cancel()queryEmbedding, err := s.embeddingService.Embed(ctx, query)if err == nil {chunks, _ = SearchChunksByVector(s.db, channelID, queryEmbedding, limit)}}// 2. Fallback เป็น keyword searchif len(chunks) == 0 {chunks, _ = SearchChunks(s.db, channelID, query, limit)}if len(chunks) == 0 {return "", nil}// 3. รวม chunks เป็น context stringresult := "Context from knowledge base:\n"for i, chunk := range chunks {result += fmt.Sprintf("%d. %s\n", i+1, chunk)}return result, nil}
// สร้าง prompt สุดท้ายสำหรับ LLMfunc buildPrompt(systemPrompt, ragContext, userMessage string) string {prompt := systemPrompt// ใส่ context จาก RAG (ถ้ามี)if ragContext != "" {prompt = ragContext + "\n\n" + prompt}return prompt}// ตัวอย่าง prompt ที่ LLM ได้รับ://// Context from knowledge base:// 1. สินค้า A ราคา 1,500 บาท รับประกัน 1 ปี ...// 2. นโยบายคืนสินค้าภายใน 30 วัน ...// 3. ช่องทางติดต่อ: Line @shop, โทร 02-xxx-xxxx ...//// คุณเป็นผู้ช่วยตอบคำถามเกี่ยวกับร้านค้า ตอบสุภาพ ตอบจากข้อมูลที่มีเท่านั้น
LLM จะตอบจากข้อมูลจริงของเรา ไม่ใช่จากความรู้ทั่วไปของ model
┌──────────────────────────────────────────────────────────────────┐│ INGESTION PHASE ││ ││ [Upload Document] ││ ↓ ││ [chunkText()] → 500 runes/chunk, 50 overlap ││ ↓ ││ [EmbeddingService.Embed()] → Gemini/OpenAI → []float32 ││ ↓ ││ [PostgreSQL + pgvector] → rag_chunks.embedding vector(3072) ││ │├──────────────────────────────────────────── ──────────────────────┤│ RETRIEVAL PHASE ││ ││ [User Query] ││ ↓ ││ [Embed Query] → vector ││ ↓ ││ [cosine similarity: embedding <=> query::vector] ││ ↓ (fallback: ILIKE keyword search) ││ [Top-K chunks] ││ ↓ ││ [Build Prompt] → context + system prompt ││ ↓ ││ [LLM Generate] → คำตอบที่อิงจากข้อมูลจริง │└──────────────────────────────────────────────────────────────────┘
| Component | Technology | ทำไม |
|---|---|---|
| Language | Go | เร็ว, concurrent, deploy ง่าย |
| Architecture | Hexagonal | แยก business logic ออกจาก infrastructure |
| Vector Store | PostgreSQL + pgvector | ใช้ DB ตัวเดียว ไม่ต้องเพิ่ม service |
| Embedding | Gemini / OpenAI | multi-provider, fallback ได้ |
| Index | IVFFlat | เร็วพอสำหรับ < 1M vectors |
| Chunking | Rune-based sliding window | รองรับภาษาไทย + overlap |
| Fallback | Keyword ILIKE | graceful degradation |
ข้อดีของ hexagonal architecture ในงาน RAG คือ — ถ้าวันหนึ่งอยากเปลี่ยนจาก pgvector เป็น Pinecone หรือ Weaviate แก้แค่ secondary adapter ไม่ต้องแก้ business logic เลย เช่นเดียวกับการเปลี่ยน embedding provider — แก้แค่ adapter ตัวเดียว ส่วนที่เหลือทำงานเหมือนเดิม
Technical Lead — building AI-powered platforms, omni-channel chat systems, and telemedicine solutions with Go, Next.js & clean architecture. 20+ years shipping software from crypto wallets to e-learning systems. Bangkok-based. Writes code late at night, brews beer on weekends.
