Introducing Contextual Retrieval
Chunk yang terpotong kehilangan konteksnya. Contextual retrieval memperbaikinya dengan menambahkan konteks sebelum embedding.
Intisari
- Masalah inti RAG naif: satu chunk kehilangan konteks dokumen induknya, sehingga tidak bisa ditemukan.
- Solusinya: tambahkan satu-dua kalimat konteks ke tiap chunk sebelum di-embed.
- Konteksnya dibuat oleh LLM, sekali saat ingest โ dan prompt caching membuat biayanya kecil.
- Gabungkan embedding (semantik) dengan BM25 (kata kunci) untuk hasil terbaik.
- Tambahkan tahap reranking kalau masih kurang akurat.
Masalahnya
Bayangkan dokumen laporan keuangan dipotong jadi chunk. Salah satu chunk berbunyi:
"Pendapatan perusahaan tumbuh 3% dibanding kuartal sebelumnya."
Kalau pengguna bertanya "berapa pertumbuhan pendapatan ACME di Q2 2023?", chunk itu kemungkinan besar tidak ditemukan. Kenapa? Karena chunk itu tidak menyebut:
- Perusahaan mana
- Kuartal mana
- Tahun berapa
Semua informasi itu ada di dokumen induknya โ tapi hilang saat dipotong.
Inilah kegagalan paling umum di sistem RAG. Bukan modelnya yang salah menjawab, tapi retrieval-nya yang tidak pernah menemukan chunk yang benar. Kalau konteks yang tepat tidak masuk ke prompt, model tidak punya kesempatan menjawab dengan benar.
Contextual retrieval
Idenya sederhana: sebelum meng-embed, tambahkan konteks singkat di depan tiap chunk.
SEBELUM (chunk mentah):
"Pendapatan perusahaan tumbuh 3% dibanding kuartal sebelumnya."
SESUDAH (dengan konteks):
"Potongan ini berasal dari laporan keuangan ACME Corp untuk Q2 2023;
bagian sebelumnya membahas pendapatan Q1 sebesar $314 juta.
Pendapatan perusahaan tumbuh 3% dibanding kuartal sebelumnya."
Sekarang chunk itu mengandung "ACME", "Q2 2023", dan "pendapatan" โ dan bisa ditemukan oleh pertanyaan tadi.
Cara membuat konteksnya
PROMPT_KONTEKS = """<dokumen>
{dokumen}
</dokumen>
Berikut satu potongan dari dokumen di atas:
<potongan>
{chunk}
</potongan>
Tulis 1-2 kalimat singkat yang menempatkan potongan ini dalam konteks
keseluruhan dokumen, untuk memperbaiki hasil pencarian.
Jawab hanya dengan kalimat konteksnya, tanpa pembuka apa pun."""
async def buat_konteks(dokumen: str, chunk: str) -> str:
r = await client.messages.create(
model="claude-haiku-4-5", # tugas sederhana, model murah sudah cukup
max_tokens=200,
system=[{
"type": "text",
"text": f"<dokumen>\n{dokumen}\n</dokumen>",
"cache_control": {"type": "ephemeral"}, # โ kunci penghematan biaya
}],
messages=[{"role": "user", "content": PROMPT_KONTEKS.format(
dokumen="", chunk=chunk,
)}],
)
return next(b.text for b in r.content if b.type == "text")
Prompt caching adalah yang membuat teknik ini terjangkau. Kamu memanggil LLM sekali per chunk, dan tiap panggilan menyertakan seluruh dokumen. Tanpa cache, dokumen 50 halaman dengan 100 chunk berarti membayar 100 kali untuk dokumen yang sama. Dengan cache, dokumennya ditulis sekali dan dibaca 99 kali dengan harga ~0,1ร.
Pipeline ingest lengkap
async def ingest_dengan_konteks(path: Path, collection) -> None:
dokumen = path.read_text(encoding="utf-8")
chunks = potong(dokumen, ukuran=1000, tumpang=200)
sem = asyncio.Semaphore(10)
async def olah(i: int, chunk: str) -> dict:
async with sem:
konteks = await buat_konteks(dokumen, chunk)
teks_lengkap = f"{konteks}\n\n{chunk}"
return {
"id": f"{path.stem}-{i}",
"teks_embed": teks_lengkap, # yang di-EMBED
"teks_asli": chunk, # yang DIKIRIM ke LLM saat menjawab
"konteks": konteks,
"sumber": str(path),
}
hasil = await asyncio.gather(*(olah(i, c) for i, c in enumerate(chunks)))
embeddings = await buat_embedding([h["teks_embed"] for h in hasil])
collection.upsert(
ids=[h["id"] for h in hasil],
embeddings=embeddings,
documents=[h["teks_asli"] for h in hasil],
metadatas=[{"sumber": h["sumber"], "konteks": h["konteks"]} for h in hasil],
)
Perhatikan pemisahan teks_embed dan teks_asli. Konteks
ditambahkan untuk memperbaiki pencarian. Saat menyusun prompt jawaban, kamu bisa
memilih mengirim chunk aslinya saja (lebih hemat token) atau versi lengkapnya (lebih jelas
bagi model). Simpan keduanya supaya kamu punya pilihan.
Hybrid search: embedding + BM25
Embedding menangkap makna; BM25 menangkap kata kunci persis. Keduanya gagal di tempat berbeda โ dan itulah kenapa menggabungkannya bekerja.
| Metode | Kuat untuk | Lemah untuk |
|---|---|---|
| Embedding | Parafrase, sinonim, konsep | Kode error, ID, nama produk, istilah langka |
| BM25 | Istilah persis, kode, nama | Pertanyaan yang tidak memakai kata yang sama |
from rank_bm25 import BM25Okapi
bm25 = BM25Okapi([c.split() for c in semua_chunk])
def cari_hybrid(query: str, emb_query: list[float], k: int = 20) -> list[str]:
# Ambil kandidat dari kedua sisi
id_vektor = cari_vektor(emb_query, k=k)
id_bm25 = [semua_id[i] for i in bm25.get_top_n(query.split(), range(len(semua_id)), n=k)]
# Gabungkan dengan Reciprocal Rank Fusion
skor: dict[str, float] = {}
for peringkat, id_ in enumerate(id_vektor):
skor[id_] = skor.get(id_, 0) + 1 / (60 + peringkat)
for peringkat, id_ in enumerate(id_bm25):
skor[id_] = skor.get(id_, 0) + 1 / (60 + peringkat)
return sorted(skor, key=skor.get, reverse=True)[:k]
Reranking
Ambil 20 kandidat teratas dari hybrid search, lalu urutkan ulang dengan model yang lebih teliti, dan ambil 5 terbaik untuk dikirim ke LLM.
Query
โ
โโ Vector search โโโ
โ โโโ> 20 kandidat โโ> Rerank โโ> 5 terbaik โโ> LLM
โโ BM25 search โโโ
Reranking lebih mahal per dokumen, jadi hanya dijalankan pada kandidat yang sudah tersaring. Ini pola dua tahap yang standar: recall dulu (ambil banyak), lalu precision (saring yang terbaik).
Urutan perbaikan yang disarankan
- RAG dasar โ chunking + embedding + cosine similarity. Ukur akurasinya.
- Tumpang tindih chunk โ 10โ20% dari ukuran chunk, supaya kalimat tidak terpotong di batas
- Contextual retrieval โ biasanya lompatan terbesar
- Hybrid search โ tambahkan BM25
- Reranking โ kalau masih kurang
Ukur di tiap langkah. Buat dataset 20โ30 pertanyaan dengan chunk jawaban yang seharusnya ditemukan, lalu hitung berapa persen yang benar-benar masuk ke top-5. Tanpa angka itu, kamu hanya menebak apakah perubahanmu memperbaiki atau justru merusak.
Ukuran chunk
| Ukuran | Kelebihan | Kekurangan |
|---|---|---|
| Kecil (200โ400 token) | Pencarian presisi | Kehilangan konteks, butuh lebih banyak chunk |
| Sedang (500โ1000) | Keseimbangan yang baik | โ |
| Besar (1500+) | Konteks utuh | Banyak isi tidak relevan ikut terkirim |
Mulai dari 800 token dengan tumpang tindih 150. Potong di batas yang alami (paragraf, judul bagian), bukan di tengah kalimat.
Yang layak dicatat untuk eval
- Recall@k โ berapa persen chunk yang benar masuk ke k teratas
- Skor kemiripan chunk teratas โ kalau rendah, mungkin memang tidak ada jawabannya
- Retrieval kosong โ pertanyaan yang tidak menghasilkan chunk di atas ambang
- Akurasi sitasi โ apakah chunk yang dikutip benar-benar mendukung jawabannya
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.