HomeArtTechHackBlockchain
ONLINE ·
Index
/
Technology
/
Article

สร้าง RAG Pipeline ด้วย Go + pgvector + Hexagonal Architecture

Operator
Khomkrid Lerdprasert
Filed
April 23, 2026
Channel
Technology
Read
~1 min
สร้าง RAG Pipeline ด้วย Go + pgvector + Hexagonal Architecture

สร้าง RAG Pipeline ด้วย Go + pgvector + Hexagonal Architecture

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 คืออะไร

RAG แบ่งเป็น 3 ขั้นตอน:

  1. Retrieval — รับคำถามจากผู้ใช้ แปลงเป็น vector แล้วค้นหาข้อมูลที่ใกล้เคียงที่สุดจาก knowledge base
  2. Augmentation — เอาข้อมูลที่ค้นเจอมาใส่เป็น context ใน prompt
  3. Generation — ส่ง prompt + context ให้ LLM สร้างคำตอบที่อิงจากข้อมูลจริง
User Query
[Embed Query] → vector
[Vector Search] → ค้นหา chunks ที่ใกล้เคียง
[Build Prompt] → context + system prompt + query
[LLM] → คำตอบที่อิงจากข้อมูลจริง

Architecture Overview

ใช้ 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) │
└──────────────┘ └───────────────────┘
  • Core Service — business logic ล้วน ไม่รู้จัก database หรือ HTTP
  • Secondary Adapter (Postgres) — จัดการ SQL, vector storage
  • Secondary Adapter (Embedding) — เรียก API สร้าง embedding (Gemini / OpenAI)
  • Primary Adapter (HTTP) — REST endpoints สำหรับ CRUD documents

1. เตรียมข้อมูล — Text Chunking

เอกสารยาวๆ ต้องแบ่งเป็น chunks ก่อนสร้าง embedding เพราะ:

  • Embedding model มีจำกัด token ต่อครั้ง
  • Chunk เล็กๆ ให้ผลค้นหาแม่นยำกว่าเอกสารทั้งฉบับ
  • ใช้ sliding window (overlap) เพื่อไม่ให้ตัดข้อความกลางประโยค
const (
ChunkSize = 500 // จำนวน rune ต่อ chunk
ChunkOverlap = 50 // overlap ระหว่าง chunk
)
// chunkText แบ่งข้อความเป็น chunks ด้วย sliding window
// ใช้ []rune เพื่อรองรับภาษาไทยและ Unicode
func chunkText(text string) []string {
if len(text) <= ChunkSize {
return []string{text}
}
var chunks []string
textRunes := []rune(text) // สำคัญ: ใช้ rune ไม่ใช่ byte
for i := 0; i < len(textRunes); i += ChunkSize - ChunkOverlap {
end := i + ChunkSize
if 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 ทำให้ข้อมูลตรงรอยต่อไม่หายไป


2. สร้าง Embedding

Embedding คือการแปลงข้อความเป็น vector ตัวเลข ที่แทนความหมาย — ข้อความที่ความหมายใกล้กัน vector จะอยู่ใกล้กันในมิติสูง

รองรับ 2 providers: Gemini (default) และ OpenAI-compatible (รวม Groq, OpenRouter)

const DefaultEmbeddingDimension = 3072
type EmbeddingService struct {
provider string // "gemini" หรือ "openai"
apiKey string
model string
baseURL string
client *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 แปลงข้อความเป็น vector
func (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)
}
}

Gemini Embedding

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
}

OpenAI-Compatible Embedding

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
}

Config

# Gemini (default)
EMBEDDING_PROVIDER=gemini
EMBEDDING_API_KEY=your-gemini-api-key
EMBEDDING_MODEL=gemini-embedding-001 # 3072 dimensions
# หรือ OpenAI
EMBEDDING_PROVIDER=openai
EMBEDDING_API_KEY=your-openai-key
EMBEDDING_MODEL=text-embedding-3-small # 1536 dimensions
# หรือ Groq / OpenRouter (OpenAI-compatible endpoint)
EMBEDDING_PROVIDER=openai
EMBEDDING_URL=https://api.groq.com/openai/v1

3. เก็บ Vector ใน PostgreSQL (pgvector)

ใช้ pgvector extension ของ PostgreSQL — ไม่ต้องตั้ง vector database แยก ใช้ Postgres ตัวเดียวที่มีอยู่แล้วได้เลย

Database Schema

-- เปิด pgvector extension
CREATE 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 dims
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- IVFFlat index สำหรับ cosine similarity search
CREATE INDEX idx_rag_chunks_embedding
ON 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

บันทึก Chunk + Embedding

// 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 vector
func CreateChunk(db *sql.DB, documentID, text string, index int, embedding []float32) error {
var embeddingStr *string
if 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
}

4. Document Ingestion — รวมทุกอย่างเข้าด้วยกัน

ขั้นตอนเมื่อผู้ใช้อัปโหลดเอกสาร:

[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. แบ่ง chunks
chunks := chunkText(input.Content)
// 2. สร้าง document record
doc := &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 + บันทึกทีละ chunk
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
for i, chunk := range chunks {
var embedding []float32
if 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


5. Vector Search — ค้นหาด้วย Cosine Similarity

เมื่อผู้ใช้ถามคำถาม ระบบจะ:

  1. แปลงคำถามเป็น embedding vector
  2. ค้นหา chunks ที่ vector ใกล้เคียงที่สุด (cosine similarity)
  3. ถ้า vector search ล้มเหลว → fallback เป็น keyword search

Vector Search (cosine similarity)

// SearchChunksByVector ค้นหา chunks ด้วย cosine similarity ผ่าน pgvector
func 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 c
JOIN rag_documents d ON c.document_id = d.id
WHERE d.channel_id = $1
AND c.embedding IS NOT NULL
ORDER BY c.embedding <=> $2::vector
LIMIT $3
`, channelID, embeddingStr, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var chunks []string
for rows.Next() {
var text string
rows.Scan(&text)
chunks = append(chunks, text)
}
return chunks, nil
}

<=> — operator ของ pgvector สำหรับ cosine distance ยิ่งค่าน้อยยิ่งใกล้ (ORDER BY ... <=> = เรียงจากใกล้สุด)

Keyword Fallback

// 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 c
JOIN rag_documents d ON c.document_id = d.id
WHERE d.channel_id = $1
AND c.chunk_text ILIKE '%' || $2 || '%'
LIMIT $3
`, channelID, query, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var chunks []string
for rows.Next() {
var text string
rows.Scan(&text)
chunks = append(chunks, text)
}
return chunks, nil
}

6. รวม Context เข้า LLM

ขั้นตอนสุดท้าย — เอา chunks ที่ค้นเจอมาสร้างเป็น context แล้วส่งเข้า LLM พร้อมคำถามผู้ใช้

// GetContext ดึง context จาก knowledge base
// ลอง vector search ก่อน ถ้าไม่ได้ fallback เป็น keyword
func (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 search
if len(chunks) == 0 {
chunks, _ = SearchChunks(s.db, channelID, query, limit)
}
if len(chunks) == 0 {
return "", nil
}
// 3. รวม chunks เป็น context string
result := "Context from knowledge base:\n"
for i, chunk := range chunks {
result += fmt.Sprintf("%d. %s\n", i+1, chunk)
}
return result, nil
}

ส่งเข้า LLM

// สร้าง prompt สุดท้ายสำหรับ LLM
func 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


Pipeline ทั้งหมด

┌──────────────────────────────────────────────────────────────────┐
│ 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] → คำตอบที่อิงจากข้อมูลจริง │
└──────────────────────────────────────────────────────────────────┘

สรุป

ComponentTechnologyทำไม
LanguageGoเร็ว, concurrent, deploy ง่าย
ArchitectureHexagonalแยก business logic ออกจาก infrastructure
Vector StorePostgreSQL + pgvectorใช้ DB ตัวเดียว ไม่ต้องเพิ่ม service
EmbeddingGemini / OpenAImulti-provider, fallback ได้
IndexIVFFlatเร็วพอสำหรับ < 1M vectors
ChunkingRune-based sliding windowรองรับภาษาไทย + overlap
FallbackKeyword ILIKEgraceful degradation

ข้อดีของ hexagonal architecture ในงาน RAG คือ — ถ้าวันหนึ่งอยากเปลี่ยนจาก pgvector เป็น Pinecone หรือ Weaviate แก้แค่ secondary adapter ไม่ต้องแก้ business logic เลย เช่นเดียวกับการเปลี่ยน embedding provider — แก้แค่ adapter ตัวเดียว ส่วนที่เหลือทำงานเหมือนเดิม

◎ Tags

##golang##rag##pgvector##ai##hexagonal-architecture
Khomkrid Lerdprasert
Operator

Khomkrid Lerdprasert

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.

GithubInstagram
Previous · transmission
สร้างเครื่องมือแปลง PDF เป็น EPUB/AZW3 สำหรับหนังสือภาษาไทย
Next · transmission
ชานชรา ที่ 1 — เพื่อนไม่ครบ ไม่เป็นไร กลับมาขี่คนเดียวได้
Metadata
Channel
Technology
Filed
April 23, 2026
Read
~1 min
Language
TH / EN
Transmit

Related

สร้างเครื่องมือแปลง PDF เป็น EPUB/AZW3 สำหรับหนังสือภาษาไทย
Khomkrid Lerdprasert
April 23, 2026
1 min
aofiee.dev
signal / noise / code · craft
© 2019 – 2026, Khomkrid Lerdprasert.
All transmissions logged.
No newsletter. No profiling. Cookies require consent.
PGP · 7F3D 2024 A21E B584 · 0x7F3D
Channels
  • Art & Culture
  • Technology
  • Hack 101
  • Blockchain 101
  • Archive / All posts
— END OF TRANSMISSION —
// powered by curiosity, coffee, & wuxia
BKK · 13°45′N · 100°30′E