""" 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__)