Retrieval Augmented Generation (RAG) adalah salah satu cara untuk membuat sebuah Large Language Model (LLM) agar bisa menjawab dengan akurat berbasis fakta. Ada banyak variasi detail implementasi RAG, tapi intinya sederhana: kita memberikan informasi berupa teks tambahan kepada LLM untuk menjawab sebuah pertanyaan.
Sebagai catatan: sudah ada banyak sekali produk RAG baik open source maupun komersial. Tulisan ini hanya sekedar memperkenalkan cara kerjanya, supaya tahu bagaimana mengevaluasi produk yang ada atau memodifikasi produknya.
Mari kita bahas beberapa konsep mengenai LLM, embedding, database vektor, dan bagaimana bisa menyusun ini untuk RAG.
Di teks ini saya akan memakai OpenAI API karena saat ini merupakan yang paling mudah dipakai, reliable dan murah. Tapi kita bisa memanfaatkan LLM apapun juga. untuk RAG ini, walau hasilnya bisa bervariasi.
LLM manapun yang dipakai, catatan pentingnya adalah: LLM sudah dilatih sesuai bahasa (manusia) yang ingin kita gunakan. Perlu dicatat bahwa beberapa LLM open source ukuran kecil hanya dilatih agar memproses teks bahasa inggris saja (sedangkan ChatGPT/OpenAI mendukung banyak bahasa manusia).
Daftar Isi
LLM memiliki batas input/output (context length)
LLM memiliki context length, batasan total input dan output. Saat ini GPT-4 memiliki context-length sangat besar (32 ribu token), ChatGPT 3.5 awalnya hanya memiliki batasan 1024 token (mengenai apa itu token, sudah pernah saya bahas di sini), sekarang dinaikkan jadi 4096 token.
Sederhananya begini: jika batasan token adalah 1024, dan pertanyaan kita 1000 token, maka jawaban LLM paling banyak adalah 24 token. Jawabannya akan terpotong. Perlu dicatat juga bahwa meski ChatGPT-4 bisa memproses 128 ribu token, tapi jawaban kadang kurang akurat jika input terlalu besar (puluhan ribu token).
Batasan ini penting diketahui karena kita ingin memproses data dari ribuan dokumen, masing-masing dokumen bisa memiliki puluhan hingga ratusan ribu token.
Catatan penting: semakin banyak token yang kita gunakan (kirim plus terima), maka semakin mahal juga biaya yang harus kita keluarkan.
LLM untuk memproses teks
Sebuah LLM bisa menjawab teks tanpa acuan jika sudah ada di data training. Jika diberi pertanyaan yang tidak ada di data training, maka ada dua kemungkinan:
- Akan menjawab tidak tahu, atau
- Mengarang jawaban ngawur
Jika ditanya mengenai Microsoft, maka biasanya LLM apapun bisa menjawab dengan benar karena ada di data trainingnya. Berikut ini contoh pertanyaan tentang perusahaan Geneus DNA, sebuah perusahaan testing DNA di Thailand yang tidak dikenal oleh ChatGPT:
Sekarang kita coba lagi, tapi kita berikan teks tambahan mengenai profil sangat singkat Geneus DNA.
Jadi intinya RAG: untuk bisa menjawab dengan benar, berikan saja teks acuan. Masih ada kemungkinan jawabannya salah, misalnya karena butuh logika kompleks, atau teks acuannya yang salah.
Teorinya kalau LLM tidak punya batasan jumlah token, kita bisa memberikan saja semua teks, lalu bisa ditanya apa saja mengenai teks itu. Tapi kembali ke topik pertama: LLM memiliki batasan jumlah token, sedangkan jumlah dokumen perusahaan mungkin ada jutaan token, jadi bagaimana mengakalinya?
RAG paling sederhana
Cara paling bodoh adalah dengan mengambil tiap paragraf teks lalu menanyakan ke LLM seperti ini:
Diberikan pertanyaan ini: <pertanyaan>, apakah teks ini dapat membantu menjawab pertanyaan (jawab dengan ya/tidak): <masukkan teks di sini>
Jika dijawab dengan ya, maka tanyakan lagi seperti ini:
berdasarkan teks <teks>, jawab pertanyaan berikut: <pertanyaan>.
Tergantung dokumen apa yang diproses, bisa ada pertanyaan yang jawabannya membutuhkan input dari banyak paragraf. Misalnya pertanyaan tentang peraturan tertentu, bisa butuh teks dari pasal 5, 7, dan 21. Jadi trik lainnya adalah: kumpulkan dulu semua teks yang membantu menjawab pertanyaan, lalu di akhir baru digabungkan semua teksnya, seperti ini:
Berdasarkan teks-teks berikut ini:
— teks 1 —
—teks 2—
————
Jawab pertanyaan berikut: <pertanyaan>
Cara lain ada sekuensial seperti ini: pertama minta AI untuk menjawab berdasarkan isi paragraf pertama.
Ini contoh yang dipakai di langchain:
Lalu kita berikan paragraf berikutnya, dengan teks baru, lalu kita minta untuk memperbaiki jawaban berdasarkan informasi terbaru.
Tentunya ada banyak masalah di sini:
- Lambat (tiap paragraf dikirim ke LLM)
- Mahal (butuh banyak komputasi)
Kategori Dokumen
Salah satu cara sederhana untuk sedikit mempercepat cara naif sebelumnya adalah dengan membagi dokumen kita ke dalam topik-topik tertentu. Misalnya ada topik “Pengiriman”, topik “Pengembalian barang”, dsb. Lalu LLM bisa ditanya semacam ini:
Kategorikan pertanyaan ini ke dalam salah satu dari 3 kategori berikut: “pengiriman”, “pengembalian barang”, “lain-lain”. <masukkan pertanyaan di sini>
Setelah mengetahui kategori ini, kita bisa memberikan teks hanya dalam kategori tersebut. Tentunya kita bisa membuat sub kategori lagi jika teksnya masih terlalu panjang. Dokumen juga bisa disusun lebih rinci dengan knowledge graph.
Cara ini bisa berhasil jika dokumen yang kita miliki bisa dikategorikan dengan baik, dan teksnya tidak terlalu banyak.
Ide dari sini bisa kita kembangkan: bagaimana caranya mencari teks yang relevan dengan cepat, dan hanya teks relevan itu saja yang diberikan ke LLM.
Embedding
Proses embedding akan mengubah sesuatu di dunia nyata menjadi angka (vector/array). “Sesuatu” di sini bisa berupa gambar, teks, suara, dsb. Untuk pembahasan di sini, embedding yang dimaksud adalah “text embedding”.
Saya berikan contoh sederhana seperti ini: kita diminta mengklasifikasikan berbagai benda. Ada banyak cara mengklasifikasinnya: dari harganya, dari kegunaannya, dari beratnya, dari warnanya, dsb. Ada ribuan cara untuk mengklasifikasikan ini.
Andaikan kita mengklasifikasikan hanya dari berat, ukuran, dan warna, kita bisa membentuk deretan angka berikut : (100, 1, 0) dengan arti: berat sekali (100), ukurannya kecil (1) dan warnanya hitam (0, dengan skala 0 hitam sampai 255 putih).
Angka-angka tadi membentuk yang namanya “vektor”. Andaikan kita tidak diberitahu apa itu kategori yang dipakai, dan kita diberi 3 vektor dari 3 benda, pertama A: (100, 1, 0), B: (99,2,0), dan C (50, 50, 50). Kita langsung tahu bahwa A dan B itu kemungkinan lebih serupa daripada A dengan C atau B dengan C, karena “jarak”-nya lebih kecil (di sini angka pertama dan kedua bedanya sedikit sekali). Definisi “jarak” ini ada banyak (ada banyak formula yang bisa mendefinisikan jarak).
Sebuah neural network bisa diajari untuk mengklasifikasikan kata menjadi vektor. Kita tidak tahu apa kategori yang dipakai oleh neural network, tapi kata yang serupa vektornya akan serupa. Ini istilahnya adalah “word embedding”, representasi vektor dari suatu kata.
Tapi satu kata saja kadang kurang berguna, kita kadang ingin embedding untuk satu kalimat atau bahkan satu paragraf. Misalnya kalimat “Saya makan ikan laut” dan “seafood aku santap” akan memiliki vektor yang berdekatan (walau urutan kata dan pilihan katanya berbeda).
Salah satu kegunaan embedding adalah untuk pencarian pintar. Kita bisa mencari kata “seafood”, dan teks yang mengandung “ikan laut”, “kepiting” juga bisa ditemukan.
Dalam konteks RAG, kita bisa mencari semua paragraf yang berhubungan dengan pertanyaan dengan cepat.
Secara praktisnya, untuk mengetahui seperti apa hasil embedding, kita bisa melihat contoh OpenAI di sini: https://platform.openai.com/docs/guides/embeddings/what-are-embeddings
from openai import OpenAI
client = OpenAI()
response = client.embeddings.create(
input="Your text string goes here",
model="text-embedding-3-small"
)
print(response.data[0].embedding)
hasilnya adalah angka-angka seperti ini:
[0.0051719858311116695, 0.017202626913785934, -0.018700333312153816, -0.018560361117124557, -0.047310721129179, -0.03031805530190468, 0.027672573924064636, 0.0036707802210003138, 0.011232797056436539, 0.006424739956855774, -0.001675296458415687, 0.01583089493215084, -0.0013157420326024294, -0.007866457104682922, 0.05990825220942497, 0.050306133925914764, -0.027504606172442436, 0.009889060631394386, -0.040396079421043396, 0.049998193979263306, -0.00041007582331076264 ...]
Pencarian vektor sederhana
Sekarang setelah tahu mengenai embedding, yang bisa kita lakukan adalah:
- Pecah dokumen menjadi paragraf, dapatkan vector embeddingnya (ini bisa lama jika ada banyak dokumen, tapi perlu dilakukan sekali)
- Ubah pertanyaan menjadi vector embedding
- Cari semua paragraf yang nilai embeddingnya “dekat” dengan pertanyaan
Mari kita implementasikan versi sederhana dengan memanggil API dari OpenAI.
Supaya posting ini tidak terlalu panjang, saya akan memakai input kalimat pendek, bukan paragraf. Yang diharapkan adalah: dari teks “I am good”, tuliskan secara terurut teks yang paling dekat/relevan dengan teks itu ke yang paling tidak relevan.
compare_embeddings(["I feel great", "I feel amazing", "I feel terrible", "She is sick"], "I am good")
Pertama kita buat fungsi dasar untuk menghasilkan embedding dari OpenAI. Perhatikan: tiap dipanggil fungsi ini akan menghabiskan uang, walau tidak banyak
from openai import OpenAI
client = OpenAI()
def create_embedding(text):
response = client.embeddings.create(
input=text,
model="text-embedding-ada-002"
)
return response.data[0].embedding
Untuk membandingkan embedding, saya akan memakai fungsi yang cukup standard: cosine similarity. Seperti ini:
def cosine_similarity(v1, v2):
dot_product = sum([a*b for a,b in zip(v1, v2)])
magnitude = (sum([a**2 for a in v1]) * sum([a**2 for a in v2])) ** 0.5
return dot_product / magnitude
Sekang kita implementasikan fungsi def compare_embeddings(inputs, query):
untuk memprint input terurut dengan pertanyaan (query
kita). Sesuai langkah yang saya berikan di atas: buat embedding
#create map from input to embedding
embeddings = {}
for input in inputs:
embeddings[input] = create_embedding(input)
Berikutnya kita buatkan embedding untuk query
#create embedding for query
query_embedding = create_embedding(query)
Lalu kita bandingkan query dengan semua embedding input:
#compare query embedding to input embeddings
similarities = {}
for input, embedding in embeddings.items():
similarities[input] = cosine_similarity(embedding, query_embedding)
Dan terakhir kita print
print("Best matches for query:", query)
for input, similarity in sorted(similarities.items(), key=lambda x: x[1], reverse=True):
print(input, similarity)
Hasilnya adalah:
Best matches for query: I am good
I feel great 0.8873072919987245
I feel amazing 0.8626087713791736
I feel terrible 0.8188751291393999
She is sick 0.81085937432138
Database Vektor
Tentunya perbandingan manual satu persatu bisa makan waktu lama. Solusi untuk ini adalah: vector database. Intinya adalah database yang bisa menyimpan tipe vector ini dan mendapatkan similaritynya. Ada ektensi tambahan untuk database yang sudah ada (misalnya pgvector untuk postgres), dan ada juga yang khusus dirancang sebagai vector database.
Mari kita ubah contoh di atas dengan memakai chromadb. Kita bisa menambahkan import chromadb
setelah menginstall chromadb dengan pip install chromadb
:
import chromadb
chroma_client = chromadb.Client()
Lalu fungsi sebelumnya bisa ditulis ulang seperti ini:
def compare_embeddings(inputs, query):
embeddings = [create_embedding(input) for input in inputs]
#create embedding for query
query_embedding = create_embedding(query)
#insert into chroma db
collection = chroma_client.create_collection(name="my_collection")
collection.add(
embeddings=embeddings,
ids = inputs
)
results = collection.query(query_embeddings=[query_embedding], n_results=len(inputs))
print("Best matches for query:", query)
for result in results["ids"]:
print(result)
Yang bisa dilihat: saya tidak membandingkan secara manual, tapi dibantu oleh database.
Best matches for query: I am good
['I feel great', 'I feel amazing', 'I feel terrible', 'She is sick']
Hasilnya sama dengan sebelumnya, tapi akan lebih cepat jika jumlah vektor yang kita simpan lebih banyak
Function calling (API retrieval)
Untuk menjawab pertanyaan dengan data dari database, cara yang lebih efisien adalah meminta LLM menghasilkan query ke sebuah API.
Pertama kita perlu mendefinisikan berbagai API yang bisa dipanggil oleh LLM. Contohnya get_current_weather(city)
untuk mendapatkan cuaca di kota tertentu, atau mungkin get_package_status(package_id)
untuk melihat status pengiriman paket.
Berikutnya kita perlu memberitahukan ke LLM bahwa kita punya fungsi yang bisa dipanggil. Di OpenAI ini istilahnya adalah “tool” yang tipenya adalah “function”. Setelah itu kita bisa bertanya pada LLM, dan ketika jawabannya membutuhkan jawaban fungsi, maka LLM akan mengembalikan semacam ini: “tolong panggilkan get_current_weather("Bandung")
“).
Sebagai pemanggil fungsi LLM, kita panggil fungsinya, lalu kirimkan hasilnya ke LLM (“ini hasilnya untuk kota Bandung”), dan LLM akan menjawab user dengan konteks jawaban dari fungsi.
Contohnya lengkapnya agak panjang, jadi bisa dilihat langsung di web OpenAI: https://platform.openai.com/docs/guides/function-calling
The devil is in the detail
Prinsip RAG sendiri sangat sederhana, tapi untuk mengaplikasikan ini ke domain tertentu butuh pemahaman yang baik mengenai domainnya, mengenai keterbatasan LLM, mengenai bagaimana informasi saat ini dikelola di dalam sistem.
Masalah pemotongan paragraf saja bisa jadi masalah, misalnya jika paragraf terakhir di halaman sebelumnya adalah: “mari kita membahas kesalahan fatal dalam managemen”, lalu di paragraf berikutnya “mike melakukan x, y, dan z, hasilnya terlihat sangat bagus di bulan pertama”, tapi di paragraf berikutnya “tapi efek jangka panjangnya sangat buruk”.
Nah jika LLM diberi konteks paragraf tengah saja, maka seolah-olah itu adalah hal yang baik, padahal di paragraf berikutnya ternyata tidak baik.