243 lines
7.5 KiB
Python
243 lines
7.5 KiB
Python
"""
|
|
rag_lab.py - Kode untuk Lab 1, 2, dan 3 (chunking, indeks & pencarian, Hit@k)
|
|
|
|
Instalasi:
|
|
pip install pypdf chromadb sentence-transformers
|
|
|
|
Cara pakai:
|
|
python rag_lab.py lab1 dokumen/contoh.pdf
|
|
python rag_lab.py lab2 # indeks semua PDF di dokumen/ lalu tanya
|
|
python rag_lab.py lab3 pertanyaan_uji.json # hitung Hit@k
|
|
|
|
Format pertanyaan_uji.json:
|
|
[
|
|
{"pertanyaan": "Berapa batas usia pendaftar?", "kata_kunci": ["usia", "35 tahun"]},
|
|
...
|
|
]
|
|
"""
|
|
|
|
import os
|
|
|
|
os.environ["HF_HUB_DISABLE_SYMLINKS_WARNING"] = "1" # sembunyikan peringatan symlink di Windows
|
|
|
|
import json
|
|
import random
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import chromadb
|
|
from chromadb.utils import embedding_functions
|
|
from pypdf import PdfReader
|
|
|
|
FOLDER_DOKUMEN = Path("dokumen")
|
|
FOLDER_DB = "chroma_db"
|
|
# Model multibahasa, cocok untuk teks Indonesia
|
|
MODEL_EMBEDDING = "paraphrase-multilingual-MiniLM-L12-v2"
|
|
|
|
_embed_fn = None
|
|
_client = None
|
|
|
|
|
|
def _embedding():
|
|
global _embed_fn
|
|
if _embed_fn is None:
|
|
_embed_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
|
|
model_name=MODEL_EMBEDDING
|
|
)
|
|
return _embed_fn
|
|
|
|
|
|
def _db():
|
|
global _client
|
|
if _client is None:
|
|
_client = chromadb.PersistentClient(path=FOLDER_DB)
|
|
return _client
|
|
|
|
|
|
# ---------------------------------------------------------------- Lab 1
|
|
def baca_pdf(path):
|
|
"""Baca seluruh teks PDF menjadi satu string."""
|
|
reader = PdfReader(str(path))
|
|
halaman = []
|
|
for p in reader.pages:
|
|
teks = p.extract_text() or ""
|
|
halaman.append(teks.strip())
|
|
return "\n\n".join(halaman)
|
|
|
|
|
|
def potong(teks, ukuran=1000, overlap_persen=15):
|
|
"""
|
|
Potong teks menjadi chunk berukuran ~`ukuran` karakter dengan overlap.
|
|
Berusaha memotong di akhir kalimat/paragraf agar tidak terpotong di tengah.
|
|
"""
|
|
overlap = int(ukuran * overlap_persen / 100)
|
|
chunks = []
|
|
mulai = 0
|
|
n = len(teks)
|
|
|
|
while mulai < n:
|
|
akhir = min(mulai + ukuran, n)
|
|
|
|
if akhir < n:
|
|
# cari titik potong terbaik di 25% terakhir jendela
|
|
batas_cari = mulai + int(ukuran * 0.75)
|
|
jendela = teks[batas_cari:akhir]
|
|
titik = max(jendela.rfind("\n\n"), jendela.rfind(". "), jendela.rfind(".\n"))
|
|
if titik != -1:
|
|
akhir = batas_cari + titik + 1
|
|
|
|
potongan = teks[mulai:akhir].strip()
|
|
if potongan:
|
|
chunks.append(potongan)
|
|
|
|
if akhir >= n:
|
|
break
|
|
mulai_baru = max(akhir - overlap, mulai + 1)
|
|
# geser maju sampai awal kata berikutnya agar tidak memotong kata
|
|
while mulai_baru < akhir and not teks[mulai_baru - 1].isspace():
|
|
mulai_baru += 1
|
|
mulai = mulai_baru
|
|
|
|
return chunks
|
|
|
|
|
|
def lab1(path_pdf):
|
|
teks = baca_pdf(path_pdf)
|
|
print(f"Total karakter: {len(teks)}")
|
|
print("\n=== 500 karakter pertama ===")
|
|
print(teks[:500])
|
|
|
|
random.seed(42)
|
|
for ukuran in (500, 1000, 1500):
|
|
overlap_persen = 15
|
|
chunks = potong(teks, ukuran, overlap_persen)
|
|
print(f"\n{'=' * 60}")
|
|
print(f"Ukuran {ukuran}, overlap {overlap_persen}% -> {len(chunks)} chunk")
|
|
for i in random.sample(range(len(chunks)), min(3, len(chunks))):
|
|
print(f"\n--- chunk #{i} ({len(chunks[i])} karakter) ---")
|
|
print(chunks[i])
|
|
akhir = chunks[i].rstrip()[-1:]
|
|
if akhir not in ".!?:)":
|
|
print("[!] Chunk tidak berakhir di tanda baca: kemungkinan terpotong")
|
|
|
|
|
|
# ---------------------------------------------------------------- Lab 2
|
|
def buat_indeks(nama_koleksi, ukuran, overlap_persen=15, folder=FOLDER_DOKUMEN):
|
|
"""Indeks semua PDF di `folder` ke koleksi baru bernama `nama_koleksi`."""
|
|
try:
|
|
_db().delete_collection(nama_koleksi)
|
|
except Exception:
|
|
pass
|
|
koleksi = _db().create_collection(
|
|
nama_koleksi,
|
|
embedding_function=_embedding(),
|
|
metadata={"hnsw:space": "cosine"},
|
|
)
|
|
|
|
pdfs = sorted(Path(folder).glob("*.pdf"))
|
|
if not pdfs:
|
|
sys.exit(f"Tidak ada PDF di folder '{folder}/'.")
|
|
|
|
for pdf in pdfs:
|
|
chunks = potong(baca_pdf(pdf), ukuran, overlap_persen)
|
|
if not chunks:
|
|
print(f"[!] {pdf.name}: tidak ada teks terbaca (PDF hasil scan?)")
|
|
continue
|
|
koleksi.add(
|
|
ids=[f"{pdf.stem}-{i}" for i in range(len(chunks))],
|
|
documents=chunks,
|
|
metadatas=[{"sumber": pdf.name, "urutan": i} for i in range(len(chunks))],
|
|
)
|
|
print(f"{pdf.name}: {len(chunks)} chunk -> koleksi '{nama_koleksi}'")
|
|
return koleksi
|
|
|
|
|
|
def ambil_koleksi(nama_koleksi):
|
|
return _db().get_collection(nama_koleksi, embedding_function=_embedding())
|
|
|
|
|
|
def query(nama_koleksi, pertanyaan, k=3, tampil=True):
|
|
"""Cari k chunk paling mirip. Mengembalikan list dict."""
|
|
hasil = ambil_koleksi(nama_koleksi).query(query_texts=[pertanyaan], n_results=k)
|
|
out = []
|
|
for doc, meta, jarak in zip(
|
|
hasil["documents"][0], hasil["metadatas"][0], hasil["distances"][0]
|
|
):
|
|
out.append({"teks": doc, "sumber": meta["sumber"], "skor": 1 - jarak})
|
|
if tampil:
|
|
print(f"\nPertanyaan: {pertanyaan}")
|
|
for r in out:
|
|
print(f" [{r['skor']:.3f}] {r['sumber']}: {r['teks'][:700].replace(chr(10), ' ')}...\n")
|
|
return out
|
|
|
|
|
|
def lab2():
|
|
buat_indeks("chunk_1000", ukuran=1000)
|
|
print("\nKetik pertanyaan (kosong untuk selesai). Coba 5 biasa + 2 parafrase.")
|
|
while True:
|
|
q = input("\n> ").strip()
|
|
if not q:
|
|
break
|
|
query("chunk_1000", q, k=3)
|
|
|
|
|
|
# ---------------------------------------------------------------- Lab 3
|
|
def hit_at_k(nama_koleksi, soal, k):
|
|
"""
|
|
Hit@k = proporsi pertanyaan yang kata kuncinya muncul
|
|
(minimal satu kata kunci) di salah satu dari k chunk teratas.
|
|
Mengembalikan (skor, daftar_pertanyaan_gagal).
|
|
"""
|
|
hit = 0
|
|
gagal = []
|
|
for s in soal:
|
|
hasil = query(nama_koleksi, s["pertanyaan"], k=k, tampil=False)
|
|
gabungan = " ".join(" ".join(r["teks"].split()) for r in hasil).lower()
|
|
if any(" ".join(kw.split()).lower() in gabungan for kw in s["kata_kunci"]):
|
|
hit += 1
|
|
else:
|
|
gagal.append(s["pertanyaan"])
|
|
return hit / len(soal), gagal
|
|
|
|
|
|
def lab3(path_soal):
|
|
soal = json.loads(Path(path_soal).read_text(encoding="utf-8"))
|
|
print(f"{len(soal)} pertanyaan uji dimuat.")
|
|
|
|
ukuran_uji = {"chunk_500": 500, "chunk_1500": 1500}
|
|
for nama, ukuran in ukuran_uji.items():
|
|
buat_indeks(nama, ukuran)
|
|
|
|
print(f"\n{'Koleksi':<12}{'Hit@1':>8}{'Hit@3':>8}{'Hit@5':>8}")
|
|
gagal_per_koleksi = {}
|
|
for nama in ukuran_uji:
|
|
baris = []
|
|
for k in (1, 3, 5):
|
|
skor, gagal = hit_at_k(nama, soal, k)
|
|
baris.append(skor)
|
|
if k == 5:
|
|
gagal_per_koleksi[nama] = gagal
|
|
print(f"{nama:<12}" + "".join(f"{b:>8.2f}" for b in baris))
|
|
|
|
print("\nPertanyaan yang gagal pada k=5:")
|
|
for nama, gagal in gagal_per_koleksi.items():
|
|
print(f"\n[{nama}] {len(gagal)} gagal")
|
|
for g in gagal:
|
|
print(f" - {g}")
|
|
|
|
print("\nTulis kesimpulan 3-4 kalimat: ukuran terbaik, jenis pertanyaan yang gagal, dugaan penyebab.")
|
|
|
|
|
|
# ---------------------------------------------------------------- CLI
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) < 2:
|
|
sys.exit(__doc__)
|
|
perintah = sys.argv[1]
|
|
if perintah == "lab1" and len(sys.argv) == 3:
|
|
lab1(sys.argv[2])
|
|
elif perintah == "lab2":
|
|
lab2()
|
|
elif perintah == "lab3" and len(sys.argv) == 3:
|
|
lab3(sys.argv[2])
|
|
else:
|
|
sys.exit(__doc__) |