teach-me-ai / Lv4
⟵ กลับหน้า Roadmap
LEVEL 4 · RAG

RAG — ให้ AI รู้ข้อมูลของเรา

⏱️ ~30 นาที 🎯 ต่อ LLM เข้ากับข้อมูลภายในบริษัทได้ด้วย embeddings + vector search บน Postgres

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 ตัว จุดสำคัญคือ — ข้อความที่ความหมายใกล้กัน จะได้เวกเตอร์ที่อยู่ใกล้กันในปริภูมิ ไม่สนว่าใช้คำเหมือนกันไหม

มิติที่ 1 มิติที่ 2 "แมว" "ลูกหมา" "Postgres" "ฐานข้อมูล" กลุ่ม "สัตว์เลี้ยง" กลุ่ม "ฐานข้อมูล"
แต่ละข้อความกลายเป็น "จุด" ในปริภูมิหลายมิติ — ความหมายใกล้กันอยู่ใกล้กัน จึงวัดความเกี่ยวข้องด้วยระยะทางได้

ตัวอย่าง ของจริง เรียก 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

ขั้น INGEST (offline) เอกสาร chunk หั่นเป็นชิ้น embed → เวกเตอร์ Postgres pgvector ขั้น QUERY (ตอนผู้ใช้ถาม) คำถาม embed คำถาม ค้น top-k vector search ชิ้นที่เจอ = context context + คำถาม → LLM สร้างคำตอบ คำตอบ + อ้างอิงแหล่ง
RAG pipeline: เอกสาร → chunk → embed → เก็บใน DB · คำถาม → embed → ค้นชิ้นที่เจอ + คำถาม → LLM → คำตอบพร้อมแหล่งอ้างอิง
⚠️

RAG ลด hallucination (การมั่วข้อมูล) แต่ไม่ทำให้หมดไป — โมเดลยังเดาเกินข้อมูลที่ให้ได้ ถ้าค้นมาผิดชิ้นหรือชิ้นที่เจอไม่มีคำตอบ ดังนั้นให้เก็บ source ไว้เสมอ แล้ว แสดงแหล่งอ้างอิงคู่กับคำตอบ เพื่อให้ผู้ใช้ตรวจสอบย้อนกลับได้

เริ่มเล็กก่อน: pgvector + ตารางเดียว + top-k เท่านี้ก็เป็น RAG ที่ใช้งานได้จริงแล้ว ยังไม่ต้องรีบไปหา vector database เฉพาะทางจนกว่าข้อมูลจะใหญ่มากหรือต้องการฟีเจอร์พิเศษ

🎯 แบบฝึกหัด

ออกแบบระบบ RAG ถาม-ตอบจากเอกสารภายในบริษัท (เช่น คู่มือ onboarding + policy HR):

  1. คุณจะ chunk เอกสารยังไง? เลือกขนาดชิ้นและ overlap เท่าไร เพราะอะไร
  2. ออกแบบ schema ตาราง Postgres + pgvector — ต้องมีคอลัมน์อะไรบ้างเพื่อให้ "อ้างอิงแหล่ง" กลับไปที่เอกสารต้นทางได้
  3. เขียน flow ของ endpoint NestJS POST /ask: รับคำถาม → embed → ค้น top-k → ประกอบ prompt → เรียก LLM → คืนคำตอบ + แหล่งอ้างอิง
  4. ถ้า vector search คืนชิ้นที่ distance สูงมาก (ไม่เกี่ยวเลย) คุณจะจัดการยังไงไม่ให้โมเดลมั่ว?

ลองตอบในแชท แล้วพิมพ์ /quiz Lv4 เพื่อทดสอบความเข้าใจ