📗 บทที่ 7 — Hybrid Search: vector อย่างเดียวไม่พอ¶
คู่กับ: หนังสือบทที่ 7
vector เก่งความหมาย แต่พลาด 2 อย่างเสมอ: รหัส/ชื่อเฉพาะ (ESP32, PR #2740) และคำว่า "ไม่" บทนี้สร้าง hybrid engine จริง: vector + keyword (BM25) + สูตรรวม RRF ตัวเดียวกับ ARRA production
In [1]:
import sys
IN_COLAB = 'google.colab' in sys.modules
if IN_COLAB:
%pip -q install chromadb sentence-transformers
import numpy as np, math, urllib.request, json
print('พร้อม ✓')
พร้อม ✓
In [2]:
# ---- ตัว embed อัจฉริยะ (เหมือนบทก่อน) ----
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_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)
print('embed: Ollama bge-m3 ✓')
else:
from sentence_transformers import SentenceTransformer
_m = SentenceTransformer('BAAI/bge-m3')
def embed_texts(texts):
return _m.encode(list(texts), normalize_embeddings=True)
print('embed: sentence-transformers ✓')
embed: Ollama bge-m3 ✓
1) vault ที่มี "รหัส/ชื่อเฉพาะ" ปน (เหมือนโน้ตจริง)¶
In [3]:
DOCS = [
'สั่งบอร์ด ESP32 มา 5 ตัวสำหรับ workshop IoT เดือนหน้า',
'PR #2740 แก้เรื่อง drift benchmark ของ vector search',
'ไมโครคอนโทรลเลอร์ราคาถูกเหมาะสอนนักศึกษาเริ่มต้น',
'สรุปวิธีตั้งค่า Arduino IDE ให้บอร์ดตระกูล espressif',
'บันทึกitems: อุปกรณ์อิเล็กทรอนิกส์ที่ต้องซื้อเข้าแล็บ',
'งานวิจัยเรื่องการวัดคุณภาพระบบค้นหาเชิงความหมาย',
]
V = embed_texts(DOCS)
print(f'{len(DOCS)} โน้ต · embed แล้ว {V.shape}')
6 โน้ต · embed แล้ว (6, 1024)
2) จุดที่ vector พลาด: ค้นรหัสเป๊ะๆ¶
In [4]:
def vector_rank(q):
qv = embed_texts([q])[0]
return list(np.argsort(-(V @ qv)))
q = 'PR #2740'
vr = vector_rank(q)
print(f'Q: {q} (vector อย่างเดียว)')
for r, i in enumerate(vr[:3], 1):
print(f' {r}. {DOCS[i][:52]}')
print()
print('→ ถ้าอันดับ 1 ไม่ใช่โน้ต PR #2740 = ตัวอย่าง vector จับรหัสเป๊ะไม่เก่ง (แล้วแต่รอบ)')
Q: PR #2740 (vector อย่างเดียว) 1. PR #2740 แก้เรื่อง drift benchmark ของ vector search 2. บันทึกitems: อุปกรณ์อิเล็กทรอนิกส์ที่ต้องซื้อเข้าแล็ 3. สั่งบอร์ด ESP32 มา 5 ตัวสำหรับ workshop IoT เดือนหน้ → ถ้าอันดับ 1 ไม่ใช่โน้ต PR #2740 = ตัวอย่าง vector จับรหัสเป๊ะไม่เก่ง (แล้วแต่รอบ)
3) BM25 ฉบับ 20 บรรทัด — keyword scoring แบบที่ FTS5 ใช้¶
$$\text{BM25}(q,d) = \sum_{t \in q} \text{IDF}(t) \cdot \frac{f(t,d)(k_1+1)}{f(t,d) + k_1(1-b+b\frac{|d|}{avgdl})}$$
In [5]:
def tokenize(s):
return [w.lower() for w in s.split() if w.strip()]
TOK = [tokenize(d) for d in DOCS]
avgdl = sum(len(t) for t in TOK) / len(TOK)
N = len(DOCS)
def bm25_rank(q, k1=1.5, b=0.75):
qt = tokenize(q)
scores = []
for toks in TOK:
s = 0.0
for t in qt:
f = toks.count(t)
if f == 0: continue
df = sum(1 for tt in TOK if t in tt)
idf = math.log((N - df + 0.5) / (df + 0.5) + 1)
s += idf * f * (k1 + 1) / (f + k1 * (1 - b + b * len(toks) / avgdl))
scores.append(s)
return list(np.argsort(-np.array(scores))), scores
br, bs = bm25_rank('PR #2740')
print('Q: PR #2740 (BM25 keyword)')
for r, i in enumerate(br[:3], 1):
print(f' {r}. (score {bs[i]:.2f}) {DOCS[i][:52]}')
print()
print('→ BM25 จับ "#2740" เป๊ะทันที — นี่คือสิ่งที่ FTS5 ใน ARRA ทำ')
Q: PR #2740 (BM25 keyword) 1. (score 2.18) PR #2740 แก้เรื่อง drift benchmark ของ vector search 2. (score 0.00) สั่งบอร์ด ESP32 มา 5 ตัวสำหรับ workshop IoT เดือนหน้ 3. (score 0.00) ไมโครคอนโทรลเลอร์ราคาถูกเหมาะสอนนักศึกษาเริ่มต้น → BM25 จับ "#2740" เป๊ะทันที — นี่คือสิ่งที่ FTS5 ใน ARRA ทำ
4) ⭐ RRF — สูตรรวมสองโลก (ตัวเดียวกับ ARRA เป๊ะ)¶
$$\text{RRF}(d) = \sum_{r \in \text{rankers}} \frac{1}{k + \text{rank}_r(d)}, \quad k=60$$
ใช้อันดับไม่ใช่คะแนน → ไม่ต้อง normalize ข้าม scale (cosine ∈[-1,1] vs BM25 ∈[0,∞))
In [6]:
def rrf(rank_lists, k=60):
scores = {}
for ranks in rank_lists:
for pos, doc_i in enumerate(ranks, 1):
scores[doc_i] = scores.get(doc_i, 0) + 1 / (k + pos)
return sorted(scores.items(), key=lambda x: -x[1])
for q in ['PR #2740', 'บอร์ดสำหรับสอน IoT']:
vr = vector_rank(q)
br, _ = bm25_rank(q)
fused = rrf([vr, br])
print(f'Q: {q}')
print(f' vector top-1: {DOCS[vr[0]][:42]}')
print(f' BM25 top-1: {DOCS[br[0]][:42]}')
print(f' ⭐ RRF top-1: (fused {fused[0][1]:.4f}) {DOCS[fused[0][0]][:42]}')
print()
print(f'สังเกต fusedScore สูงสุดที่เป็นไปได้ = 1/61+1/61 = {2/61:.6f}')
print(f'ARRA production มี fusedScore 0.016393 = 1/61 พอดี = doc ที่อันดับ 1 ใน ranker เดียว!')
Q: PR #2740 vector top-1: PR #2740 แก้เรื่อง drift benchmark ของ vec BM25 top-1: PR #2740 แก้เรื่อง drift benchmark ของ vec ⭐ RRF top-1: (fused 0.0328) PR #2740 แก้เรื่อง drift benchmark ของ vec
Q: บอร์ดสำหรับสอน IoT vector top-1: สั่งบอร์ด ESP32 มา 5 ตัวสำหรับ workshop Io BM25 top-1: สั่งบอร์ด ESP32 มา 5 ตัวสำหรับ workshop Io ⭐ RRF top-1: (fused 0.0328) สั่งบอร์ด ESP32 มา 5 ตัวสำหรับ workshop Io สังเกต fusedScore สูงสุดที่เป็นไปได้ = 1/61+1/61 = 0.032787 ARRA production มี fusedScore 0.016393 = 1/61 พอดี = doc ที่อันดับ 1 ใน ranker เดียว!
✅ วัดผลตัวเอง #7¶
In [7]:
# 1) BM25 ต้องจับรหัสเป๊ะ
br, bs = bm25_rank('PR #2740')
assert '2740' in DOCS[br[0]], 'BM25 ต้องเจอโน้ต PR #2740 เป็นอันดับ 1'
# 2) RRF ต้องไม่แพ้ทั้งสองด้าน: รหัสเป๊ะก็เจอ ความหมายก็เจอ
fused_code = rrf([vector_rank('PR #2740'), bm25_rank('PR #2740')[0]])
assert '2740' in DOCS[fused_code[0][0]], 'hybrid ต้องเจอรหัสเป๊ะ'
fused_sem = rrf([vector_rank('บอร์ดสำหรับสอน IoT'), bm25_rank('บอร์ดสำหรับสอน IoT')[0]])
assert any(w in DOCS[fused_sem[0][0]] for w in ['ESP32', 'ไมโครคอนโทรลเลอร์']), 'hybrid ต้องเจอความหมาย'
# 3) สูตร RRF ตรงกับ ARRA
assert abs(1/(60+1) - 0.016393) < 1e-6
print('✅ ผ่านทั้ง 3! hybrid = จับทั้งรหัสเป๊ะ (BM25) + ความหมาย (vector) — RRF k=60 สูตรเดียวกับ ARRA')
✅ ผ่านทั้ง 3! hybrid = จับทั้งรหัสเป๊ะ (BM25) + ความหมาย (vector) — RRF k=60 สูตรเดียวกับ ARRA
📊 เห็นภาพ — RRF fused score (Q: PR #2740)¶
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
q='PR #2740'; vr=vector_rank(q); br,_=bm25_rank(q); fused=rrf([vr,br])[:5]
lb=[DOCS[i][:22] for i,_ in fused]; sc=[s for _,s in fused]
plt.figure(figsize=(8,4)); plt.barh(lb[::-1],sc[::-1],color='#2a78d6')
plt.title(f'บทที่ 7 — RRF รวม vector+BM25 (Q: {q})'); plt.xlabel('RRF fused score')
plt.tight_layout(); plt.show()
🏋️ แบบฝึก¶
- เพิ่ม ranker ที่ 3 (เช่น เรียงตามความใหม่) เข้า
rrf([...])— ผลเปลี่ยนยังไง? - ลอง k=5 กับ k=200 — อันดับต้นถ่วงแรงต่างกันยังไง? (หนังสือบทที่ 7 §k)
บทต่อไป: ch08_ingest_vault.ipynb — กินโน้ตทั้งโฟลเดอร์เข้า second brain