Skip to content

10.5 Helpdesk Dokumen (RAG)

Topologi: A — Agent tunggal Materi yang dipakai: 5.1, 5.2, 5.3Prasyarat: 10.1

Masalahnya

Kasus 10.1 menaruh 15 butir kebijakan di instructions — dan itu pilihan yang tepat pada skalanya.

Sekarang tim CS punya 80 halaman dokumen kebijakan: pengembalian barang, garansi, pengiriman, keanggotaan. Dokumen itu diperbarui tim legal setiap bulan, dan mereka tidak menyentuh kode.

Batas yang disebut di kasus 10.1 sudah terlewat, ketiganya sekaligus.

Ada masalah kedua yang lebih halus. Pelanggan tidak memakai istilah yang dipakai dokumen:

Yang diketik pelangganYang tertulis di dokumen
"stikernya sudah saya buka""segel garansi sudah rusak"
"siapa yang bayar kirim balik""ongkos kirim pengembalian ditanggung"
"barangnya salah""salah kirim"

Pencarian kata kunci gagal total di ketiganya.

Keputusan arsitektur

Keputusan 1 — Ukuran potongan

Ini keputusan yang paling menentukan mutu, seperti dibahas di 5.2.

maxSizeAkibatnya pada dokumen kebijakan
128–256Satu aturan terbelah; "dalam kasus tersebut" kehilangan rujukannya
300–512Satu bagian kebijakan utuh dalam satu potongan
1024+Satu potongan memuat 3 topik; hasil pencarian membawa banyak hal tak relevan

Pilihan: maxSize: 300, overlap: 40.

Alasannya berasal dari bentuk dokumennya, bukan dari angka umum. Dokumen kebijakan ini terstruktur per heading ##, dan tiap bagian panjangnya 2–4 kalimat. Ukuran 300 kira-kira sepadan dengan satu bagian.

overlap: 40 (sekitar 13%) melindungi kalimat yang jatuh tepat di batas.

Keputusan 2 — Strategi chunking

StrategiCocok di sini?
characterTidak — memotong di tengah kalimat
recursiveYa — menghormati struktur, default yang wajar
markdown / semantic-markdownYa, bahkan lebih baik untuk dokumen ber-heading

Pilihan: recursive untuk versi pertama.

semantic-markdown memotong berdasarkan keluarga heading, yang secara teori lebih pas untuk dokumen ini. Tapi ia memerlukan parameter tambahan (joinThreshold, modelName) dan hasilnya lebih sulit diprediksi. Mulai dari recursive, ukur hasilnya, pindah kalau memang kurang.

Keputusan 3 — Vector store

libSQLpgvector
SetupBerkas, nol layananPerlu Postgres
Cocok untukRibuan potonganRatusan ribu+
Multi-prosesTidak (berbasis berkas)Ya
Sudah dipakai proyek ini?Ya, untuk storageBelum

Pilihan: libSQL.

80 halaman menghasilkan sekitar 300–400 potongan. Itu jauh di bawah batas libSQL, dan proyek ini sudah memakainya untuk storage — satu sistem lebih sedikit untuk dioperasikan.

Pindah ke pgvector saat masuk multi-instance, seperti dibahas di 9.1.

Keputusan 4 — Ingest kapan dijalankan?

Saat startupSkrip terpisah
KesederhanaanTinggiPerlu perintah sendiri
Waktu startupLambat, dan makin lambatCepat
BiayaEmbedding diulang tiap deploySekali per perubahan dokumen
Cocok untukPrototipeProduksi

Pilihan: skrip terpisah (npm run ingest).

Ini penerapan langsung pemisahan tahap ingest dan query dari 5.1. Menjalankan ingest saat startup berarti membayar embedding 400 potongan setiap kali server restart.

Struktur direktori

text
helpdesk-cs/
├── dokumen/                      ← sumber, diedit tim legal
│   ├── pengembalian.md
│   ├── garansi.md
│   └── pengiriman.md
├── scripts/
│   └── ingest.ts                 ← dijalankan manual: npm run ingest
├── src/
│   ├── infra/
│   │   └── vector.ts             ← satu tempat konfigurasi vector store
│   └── mastra/
│       ├── agents/
│       │   └── cs-agent.ts
│       ├── tools/
│       │   └── cari-kebijakan.ts ← createVectorQueryTool
│       └── index.ts
└── package.json

dokumen/ sengaja di luar src/ — isinya bukan kode, dan orang non-teknis harus bisa mengeditnya lewat antarmuka Git yang sederhana.

Alur

Dua alur terpisah, dan pemisahan itu adalah inti kasus ini.

Implementasi

scripts/ingest.ts

ts
import { readFile, readdir } from 'node:fs/promises'
import { join } from 'node:path'
import { MDocument } from '@mastra/rag'
import { embedMany } from 'ai'
import { ModelRouterEmbeddingModel } from '@mastra/core/llm'
import { vectorStore, INDEX, EMBEDDER } from '../src/infra/vector.ts'

const DIR = 'dokumen'

async function main() {
  const berkas = (await readdir(DIR)).filter(f => f.endsWith('.md'))
  const semuaChunk: { text: string; sumber: string }[] = []

  for (const nama of berkas) {
    const isi = await readFile(join(DIR, nama), 'utf-8')
    const doc = MDocument.fromMarkdown(isi)

    const chunks = await doc.chunk({
      strategy: 'recursive',
      maxSize: 300,
      overlap: 40,
    })

    for (const c of chunks) {
      semuaChunk.push({ text: c.text, sumber: nama })
    }
    console.log(`${nama}: ${chunks.length} potongan`)
  }

  const { embeddings } = await embedMany({
    values: semuaChunk.map(c => c.text),
    model: EMBEDDER,
  })

  await vectorStore.createIndex({
    indexName: INDEX,
    dimension: embeddings[0].length,
  })

  await vectorStore.upsert({
    indexName: INDEX,
    vectors: embeddings,
    // metadata inilah yang membuat jawaban bisa menyebut sumbernya
    metadata: semuaChunk.map(c => ({ text: c.text, sumber: c.sumber })),
  })

  console.log(`selesai: ${semuaChunk.length} potongan, dimensi ${embeddings[0].length}`)
}

main()

metadata adalah bagian yang paling sering dilupakan. Tanpa menyimpan text dan sumber di sana, hasil pencarian hanya berupa skor dan ID — tidak ada isi yang bisa diberikan ke model, dan tidak ada rujukan yang bisa disebutkan ke pelanggan.

src/infra/vector.ts

ts
import { LibSQLVector } from '@mastra/libsql'
import { ModelRouterEmbeddingModel } from '@mastra/core/llm'

export const INDEX = 'kebijakan'

// Satu tempat. Model embedding HARUS sama antara ingest dan query.
export const EMBEDDER = new ModelRouterEmbeddingModel('openai/text-embedding-3-small')

export const vectorStore = new LibSQLVector({
  id: 'kebijakan-vector',
  url: process.env.VECTOR_URL || 'file:./vector.db',
})

Kenapa embedder ditaruh di satu berkas bersama

Ini bukan sekadar kerapian. Ingat dari 5.3: kalau model embedding saat query berbeda dari saat ingest, hasil pencarian menjadi acak tanpa error apa pun.

Dengan mengekspor satu konstanta EMBEDDER yang dipakai kedua tempat, ketidakcocokan itu mustahil terjadi karena kelalaian.

src/mastra/tools/cari-kebijakan.ts

ts
import { createVectorQueryTool } from '@mastra/rag'
import { EMBEDDER, INDEX } from '../../infra/vector.ts'

export const cariKebijakan = createVectorQueryTool({
  vectorStoreName: 'kebijakanVector',
  indexName: INDEX,
  model: EMBEDDER,

  // Deskripsi bawaannya terlalu umum — selalu ganti.
  id: 'cari-kebijakan',
  description:
    'Cari di dokumen kebijakan toko: pengembalian barang, garansi, ' +
    'pengiriman, dan keanggotaan. Pakai untuk SEMUA pertanyaan tentang ' +
    'aturan atau prosedur. BUKAN untuk status pesanan tertentu.',
})

Ingat dari 5.3: id, description, dan modelhanya bisa diatur saat pembuatan. Deskripsi bawaan berbunyi "Access the knowledge base to find information needed to answer user questions" — terlalu umum begitu agent punya tool lain.

src/mastra/agents/cs-agent.ts

ts
import { Agent } from '@mastra/core/agent'
import { cariKebijakan } from '../tools/cari-kebijakan.ts'

export const csAgent = new Agent({
  id: 'cs-agent',
  name: 'CS Agent',
  instructions: `Kamu asisten customer service toko online.

Pakai cariKebijakan untuk SETIAP pertanyaan tentang aturan atau prosedur,
sebelum menjawab. Jangan menjawab dari pengetahuan umum.

Jawab HANYA berdasarkan isi dokumen yang kamu temukan. Kalau dokumen tidak
memuat jawabannya, katakan terus terang bahwa hal itu tidak diatur dalam
kebijakan yang kamu akses, lalu sarankan menghubungi CS manusia.

Sebutkan bagian kebijakan yang menjadi dasar jawabanmu.`,
  model: 'openai/gpt-5-mini',
  tools: { cariKebijakan },
})

Keempat elemen instruksi RAG dari 5.3 ada di sini: perintah mencari dulu, batasan sumber, jalan keluar saat tidak ketemu, dan permintaan menyebut rujukan.

Hasil nyata

Pipeline dijalankan sungguhan pada dokumen kebijakan pengembalian.

Tahap ingest:

text
INGEST: 5 potongan
  [0] # Kebijakan Pengembalian Barang  ## Syarat Umum Pengembalian barang da...
  [1] ## Barang Elektronik Untuk barang elektronik, masa pengembalian diperp...
  [2] ## Barang yang Tidak Dapat Dikembalikan Produk kesehatan pribadi, paka...
  [3] ## Proses Refund Refund diproses dalam 3 hari kerja setelah barang dit...
  [4] ## Ongkos Kirim Ongkos kirim pengembalian ditanggung pembeli, kecuali ...
  dimensi vektor: 1536
  tersimpan ke vector store

maxSize: 300 menghasilkan potongan yang rapi mengikuti heading — tiap bagian kebijakan menjadi satu potongan utuh. Itu yang diinginkan.

Tahap query — perhatikan kata yang dipakai penanya:

text
> TV saya rusak tapi stikernya sudah saya buka, bisa balikin?
  #1 skor 0.408 : ## Barang Elektronik Untuk barang elektronik, masa
                   pengembalian diperpanjang menjadi 14 hari. N...
  #2 skor 0.348 : ## Barang yang Tidak Dapat Dikembalikan Produk kesehatan
                   pribadi, pakaian dalam, makanan, dan b...

> siapa yang bayar kirim balik kalau barangnya salah?
  #1 skor 0.619 : ## Ongkos Kirim Ongkos kirim pengembalian ditanggung
                   pembeli, kecuali bila barang cacat produks...
  #2 skor 0.482 : ## Proses Refund Refund diproses dalam 3 hari kerja...

Inilah yang tidak bisa dilakukan pencarian kata kunci:

Penanya menulisDokumen menulisTetap ketemu?
"stikernya sudah saya buka""segel garansi sudah rusak"✅ peringkat 1
"TV""barang elektronik"
"kirim balik""ongkos kirim pengembalian"✅ peringkat 1
"barangnya salah""salah kirim"

Tidak ada satu pun kata yang sama persis, tapi potongan yang benar selalu di peringkat 1.

Perhatikan angka skornya

Pertanyaan kedua mendapat skor 0.619, yang pertama hanya 0.408. Itu masuk akal — pertanyaan kedua hampir memetakan satu bagian secara langsung, sementara yang pertama menyinggung dua aturan sekaligus (elektronik dan segel).

Skor rendah bukan berarti salah. Tapi kalau skor peringkat 1 konsisten di bawah ~0.3, biasanya itu tanda potonganmu terlalu besar atau pertanyaannya memang tidak terjawab dokumen.

Best practice yang dipakai di sini

1. Satu konstanta EMBEDDER dipakai ingest dan query. Menghilangkan kelas bug yang paling sulit didiagnosis di RAG.

2. metadata menyimpan text dan sumber. Tanpa text, tidak ada isi untuk diberikan ke model. Tanpa sumber, jawaban tidak bisa menyebut rujukan — dan pelanggan tidak bisa memverifikasi.

3. Ingest sebagai perintah terpisah. npm run ingest dijalankan saat dokumen berubah, bukan tiap deploy.

4. Dokumen di luar src/. Tim legal mengedit Markdown, bukan kode.

5. Ukuran potongan diturunkan dari bentuk dokumen. 300 dipilih karena tiap bagian kebijakan panjangnya 2–4 kalimat — bukan karena 300 angka yang bagus.

Kapan pola ini salah

SituasiKenapa gagalKe mana
Pertanyaan tentang pesanan tertentuRAG mencari teks, bukan baris database10.2 Tool
Dokumen puluhan ribu halamanlibSQL berbasis berkas tidak memadaipgvector, 9.1
Hasil sering benar topik tapi salah detailPerlu penyusunan ulang hasilReranking, 5.3
Perlu tahu berapa persen jawaban akuratKesan tidak cukup10.15 Platform produksi

Baris terakhir penting. Sistem RAG selalu terlihat bekerja saat kamu mencobanya sendiri dengan lima pertanyaan yang kamu tahu jawabannya. Mengukurnya butuh scorer — dan itu isi kasus 10.15.

Coba sendiri

Bangun helpdesk untuk dokumen yang benar-benar kamu punya — panduan produk, dokumentasi internal, atau peraturan apa pun sepanjang minimal 10 halaman.

Selesai kalau

  • [ ] npm run ingest berjalan terpisah dari server, dan mencetak jumlah potongan
  • [ ] Tiga pertanyaan yang memakai kata berbeda dari dokumen tetap menemukan potongan yang benar
  • [ ] Pertanyaan yang jawabannya tidak ada di dokumen dijawab jujur "tidak diatur", bukan dikarang
  • [ ] Jawaban menyebutkan bagian dokumen sebagai rujukan
  • [ ] Kamu mencoba maxSize 150 dan 800, lalu bisa menjelaskan mana yang lebih baik untuk dokumenmu berdasarkan potongan yang kamu baca
  • [ ] EMBEDDER didefinisikan sekali dan dipakai di kedua tempat

Kalau macet

Hasil pencarian acak dan tidak nyambung? Kemungkinan besar model embedding saat query berbeda dari saat ingest. Periksa keduanya memakai konstanta yang sama.

metadata.text bernilai undefined saat query? Kamu lupa menyimpannya saat upsert. Vector store menyimpan vektor; isi teksnya harus kamu titipkan sendiri lewat metadata.

Agent menjawab tanpa memanggil tool? Instruksinya kurang tegas. Kata "SETIAP pertanyaan... sebelum menjawab" lebih efektif daripada "pakai tool ini bila perlu".

Lanjut ke mana

Kelima kasus pertama semuanya bereaksi terhadap pengguna. Kasus berikutnya membalik itu: agent yang bekerja tanpa diminta, pada jadwal yang kamu tentukan — 10.6 Laporan Saham Terjadwal.

Materi belajar mandiri. Bukan dokumentasi resmi Mastra.