📗 บทที่ 9 — RAG: ให้ AI ตอบจากโน้ตของเรา (พร้อมอ้างอิง)¶
คู่กับ: หนังสือบทที่ 9
RAG = Retrieval-Augmented Generation: ค้นโน้ตที่เกี่ยว → ส่งให้ LLM เรียบเรียงตอบ พร้อม cite และที่สำคัญไม่แพ้กัน: ถ้าไม่มีข้อมูล ต้องบอกว่าไม่มี ไม่ใช่มโน
ในเครื่อง: ใช้ Ollama (gemma3) · บน Colab: ข้ามส่วน LLM ได้ (มีเซลล์สรุป logic ให้)
In [1]:
import sys
IN_COLAB = 'google.colab' in sys.modules
if IN_COLAB:
%pip -q install chromadb sentence-transformers
import chromadb, urllib.request, json
import numpy as np
print('พร้อม ✓')
พร้อม ✓
In [2]:
# ---- embed อัจฉริยะ + เช็ค LLM ----
def _ollama_ok():
try:
urllib.request.urlopen('http://localhost:11434/api/tags', timeout=2); return True
except Exception:
return False
HAS_OLLAMA = _ollama_ok()
if HAS_OLLAMA:
def embed_texts(texts):
req = urllib.request.Request('http://localhost:11434/api/embed',
data=json.dumps({'model': 'bge-m3', 'input': list(texts)}).encode(),
headers={'Content-Type': 'application/json'})
with urllib.request.urlopen(req, timeout=180) as r:
V = np.array(json.load(r)['embeddings'])
return V / np.linalg.norm(V, axis=1, keepdims=True)
else:
from sentence_transformers import SentenceTransformer
_m = SentenceTransformer('BAAI/bge-m3')
def embed_texts(texts):
return _m.encode(list(texts), normalize_embeddings=True)
from chromadb.utils.embedding_functions import EmbeddingFunction
class BgeM3(EmbeddingFunction):
def __init__(self): pass
def __call__(self, texts): return embed_texts(texts).tolist()
print('embed:', 'Ollama' if HAS_OLLAMA else 'sentence-transformers', '· LLM:', 'Ollama gemma3' if HAS_OLLAMA else 'ไม่มี (โชว์ logic อย่างเดียว)')
embed: Ollama · LLM: Ollama gemma3
1) vault (ใช้โครงจากบทที่ 8)¶
In [3]:
client = chromadb.PersistentClient(path='./chroma_db')
try:
client.delete_collection('rag_vault')
except Exception:
pass
# ⚠️ กับดักจริง: Chroma default วัดระยะแบบ L2 — ต้องระบุ cosine เอง
# (ตรงกับ ARRA production: .distanceType('cosine') ใน lancedb.ts)
col = client.create_collection('rag_vault', embedding_function=BgeM3(),
metadata={'hnsw:space': 'cosine'})
col.upsert(
ids=['r1', 'r2', 'r3', 'r4'],
documents=[
'แผน workshop วันที่ 26 กรกฎาคม: สอน vector search ให้นักวิจัย เริ่มจากเดโมก่อนค่อยลงสมการ',
'อุปกรณ์ workshop: โน้ตบุ๊กติดตั้ง Python และ Jupyter ล่วงหน้า มีปลั๊กไฟทุกโต๊ะ',
'งานวิจัย embedding: bge-m3 ได้ GAP 0.393 กว้างสุดในโมเดลที่เทียบ เหมาะกับภาษาไทย',
'บันทึกกาแฟ cold brew: กาแฟ 100g น้ำ 1L แช่ 18 ชั่วโมง',
],
metadatas=[{'source': 'workshop-plan.md'}, {'source': 'workshop-plan.md'},
{'source': 'research-embedding.md'}, {'source': 'coffee-notes.md'}],
)
print('vault:', col.count(), 'chunks')
vault: 4 chunks
2) ⭐ Retrieve + ประกอบ context พร้อมแหล่ง (หัวใจ RAG)¶
กติกา 2 ข้อที่มือใหม่มักพลาด:
- threshold: score ต่ำกว่าเกณฑ์ → ไม่ป้อน LLM (กัน noise ทำตอบมั่ว)
- แนบ source ทุกชิ้น → LLM cite ได้ → คน verify ได้
In [4]:
THRESHOLD = 0.45 # จูนจากข้อมูลจริง: คำถามเกี่ยว ~0.52-0.57, ไม่เกี่ยว ~0.40 (บทที่ 5 สเกลคะแนน)
def retrieve(q, k=3):
res = col.query(query_texts=[q], n_results=k)
hits = []
for doc, meta, dist in zip(res['documents'][0], res['metadatas'][0], res['distances'][0]):
score = 1 - dist
if score >= THRESHOLD:
hits.append({'text': doc, 'source': meta['source'], 'score': score})
return hits
def build_context(hits):
return '\n\n'.join(f"[{h['source']}] {h['text']}" for h in hits)
hits = retrieve('workshop วันไหน สอนอะไร')
print(build_context(hits))
[workshop-plan.md] แผน workshop วันที่ 26 กรกฎาคม: สอน vector search ให้นักวิจัย เริ่มจากเดโมก่อนค่อยลงสมการ [workshop-plan.md] อุปกรณ์ workshop: โน้ตบุ๊กติดตั้ง Python และ Jupyter ล่วงหน้า มีปลั๊กไฟทุกโต๊ะ
3) Generate: ส่ง context + กติกาให้ LLM¶
In [5]:
def ask_llm(q):
hits = retrieve(q)
if not hits:
return 'ไม่พบข้อมูลเรื่องนี้ใน vault ครับ', [] # ⭐ abstain — ซื่อสัตย์ดีกว่ามโน
if not HAS_OLLAMA:
return f'(ไม่มี LLM ในเครื่อง — context ที่จะส่งคือ {len(hits)} ชิ้น)', hits
prompt = f'''ตอบคำถามจากบันทึกด้านล่างเท่านั้น ห้ามเดาข้อมูลนอกบันทึก\nอ้างอิงชื่อไฟล์ในวงเล็บท้ายประโยคที่ใช้ข้อมูลนั้น เช่น (workshop-plan.md)\nถ้าบันทึกไม่มีคำตอบ ให้บอกว่าไม่พบข้อมูล\n\nบันทึก:\n{build_context(hits)}\n\nคำถาม: {q}\nคำตอบ:'''
req = urllib.request.Request('http://localhost:11434/api/generate',
data=json.dumps({'model': 'gemma3:4b', 'prompt': prompt, 'stream': False,
'options': {'temperature': 0}}).encode(),
headers={'Content-Type': 'application/json'})
with urllib.request.urlopen(req, timeout=300) as r:
return json.load(r)['response'].strip(), hits
answer, hits = ask_llm('workshop วันไหน แล้วต้องเตรียมอะไรบ้าง')
print('🤖', answer)
🤖 Workshop วันที่ 26 กรกฎาคม (workshop-plan.md) และต้องเตรียมอุปกรณ์คือ โน้ตบุ๊กติดตั้ง Python และ Jupyter ล่วงหน้า พร้อมปลั๊กไฟทุกโต๊ะ (workshop-plan.md)
4) ⭐ Abstention: ถามสิ่งที่ไม่มีใน vault — ต้องไม่มโน¶
In [6]:
answer_none, hits_none = ask_llm('ราคาหุ้นวันนี้เป็นยังไง')
print('Q: ราคาหุ้นวันนี้เป็นยังไง')
print('🤖', answer_none)
print()
print(f'(retrieval คืน {len(hits_none)} ชิ้นหลัง threshold — ไม่มีอะไรเกี่ยวพอ → ไม่ป้อน LLM เลย)')
Q: ราคาหุ้นวันนี้เป็นยังไง 🤖 ไม่พบข้อมูลเรื่องนี้ใน vault ครับ (retrieval คืน 0 ชิ้นหลัง threshold — ไม่มีอะไรเกี่ยวพอ → ไม่ป้อน LLM เลย)
✅ วัดผลตัวเอง #9¶
In [7]:
# 1) คำถามที่มีข้อมูล: ต้องได้ context ที่เกี่ยว
hits = retrieve('workshop วันไหน สอนอะไร')
assert len(hits) >= 1 and any('26' in h['text'] for h in hits), 'ต้อง retrieve เจอแผน workshop'
# 2) ทุก hit ต้องมี source (verify ได้)
assert all(h['source'] for h in hits), 'ทุกชิ้นต้องมีแหล่งอ้างอิง'
# 3) คำถามนอก vault: ต้อง abstain (คืน 0 หลัง threshold)
assert len(retrieve('ราคาหุ้นวันนี้เป็นยังไง')) == 0, 'เรื่องนอก vault ต้องไม่ผ่าน threshold'
# 4) ถ้ามี LLM: คำตอบต้องอิงข้อเท็จจริงจากโน้ต
if HAS_OLLAMA:
assert '26' in answer or 'กรกฎาคม' in answer, 'คำตอบต้องมีข้อเท็จจริงจากโน้ต (วันที่ 26 ก.ค.)'
print('✅ ผ่าน! RAG ครบวงจร: retrieve→threshold→cite→ตอบ · นอก vault→บอกไม่พบ (ไม่มโน)')
✅ ผ่าน! RAG ครบวงจร: retrieve→threshold→cite→ตอบ · นอก vault→บอกไม่พบ (ไม่มโน)
📊 เห็นภาพ — threshold คัดของเกี่ยว vs abstain¶
In [8]:
# @@VIZ@@
import matplotlib, matplotlib.pyplot as plt
from matplotlib import font_manager as _fm
_av={f.name for f in _fm.fontManager.ttflist}
for _f in ['Thonburi','Loma','Sarabun','Noto Sans Thai','TH Sarabun New']:
if _f in _av: matplotlib.rcParams['font.family']=_f; break
matplotlib.rcParams['axes.unicode_minus']=False
fig,axes=plt.subplots(1,2,figsize=(10,3.6),sharey=True)
for ax,q in zip(axes,['workshop วันไหน','ราคาหุ้นวันนี้']):
res=col.query(query_texts=[q],n_results=4); s=[1-d for d in res['distances'][0]]
c=['#1baf7a' if x>=THRESHOLD else '#e34948' for x in s]
ax.bar(range(len(s)),s,color=c); ax.axhline(THRESHOLD,ls='--',color='#888')
ax.set_title(q); ax.set_xlabel('อันดับผล')
axes[0].set_ylabel('cosine'); fig.suptitle('บทที่ 9 — เขียว=ผ่าน threshold(ตอบ) · แดง=abstain (ไม่พบ)')
plt.tight_layout(); plt.show()
🏋️ แบบฝึก¶
- ลด THRESHOLD เป็น 0.0 แล้วถามเรื่องหุ้นอีกครั้ง — LLM ได้ context อะไร? คำตอบเปลี่ยนไหม? (นี่คือเหตุผลที่ threshold สำคัญ)
- เปลี่ยนกติกาใน prompt ให้ตอบเป็น bullet — LLM ทำตามไหม?
บทต่อไป: ch10 เรื่องเล่าจริง: ทำไม ARRA ย้ายจาก ChromaDB → LanceDB