RAG — ให้ AI รู้ข้อมูลของเรา
LLM เก่งเรื่องภาษา แต่มันไม่รู้ข้อมูล ภายในบริษัทเรา และไม่รู้เรื่อง ล่าสุด ที่เกิดหลังวันตัดข้อมูลฝึก (training cutoff) เช่น เอกสาร spec ภายใน, ticket ลูกค้า, หรือ policy ที่เพิ่งอัปเดตเมื่อวาน บทนี้จะสอนวิธีที่ dev ใช้กันจริงเพื่อ "ป้อนความรู้เฉพาะทาง" ให้โมเดลตอนตอบ นั่นคือ RAG (Retrieval-Augmented Generation — การเสริมการตอบด้วยข้อมูลที่ค้นมา)
ปัญหา: LLM ไม่รู้ข้อมูลเรา และ context จำกัด
ทางแก้แบบง่ายสุดคือ "ก็แปะเอกสารทั้งหมดใน prompt สิ" — แต่ทำไม่ได้ เพราะ context window (หน้าต่างบริบท) มีขนาดจำกัด คุณยัดคู่มือ 500 หน้าเข้าไปไม่ได้ และต่อให้ยัดได้ก็ แพงและช้า เพราะจ่ายตาม token ทุกครั้งที่ถาม
แนวคิดของ RAG คือ อย่าแปะทั้งเล่ม — แปะเฉพาะหน้าที่เกี่ยวกับคำถาม เหมือนตอนสอบแบบเปิดหนังสือ เราไม่อ่านทั้งเล่มใหม่ทุกข้อ แต่เปิดไปหน้าที่เกี่ยวข้องกับคำถามนั้น ปัญหาจึงเหลือแค่: จะ "หา" หน้าที่เกี่ยวข้องเจอได้ยังไง?
Embeddings — แปลงความหมายเป็นตัวเลข
หัวใจของ RAG คือ embeddings (เวกเตอร์แทนความหมาย): เราเอาข้อความไปผ่านโมเดล embedding แล้วได้ผลลัพธ์เป็น ลิสต์ตัวเลข (เวกเตอร์) ยาวคงที่ เช่น 1,536 ตัว จุดสำคัญคือ — ข้อความที่ความหมายใกล้กัน จะได้เวกเตอร์ที่อยู่ใกล้กันในปริภูมิ ไม่สนว่าใช้คำเหมือนกันไหม
ตัวอย่าง ของจริง เรียก embedding model ผ่าน OpenAI SDK (TypeScript) — embedding เป็นบริการแยก ไม่ใช่ตัวเดียวกับ LLM ที่ใช้ตอบคำถาม:
// npm i openai
import OpenAI from "openai";
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const res = await openai.embeddings.create({
model: "text-embedding-3-small", // ได้เวกเตอร์ยาว 1536
input: "ยกเลิกออเดอร์ยังไง",
});
const vector: number[] = res.data[0].embedding;
// → [0.021, -0.114, 0.087, ... ] ยาว 1536 ตัว
Anthropic ไม่มี embedding endpoint ของตัวเอง — เอกสารทางการแนะนำให้ใช้ผู้ให้บริการ embedding แยก เช่น Voyage AI หรือ OpenAI (ตามตัวอย่างนี้) ส่วน Claude เอาไว้ใช้ตอน "generate" คำตอบซึ่งเป็นขั้นสุดท้ายของ pipeline
Vector search + similarity (cosine)
เมื่อทุกชิ้นข้อความเป็นเวกเตอร์แล้ว การ "ค้นหาสิ่งที่เกี่ยวข้อง" ก็กลายเป็นเรื่องคณิตศาสตร์ล้วนๆ: แปลงคำถามเป็นเวกเตอร์เหมือนกัน แล้วหาเวกเตอร์ในคลังที่ อยู่ใกล้ที่สุด วิธีวัดความใกล้ที่นิยมสุดคือ cosine similarity (ความคล้ายเชิงโคไซน์) — ดูที่ ทิศทาง ของเวกเตอร์ ไม่สนความยาว
| ค่า cosine | แปลว่า | ตัวอย่าง |
|---|---|---|
| ≈ 1.0 | ความหมายเหมือนกันมาก | "ยกเลิกออเดอร์" ↔ "cancel order" |
| ≈ 0.0 | ไม่เกี่ยวกันเลย | "ยกเลิกออเดอร์" ↔ "สูตรทำต้มยำ" |
| < 0 | ทิศตรงข้าม (พบน้อยในข้อความจริง) | — |
คนมักพูดเป็น "ระยะทาง" (distance) แทน "ความคล้าย" — ระยะ น้อย = คล้าย มาก
pgvector มี operator ให้ทั้ง cosine distance (<=>), L2 (<->) และ inner product (<#>)
ใช้ pgvector บน Postgres
ข่าวดีสำหรับคนถนัด Postgres อยู่แล้ว: ไม่ต้องหา vector database แยกก็ได้ ใช้ extension
pgvector เพิ่มชนิดข้อมูล vector เข้าไปในตารางเดิมได้เลย เก็บข้อความกับเวกเตอร์
ไว้ที่เดียว query ด้วย SQL ที่คุ้นมือ
-- เปิด extension (ครั้งเดียวต่อ database)
CREATE EXTENSION IF NOT EXISTS vector;
-- ตารางเก็บชิ้นเอกสาร + เวกเตอร์ของมัน
CREATE TABLE doc_chunks (
id bigserial PRIMARY KEY,
source text, -- ไฟล์/URL ต้นทาง ไว้อ้างอิงแหล่ง
content text, -- ข้อความจริงของชิ้นนี้
embedding vector(1536) -- ต้องตรงกับมิติของ embedding model
);
-- index แบบ approximate เพื่อให้ค้นเร็วบนข้อมูลเยอะ
CREATE INDEX ON doc_chunks
USING hnsw (embedding vector_cosine_ops);
ตอน query: เอาเวกเตอร์ของคำถามมา หาชิ้นที่ระยะ cosine น้อยสุด (เกี่ยวข้องสุด) แค่ไม่กี่ชิ้น:
-- $1 = เวกเตอร์ของคำถามผู้ใช้ (bind เป็น parameter เสมอ)
SELECT id, source, content,
embedding <=> $1 AS distance -- <=> คือ cosine distance
FROM doc_chunks
ORDER BY embedding <=> $1 -- เรียงจากใกล้สุด → ไกลสุด
LIMIT 5; -- เอาแค่ top-k ชิ้นที่เกี่ยวสุด
อย่าประกอบเวกเตอร์เข้า SQL ด้วยการต่อ string เด็ดขาด — ใช้ parameterized query ($1) เสมอ
เพื่อกัน SQL injection และให้ query planner ทำงานถูก
Chunking + retrieval + ประกอบเป็น context
เราไม่ embed เอกสารทั้งไฟล์เป็นก้อนเดียว เพราะเวกเตอร์เดียวจะแทนหลายเรื่องปนกันจนความหมายเบลอ และเวลาดึงมาก็ได้ข้อความยาวเกินจำเป็น เราจึงทำ chunking (การหั่นเอกสารเป็นชิ้น) — แบ่งเป็นชิ้นขนาดพอดี (เช่น 300–800 token ต่อชิ้น) มักให้ เหลื่อมกันเล็กน้อย (overlap) กันใจความขาดตอนตรงรอยต่อ
| ขั้นตอน | ทำที่ไหน | ทำเมื่อไหร่ |
|---|---|---|
| Chunk + Embed + เก็บลง DB | งาน ingest (นำเข้าข้อมูล, offline) | ตอนเพิ่ม/แก้เอกสาร |
| Embed คำถาม + vector search | ตอน request เข้ามา | ทุกครั้งที่ผู้ใช้ถาม |
| ประกอบ context + เรียก LLM | ตอน request เข้ามา | ทุกครั้งที่ผู้ใช้ถาม |
ชิ้นที่ค้นเจอ (top-k — k ชิ้นที่ใกล้คำถามที่สุด) เอามาต่อกันเป็นบล็อกข้อความ แล้วยัดใส่ prompt พร้อมคำสั่งให้โมเดล ตอบโดยอ้างอิงเฉพาะข้อมูลที่ให้เท่านั้น:
const context = chunks.map(c => c.content).join("\n---\n");
const prompt = `ใช้เฉพาะข้อมูลอ้างอิงด้านล่างในการตอบ
ถ้าข้อมูลไม่พอ ให้ตอบว่า "ไม่พบข้อมูล" อย่าเดา
[ข้อมูลอ้างอิง]
${context}
[คำถาม]
${question}`;
const answer = await chat({ model, prompt });
ภาพรวม: RAG pipeline
RAG ลด hallucination (การมั่วข้อมูล) แต่ไม่ทำให้หมดไป — โมเดลยังเดาเกินข้อมูลที่ให้ได้
ถ้าค้นมาผิดชิ้นหรือชิ้นที่เจอไม่มีคำตอบ ดังนั้นให้เก็บ source ไว้เสมอ แล้ว
แสดงแหล่งอ้างอิงคู่กับคำตอบ เพื่อให้ผู้ใช้ตรวจสอบย้อนกลับได้
เริ่มเล็กก่อน: pgvector + ตารางเดียว + top-k เท่านี้ก็เป็น RAG ที่ใช้งานได้จริงแล้ว ยังไม่ต้องรีบไปหา vector database เฉพาะทางจนกว่าข้อมูลจะใหญ่มากหรือต้องการฟีเจอร์พิเศษ
ออกแบบระบบ RAG ถาม-ตอบจากเอกสารภายในบริษัท (เช่น คู่มือ onboarding + policy HR):
- คุณจะ chunk เอกสารยังไง? เลือกขนาดชิ้นและ overlap เท่าไร เพราะอะไร
- ออกแบบ schema ตาราง Postgres + pgvector — ต้องมีคอลัมน์อะไรบ้างเพื่อให้ "อ้างอิงแหล่ง" กลับไปที่เอกสารต้นทางได้
- เขียน flow ของ endpoint NestJS
POST /ask: รับคำถาม → embed → ค้น top-k → ประกอบ prompt → เรียก LLM → คืนคำตอบ + แหล่งอ้างอิง - ถ้า vector search คืนชิ้นที่ distance สูงมาก (ไม่เกี่ยวเลย) คุณจะจัดการยังไงไม่ให้โมเดลมั่ว?
ลองตอบในแชท แล้วพิมพ์ /quiz Lv4 เพื่อทดสอบความเข้าใจ