BeSoK-TES / py /rag_engine.py
IndahPurn's picture
Update py/rag_engine.py
9a93e0a verified
Raw
History Blame Contribute Delete
24.3 kB
import os
import json
import random
import re
import time
import numpy as np # untuk vektorisasi cosine
import pypdf # SDK Gemini terbaru
from google import genai
from google.genai import types
from pydantic import BaseModel, Field # validasi skema output JSON
from rank_bm25 import BM25Okapi # algoritma BM25 (sparse retrieval)
# chunk_text & normalize_pdf_text dipisah ke text_utils.py supaya file ini
# tetap fokus pada alur RAG, bukan detail pembersihan teks.
from py.text_utils import chunk_text, normalize_pdf_text
# ===================================================================
# KONFIGURASI - semua "tombol putar" sistem ada di sini
# ===================================================================
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATASET_DIR = os.path.join(BASE_DIR, "assets", "dataset")
DB_FILE_PATH = os.path.join(DATASET_DIR, "vector_db.json")
# Dua model Gemini yg dipakai:
# - EMBEDDING_MODEL : ubah teks jadi vektor
# - GENERATION_MODEL : ubah konteks+instruksi jadi soal kuis
# Keduanya bisa diganti versi lain tanpa mengubah kode lain di sini.
EMBEDDING_MODEL = "gemini-embedding-001"
GENERATION_MODEL = "gemini-2.5-flash"
# TASK_TYPE adalah "petunjuk" ke model embedding tentang akan dipakai untuk apa.
# Setiap pilihan memberi distribusi vektor yang berbeda:
#
# "RETRIEVAL_QUERY" + "RETRIEVAL_DOCUMENT"
# Pasangan ASIMETRIS, dilatih untuk RANKING retrieval terbaik
# (Recall@k tinggi). Tapi nilai cosine absolutnya cenderung
# rendah (~0.7-0.8), walau peringkatnya sudah benar.
#
# "SEMANTIC_SIMILARITY"
# SIMETRIS — keduanya (kueri & dokumen) berada di ruang yang sama,
# dilatih supaya cosine mencerminkan kemiripan sebenarnya.
# Nilai antar teks yg berkaitan biasanya 0.85-0.95.
# SEMANTIC_SIMILARITY untuk semua embedding.
EMBED_TASK_TYPE = "SEMANTIC_SIMILARITY"
TOP_K = 15 # berapa chunk konteks dikirim ke LLM. Makin banyak makin
# kaya konteks tapi lebih boros token & bisa "noisy".
BATCH_SIZE = 100 # API Gemini menerima banyak teks per panggilan.
# Kita batch supaya hemat panggilan + aman dari limit.
GEN_TEMPERATURE = 0.9 # 0 = kaku/deterministik, 1 = kreatif.
# 0.9 = variatif tapi tetap setia ke konteks.
# Dinaikkan kalau STRUKTUR data berubah -> database lama auto-rebuild.
# Cara aman menghindari "diam-diam pakai data basi yang formatnya beda".
SCHEMA_VERSION = 3
# Konstanta RRF (Reciprocal Rank Fusion). 60 = default standar di literatur
# (Cormack et al. 2009). Lihat fungsi hybrid_search untuk pemakaiannya.
RRF_K = 60
# Konten skripsi punya 3 tingkat kesulitan; tiap PDF dilabeli salah satunya.
LEVELS = ["mudah", "sedang", "sulit"]
# Mapping dari "topik" yg dikirim frontend -> level chunk yg akan diambil.
# Misal frontend kirim "level_1" -> hanya retrieve dari chunk dataset-mudah.pdf.
LEVEL_MAP = {"level_1": "mudah", "level_2": "sedang", "level_3": "sulit"}
# ===================================================================
# AUTHENTIKASI - muat .env supaya GEMINI_API_KEY tersedia di env var
# ===================================================================
# Kenapa di sini juga? Supaya rag_engine bisa jalan STANDALONE
# (mis. saat evaluator dijalankan tanpa lewat main.py). Kalau lewat
# uvicorn, main.py sudah memanggil load_dotenv duluan -> panggilan
# kedua di sini tidak masalah (no-op, aman).
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
# Client SDK baru otomatis membaca API key dari env var
# GEMINI_API_KEY atau GOOGLE_API_KEY.
client = genai.Client()
# ===================================================================
# STATE GLOBAL - data yg disimpan di memori selama server hidup
# ===================================================================
vector_db = [] # list dict: [{"id","text","vector","level","source"}, ...]
retrieval_health_score = 0.0 # angka similarity utama (yg dipakai di laporan)
_doc_matrix = None # matriks vektor semua chunk (N x D), pre-compute
_doc_norms = None # panjang (norm) tiap baris vektor (cache)
_bm25_index = None # indeks BM25 untuk pencarian kata kunci
# ===================================================================
# SKEMA OUTPUT KUIS (Pydantic + structured output)
# ===================================================================
# Dengan response_schema=list[SoalKuis], Gemini DIJAMIN mengembalikan
# JSON yang bentuknya persis seperti ini. Tidak perlu lagi parsing
# string manual atau berdoa LLM patuh format. Field(description=...)
# menjadi petunjuk kepada model tentang isi field tsb.
class SoalKuis(BaseModel):
pertanyaan: str = Field(description="Teks pertanyaan kuis")
pilihan: list[str] = Field(description="Tepat 4 opsi jawaban")
jawaban: str = Field(description="Jawaban benar; HARUS sama persis dengan salah satu string di 'pilihan'")
penjelasan: str = Field(description="Penjelasan singkat mengapa jawaban itu benar")
# ===================================================================
# UTIL: retry & tokenizer
# ===================================================================
def _with_retry(fn, max_attempts=3, base_delay=1.0):
"""
Panggil fungsi fn(). Kalau gagal, coba lagi maksimal 3 kali dengan
exponential backoff (1 detik -> 2 detik -> 4 detik). Berguna untuk:
- Hiccup jaringan sesaat
- Rate limit Gemini (HTTP 429)
- Error sementara di server Google
Kalau tetap gagal di percobaan terakhir, error-nya dilemparkan keluar.
"""
for attempt in range(max_attempts):
try:
return fn()
except Exception as e:
if attempt == max_attempts - 1:
raise
delay = base_delay * (2 ** attempt) # 1s -> 2s -> 4s
print(f" [retry] gagal: {e}. Coba lagi dalam {delay:.1f}s...")
time.sleep(delay)
def _tokenize(text):
"""
Pecah teks jadi daftar kata untuk dipakai BM25.
- Lowercase semua supaya pencocokan case-insensitive.
- Ambil hanya karakter: huruf/angka/Arab/apostrof. Tanda baca dibuang.
- \\u0600-\\u06FF = rentang Unicode untuk aksara Arab.
- Apostrof (') dipertahankan -> penting untuk istilah seperti a'la, i'tidal.
"""
return re.findall(r"[\w\u0600-\u06FF']+", text.lower())
# ===================================================================
# EMBEDDING - panggil API Gemini untuk ubah teks -> vektor
# ===================================================================
def embed_texts(texts, task_type):
"""
Ubah list teks jadi list vektor (embedding).
- Otomatis dipecah jadi batch supaya tidak melebihi limit API.
- Dilindungi retry untuk hiccup jaringan.
Output: list of list-of-float. Tiap vektor punya dimensi tetap
(3072 untuk gemini-embedding-001). Vektor inilah yang dipakai
untuk hitung cosine similarity nanti.
"""
vectors = []
for i in range(0, len(texts), BATCH_SIZE):
batch = texts[i:i + BATCH_SIZE]
resp = _with_retry(lambda: client.models.embed_content(
model=EMBEDDING_MODEL,
contents=batch,
config=types.EmbedContentConfig(task_type=task_type),
))
# resp.embeddings = list objek ContentEmbedding; .values = vektor float
vectors.extend([e.values for e in resp.embeddings])
return vectors
# ===================================================================
# INDEX - bangun struktur data yg dipakai berulang utk pencarian
# ===================================================================
def _rebuild_indexes():
"""
Pra-hitung 3 hal SEKALI saja supaya pencarian (yg dipanggil tiap
request) jadi cepat:
1. _doc_matrix = matriks NumPy (N chunk x D dimensi). Dengan ini,
cosine seluruh chunk thd kueri bisa dihitung
dengan SATU perkalian matriks (super cepat).
2. _doc_norms = panjang setiap vektor chunk. Cache supaya tidak
dihitung ulang setiap query.
3. _bm25_index = indeks BM25 di atas teks chunk. BM25Okapi membangun
struktur internal (IDF, panjang dokumen, dst).
"""
global _doc_matrix, _doc_norms, _bm25_index
if vector_db:
_doc_matrix = np.array([it["vector"] for it in vector_db], dtype=np.float32)
_doc_norms = np.linalg.norm(_doc_matrix, axis=1)
_bm25_index = BM25Okapi([_tokenize(c["text"]) for c in vector_db])
def _similarities(query_vec):
"""
Hitung cosine similarity antara satu vektor kueri vs SEMUA dokumen.
RUMUS COSINE: cos(a, b) = (a . b) / (|a| * |b|)
a . b = dot product (jumlah perkalian elemen)
|a| = panjang vektor a (L2 norm)
|b| = panjang vektor b
Implementasi NumPy tervektorisasi (no for-loop, jauh lebih cepat):
_doc_matrix @ q -> dot product semua dokumen dgn kueri sekaligus
_doc_norms * |q| -> penyebut untuk masing-masing
+1e-10 -> cegah pembagian dengan nol (jaga-jaga)
Output: array berukuran N (jumlah chunk), tiap elemen = skor kemiripan
antara kueri dengan chunk ke-i.
"""
q = np.array(query_vec, dtype=np.float32)
denom = (_doc_norms * np.linalg.norm(q)) + 1e-10
return (_doc_matrix @ q) / denom
def similarities_for(query_vec):
"""Helper publik (dense-only). Dipakai modul lain mis. evaluator.py."""
if _doc_matrix is None:
_rebuild_indexes()
return _similarities(query_vec)
# ===================================================================
# HYBRID RETRIEVAL - kombinasi DENSE + BM25 pakai RRF
# ===================================================================
def hybrid_search(query_text, query_vec, target_level=None, top_n=None):
"""
Pencarian HYBRID = gabungan 2 metode retrieval yg saling melengkapi:
DENSE (cosine antar embedding):
- Bagus untuk menangkap MAKNA. Mis. "berapa kali sholat sehari"
akan dekat ke chunk "sholat fardhu lima waktu" walau kata-katanya
berbeda.
- Lemah untuk istilah eksak (angka, nama Arab transliterasi).
BM25 (cocokkan kata kunci):
- Bagus untuk ISTILAH PERSIS, angka, nama. Mis. "2 rakaat" akan
match chunk yg literal mengandung "2 rakaat".
- Lemah untuk parafrase (sinonim tidak dianggap mirip).
RRF (Reciprocal Rank Fusion):
- Tidak menggabungkan SKOR mentah dari dua metode (karena skalanya
beda — cosine 0..1, BM25 bisa puluhan).
- Menggabungkan RANK (peringkat) saja, jadi adil.
- Rumus: skor(dok) = sum dari 1/(K + rank) di tiap retriever.
- K = 60 = konstanta empiris standar di literatur.
- Dokumen yang tinggi di KEDUA metode -> RRF score paling tinggi.
target_level (opsional): kalau diisi (mis. "mudah"), chunk dari level
itu diprioritaskan di awal kedua daftar peringkat -> mendapat
skor RRF lebih tinggi -> kemungkinan besar terpilih.
"""
if _doc_matrix is None or _bm25_index is None:
_rebuild_indexes()
# Langkah 1: hitung skor dari kedua metode
sims = _similarities(query_vec)
bm25_scores = _bm25_index.get_scores(_tokenize(query_text))
# Langkah 2: urutkan dari skor tertinggi ke terendah (descending).
# np.argsort default ASCENDING -> [::-1] membalik jadi DESCENDING.
dense_order = [int(i) for i in np.argsort(sims)[::-1]]
bm25_order = [int(i) for i in np.argsort(bm25_scores)[::-1]]
# Langkah 3: kalau ada filter level, prioritaskan chunk level itu
# dgn cara memajukan posisinya di kedua urutan (others dipindah ke
# belakang) -> rank-nya jadi lebih kecil -> RRF score lebih tinggi.
if target_level:
def _level_first(order):
same = [i for i in order if vector_db[i].get("level") == target_level]
other = [i for i in order if vector_db[i].get("level") != target_level]
return same + other
dense_order = _level_first(dense_order)
bm25_order = _level_first(bm25_order)
# Langkah 4: RRF — jumlahkan 1/(K + rank) dari kedua sumber.
# enumerate dari 1 -> rank dimulai dari 1 (bukan 0).
rrf = {}
for rank, idx in enumerate(dense_order, start=1):
rrf[idx] = rrf.get(idx, 0.0) + 1.0 / (RRF_K + rank)
for rank, idx in enumerate(bm25_order, start=1):
rrf[idx] = rrf.get(idx, 0.0) + 1.0 / (RRF_K + rank)
# Langkah 5: urutkan dari skor RRF terbesar -> ambil top_n teratas.
final = sorted(rrf.keys(), key=lambda i: rrf[i], reverse=True)
return final[:top_n] if top_n is not None else final
# ===================================================================
# KESEHATAN RETRIEVAL (angka utama yg dilaporkan di skripsi)
# ===================================================================
# Pertanyaan patokan, satu per level. Dipakai untuk mengukur
# "seberapa cocok materi di tiap level dgn pertanyaan tipikalnya".
BENCHMARK_QUERIES = [
"Materi sholat anak dasar: nama sholat wajib, waktu pelaksanaan pagi siang malam, jumlah rakaat, dan niat sholat.",
"Materi sholat anak sedang: Syarat sah sholat, rukun sholat, dan tata cara gerakan sholat lengkap dari takbiratul ihram sampai salam.",
"Materi sholat anak sulit: Hafalan bacaan sholat, doa iftitah, bacaan rukuk, bacaan sujud, dan bacaan tahiyat akhir.",
]
def calculate_retrieval_health():
"""
Hitung skor "kemiripan retrieval" yg ditampilkan ke user / laporan.
Cara kerja:
Untuk setiap level, embed pertanyaan patokannya dgn task_type yang
sama dgn embedding chunk. Lalu cari chunk PALING MIRIP di DALAM
level itu. Skor = cosine top-1. Rata-rata ketiga level = skor global.
Skor ini = indikator: "apakah materi di tiap level memang relevan
dgn pertanyaan tipikal level itu?"
Karena task_type-nya SEMANTIC_SIMILARITY, nilai biasanya 0.85-0.95.
"""
global retrieval_health_score
print("-> Mengecek kesehatan retrieval per-level...")
q_embeddings = embed_texts(BENCHMARK_QUERIES, EMBED_TASK_TYPE)
scores = []
for level, qv in zip(LEVELS, q_embeddings):
sims = _similarities(qv)
# Filter: ambil indeks chunk yg levelnya cocok
level_idx = [i for i, c in enumerate(vector_db) if c.get("level") == level]
# Cari nilai cosine TERTINGGI di antara chunk level itu
best = float(sims[level_idx].max()) if level_idx else 0.0
scores.append(best)
print(f" {level:<8}: top-1 = {best * 100:.1f}%")
retrieval_health_score = sum(scores) / len(scores) if scores else 0.0
print(f" Rata-rata: {retrieval_health_score * 100:.1f}%\n")
# ===================================================================
# STAGE 1 - INDEXING (dijalankan sekali di startup)
# ===================================================================
def setup_vector_db():
"""
Bangun database vektor dari PDF. Dipanggil di startup server.
- Kalau sudah ada vector_db.json yg KOMPATIBEL -> langsung dimuat (cepat).
- Kalau belum ada / model berubah / schema berubah -> bangun ulang dari PDF.
"""
global vector_db
print("\n=== [STAGE 1] PROSES LEARNING & INDEXING SISTEM RAG ===")
# --- Coba muat database yang sudah ada ---
if os.path.exists(DB_FILE_PATH):
with open(DB_FILE_PATH, "r", encoding="utf-8") as f:
saved = json.load(f)
# Hanya pakai cache kalau MODEL & STRUKTUR-nya sama dgn konfigurasi
# saat ini. Kalau model embedding berbeda, dimensi vektor berbeda,
# dan vektor lama tidak boleh dipakai (akan beri hasil ngawur).
compatible = (
isinstance(saved, dict)
and saved.get("model") == EMBEDDING_MODEL
and saved.get("schema_version") == SCHEMA_VERSION
)
if compatible:
vector_db = saved["chunks"]
print(f"-> Memuat Ingatan AI: {len(vector_db)} memori dipulihkan.")
_rebuild_indexes()
calculate_retrieval_health()
return
else:
print("-> Model/struktur berubah. Membangun ulang database...")
# --- Bangun database dari PDF ---
vector_db = []
chunk_id = 0
print(f"-> Embedding dengan model: {EMBEDDING_MODEL}\n")
# Loop per level: tiap PDF dianggap punya konten untuk satu tingkat kesulitan
for level in LEVELS:
pdf_file_path = os.path.join(DATASET_DIR, f"dataset-{level}.pdf")
if not os.path.exists(pdf_file_path):
print(f" PERINGATAN: {pdf_file_path} tidak ditemukan!")
continue
# 1. Baca PDF -> gabungkan teks semua halaman
pdf_reader = pypdf.PdfReader(pdf_file_path)
raw_text = " ".join([page.extract_text() or "" for page in pdf_reader.pages])
# 2. Bersihkan teks (rapikan spasi, pertahankan apostrof, dst)
text_content = normalize_pdf_text(raw_text)
# 3. Pecah jadi chunk (~100 kata yg menghormati batas kalimat)
chunks = chunk_text(text_content)
if not chunks:
continue
# 4. Embedding semua chunk sekaligus (batched + retry)
embeddings = embed_texts(chunks, EMBED_TASK_TYPE)
# 5. Simpan ke vector_db DENGAN METADATA (level, source).
# Metadata penting supaya retrieval bisa difilter per level
# dan supaya nanti bisa menampilkan "soal ini dari PDF apa".
for i, ch in enumerate(chunks):
vector_db.append({
"id": chunk_id,
"text": ch, # teks asli (utk konteks LLM)
"vector": embeddings[i], # embedding (utk cari kemiripan)
"level": level, # metadata: tingkat kesulitan
"source": f"dataset-{level}.pdf", # metadata: asal PDF (sitasi)
})
chunk_id += 1
print(f" dataset-{level}.pdf -> {len(chunks)} chunk dipelajari")
if not vector_db:
print("-> GAGAL: Tidak ada satupun materi yang berhasil dipelajari.")
return
# Simpan ke disk supaya lain kali bisa langsung dimuat (cache).
# Sertakan METADATA model & versi schema -> deteksi inkompatibilitas otomatis.
with open(DB_FILE_PATH, "w", encoding="utf-8") as f:
json.dump({
"schema_version": SCHEMA_VERSION, # versi struktur
"model": EMBEDDING_MODEL, # nama model yg dipakai
"dim": len(vector_db[0]["vector"]), # dimensi vektor (info)
"chunks": vector_db, # data sebenarnya
}, f, ensure_ascii=False, indent=4)
print(f"-> Selesai. Total {len(vector_db)} blok memori tersimpan.")
_rebuild_indexes()
calculate_retrieval_health()
# ===================================================================
# STAGE 2 - RETRIEVAL + GENERATE (dijalankan tiap permintaan kuis)
# ===================================================================
def generate_quiz(topik):
"""
Pipeline lengkap untuk satu permintaan kuis dari frontend:
1. Embed pertanyaan user
2. Cari top-K chunk paling relevan (hybrid + filter level)
3. Kirim chunk + instruksi ke Gemini -> dapat 5 soal kuis JSON
4. Validasi & acak urutan -> kembalikan ke frontend
"""
if not vector_db:
raise Exception("Vektor DB kosong! Jalankan setup_vector_db() dulu.")
if _doc_matrix is None or _bm25_index is None:
_rebuild_indexes()
print(f"\n=== [STAGE 2] RETRIEVAL & GENERATE ({topik.upper()}) ===")
# Pilih kueri & label kesulitan berdasarkan topik yg dikirim frontend
if topik == "level_1":
user_query = BENCHMARK_QUERIES[0]
tingkat_kesulitan = "SANGAT MUDAH (Kelas 1 SD)"
elif topik == "level_2":
user_query = BENCHMARK_QUERIES[1]
tingkat_kesulitan = "SEDANG (Kelas 3 SD)"
elif topik == "level_3":
user_query = BENCHMARK_QUERIES[2]
tingkat_kesulitan = "SULIT (Kelas 6 SD)"
else:
user_query = "Informasi sholat dan keutamaannya."
tingkat_kesulitan = "UMUM"
target_level = LEVEL_MAP.get(topik)
# --- Retrieval HYBRID + filter level ---
query_vec = embed_texts([user_query], EMBED_TASK_TYPE)[0]
top_indices = hybrid_search(user_query, query_vec,
target_level=target_level, top_n=TOP_K)
top_chunks = [vector_db[i] for i in top_indices]
# Gabungkan teks chunk jadi satu blok konteks panjang utk LLM.
# "\n\n" antar chunk membantu LLM mengenali batas paragraf.
context_text = "\n\n".join(c["text"] for c in top_chunks)
sources = [c.get("source", "?") for c in top_chunks]
print(f" Kemiripan retrieval (global health): {retrieval_health_score * 100:.1f}%")
print(f" Filter level: {target_level or 'semua'}")
print(f" {len(top_chunks)} paragraf konteks; asal: {dict((s, sources.count(s)) for s in set(sources))}\n")
# --- Generate kuis pakai Gemini ---
print(f"-> Merakit kuis dengan: {GENERATION_MODEL}")
prompt = f"""
Kamu adalah pembuat soal kuis untuk anak. Berdasarkan MATERI di bawah,
buat 5 soal pilihan ganda tentang sholat.
MATERI:
{context_text}
ATURAN ISI SOAL:
- Sesuaikan gaya bahasa & tingkat kesulitan menjadi: {tingkat_kesulitan}.
- Sapa anak dengan sebutan 'Anak Pintar' bila cocok.
- Wajib minimal 2 soal berbentuk SOAL CERITA / studi kasus sehari-hari,
memakai nama anak (Andi, Budi, Aisyah, Fatimah, dll).
Contoh: "Saat bel istirahat berbunyi, Andi melihat bayangan benda sama
panjang dengan aslinya. Sholat apa yang harus Andi kerjakan?"
- Gali detail unik dari materi; hindari soal teori yang kaku dan berulang.
- Setiap soal punya tepat 4 pilihan, dan 'jawaban' HARUS sama persis dengan
salah satu teks di 'pilihan'.
""".strip()
# Structured output: response_mime_type + response_schema membuat
# Gemini DIJAMIN mengembalikan JSON valid sesuai skema list[SoalKuis].
# Tidak perlu lagi prompt "jangan pakai markdown" atau regex
# untuk membersihkan jawaban!
response = _with_retry(lambda: client.models.generate_content(
model=GENERATION_MODEL,
contents=prompt,
config=types.GenerateContentConfig(
temperature=GEN_TEMPERATURE,
response_mime_type="application/json",
response_schema=list[SoalKuis],
),
))
# response.parsed sudah jadi list[SoalKuis] (objek pydantic).
# Fallback ke parse manual kalau entah kenapa parsing internal gagal.
parsed = response.parsed
if not parsed:
parsed = [SoalKuis(**d) for d in json.loads(response.text)]
# Validasi terakhir: pastikan 'jawaban' BENAR-BENAR salah satu 'pilihan'
# (cocokkan walau whitespace beda). Soal yg jawabannya tidak match
# dianggap rusak dan dibuang -> mencegah kuis dgn "soal tanpa jawaban".
quiz_data = []
for soal in parsed:
d = soal.model_dump() # ubah pydantic obj -> dict biasa
match = next((opt for opt in d["pilihan"] if opt.strip() == d["jawaban"].strip()), None)
if match is None:
continue
d["jawaban"] = match # normalisasi nilai jawaban (samakan dgn opsi)
quiz_data.append(d)
if not quiz_data:
raise Exception("AI tidak menghasilkan soal yang valid. Coba klik levelnya lagi!")
# Acak urutan soal supaya tiap kali user main, kuisnya terasa baru
random.shuffle(quiz_data)
print(f"-> BERHASIL: {len(quiz_data)} soal dikirim ke layar!")
return quiz_data