Membangun Chatbot Pembaca PDF Berbasis RAG dan Gemini
Salah satu hal yang membuat saya tertarik dengan dunia AI belakangan ini adalah bagaimana model bahasa bisa memanfaatkan dokumen eksternal untuk menghasilkan jawaban yang akurat. Bukan sekadar menerima prompt, tetapi benar-benar merujuk isi dokumen seperti seorang asisten riset.
Pada proyek ini, saya membangun chatbot RAG yang mampu membaca file PDF, mengambil konteks yang relevan, dan menjawab pertanyaan menggunakan Gemini sebagai LLM.
Di bagian ini saya akan menjelaskan sedikit lebih teknis bagaimana sistem berjalan.
1. Ekstraksi PDF dan Pemotongan Teks (Chunking)
Langkah pertama adalah membaca isi PDF dan memecahnya menjadi potongan teks pendek. Ini perlu, karena LLM tidak bisa langsung membaca dokumen panjang secara keseluruhan.
Berikut fungsi sederhana untuk membaca PDF:
from PyPDF2 import PdfReader
def load_pdf_files(folder_path="data/pdfs"):
docs = []
for file_name in os.listdir(folder_path):
if file_name.endswith(".pdf"):
path = os.path.join(folder_path, file_name)
reader = PdfReader(path)
text = ""
for page in reader.pages:
text += page.extract_text() or ""
docs.append({"file_name": file_name, "content": text})
return docs
Setelah teks diambil, teks tersebut dipotong menjadi chunks:
from langchain.text_splitter import RecursiveCharacterTextSplitter
def split_text_into_chunks(text, chunk_size=1000, overlap=200):
splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=overlap,
)
return splitter.split_text(text)
Teknik ini penting untuk meningkatkan akurasi pencarian embedding.
2. Membuat Embedding untuk Setiap Chunk
Setiap potongan teks diubah menjadi embedding, yaitu representasi numerik yang memungkinkan ChromaDB menghitung kesamaan antar teks.
Saya menggunakan SentenceTransformer:
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("all-MiniLM-L6-v2")
embeddings = model.encode(chunks, show_progress_bar=True)
Model ini relatif ringan namun akurat, sehingga cocok untuk aplikasi real-time.
3. Menyimpan Embedding ke Vectorstore (ChromaDB)
ChromaDB menjadi “otak memori” sistem. Semua embedding dari PDF disimpan di sini.
Cara membuat collection dan menyimpan data:
from chromadb import PersistentClient
client = PersistentClient(path="vectorstore")
collection = client.get_or_create_collection("pdf_chunks")
collection.add(
ids=ids,
documents=chunks,
metadatas=metadatas,
embeddings=embeddings
)
Dengan ini, ketika pengguna bertanya, kita bisa melakukan pencarian berbasis kemiripan vektor
4. Proses RAG: Mengambil Konteks untuk Pertanyaan Pengguna
Setelah vectorstore berisi data, pertanyaan pengguna diproses sebagai embedding, lalu dicari chunk yang paling relevan:
query_emb = model.encode([query])[0]
results = collection.query(
query_embeddings=[query_emb],
n_results=4,
include=["documents"]
)
context = "\n\n".join(results["documents"][0])
Potongan-potongan teks inilah yang akan diberikan ke Gemini.
5. Menggabungkan Konteks dengan Gemini
Karena Gemini digunakan sebagai LLM utama, saya memberikan prompt terstruktur seperti ini:
prompt = f"""
Konteks berikut berasal dari dokumen:
{context}
Pertanyaan:
{query}
Jawab hanya berdasarkan konteks.
Jika jawabannya tidak ditemukan, katakan:
"Saya tidak menemukan jawabannya di dokumen ini."
"""
response = gemini.models.generate_content(
model="gemini-2.5-flash",
contents=prompt
)
Model kemudian memberikan jawaban yang relevan berdasarkan konteks dokumen.
6. Antarmuka Web Menggunakan Streamlit
Untuk membuat sistem lebih mudah digunakan, saya menggunakan Streamlit.
Antarmuka untuk unggah PDF:
with st.sidebar:
pdf_file = st.file_uploader("Upload PDF", type=["pdf"])
if pdf_file:
add_pdf_to_vectorstore(pdf_file)
Field pertanyaan dan jawaban:
query = st.text_input("Tanyakan sesuatu:")
if st.button("Tanya"):
answer = query_rag(query, model, collection, gemini, history)
st.write(answer)
Seluruh UI berjalan secara real-time.
7. Pengalaman Mengatasi Error
Beberapa error yang cukup sering muncul:
1. Vectorstore tidak bisa dihapus (Windows locked file)
→ Saya menambahkan retry-delete agar folder bisa dihapus setelah Streamlit merilis lock.
2. Embedding model gagal terunduh
→ Saya beri fallback dan pengecekan ulang.
3. Gemini terkadang overload atau tidak merespon
→ Ditangani dengan blok try-except dan pesan error yang jelas.
Setelah sistem RAG dasar berjalan, saya cukup banyak bereksperimen untuk membuat aplikasi ini terasa lebih natural, cepat, dan stabil. Di bagian ini saya ingin membahas beberapa hal yang menurut saya cukup penting jika ingin membangun aplikasi RAG yang benar-benar usable, terutama ketika dijalankan oleh pengguna non-teknis melalui Streamlit.
Optimasi Performa (Menghindari Proses yang Tidak Perlu)
Salah satu kesalahan umum ketika membuat aplikasi RAG adalah menjalankan ulang proses embedding setiap kali pengguna melakukan sesuatu. Misalnya, setiap pertanyaan dihitung ulang embedding-nya, atau lebih parah lagi setiap kali Streamlit melakukan rerun, semua PDF diproses ulang.
Saya memastikan hal ini tidak terjadi dengan dua cara:
1. Cache Model Embedding
Model embedding cukup besar dan butuh waktu untuk dimuat. Jadi saya cache menggunakan @st.cache_resource
@st.cache_resource
def load_embedding_model():
model = SentenceTransformer(EMBED_MODEL_NAME)
model.encode(["tes"], show_progress_bar=False)
return model
Dengan ini, model hanya dimuat sekali per sesi Streamlit, bukan setiap rerun.
2. Memproses PDF Hanya Saat Diperlukan
Streamlit melakukan rerun setiap kali user menekan tombol. Agar PDF tidak diproses berkali-kali tanpa alasan, saya menambahkan flag status:
if "has_pdf" not in st.session_state:
st.session_state["has_pdf"] = False
Role ini sederhana tapi efektif:
PDF hanya diproses ketika diupload, dan setelah selesai, form input pertanyaan baru muncul.
Memilih Model Embedding: Ringan vs Akurat
Awalnya saya menggunakan model berikut:
multi-qa-mpnet-base-dot-v1Kelebihan:
- sangat akurat
- cocok untuk teks akademik
Kekurangan:
- ukuran besar (sekitar 400MB)
- lama di-download
- berat ketika di-hosting tanpa GPU
Akhirnya saya turun ke model yang lebih ringan:
all-MiniLM-L6-v2
Kelebihan:
- cepat, ringan (±80MB)
- cukup akurat untuk jurnal dan skripsi
- ideal untuk deployment di Streamlit Cloud / server biasa
Perbandingan waktu encode untuk 100 chunk:
| Model | Waktu Encode | Akurasi Pencarian |
|---|---|---|
| multi-qa-mpnet-base-dot-v1 | ~8–12 detik | Sangat bagus |
| all-MiniLM-L6-v2 | ~2–3 detik | Bagus untuk umum |
lebih baik cepat tapi cukup akurat daripada akurat tapi terlalu lambat untuk real-world use.
Membuat Sistem Memory Percakapan
Saya ingin chatbot ini terasa seperti benar-benar berdialog, bukan sekadar menjawab pertanyaan satu arah. Tetapi saya tidak ingin menyimpan terlalu banyak percakapan karena dapat membuat prompt jadi terlalu panjang.
Solusinya: menyimpan 5 percakapan terakhir di session_state:
if "history" not in st.session_state:
st.session_state["history"] = []Lalu setiap kali pengguna bertanya:
st.session_state["history"].append((query, answer))Dan saya gabungkan ke prompt sebagai "Percakapan sebelumnya":
history_text = "\n".join([
f"User: {q}\nGemini: {a}"
for q, a in history[-5:]
])Dengan cara ini, Gemini tetap memiliki sedikit konteks tanpa membuat prompt membengkak.
Pengalaman Debugging: ChromaDB di Windows
Ini bagian yang paling unik—dan menurut saya cukup merepotkan.
Chroma di Windows terkadang mengunci file database (chroma.sqlite3). Jika belum dilepas oleh OS, folder tidak bisa dihapus. Ini jadi masalah ketika saya ingin menambahkan tombol “hapus semua data”.
Saya akali dengan mekanisme retry delete:
for _ in range(5):
try:
shutil.rmtree(path)
return except PermissionError:
time.sleep(1)Tidak ideal, tapi cukup efektif selama aplikasi berjalan.
Contoh Fitur Hapus Vectorstore di Streamlit
Hasil akhirnya seperti ini:
if st.button("🗑️ Hapus semua data vectorstore"):
reset_environment()
st.session_state["has_pdf"] = False
st.session_state["history"] = []
st.success("Semua data berhasil dihapus.")
st.rerun()
Jika di klik:
- folder PDF dihapus
- vectorstore dihapus
- history chat dibersihkan
- UI direset jadi tampilan awal
Ini membuat UX terasa rapi dan jelas.
Integrasi dengan Gemini
Saya membuat prompt yang memberi instruksi jelas pada Gemini agar:
- tidak berhalusinasi
- tidak menjawab jika konteks tidak tersedia
- tetap fleksibel untuk menyimpulkan jika konteks ada
Berikut potongan prompt-nya:
prompt = f"""
Konteks berikut berasal dari dokumen:
{context}
Pertanyaan:
{query}
Instruksi:
- Jawab hanya jika informasinya ada di konteks.
- Jika tidak ditemukan, jawab: \"Saya tidak menemukan jawabannya di dokumen ini.\"
- Kamu boleh meringkas atau menyimpulkan, tapi tetap berdasarkan konteks.
"""Kunci utamanya: klarifikasi batasan.
Kalau tidak, model cenderung mengarang jawaban.
Membangun chatbot RAG ini memberi saya banyak pelajaran berharga, bukan hanya soal teknis seperti embedding, vectorstore, atau integrasi dengan Gemini, tetapi juga bagaimana sebuah sistem AI benar-benar digunakan oleh manusia
Hasil akhirnya adalah sebuah alat yang mampu membaca dokumen PDF dan menjawab pertanyaan dengan cukup akurat, tanpa perlu menyalin manual isi dokumen atau mencari satu per satu. Sistem ini masih punya ruang untuk dikembangkan, misalnya dengan menambahkan fitur ringkasan otomatis, mendukung lebih banyak format dokumen, atau menyatukan beberapa PDF menjadi satu basis pengetahuan.
Tetapi sejauh ini, proyek ini menjadi contoh bagaimana RAG dan model bahasa modern seperti Gemini bisa bekerja bersama untuk membantu tugas sehari-hari, khususnya dalam membaca jurnal, laporan, atau dokumen teknis. Saya berharap pengalaman ini bisa bermanfaat bagi orang lain yang ingin memulai perjalanan serupa.