commit 4f2a26e6960cb16e7deb8b998d4f24c17b9300d0 Author: matthewflrnt Date: Mon Nov 2 11:32:33 2026 +0700 initial commit diff --git a/Dokumentasi_Output_Lab.docx b/Dokumentasi_Output_Lab.docx new file mode 100644 index 0000000..8f445c0 Binary files /dev/null and b/Dokumentasi_Output_Lab.docx differ diff --git a/__pycache__/evaluasi_retrieval.cpython-312.pyc b/__pycache__/evaluasi_retrieval.cpython-312.pyc new file mode 100644 index 0000000..5e58f51 Binary files /dev/null and b/__pycache__/evaluasi_retrieval.cpython-312.pyc differ diff --git a/__pycache__/main.cpython-312.pyc b/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000..10f1bbd Binary files /dev/null and b/__pycache__/main.cpython-312.pyc differ diff --git a/__pycache__/search.cpython-312.pyc b/__pycache__/search.cpython-312.pyc new file mode 100644 index 0000000..190aeb7 Binary files /dev/null and b/__pycache__/search.cpython-312.pyc differ diff --git a/chroma_db/0afb79e6-2dff-4c34-b538-037b57c0e8a3/data_level0.bin b/chroma_db/0afb79e6-2dff-4c34-b538-037b57c0e8a3/data_level0.bin new file mode 100644 index 0000000..5efb1b9 Binary files /dev/null and b/chroma_db/0afb79e6-2dff-4c34-b538-037b57c0e8a3/data_level0.bin differ diff --git a/chroma_db/0afb79e6-2dff-4c34-b538-037b57c0e8a3/header.bin b/chroma_db/0afb79e6-2dff-4c34-b538-037b57c0e8a3/header.bin new file mode 100644 index 0000000..bb54792 Binary files /dev/null and b/chroma_db/0afb79e6-2dff-4c34-b538-037b57c0e8a3/header.bin differ diff --git a/chroma_db/0afb79e6-2dff-4c34-b538-037b57c0e8a3/length.bin b/chroma_db/0afb79e6-2dff-4c34-b538-037b57c0e8a3/length.bin new file mode 100644 index 0000000..2531fb0 Binary files /dev/null and b/chroma_db/0afb79e6-2dff-4c34-b538-037b57c0e8a3/length.bin differ diff --git a/chroma_db/0afb79e6-2dff-4c34-b538-037b57c0e8a3/link_lists.bin b/chroma_db/0afb79e6-2dff-4c34-b538-037b57c0e8a3/link_lists.bin new file mode 100644 index 0000000..e69de29 diff --git a/chroma_db/686a47f5-9719-4b1a-888c-8e289fe408d7/data_level0.bin b/chroma_db/686a47f5-9719-4b1a-888c-8e289fe408d7/data_level0.bin new file mode 100644 index 0000000..5efb1b9 Binary files /dev/null and b/chroma_db/686a47f5-9719-4b1a-888c-8e289fe408d7/data_level0.bin differ diff --git a/chroma_db/686a47f5-9719-4b1a-888c-8e289fe408d7/header.bin b/chroma_db/686a47f5-9719-4b1a-888c-8e289fe408d7/header.bin new file mode 100644 index 0000000..bb54792 Binary files /dev/null and b/chroma_db/686a47f5-9719-4b1a-888c-8e289fe408d7/header.bin differ diff --git a/chroma_db/686a47f5-9719-4b1a-888c-8e289fe408d7/length.bin b/chroma_db/686a47f5-9719-4b1a-888c-8e289fe408d7/length.bin new file mode 100644 index 0000000..cb3e162 Binary files /dev/null and b/chroma_db/686a47f5-9719-4b1a-888c-8e289fe408d7/length.bin differ diff --git a/chroma_db/686a47f5-9719-4b1a-888c-8e289fe408d7/link_lists.bin b/chroma_db/686a47f5-9719-4b1a-888c-8e289fe408d7/link_lists.bin new file mode 100644 index 0000000..e69de29 diff --git a/chroma_db/b200ee9e-978b-4ad9-9cca-8215ffdd6dbf/data_level0.bin b/chroma_db/b200ee9e-978b-4ad9-9cca-8215ffdd6dbf/data_level0.bin new file mode 100644 index 0000000..5efb1b9 Binary files /dev/null and b/chroma_db/b200ee9e-978b-4ad9-9cca-8215ffdd6dbf/data_level0.bin differ diff --git a/chroma_db/b200ee9e-978b-4ad9-9cca-8215ffdd6dbf/header.bin b/chroma_db/b200ee9e-978b-4ad9-9cca-8215ffdd6dbf/header.bin new file mode 100644 index 0000000..bb54792 Binary files /dev/null and b/chroma_db/b200ee9e-978b-4ad9-9cca-8215ffdd6dbf/header.bin differ diff --git a/chroma_db/b200ee9e-978b-4ad9-9cca-8215ffdd6dbf/length.bin b/chroma_db/b200ee9e-978b-4ad9-9cca-8215ffdd6dbf/length.bin new file mode 100644 index 0000000..07d81df Binary files /dev/null and b/chroma_db/b200ee9e-978b-4ad9-9cca-8215ffdd6dbf/length.bin differ diff --git a/chroma_db/b200ee9e-978b-4ad9-9cca-8215ffdd6dbf/link_lists.bin b/chroma_db/b200ee9e-978b-4ad9-9cca-8215ffdd6dbf/link_lists.bin new file mode 100644 index 0000000..e69de29 diff --git a/chroma_db/chroma.sqlite3 b/chroma_db/chroma.sqlite3 new file mode 100644 index 0000000..b9380c8 Binary files /dev/null and b/chroma_db/chroma.sqlite3 differ diff --git a/dokumen/2025 Pedoman Akademik-FT-S1 Informatika.pdf b/dokumen/2025 Pedoman Akademik-FT-S1 Informatika.pdf new file mode 100644 index 0000000..f75e517 Binary files /dev/null and b/dokumen/2025 Pedoman Akademik-FT-S1 Informatika.pdf differ diff --git a/evaluasi_retrieval.py b/evaluasi_retrieval.py new file mode 100644 index 0000000..7c59237 --- /dev/null +++ b/evaluasi_retrieval.py @@ -0,0 +1,78 @@ +from main import koleksi_kecil, koleksi_besar + +# Lab 3 — 15 pasangan (pertanyaan, kata/frasa kunci yang HARUS ada di chunk jawaban yang benar) +UJI = [ + ("Apa syarat cuti akademik?", "cuti akademik"), + ("Berapa batas kehadiran untuk ujian?", "75 persen"), + ("Berapa maksimal cuti akademik selama masa studi?", "2 semester"), + ("Kapan batas pengajuan cuti akademik dilakukan?", "2 minggu"), + ("Berapa SKS maksimal jika IP semester sebelumnya 3.20?", "24 SKS"), + ("Berapa SKS minimal untuk kelulusan program sarjana?", "144 SKS"), + ("Apa syarat IPK minimal untuk lulus program sarjana?", "2.00"), + ("Berapa lama masa studi maksimal program sarjana?", "14 semester"), + ("Berapa SKS minimal yang harus ditempuh untuk mengambil skripsi?", "110 SKS"), + ("Berapa syarat nilai minimal mata kuliah Metodologi Penelitian?", "nilai C"), + ("Berapa kali bimbingan minimal sebelum sidang skripsi?", "8 kali"), + ("Apa sanksi bagi mahasiswa yang melakukan kecurangan ujian?", "nilai E"), + ("Berapa lama waktu ujian susulan dapat diajukan setelah jadwal?", "3 hari"), + ("Berapa nilai minimal untuk mendapatkan predikat Cum Laude?", "3.51"), + ("Berapa persentase maksimal kemiripan turnitin untuk naskah skripsi?", "20 persen"), +] + + +def hit_at_k(uji, k=3, target_koleksi=koleksi_kecil): + if not uji: + return 0.0 + benar = 0 + for pertanyaan, kunci in uji: + docs = target_koleksi.query(query_texts=[pertanyaan], n_results=k)["documents"][0] + if any(kunci.lower() in d.lower() for d in docs): + benar += 1 + return benar / len(uji) + + +def tampilkan_detail_gagal(uji, k, target_koleksi, nama_koleksi): + gagal = [] + for pertanyaan, kunci in uji: + docs = target_koleksi.query(query_texts=[pertanyaan], n_results=k)["documents"][0] + if not any(kunci.lower() in d.lower() for d in docs): + gagal.append((pertanyaan, kunci)) + if gagal: + print(f"\nPertanyaan yang gagal pada {nama_koleksi} (Hit@{k}):") + for p, kw in gagal: + print(f" - {p} (kunci: '{kw}')") + else: + print(f"\nSemua pertanyaan berhasil ditemukan pada {nama_koleksi} untuk Hit@{k}!") + + +KESIMPULAN = """ +KESIMPULAN EVALUASI (LAB 3): +1. Ukuran chunk kecil (500 karakter) umumnya menghasilkan Hit@1 yang lebih presisi untuk pertanyaan spesifik (seperti angka SKS atau batas persentase), karena vektor embedding tidak terdilusi oleh topik lain di dalam satu chunk yang terlalu panjang. +2. Sebaliknya, ukuran chunk besar (1500 karakter) unggul pada Hit@3 dan Hit@5 ketika kata kunci jawaban berada agak jauh dari kalimat topik utamanya, namun rentan menurunkan akurasi Hit@1 karena satu chunk menggabungkan beberapa sub-pasal sekaligus. +3. Kegagalan retrieval biasanya terjadi pada pertanyaan yang jawabannya terpotong tepat di batas chunk (boundary split) atau pertanyaan yang menggunakan angka/istilah singkat yang mirip antar-pasal (misalnya angka semester atau SKS). +4. Solusi untuk meningkatkan performa di proyek RAG adalah menggunakan chunking berbasis struktur kalimat/paragraf dengan ukuran moderat (500–800 karakter) dan overlap 10–20% serta mengambil k=3 hingga k=5 chunk teratas. +""".strip() + + +if __name__ == "__main__": + if koleksi_kecil.count() == 0 or koleksi_besar.count() == 0: + print("Koleksi evaluasi masih kosong. Jalankan `python main.py` terlebih dahulu setelah mengisi folder `dokumen/`.") + else: + print("=" * 70) + print("LAB 3 — EVALUASI HIT@K PADA DUA UKURAN CHUNK BERBEDA") + print("=" * 70) + print(f"Jumlah pertanyaan uji: {len(UJI)}\n") + print(f"{'Metrik':<10} | {'Chunk 500 (Overlap 75)':<25} | {'Chunk 1500 (Overlap 225)':<25}") + print("-" * 66) + for k in (1, 3, 5): + skor_500 = hit_at_k(UJI, k=k, target_koleksi=koleksi_kecil) + skor_1500 = hit_at_k(UJI, k=k, target_koleksi=koleksi_besar) + print(f"Hit@{k:<6} | {skor_500:<25.2f} | {skor_1500:<25.2f}") + + tampilkan_detail_gagal(UJI, k=1, target_koleksi=koleksi_kecil, nama_koleksi="Chunk 500") + tampilkan_detail_gagal(UJI, k=1, target_koleksi=koleksi_besar, nama_koleksi="Chunk 1500") + + print("\n" + "=" * 70) + print(KESIMPULAN) + print("=" * 70) + diff --git a/main.py b/main.py new file mode 100644 index 0000000..9e1a3b3 --- /dev/null +++ b/main.py @@ -0,0 +1,131 @@ +import os +import random + +# Gunakan cache model lokal jika sudah terunduh agar tidak lambat saat offline +os.environ.setdefault("HF_HUB_OFFLINE", "1") +os.environ.setdefault("TRANSFORMERS_OFFLINE", "1") + +import chromadb +from chromadb.utils import embedding_functions +from pathlib import Path +from pypdf import PdfReader + +def baca_pdf(path): + reader = PdfReader(path) + return "\n".join((p.extract_text() or "") for p in reader.pages) + +def potong(teks, ukuran=800, tumpang=100): + """Pemotongan ukuran tetap dengan overlap (versi paling sederhana).""" + hasil, mulai = [], 0 + while mulai < len(teks): + hasil.append(teks[mulai:mulai + ukuran]) + mulai += ukuran - tumpang + return [h for h in hasil if h.strip()] + + +# model embedding multilingual (jalan lokal, tanpa API key) +emb = embedding_functions.SentenceTransformerEmbeddingFunction( + model_name="paraphrase-multilingual-MiniLM-L12-v2") + +client = chromadb.PersistentClient(path="./chroma_db") # tersimpan di folder + +# Koleksi utama (ukuran chunk default 800, overlap 100) +koleksi = client.get_or_create_collection( + name="pedoman", embedding_function=emb, + metadata={"hnsw:space": "cosine"}) # ukuran jarak: cosine + +# Dua koleksi tambahan untuk Lab 3 (perbandingan dua ukuran chunk berbeda) +koleksi_kecil = client.get_or_create_collection( + name="pedoman_chunk_500", embedding_function=emb, + metadata={"hnsw:space": "cosine"}) + +koleksi_besar = client.get_or_create_collection( + name="pedoman_chunk_1500", embedding_function=emb, + metadata={"hnsw:space": "cosine"}) + + +def eksperimen_chunking(folder="dokumen"): + """Lab 1 — Eksperimen chunking (ukuran 500, 1000, 1500 dengan overlap 15%).""" + daftar_pdf = sorted(Path(folder).glob("*.pdf")) + if not daftar_pdf: + print(f"Tidak ada file PDF di '{folder}/'. Masukkan minimal 1 PDF terlebih dahulu.") + return + + # Pilih PDF dengan teks terpanjang agar ukuran 1500 pun memiliki >= 3 chunk + pdf_pertama = max(daftar_pdf, key=lambda p: len(baca_pdf(p))) + teks = baca_pdf(pdf_pertama) + + print("=" * 70) + print(f"LAB 1 — EKSPERIMEN CHUNKING (Sampel: {pdf_pertama.name})") + print("=" * 70) + print("\n[1] 500 Karakter Pertama:") + print("-" * 70) + print(teks[:500]) + print("-" * 70) + + # Tiga pengaturan ukuran chunk (500, 1000, 1500) dengan overlap 15% (antara 10-20%) + pengaturan = [ + (500, 75), # overlap 15% + (1000, 150), # overlap 15% + (1500, 225), # overlap 15% + ] + + random.seed(42) + print("\n[2] Perbandingan Jumlah Chunk & 3 Sampel Acak Tiap Ukuran:") + for ukuran, tumpang in pengaturan: + chunks = potong(teks, ukuran=ukuran, tumpang=tumpang) + print(f"\n>>> Ukuran = {ukuran}, Overlap = {tumpang} ({tumpang*100//ukuran}%) → Total: {len(chunks)} chunk") + sampel = random.sample(chunks, k=min(3, len(chunks))) + for idx, c in enumerate(sampel, start=1): + cuplikan = c.replace("\n", " ") + print(f" Sampel {idx}: \"{cuplikan[:120]} ... {cuplikan[-80:]}\"") + + +def indeks_ke_koleksi(target_koleksi, daftar_pdf, ukuran=800, tumpang=100, tampilkan_log=True): + """Memotong semua PDF dan melakukan upsert ke koleksi ChromaDB tertentu.""" + for pdf in daftar_pdf: + chunks = potong(baca_pdf(pdf), ukuran=ukuran, tumpang=tumpang) + if not chunks: + if tampilkan_log: + print(f"{pdf.name} → dilewati (tidak ada teks)") + continue + target_koleksi.upsert( + ids=[f"{pdf.stem}-{i}" for i in range(len(chunks))], + documents=chunks, + metadatas=[{"sumber": pdf.name, "chunk": i} for i in range(len(chunks))], + ) + if tampilkan_log: + print(f" {pdf.name} → {len(chunks)} chunk (ukuran={ukuran}, overlap={tumpang})") + + +def indeks_dokumen(folder="dokumen"): + """Lab 2 (Bagian 1) & Persiapan Lab 3 — Bangun indeks dari folder dokumen/.""" + folder_path = Path(folder) + if not folder_path.exists(): + folder_path.mkdir(parents=True, exist_ok=True) + print(f"Folder '{folder}/' telah dibuat. Silakan letakkan 2–3 file PDF di dalamnya.") + return + + daftar_pdf = sorted(folder_path.glob("*.pdf")) + if not daftar_pdf: + print(f"Tidak ada file PDF di '{folder}/'. Silakan letakkan 2–3 file PDF di dalamnya.") + return + + print("\n" + "=" * 70) + print("LAB 2 — BANGUN INDEKS CHROMADB") + print("=" * 70) + print("[Koleksi Utama: 'pedoman' (ukuran=800, overlap=100)]") + indeks_ke_koleksi(koleksi, daftar_pdf, ukuran=800, tumpang=100, tampilkan_log=True) + + print("\n[Koleksi Lab 3: 'pedoman_chunk_500' (ukuran=500, overlap=75)]") + indeks_ke_koleksi(koleksi_kecil, daftar_pdf, ukuran=500, tumpang=75, tampilkan_log=True) + + print("\n[Koleksi Lab 3: 'pedoman_chunk_1500' (ukuran=1500, overlap=225)]") + indeks_ke_koleksi(koleksi_besar, daftar_pdf, ukuran=1500, tumpang=225, tampilkan_log=True) + + +if __name__ == "__main__": + eksperimen_chunking() + indeks_dokumen() + + diff --git a/search.py b/search.py new file mode 100644 index 0000000..7d1a72e --- /dev/null +++ b/search.py @@ -0,0 +1,45 @@ +from main import koleksi + +# Lab 2 — 5 Pertanyaan langsung sesuai isi dokumen +PERTANYAAN_UTAMA = [ + "Apa syarat cuti akademik?", + "Berapa batas kehadiran minimal untuk mengikuti ujian akhir semester?", + "Berapa beban maksimal SKS yang dapat diambil dalam satu semester?", + "Apa syarat IPK minimal untuk mengambil mata kuliah Skripsi?", + "Berapa lama batas waktu maksimal masa studi program sarjana?", +] + +# Lab 2 — 2 Pertanyaan dengan kata berbeda dari teks dokumen (parafrase) +PERTANYAAN_PARAFRASE = [ + # Parafrase dari "cuti akademik" -> "berhenti kuliah sementara waktu" + "Bagaimana prosedur jika mahasiswa ingin berhenti kuliah sementara waktu?", + # Parafrase dari "batas kehadiran 75 persen untuk ujian" -> "jarang masuk kelas apakah boleh ikut UAS" + "Jika mahasiswa sering absen kelas, berapa minimal presensi supaya tetap diizinkan ikut UAS?", +] + + +def cari_dan_tampilkan(daftar_pertanyaan, judul, n_results=3): + print("=" * 70) + print(judul) + print("=" * 70) + for idx, pertanyaan in enumerate(daftar_pertanyaan, start=1): + print(f"\nQ{idx}: {pertanyaan}") + hasil = koleksi.query(query_texts=[pertanyaan], n_results=n_results) + for rank, (doc, meta, jarak) in enumerate( + zip(hasil["documents"][0], hasil["metadatas"][0], hasil["distances"][0]), + start=1, + ): + cuplikan = doc[:200].replace("\n", " ") + print(f" #{rank} [{meta['sumber']} · chunk {meta['chunk']}] jarak={jarak:.3f}") + print(f" {cuplikan} ...") + + +if __name__ == "__main__": + if koleksi.count() == 0: + print("Koleksi masih kosong. Jalankan `python main.py` terlebih dahulu setelah mengisi folder `dokumen/`.") + else: + cari_dan_tampilkan(PERTANYAAN_UTAMA, "LAB 2 (BAGIAN 2) — 5 PERTANYAAN PENCARIAN") + print() + cari_dan_tampilkan(PERTANYAAN_PARAFRASE, "LAB 2 (BAGIAN 3) — 2 PERTANYAAN PARAFRASE (SEMANTIC SEARCH)") + +