9. Membangun Sistem RAG Pertama Anda
Setiap aplikasi yang telah kita bangun sejauh ini hanya mengandalkan pengetahuan yang sudah dipelajari oleh LLM. Itulah mengapa aplikasi tersebut tidak bisa menjawab pertanyaan tentang informasi yang tidak pernah dipelajari LLM—seperti dokumen internal perusahaan Anda atau manual produk.
RAG (Retrieval-Augmented Generation) menyelesaikan masalah ini. Ketika pertanyaan pengguna masuk, sistem pertama-tama mengambil dokumen yang relevan, kemudian meneruskan konten yang diambil bersama dengan pertanyaan ke LLM sehingga dapat menjawab berdasarkan konten tersebut. Anda menggabungkan kemampuan penalaran LLM dengan pengetahuan dokumen Anda.
Dalam bab ini, kita akan membangun pipeline RAG lengkap dari persiapan dokumen (pemuatan, chunking, embedding) hingga generasi jawaban berbasis retrieval. Sistem yang sudah jadi akan mengambil dokumen yang relevan ketika pertanyaan masuk, kemudian meneruskannya bersama dengan pertanyaan ke LLM sehingga menjawab berdasarkan konten dokumen tersebut. Sistem ini menjawab dengan akurat ketika informasi ada dalam dokumen, dan dengan jujur mengatakan "Saya tidak tahu" ketika tidak ada—inilah inti dari RAG yang dapat dipercaya.
9.1) Memahami RAG
9.1.1) Masalahnya: LLM Tidak Mengetahui Data Anda
LLM dilatih pada data internet seperti Wikipedia, artikel berita, dan kode publik. Mereka tidak tahu tentang dokumen internal perusahaan Anda atau kontrak yang Anda terima kemarin. Jadi mereka tidak bisa menjawab pertanyaan seperti:
- "Apa kebijakan cuti perusahaan kami?"
- "Ringkas laporan penjualan kuartal ini"
- "Apa syarat pengembalian dana dalam kontrak yang baru saja saya terima?"
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-5-mini")
# Tanya tentang dokumen pribadi yang tidak pernah dilihat LLM
response = llm.invoke("Apa kebijakan pengembalian dana untuk Acme Corp?")
print(response.content)Output:
Saya tidak memiliki informasi spesifik tentang kebijakan pengembalian dana Acme Corp. Saya sarankan untuk memeriksa situs web resmi mereka atau menghubungi tim dukungan pelanggan mereka secara langsung untuk informasi yang paling akurat dan terkini.Dalam contoh ini, LLM dengan jujur mengatakan tidak tahu. (Atau mungkin menghasilkan jawaban yang terdengar masuk akal tetapi tidak berdasar.)
Tapi bagaimana jika kita menyediakan dokumen kebijakan pengembalian dana bersama dengan pertanyaan? LLM akan memberikan jawaban yang akurat berdasarkan konten yang disediakan. Ini adalah ide inti di balik RAG.
9.1.2) Bagaimana Seharusnya Kita Menyediakan Dokumen?
Pendekatan paling sederhana adalah menyalin dan menempelkan seluruh dokumen ke dalam prompt. Ini sebenarnya bekerja dengan baik untuk dokumen pendek.
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-5-mini")
# Dalam kenyataannya, ini akan jauh lebih panjang, tapi mari kita asumsikan berikut adalah seluruh dokumen
document_text = """
Kebijakan Pengembalian Dana (Berlaku Januari 2026):
- Pengembalian dana penuh dalam 30 hari sejak pembelian dengan tanda terima asli.
- Setelah 30 hari, hanya kredit toko.
- Produk digital tidak dapat dikembalikan setelah diunduh.
- Barang cacat dapat dikembalikan kapan saja untuk pengembalian dana penuh.
"""
response = llm.invoke(
f"""Silakan jawab berdasarkan dokumen berikut:
{document_text}
Pertanyaan: Apa kebijakan pengembalian dana untuk produk digital?"""
)
print(response.content)Output:
Produk digital tidak dapat dikembalikan setelah diunduh.
...Ini bekerja dengan baik untuk dokumen pendek. Tapi bagaimana jika dokumennya sangat besar? Ini menyebabkan masalah serius berikut:
1. Batasan Context Window: LLM memiliki jumlah token terbatas yang dapat diproses sekaligus. Untuk GPT-5-mini, itu adalah 400K token. Namun, seluruh dokumentasi perusahaan Anda dapat dengan mudah melebihi ini. Bahkan jika muat, respons menjadi lebih lambat dan kurang akurat seiring konteks menjadi lebih panjang.
2. Biaya: API LLM mengenakan biaya per token. Mengirim seluruh dokumen ketika hanya satu atau dua paragraf yang diperlukan membuat biaya melonjak.
3. Degradasi Akurasi: Ketika Anda menyertakan seluruh dokumen, informasi yang sebenarnya Anda butuhkan terkubur dalam konten yang tidak relevan. Perhatian LLM teralihkan oleh informasi yang tidak terkait, menurunkan kualitas jawaban.
RAG menyelesaikan ketiga masalah dengan mengambil dan menyediakan hanya bagian yang relevan dari dokumen.
9.1.3) Ide Inti: Ambil Bagian yang Relevan dan Sediakan Bersama dengan Pertanyaan
Inti dari RAG sederhana: Sebelum mengirim pertanyaan ke LLM, pertama-tama temukan bagian yang relevan dari dokumen Anda dan sediakan bersama dengan pertanyaan.
Begini cara kerjanya:
- Pengguna mengajukan pertanyaan.
- Sistem mengambil (Retrieval) konten yang relevan dari penyimpanan dokumen.
- Konten yang diambil ditambahkan (Augmentation) ke prompt bersama dengan pertanyaan.
- LLM menghasilkan (Generation) jawaban berdasarkan konten yang diambil.
Ketiga langkah ini adalah dari mana RAG (Retrieval-Augmented Generation) mendapatkan namanya.
9.1.4) Bagaimana Kita Mengambil Konten yang Relevan? (Keterbatasan Pencocokan Kata Kunci)
Langkah retrieval sangat penting untuk RAG. Anda perlu menyediakan konten yang relevan untuk mendapatkan jawaban yang tepat. Jadi bagaimana kita mengambil konten yang terkait dengan pertanyaan?
Metode paling sederhana adalah pencocokan kata kunci: menemukan dokumen yang berisi kata-kata dari pertanyaan. Misalnya, jika seseorang bertanya "Apa kebijakan pengembalian dana untuk produk digital?" Anda akan mencari dokumen yang berisi kata "pengembalian dana," "digital," dan "produk."
Tapi pencocokan kata kunci memiliki kelemahan kritis: hanya dapat menemukan kecocokan kata yang tepat.
Katakanlah Anda memiliki dokumen kebijakan pengembalian dana dengan konten ini:
"Pengembalian dana penuh tersedia dalam 30 hari sejak pembelian."
Apa yang terjadi ketika pengguna bertanya "Bagaimana cara mendapatkan uang saya kembali?" Dokumen ini tidak akan diambil. Dokumen tidak berisi frasa "mendapatkan uang saya kembali." Manusia memahami bahwa "pengembalian dana" dan "mendapatkan uang saya kembali" memiliki arti yang sama, tetapi pencarian kata kunci hanya mencocokkan kata, jadi gagal menemukannya.
Pencarian kata kunci hanya mencocokkan kata. Bahkan ketika artinya sama, jika kata-katanya berbeda, tidak akan menemukannya.
Solusinya adalah pencarian semantik. Dan yang memungkinkan itu adalah embedding.
9.1.5) Embedding: Mengonversi Teks menjadi Vektor Numerik
Embedding merepresentasikan makna teks sebagai daftar angka (sebuah vektor). Ketika Anda memasukkan teks ke dalam model embedding, ia mengonversinya menjadi vektor ratusan hingga ribuan angka.
from langchain_openai import OpenAIEmbeddings
embeddings_model = OpenAIEmbeddings(model="text-embedding-3-small")
# Embed satu kalimat
vector = embeddings_model.embed_query("Bagaimana cara mendapatkan uang saya kembali?")
print(f"Dimensi vektor: {len(vector)}")
print(f"5 nilai pertama: {vector[:5]}")Output:
Dimensi vektor: 1536
5 nilai pertama: [0.0123, -0.0456, 0.0789, -0.0234, 0.0567]Dimensi adalah jumlah nilai yang membentuk vektor. Model text-embedding-3-small merepresentasikan semua teks sebagai 1.536 angka.
Mengapa kita memerlukan begitu banyak angka? Karena setiap dimensi menangkap aspek makna yang berbeda:
- Beberapa dimensi mungkin membedakan "aksi/keadaan"
- Yang lain mungkin merepresentasikan derajat "konkret/abstrak"
- Yang lain lagi mungkin menunjukkan sentimen "positif/negatif"
- ... (1.536 fitur semantik—meskipun kita tidak bisa benar-benar menginterpretasikan apa yang diwakili setiap dimensi)
Sama seperti koordinat 2D (x, y) merepresentasikan titik pada bidang, vektor 1.536 dimensi merepresentasikan titik dalam "ruang makna" 1.536 dimensi. Lebih banyak dimensi memungkinkan perbedaan yang lebih halus dalam makna.
Makna yang mirip terletak berdekatan dalam ruang makna. "Metode pengembalian dana" dan "mendapatkan uang kembali" menggunakan kata yang berbeda, tetapi karena memiliki makna yang mirip, mereka ditempatkan berdekatan dalam ruang makna.
9.1.6) Pencarian Semantik: Makna Mirip, Jarak Lebih Dekat
Setelah Anda mengonversi dokumen dan query menjadi vektor, Anda dapat menemukan dokumen yang paling relevan dengan mengukur kesamaan antara vektor. Ini disebut pencarian semantik — mencari berdasarkan kesamaan semantik daripada pencocokan kata kunci.
Ukuran kesamaan yang paling umum adalah cosine similarity, yang mengukur sudut antara dua vektor. Ketika vektor menunjuk ke arah yang mirip, kesamaan lebih tinggi. Lebih dekat ke 1.0 berarti makna sangat mirip, sementara lebih dekat ke 0 berarti relevansi rendah.
Mari kita hitung secara nyata:
from langchain_openai import OpenAIEmbeddings
import numpy as np
embeddings_model = OpenAIEmbeddings(model="text-embedding-3-small")
# Embed query dan dua dokumen kandidat
query_vec = embeddings_model.embed_query("Berapa periode pengembalian dana?")
doc1_vec = embeddings_model.embed_query("Pengembalian dana penuh tersedia dalam 30 hari sejak pembelian.") # Terkait
doc2_vec = embeddings_model.embed_query("Kantor kami terletak di pusat kota Seattle.") # Tidak terkait
def cosine_similarity(a, b):
"""Hitung cosine similarity antara dua vektor."""
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
sim1 = cosine_similarity(query_vec, doc1_vec)
sim2 = cosine_similarity(query_vec, doc2_vec)
print(f"Query vs dok 'pengembalian dana': {sim1:.4f}")
print(f"Query vs dok 'kantor': {sim2:.4f}")Output:
Query vs dok 'pengembalian dana': 0.6415
Query vs dok 'kantor': 0.1706(Nilai aktual dapat bervariasi berdasarkan model)
Dokumen pengembalian dana mendapat skor jauh lebih tinggi. Model embedding memahami bahwa "periode pengembalian dana" dan "pengembalian dana penuh dalam 30 hari" terkait secara semantik. Ini adalah pencarian semantik, dan ini adalah mekanisme inti dari RAG.
9.1.7) Gambaran Umum Pipeline RAG
Menggabungkan konsep yang telah kita pelajari menciptakan pipeline RAG berikut:
Pipeline terdiri dari dua fase:
Ingesti Pengetahuan (dilakukan sekali di awal, atau ketika dokumen berubah):
- Pemuatan Dokumen: Ekstrak data teks dari berbagai sumber (Markdown, PDF, dll.).
- Pemisahan Teks (Chunking): Bagi dokumen panjang menjadi chunk yang lebih kecil untuk meningkatkan presisi retrieval dan mematuhi batasan input LLM.
- Konversi Vektor (Embedding): Gunakan model embedding untuk mengonversi chunk menjadi vektor numerik berbasis makna.
- Penyimpanan Vektor: Simpan vektor yang dikonversi dan teks asli dalam database vektor (pengindeksan).
Retrieval dan Generasi Jawaban (dilakukan untuk setiap pertanyaan pengguna):
- Embedding Pertanyaan: Konversi pertanyaan pengguna menjadi vektor numerik menggunakan model yang sama yang digunakan selama ingesti.
- Pencarian Kesamaan (Retrieval): Ekstrak top-K chunk yang secara semantik paling dekat dengan vektor pertanyaan dari database vektor.
- Augmentasi Prompt: Gabungkan pertanyaan asli dengan chunk yang diambil untuk menambah prompt.
- Generasi Jawaban: LLM merujuk chunk yang disediakan untuk menghasilkan jawaban yang berdasar.
9.2) Pemuatan dan Chunking Dokumen
Bagian ini mencakup dua langkah pertama dari fase ingesti pengetahuan pipeline RAG:
- Pemuatan Dokumen: Membaca data teks dari file
- Pemisahan Teks (Chunking): Memecah data teks menjadi potongan kecil yang dapat dicari
Di bagian berikutnya (9.3), kita akan mempelajari cara mengonversi chunk ini menjadi vektor dan menyimpannya.
9.2.1) Memuat Dokumen dari File
Langkah pertama dalam pipeline RAG adalah memuat dokumen ke dalam objek Python. LangChain menyediakan document loader — kelas yang mendukung berbagai format file. Loader utama adalah:
TextLoader: File teks biasa (.txt) dan Markdown (.md)PyPDFLoader: File PDF (.pdf), dimuat halaman demi halamanCSVLoader: File CSV (.csv), dengan setiap baris dimuat sebagai dokumen terpisahUnstructuredMarkdownLoader: File Markdown (.md), dengan kesadaran struktural (header, daftar, dll.)
Terlepas dari loader mana yang Anda gunakan, hasilnya selalu dikembalikan sebagai daftar objek Document. Setiap Document memiliki dua atribut kunci:
page_content: Konten teks dokumenmetadata: Dictionary yang berisi meta-informasi seperti path file dan nomor halaman
Dalam tutorial ini, kita akan menggunakan TextLoader untuk memuat file Markdown.
Menyiapkan Dokumen Sampel
Pertama, mari kita buat beberapa dokumen sampel untuk dikerjakan. Buat folder data/docs/ di proyek Anda dan tambahkan file berikut:
mkdir -p data/docsBuat data/docs/refund_policy.md:
# Kebijakan Pengembalian Dana
**Tanggal Berlaku**: 1 Januari 2026
## Pengembalian Standar
Semua produk fisik dapat dikembalikan dalam 30 hari sejak pembelian untuk pengembalian dana penuh.
Tanda terima asli atau email konfirmasi pesanan diperlukan. Barang harus dalam kemasan asli dan kondisi tidak terpakai.
Setelah 30 hari, pengembalian diterima hanya untuk kredit toko. Kredit toko tidak kedaluwarsa.
## Produk Digital
Produk digital (lisensi perangkat lunak, e-book, kursus online) tidak dapat dikembalikan setelah tautan unduhan atau akses diaktifkan. Jika Anda mengalami masalah teknis yang mencegah akses, hubungi dukungan dalam 7 hari untuk penggantian atau pengembalian dana.
## Barang Cacat
Barang cacat dapat dikembalikan kapan saja untuk pengembalian dana penuh atau penggantian.
Harap sertakan deskripsi cacat. Biaya pengiriman untuk pengembalian barang cacat ditanggung oleh perusahaan.
## Layanan Berlangganan
Langganan bulanan dapat dibatalkan kapan saja. Pengembalian dana diproporsikan berdasarkan hari yang tersisa dalam siklus penagihan. Langganan tahunan dapat dikembalikan penuh dalam 14 hari pertama. Setelah 14 hari, tidak ada pengembalian dana tetapi akses berlanjut hingga akhir periode penagihan.Buat data/docs/shipping_info.md:
# Informasi Pengiriman
## Pengiriman Domestik
Pengiriman standar (5-7 hari kerja): Gratis untuk pesanan di atas $50, jika tidak $5.99.
Pengiriman ekspres (2-3 hari kerja): $12.99.
Pengiriman semalam (hari kerja berikutnya): $24.99.
## Pengiriman Internasional
Pesanan internasional dikirim melalui pos udara terlacak. Waktu pengiriman bervariasi berdasarkan tujuan, biasanya 10-21 hari kerja. Biaya pengiriman internasional dihitung saat checkout berdasarkan berat dan tujuan.
Bea cukai dan pajak impor adalah tanggung jawab pembeli dan tidak termasuk dalam biaya pengiriman.
## Pelacakan Pesanan
Semua pesanan termasuk nomor pelacakan yang dikirim melalui email dalam 24 jam sejak pengiriman.
Lacak pesanan Anda melalui tautan pelacakan di email Anda atau melalui situs web kurir.
## Paket Hilang atau Rusak
Jika paket Anda hilang atau tiba rusak, hubungi dukungan dalam 48 jam.
Kami akan mengirim penggantian tanpa biaya tambahan. Untuk barang rusak, harap berikan foto kerusakan dan kemasan.Sekarang muat file-file ini menggunakan TextLoader:
from pathlib import Path
from langchain_community.document_loaders import TextLoader
# Muat semua file .md dari direktori data/docs
docs_dir = Path("data/docs")
for md_file in docs_dir.glob("*.md"):
loader = TextLoader(str(md_file), encoding="utf-8")
docs = loader.load()
if docs: # Periksa bahwa file tidak kosong
doc = docs[0] # File tunggal = Document tunggal
print(f"File: {doc.metadata['source']}")
print(f"Panjang: {len(doc.page_content)} karakter")
print(f"Pratinjau: {doc.page_content[:80]}...")
print()Output:
File: data/docs/refund_policy.md
Panjang: 1166 karakter
Pratinjau: # Kebijakan Pengembalian Dana
...
File: data/docs/shipping_info.md
Panjang: 972 karakter
Pratinjau: # Informasi Pengiriman
...Catatan:
TextLoadermengambil satu path file sebagai input, tetapi mengembalikanList[Document]untuk antarmuka yang konsisten dengan loader lain. (Misalnya,PDFLoadermengembalikan beberapa Document — satu per halaman.)
9.2.2) Membagi Dokumen menjadi Chunk: Chunking
Kedua dokumen di atas sengaja pendek untuk keperluan tutorial ini. Dalam aplikasi nyata, Anda akan sering bekerja dengan dokumen yang ratusan atau ribuan halaman. Jika Anda meng-embed seluruh dokumen sebagai satu vektor, ribuan konsep dikompresi menjadi satu — membuat tidak mungkin untuk mengambil secara akurat apa yang sebenarnya Anda butuhkan.
Chunking adalah proses membagi dokumen menjadi potongan kecil yang bermakna. Tujuannya sederhana: ketika pengguna mengajukan pertanyaan, hanya paragraf spesifik yang langsung relevan dengan jawaban yang harus diambil — bukan seluruh dokumen.
Ukuran chunk secara langsung mempengaruhi kualitas retrieval dan jawaban:
- Terlalu besar: Beberapa topik tercampur dalam satu chunk, membuat embedding kurang akurat dan retrieval lebih sulit. Bahkan ketika chunk yang tepat ditemukan, konten yang tidak relevan diteruskan ke LLM, menurunkan kualitas jawaban.
- Terlalu kecil: LLM mungkin tidak menerima informasi yang cukup untuk menjawab dengan benar. Misalnya, jika hanya kalimat "Pengiriman standar adalah $5.99" yang diambil, LLM tidak dapat mengetahui bahwa ini hanya berlaku untuk pesanan di bawah $50.
- Pas: Setiap chunk mencakup satu topik dengan konteks yang cukup, memungkinkan retrieval dan jawaban yang akurat.
9.2.3) Mengontrol Ukuran dan Overlap Chunk
Untuk membagi dokumen menjadi chunk, Anda memerlukan text splitter. Text splitter adalah alat LangChain yang memecah dokumen panjang menjadi potongan yang lebih kecil. Memilih splitter yang tepat itu penting.
RecursiveCharacterTextSplitter: Mencoba beberapa separator dalam urutan hierarkis untuk mempertahankan sebanyak mungkin konteks. Splitter yang paling banyak digunakan untuk tujuan umum.CharacterTextSplitter: Membagi pada satu separator (default:\n\n). Cocok untuk dokumen dengan struktur sederhana.MarkdownHeaderTextSplitter: Membagi pada header Markdown (#,##). Efektif ketika Anda ingin mempertahankan struktur daftar isi dokumen.
Mengapa RecursiveCharacterTextSplitter efektif?
Splitter ini bekerja dengan mencoba separator dari unit terbesar ke terkecil untuk menemukan titik pemisahan terbaik. Urutan default adalah sebagai berikut (dapat diubah melalui parameter separators):
paragraf (\n\n) → baris baru (\n) → kata ( )
Selalu mencoba membagi pada unit bermakna terbesar terlebih dahulu. Jika paragraf melebihi chunk_size, ia kembali ke baris baru, kemudian kata. Karena selalu menemukan titik pemisahan yang paling alami daripada memotong secara sewenang-wenang di tengah kata, chunk yang dihasilkan lebih mungkin berisi informasi yang lengkap secara semantik.
Parameter Kunci
chunk_size: Jumlah karakter maksimum per chunk. Misalnya,chunk_size=400berarti tidak ada chunk yang akan melebihi 400 karakter.chunk_overlap: Jumlah karakter yang tumpang tindih antara chunk yang berdekatan. Misalnya,chunk_overlap=80berarti 80 karakter terakhir dari satu chunk diulang di awal chunk berikutnya.separators: Daftar separator yang digunakan untuk membagi teks, dicoba dalam urutan prioritas. Jika membagi pada separator saat ini akan melebihichunk_size, separator berikutnya dicoba untuk menghindari melebihichunk_size.
Apa itu overlap dan mengapa diperlukan?
Overlap berarti chunk yang berdekatan berbagi beberapa konten — akhir dari satu chunk disertakan di awal chunk berikutnya.
Alasannya adalah untuk memastikan bahwa setiap chunk dapat berdiri sendiri dengan konteks yang cukup. Ketika membaca sepotong dokumen tanpa pengetahuan tentang apa yang datang sebelumnya, bisa sulit untuk memahami mengapa konten tertentu disebutkan. Overlap menjaga akhir dari satu chunk mengalir ke yang berikutnya, sehingga chunk mana pun yang diambil, kontennya terbaca secara alami.
Sekarang mari kita benar-benar membagi dokumen:
from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
# Muat dokumen
loader = TextLoader("data/docs/refund_policy.md", encoding="utf-8")
docs = loader.load()
# Konfigurasi splitter
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=400,
chunk_overlap=80,
separators=["\n## ", "\n\n", "\n", " "],
)
chunks = text_splitter.split_documents(docs)
print(f"Dibagi menjadi {len(chunks)} chunk\n")
for i, chunk in enumerate(chunks):
print(f"--- Chunk {i} (sumber: {chunk.metadata['source']}) ---")
print(f"Panjang: {len(chunk.page_content)} karakter")
print(chunk.page_content[:120])
print()Output:
Dibagi menjadi 4 chunk
--- Chunk 0 (sumber: data/docs/refund_policy.md) ---
Panjang: 374 karakter
# Kebijakan Pengembalian Dana
...
--- Chunk 1 (sumber: data/docs/refund_policy.md) ---
Panjang: 267 karakter
## Produk Digital
...
--- Chunk 2 (sumber: data/docs/refund_policy.md) ---
Panjang: 204 karakter
## Barang Cacat
...
--- Chunk 3 (sumber: data/docs/refund_policy.md) ---
Panjang: 315 karakter
## Layanan Berlangganan
...Catatan: Dalam contoh di atas, tidak ada overlap yang terjadi. Ini karena setiap paragraf dibagi dengan bersih berdasarkan separator pertama (
\n##) sambil tetap dalamchunk_size. Overlap hanya terjadi ketika paragraf tertentu lebih panjang darichunk_sizedan harus dibagi menjadi dua atau lebih bagian.
9.3) Penyimpanan dan Retrieval Vektor dengan ChromaDB
9.3.1) Apa Itu Vector Store?
Vector store (juga disebut database vektor) adalah database yang dioptimalkan untuk menyimpan dan mencari data menggunakan vektor embedding. Tidak seperti database tradisional di mana Anda melakukan query berdasarkan nilai field yang tepat (SELECT * FROM products WHERE category = 'electronics'), vector store menemukan item dengan makna paling mirip dengan query Anda.
Dalam RAG, vector store menyimpan chunk dokumen bersama dengan embedding mereka. Ketika pengguna mengajukan pertanyaan, pertanyaan dikonversi menjadi vektor, dan vector store mengambil chunk dengan vektor yang paling mirip.
9.3.2) Memilih Vector Store dan Menyiapkan ChromaDB
Vector store populer termasuk ChromaDB, Pinecone, Weaviate, dan pgvector (ekstensi PostgreSQL). Mereka berbeda dalam model hosting (lokal vs. cloud), skala, dan kompleksitas operasional. Untuk buku ini, kita akan menggunakan ChromaDB — ini open-source, berjalan sepenuhnya di mesin lokal Anda tanpa pengaturan server, dan berguna tidak hanya untuk pengembangan tetapi juga untuk beban kerja produksi kecil hingga menengah.
ChromaDB dapat digunakan dalam beberapa cara:
- Mode lokal (pip): Instal sebagai library Python dan gunakan segera. Anda dapat menyimpan dan memuat data dalam direktori lokal tanpa infrastruktur server terpisah.
- Server standalone (Docker): Jalankan ChromaDB sebagai proses server terpisah. Berguna ketika beberapa aplikasi perlu berbagi vector store yang sama.
- Layanan cloud terkelola (Chroma Cloud): Gunakan ChromaDB sebagai layanan cloud. Chroma Cloud menangani hosting, scaling, dan pemeliharaan, memungkinkan Anda memberikan layanan yang stabil tanpa beban manajemen infrastruktur.
Mari kita instal ChromaDB menggunakan pip:
pip install chromadb langchain-chromachromadb adalah library vector store inti, dan langchain-chroma adalah paket integrasi yang memungkinkan Anda menggunakan ChromaDB langsung dalam library LangChain.
9.3.3) Memilih Model Embedding
Hal pertama yang harus diputuskan adalah model embedding mana yang akan digunakan. Vektor embedding hanya dapat dibandingkan ketika dihasilkan oleh model yang sama. Oleh karena itu, Anda harus menggunakan model embedding yang sama untuk menyimpan dokumen dan melakukan query.
OpenAI menyediakan model embedding berikut:
| Model | Dimensi | Catatan |
|---|---|---|
text-embedding-3-small | 1536 | Keseimbangan baik antara kualitas dan biaya |
text-embedding-3-large | 3072 | Kualitas lebih tinggi, biaya lebih tinggi |
Untuk buku ini, kita akan menggunakan model text-embedding-3-small OpenAI. Ini menawarkan efisiensi tinggi dengan biaya rendah, menjadikannya pilihan praktis untuk pencarian umum, RAG, dan proyek yang sadar biaya.
from langchain_openai import OpenAIEmbeddings
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
# Verifikasi berfungsi
test_vector = embedding_model.embed_query("test")
print(f"Dimensi embedding: {len(test_vector)}")Output:
Dimensi embedding: 1536Catatan biaya: Panggilan API embedding jauh lebih murah daripada panggilan LLM, tetapi mereka memang menimbulkan biaya. Ketika menyimpan dokumen dalam database (pengindeksan), satu panggilan API diperlukan per chunk, dan ketika pengguna mengajukan pertanyaan (retrieval), satu panggilan API diperlukan untuk pertanyaan. Untuk harga saat ini, lihat halaman harga OpenAI.
9.3.4) Menyimpan Chunk di ChromaDB
Sekarang mari kita satukan semuanya. Kita akan memuat dokumen, membaginya menjadi chunk, meng-embed chunk, dan menyimpannya bersama dengan vektor embedding mereka di ChromaDB.
# ingest.py - Pipeline ingesti lengkap
from langchain_community.document_loaders import DirectoryLoader, TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
# Langkah 1: Muat dokumen
# DirectoryLoader: memindai direktori dan memuat file yang cocok.
# Pemuatan sebenarnya didelegasikan ke loader yang ditentukan di loader_cls.
loader = DirectoryLoader(
"data/docs/", glob="**/*.md",
loader_cls=TextLoader, loader_kwargs={"encoding": "utf-8"},
)
documents = loader.load()
print(f"Memuat {len(documents)} dokumen")
# Langkah 2: Bagi menjadi chunk
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=400,
chunk_overlap=80,
separators=["\n## ", "\n\n", "\n", " ", ""],
)
chunks = text_splitter.split_documents(documents)
print(f"Membuat {len(chunks)} chunk")
# Langkah 3: Buat model embedding
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
# Langkah 4: Buat vector store dan ingest chunk
vector_store = Chroma.from_documents(
documents=chunks,
embedding=embedding_model,
persist_directory="data/chroma_db",
collection_name="company_docs",
)
print(f"Menyimpan {len(chunks)} chunk di ChromaDB di data/chroma_db/")Output:
Memuat 2 dokumen
Membuat 8 chunk
Menyimpan 8 chunk di ChromaDB di data/chroma_db/Metode Chroma.from_documents() melakukan dua tugas dalam satu panggilan:
- Meneruskan chunk yang disediakan melalui parameter
documentsmelalui model embedding untuk mendapatkan vektor embedding. - Menyimpan setiap chunk bersama dengan vektor embedding-nya di ChromaDB.
9.3.5) Memuat Vector Store yang Persisten
Di bagian sebelumnya, kita menyimpan dokumen di vector store. Operasi penyimpanan ini hanya perlu dilakukan sekali di awal (atau ketika dokumen berubah). Setelah itu, Anda dapat dengan mudah memuat vector store yang persisten dan menggunakannya langsung.
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
# Muat vector store yang persisten — tidak perlu re-embedding
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = Chroma(
persist_directory="data/chroma_db",
collection_name="company_docs",
embedding_function=embedding_model,
)
print(f"Memuat vector store dengan {len(vector_store.get()['ids'])} chunk")Output:
Memuat vector store dengan 8 chunkSekarang Anda dapat mulai mencari segera dengan hanya memuat vector store yang persisten, tanpa perlu meng-embed ulang dokumen Anda.
9.3.6) Pencarian Kesamaan
Dengan vector store dimuat, Anda sekarang dapat mencari chunk yang secara semantik mirip dengan query. Parameter top-K menentukan berapa banyak hasil yang akan dikembalikan:
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = Chroma(
persist_directory="data/chroma_db",
collection_name="company_docs",
embedding_function=embedding_model,
)
# Cari chunk yang terkait dengan pertanyaan
query = "Bisakah saya mengembalikan produk digital?"
results = vector_store.similarity_search(query, k=2)
print(f"Query: {query}")
print(f"Menemukan {len(results)} hasil\n")
for i, doc in enumerate(results):
print(f"--- Hasil {i + 1} (sumber: {doc.metadata['source']}) ---")
print(doc.page_content[:200])
print()Output:
Query: Bisakah saya mengembalikan produk digital?
Menemukan 2 hasil
--- Hasil 1 (sumber: data/docs/refund_policy.md) ---
## Produk Digital
...
--- Hasil 2 (sumber: data/docs/refund_policy.md) ---
# Kebijakan Pengembalian Dana
**Tanggal Berlaku**: 1 Januari 2026
## Pengembalian Standar
...Untuk query ini, chunk produk digital diambil dengan kesamaan tertinggi, diikuti oleh chunk kebijakan pengembalian dana.
Anda juga dapat mengambil hasil dengan skor kesamaan mereka menggunakan similarity_search_with_score:
results_with_scores = vector_store.similarity_search_with_score(query, k=2)
for doc, score in results_with_scores:
# ChromaDB mengembalikan jarak (lebih rendah = lebih mirip)
print(f"Skor: {score:.4f} | Sumber: {doc.metadata['source']}")
print(f" {doc.page_content[:200]}...")
print()Output:
Skor: 0.5942 | Sumber: data/docs/refund_policy.md
## Produk Digital
...
Skor: 0.9577 | Sumber: data/docs/refund_policy.md
# Kebijakan Pengembalian Dana
...Perhatikan bahwa ChromaDB menggunakan skor jarak (lebih rendah lebih mirip), bukan skor kesamaan (lebih tinggi lebih mirip). Chunk produk digital memiliki jarak terendah 0.5942, menjadikannya hasil paling relevan.
9.4) Membangun Rantai RAG Lengkap
Sekarang kita akan membangun sistem RAG lengkap: ambil dokumen yang relevan terlebih dahulu, kemudian teruskan bersama dengan pertanyaan ke LLM untuk menghasilkan jawaban berdasarkan informasi yang disediakan.
9.4.1) Merancang Template Prompt
Bagian paling penting dari template prompt adalah menginstruksikan LLM untuk menjawab berdasarkan hanya pada konteks yang disediakan. Tanpa instruksi ini, LLM mungkin mengabaikan hasil pencarian dan membuat jawaban berdasarkan data pelatihannya.
from langchain_core.prompts import ChatPromptTemplate
rag_prompt = ChatPromptTemplate.from_messages([
("system",
"Kamu adalah perwakilan layanan pelanggan. "
"Jawab pertanyaan pengguna menggunakan HANYA konteks yang disediakan. "
"Jika konteks tidak berisi informasi yang cukup untuk menjawab, "
"katakan \"Saya tidak memiliki informasi yang cukup untuk menjawab pertanyaan itu.\"\n\n"
"Konteks:\n{context}"),
("human", "{question}"),
])Pesan sistem memaksa LLM untuk menjawab hanya menggunakan konteks yang disediakan. Yang penting, instruksi untuk mengatakan "Saya tidak memiliki informasi yang cukup" ketika konteks tidak mencukupi mencegah LLM membuat jawaban yang terdengar masuk akal tetapi tidak didukung.
9.4.2) Membangun Rantai RAG
Kita sekarang memiliki semua komponen yang siap. Kita hanya perlu menghubungkan retriever, template prompt, dan LLM.
Sistem RAG yang sudah jadi akan bekerja sebagai berikut:
- Terima pertanyaan pengguna
- Ambil chunk yang relevan dari vector store
- Teruskan chunk dan pertanyaan ke template prompt untuk menghasilkan prompt
- Hasilkan jawaban dengan LLM
Mari kita hubungkan rantai RAG menggunakan operator LCEL | dari Bab 6.
# rag_chain.py - Pipeline RAG lengkap
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_chroma import Chroma
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
def format_docs(docs):
"""Gabungkan dokumen yang diambil menjadi string konteks tunggal."""
return "\n\n---\n\n".join(doc.page_content for doc in docs)
def build_rag_chain():
"""Bangun dan kembalikan rantai RAG lengkap."""
# Muat vector store
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = Chroma(
persist_directory="data/chroma_db",
collection_name="company_docs",
embedding_function=embedding_model,
)
# Buat retriever (k=3 berarti kembalikan 3 chunk teratas)
retriever = vector_store.as_retriever(search_kwargs={"k": 3})
# Definisikan prompt
rag_prompt = ChatPromptTemplate.from_messages([
("system",
"Kamu adalah perwakilan layanan pelanggan. "
"Jawab pertanyaan pengguna menggunakan HANYA konteks yang disediakan. "
"Jika konteks tidak berisi informasi yang cukup untuk menjawab, "
"katakan \"Saya tidak memiliki informasi yang cukup untuk menjawab pertanyaan itu.\"\n\n"
"Konteks:\n{context}"),
("human", "{question}"),
])
# Inisialisasi LLM
llm = ChatOpenAI(model="gpt-5-mini")
# Susun rantai menggunakan LCEL
rag_chain = (
{"context": retriever | format_docs, "question": lambda x: x}
| rag_prompt
| llm
| StrOutputParser()
)
return rag_chain
if __name__ == "__main__":
chain = build_rag_chain()
answer = chain.invoke("Bisakah saya mengembalikan produk digital?")
print(answer)Output:
Produk digital tidak dapat dikembalikan setelah tautan unduhan atau akses diaktifkan.
Jika Anda mengalami masalah teknis yang mencegah akses, hubungi dukungan dalam 7 hari untuk penggantian atau pengembalian dana.Mari kita uraikan komposisi rantai langkah demi langkah:
rag_chain = (
{"context": retriever | format_docs, "question": lambda x: x}
| rag_prompt
| llm
| StrOutputParser()
)Ketika Anda memanggil chain.invoke("Bisakah saya mengembalikan produk digital?"), inilah yang terjadi:
- Langkah dictionary:
retriever | format_docs: Mencari vector store dengan pertanyaan dan menggabungkan chunk menjadi string tunggallambda x: x: Meneruskan pertanyaan tanpa perubahan- Hasil:
{"context": "chunk yang diambil (digabungkan menjadi string tunggal)", "question": "Bisakah saya mengembalikan produk digital?"}
rag_prompt: Mengisi placeholder{context}dan{question}dalam template prompt dengan nilai dictionaryllm: Mengirim prompt yang sudah lengkap ke LLMStrOutputParser(): Mengekstrak hanya teks dari respons LLM
Untuk detail lebih lanjut tentang cara kerja LCEL, lihat Bab 6.
9.4.3) Menguji dengan Pertanyaan yang Dapat dan Tidak Dapat Dijawab
Sistem RAG harus menangani pertanyaan yang dapat dijawab (informasi ada dalam dokumen) dan pertanyaan yang tidak dapat dijawab (informasi tidak ada dalam dokumen). Mari kita uji kedua skenario:
# test_rag.py - Uji rantai RAG dengan berbagai pertanyaan
from rag_chain import build_rag_chain
chain = build_rag_chain()
test_questions = [
# Dapat dijawab — informasi ada dalam dokumen
"Apa kebijakan pengembalian dana untuk produk fisik?",
"Berapa biaya pengiriman ekspres?",
"Bisakah saya mengembalikan barang cacat setelah 6 bulan?",
# Tidak dapat dijawab — informasi TIDAK ada dalam dokumen
"Apa kebijakan cuti karyawan?",
"Bahasa pemrograman apa yang digunakan?",
]
for question in test_questions:
print(f"P: {question}")
answer = chain.invoke(question)
print(f"J: {answer}\n")
print("-" * 60)Output:
P: Apa kebijakan pengembalian dana untuk produk fisik?
J: Semua produk fisik dapat dikembalikan dalam 30 hari sejak pembelian untuk pengembalian dana penuh. ...
------------------------------------------------------------
P: Berapa biaya pengiriman ekspres?
J: Pengiriman ekspres (2–3 hari kerja) biayanya $12.99.
------------------------------------------------------------
P: Bisakah saya mengembalikan barang cacat setelah 6 bulan?
J: Ya. Barang cacat dapat dikembalikan kapan saja untuk pengembalian dana penuh atau penggantian. ...
------------------------------------------------------------
P: Apa kebijakan cuti karyawan?
J: Saya tidak memiliki informasi yang cukup untuk menjawab pertanyaan itu.
------------------------------------------------------------
P: Bahasa pemrograman apa yang digunakan?
J: Saya tidak memiliki informasi yang cukup untuk menjawab pertanyaan itu.
------------------------------------------------------------Hasilnya menunjukkan persis perilaku yang kita inginkan:
- Pertanyaan yang dapat dijawab: Memberikan jawaban yang akurat berdasarkan dokumen yang diambil. LLM tidak menambahkan informasi yang tidak terkandung dalam dokumen.
- Pertanyaan yang tidak dapat dijawab: Merespons dengan "Saya tidak memiliki informasi yang cukup untuk menjawab pertanyaan itu." LLM dengan benar mengidentifikasi bahwa konteks yang diambil tidak memiliki informasi yang relevan dan menolak untuk membuat jawaban.
Ini adalah kekuatan RAG. LLM Anda menjawab pertanyaan tentang data Anda dan dengan jujur mengakui ketika tidak tahu. Setiap jawaban didukung oleh dokumen, membuat sistem jauh lebih dapat dipercaya daripada LLM standar.