📗 บทที่ 11 — วัดผลอย่างเป็นระบบ: Golden Set + Recall@k¶
คู่กับ: หนังสือบทที่ 11 · หัวใจของธีม "วัดผลได้อย่างมั่นใจ"
ทั้งเล่มเราพูดว่า "bge-m3 ดีกว่า" — บทนี้พิสูจน์ด้วยตัวเลขที่ทุกคนรันซ้ำได้ ไม่ใช่ความรู้สึก ไม่ใช่ benchmark ของคนอื่น — วัดบนข้อมูลของเราเอง
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]:
# ---- สอง embedder ที่จะเทียบ ----
def _ollama_ok():
try:
urllib.request.urlopen('http://localhost:11434/api/tags', timeout=2); return True
except Exception:
return False
if _ollama_ok():
def embed_bge(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)
print('bge-m3: Ollama ✓')
else:
from sentence_transformers import SentenceTransformer
_bge = SentenceTransformer('BAAI/bge-m3')
def embed_bge(texts):
return _bge.encode(list(texts), normalize_embeddings=True)
# MiniLM (ตัวแถมของ Chroma) — เรียกผ่าน default embedding function
from chromadb.utils.embedding_functions import DefaultEmbeddingFunction
_mini = DefaultEmbeddingFunction()
def embed_mini(texts):
V = np.array(_mini(list(texts)))
return V / np.linalg.norm(V, axis=1, keepdims=True)
print('MiniLM: default EF ✓')
bge-m3: Ollama ✓ MiniLM: default EF ✓
1) ⭐ Golden Set — ข้อสอบของระบบค้นหา¶
(query, เฉลย doc ที่เกี่ยวจริง) ที่คนตัดสินไว้ล่วงหน้า — สร้างครั้งเดียว ใช้วัดได้ตลอดไป
In [3]:
DOCS = [
'วิธีสอนนักศึกษาให้เข้าใจ vector search เริ่มจาก cosine similarity', # 0
'ประชุมกับอาจารย์ฝน เรื่อง workshop วันที่ 26 กรกฎาคม', # 1
'สูตรกาแฟ cold brew กาแฟ 100g น้ำ 1L แช่ 18 ชั่วโมง', # 2
'รายการซื้อของ นม ไข่ ขนมปัง กาแฟดริป', # 3
'งานวิจัย embedding หลายภาษา bge-m3 เหมาะกับภาษาไทย', # 4
'บันทึกออกกำลังกาย วิ่ง 5 กิโลเมตร ทุกเช้าวันเสาร์', # 5
'แผนเที่ยวเชียงใหม่ จองที่พักย่านนิมมาน 3 คืน', # 6
'โค้ด Python อ่านไฟล์ CSV ด้วย pandas แล้วสรุปยอดรายเดือน', # 7
]
GOLDEN = [
('อยากสอนเรื่อง AI ค้นหาข้อมูล', {0, 4}),
('นัดหมายกับใครไว้บ้าง', {1}),
('เครื่องดื่มที่ชอบ', {2, 3}),
('โมเดลที่เข้าใจภาษาไทย', {4}),
('การออกกำลังกายของฉัน', {5}),
('ทริปเที่ยวภาคเหนือ', {6}),
('เขียนโปรแกรมจัดการข้อมูล', {7}),
]
print(f'ข้อสอบ {len(GOLDEN)} ข้อ บน corpus {len(DOCS)} โน้ต')
ข้อสอบ 7 ข้อ บน corpus 8 โน้ต
2) Metric: Recall@k และ MRR¶
$$\text{Recall@}k = \frac{|\text{เฉลยที่ติด top-}k|}{|\text{เฉลยทั้งหมด}|} \qquad \text{MRR} = \frac{1}{N}\sum \frac{1}{\text{อันดับของเฉลยตัวแรก}}$$
In [4]:
def evaluate(embed_fn, k=3):
D = embed_fn(DOCS)
recalls, rrs = [], []
for q, relevant in GOLDEN:
qv = embed_fn([q])[0]
ranks = list(np.argsort(-(D @ qv)))
topk = set(ranks[:k])
recalls.append(len(topk & relevant) / len(relevant))
first = min(ranks.index(r) for r in relevant) + 1
rrs.append(1 / first)
return np.mean(recalls), np.mean(rrs)
r_mini, mrr_mini = evaluate(embed_mini)
r_bge, mrr_bge = evaluate(embed_bge)
print(f'{"โมเดล":<12} {"Recall@3":>10} {"MRR":>8}')
print('-' * 32)
print(f'{"MiniLM":<12} {r_mini:>10.2f} {mrr_mini:>8.2f}')
print(f'{"bge-m3":<12} {r_bge:>10.2f} {mrr_bge:>8.2f}')
โมเดล Recall@3 MRR -------------------------------- MiniLM 0.36 0.37 bge-m3 0.93 1.00
3) เจาะดูรายข้อ — ข้อไหน MiniLM พลาด¶
In [5]:
D_mini, D_bge = embed_mini(DOCS), embed_bge(DOCS)
for q, relevant in GOLDEN:
t_mini = set(np.argsort(-(D_mini @ embed_mini([q])[0]))[:3])
t_bge = set(np.argsort(-(D_bge @ embed_bge([q])[0]))[:3])
m = '✓' if t_mini & relevant else '✗'
b = '✓' if t_bge & relevant else '✗'
print(f'MiniLM {m} · bge-m3 {b} {q}')
MiniLM ✗ · bge-m3 ✓ อยากสอนเรื่อง AI ค้นหาข้อมูล
MiniLM ✗ · bge-m3 ✓ นัดหมายกับใครไว้บ้าง
MiniLM ✓ · bge-m3 ✓ เครื่องดื่มที่ชอบ MiniLM ✗ · bge-m3 ✓ โมเดลที่เข้าใจภาษาไทย
MiniLM ✓ · bge-m3 ✓ การออกกำลังกายของฉัน
MiniLM ✓ · bge-m3 ✓ ทริปเที่ยวภาคเหนือ MiniLM ✗ · bge-m3 ✓ เขียนโปรแกรมจัดการข้อมูล
✅ วัดผลตัวเอง #11¶
In [6]:
assert r_bge >= 0.85, f'bge-m3 recall@3 ควร ≥ 0.85 (ได้ {r_bge:.2f})'
assert r_bge > r_mini, 'bge-m3 ต้องชนะ MiniLM บน golden set ไทย'
assert mrr_bge > mrr_mini, 'MRR ก็ต้องชนะด้วย'
print(f'✅ ผ่าน! พิสูจน์ด้วยตัวเลข: bge-m3 (R@3={r_bge:.2f}, MRR={mrr_bge:.2f}) ')
print(f' ชนะ MiniLM (R@3={r_mini:.2f}, MRR={mrr_mini:.2f}) บนข้อสอบไทยของเราเอง')
print()
print('📌 นี่คือวิธี "วัดผลได้อย่างมั่นใจ": golden set ของเราเอง + metric มาตรฐาน + รันซ้ำได้ทุกคน')
✅ ผ่าน! พิสูจน์ด้วยตัวเลข: bge-m3 (R@3=0.93, MRR=1.00) ชนะ MiniLM (R@3=0.36, MRR=0.37) บนข้อสอบไทยของเราเอง 📌 นี่คือวิธี "วัดผลได้อย่างมั่นใจ": golden set ของเราเอง + metric มาตรฐาน + รันซ้ำได้ทุกคน
4) ใช้ต่อยังไงในชีวิตจริง¶
- regression test: รัน evaluate() ทุกครั้งที่เปลี่ยนอะไร (โมเดล/chunk/threshold) — คะแนนตก = รู้ทันที
- เลือกโมเดลใหม่: มีตัวใหม่มา → วัดบน golden set เรา ไม่ใช่เชื่อ leaderboard (deep-technical Ch39)
- ขยายข้อสอบ: เก็บ query จริงที่เคยค้นไม่เจอ → เพิ่มเข้า golden set → ข้อสอบแกร่งขึ้นเรื่อยๆ
📊 เห็นภาพ — วัดผลจริง (bge-m3 vs MiniLM)¶
In [7]:
# @@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
import numpy as np
metrics=['Recall@3','MRR']; mini=[r_mini,mrr_mini]; bge=[r_bge,mrr_bge]
x=np.arange(2); w=0.35; plt.figure(figsize=(7,4))
b1=plt.bar(x-w/2,mini,w,label='MiniLM',color='#e34948')
b2=plt.bar(x+w/2,bge,w,label='bge-m3',color='#1baf7a')
plt.bar_label(b1,fmt='%.2f'); plt.bar_label(b2,fmt='%.2f')
plt.xticks(x,metrics); plt.ylim(0,1.15); plt.legend()
plt.title('บทที่ 11 — golden set ไทย: bge-m3 ชนะชัดด้วยตัวเลข'); plt.tight_layout(); plt.show()
🏋️ แบบฝึก¶
- เพิ่มข้อสอบอีก 3 ข้อจากโดเมนของคุณ — คะแนนสองโมเดลเปลี่ยนไหม?
- ลอง k=1 (เข้มสุด) — โมเดลไหนตกก่อน?
บทสุดท้าย: ch12 privacy & local-first — ทำไมทั้งหมดนี้ต้องอยู่ในเครื่องเรา