Katalog, query, dan index lengkap — tinggal dua hal yang memisahkan proyek mainan dari fitur siap produksi: bagaimana hasil dibelah menjadi halaman, dan bukti bahwa index benar-benar meringankan kerja server. Bagian penutup ini mengerjakan keduanya. Hasil pencarian teks dihalaman lewat sort skor dengan skip/limit, hasil filter memakai keyset pagination harga + _id seperti proyek blog, lalu explain("executionStats") menghitung docsExamined sebelum dan sesudah index bekerja.

Dua Jalur Pagination #

Kedua jenis hasil menuntut strategi berbeda:

Jenis hasilUrutanPaginationAlasan
Pencarian teksskor textScoreskip/limitSkor dihitung per query dan tidak bisa dipakai sebagai predikat filter kursor
Filter harga{ harga: 1, _id: 1 }keyset harga + _idIndex compound menyediakan urutan total; kursor melompat tanpa memindai offset

Batas teknisnya tegas: textScore hadir hanya sebagai nilai $meta di projection dan sort — MongoDB tidak mengizinkannya menjadi kondisi filter. Kursor keyset butuh kondisi harga > kursor atau harga = kursor AND _id > kursor, dan tidak ada padanan untuk skor. Karena itu hasil teks memakai offset — mahal di halaman dalam, tetapi hasil pencarian jarang ditelusuri sampai halaman ke-10 — sementara daftar filter yang sering di-scroll jauh memakai keyset.

Pagination Hasil Teks #

Sepuluh hasil pensil dibelah menjadi dua halaman berisi lima, keduanya diurutkan skor hasil weights Bagian 3:

js
use proyek_pencarian
db.katalog.find(
  { $text: { $search: "pensil" } },
  { _id: 0, nama: 1, skor: { $meta: "textScore" } }
).sort({ skor: { $meta: "textScore" } }).limit(5).toArray()
db.katalog.find(
  { $text: { $search: "pensil" } },
  { _id: 0, nama: 1, skor: { $meta: "textScore" } }
).sort({ skor: { $meta: "textScore" } }).skip(5).limit(5).toArray()

Output yang Dihasilkan:

text
switched to db proyek_pencarian
[
  { nama: 'Pensil Warna 24 Warna', skor: 6.8125 },
  { nama: 'Rautan Pensil Dua Lubang', skor: 6.805555555555555 },
  { nama: 'Pensil Kayu HB', skor: 6.666666666666666 },
  { nama: 'Pensil Mekanik 0.5 mm', skor: 6.555555555555555 },
  {
    nama: 'Teknik Sketsa dengan Pensil untuk Pemula',
    skor: 5.833333333333334
  }
]
[
  { nama: 'Buku Tulis 58 Lembar', skor: 0.6 },
  { nama: 'Buku Aktivitas Anak: Mewarnai', skor: 0.5625 },
  { nama: 'Penghapus Karet Putih', skor: 0.5625 },
  { nama: 'Panduan Menggambar untuk Pemula', skor: 0.5555555555555556 },
  { nama: 'Kertas Gambar A3 160 gram', skor: 0.55 }
]

Halaman pertama memuat lima produk yang cocok di nama — efek weights masih terlihat di sini. Halaman kedua diambil dengan skip(5): server menjalankan query yang sama, membuang lima hasil pertama, lalu mengirim lima berikutnya. Perhatikan Buku Aktivitas Anak dan Penghapus Karet Putih berbagi skor 0.5625 — dua document dengan skor seri tidak punya urutan yang dijamin, jadi posisi keduanya bisa bertukar. Untuk daftar belanja itu bisa diterima; untuk urutan yang wajib stabil, keyset berikutlah jawabannya.

Keyset untuk Filter Harga #

Daftar produk per kategori diurutkan { harga: 1, _id: 1 }. Halaman berikutnya tidak memakai offset, melainkan kursor: pasangan harga dan _id dari item terakhir halaman sebelumnya. Query kedua di bawah ini memakai kursor { harga: 6000, _id: ...035 } milik Pensil Kayu HB:

js
db.katalog.find(
  { kategori: "alat tulis" },
  { _id: 1, nama: 1, harga: 1 }
).sort({ harga: 1, _id: 1 }).limit(5).toArray()
db.katalog.find(
  {
    kategori: "alat tulis",
    $or: [
      { harga: { $gt: 6000 } },
      { harga: 6000, _id: { $gt: ObjectId("67c100000000000000000035") } },
    ],
  },
  { _id: 1, nama: 1, harga: 1 }
).sort({ harga: 1, _id: 1 }).limit(5).toArray()

Output yang Dihasilkan:

text
[
  {
    _id: ObjectId('67c100000000000000000058'),
    nama: 'Amplop Kertas Kecil',
    harga: 3500
  },
  {
    _id: ObjectId('67c100000000000000000043'),
    nama: 'Pulpen Bola Biru',
    harga: 4500
  },
  {
    _id: ObjectId('67c100000000000000000056'),
    nama: 'Map Plastik Kancing',
    harga: 4500
  },
  {
    _id: ObjectId('67c100000000000000000039'),
    nama: 'Buku Tulis 58 Lembar',
    harga: 5500
  },
  {
    _id: ObjectId('67c100000000000000000035'),
    nama: 'Pensil Kayu HB',
    harga: 6000
  }
]
[
  {
    _id: ObjectId('67c100000000000000000046'),
    nama: 'Penghapus Karet Putih',
    harga: 6000
  },
  {
    _id: ObjectId('67c100000000000000000040'),
    nama: 'Buku Gambar A4 Blanko',
    harga: 6500
  },
  {
    _id: ObjectId('67c100000000000000000050'),
    nama: 'Busur Derajat 15 cm',
    harga: 7000
  },
  {
    _id: ObjectId('67c100000000000000000049'),
    nama: 'Penggaris Segitiga Set',
    harga: 7500
  },
  {
    _id: ObjectId('67c100000000000000000055'),
    nama: 'Isi Stapler 24/6',
    harga: 7500
  }
]

Halaman pertama ditutup Pensil Kayu HB dengan harga Rp6.000. Halaman kedua dibuka Penghapus Karet Putih — harga yang sama persis, Rp6.000. Cabang $or pertama (harga > 6000) tidak menangkapnya; cabang kedua (harga = 6000 dan _id lebih besar dari kursor) yang meloloskannya. Inilah tie-breaker _id bekerja: pasangan harga kembar tidak pernah terlewati maupun tampil dua kali, dan compound index { kategori: 1, harga: 1 } melayani kedua cabang tanpa memindai offset.

Mengukur docsExamined #

Klaim “index membuat query cepat” perlu angka. explain("executionStats") menyediakannya: totalDocsExamined menghitung berapa document yang benar-benar dibuka server. Skrip terakhir membandingkan dua pasangan — regex vs text index untuk pencarian, dengan vs tanpa index untuk filter:

js
// uji-performa.js — ukur docsExamined: text vs regex, filter dengan vs tanpa index
// Jalankan setelah buat-index.js:
//   mongosh "mongodb://127.0.0.1:27017/?directConnection=true" --file src/uji-performa.js

var dbp = db.getSiblingDB("proyek_pencarian")

function rantaiTahap(rencana) {
  let tahap = rencana
  const nama = []
  while (tahap) {
    nama.push(tahap.stage)
    tahap = tahap.inputStage
  }
  return nama.join(" -> ")
}

function ukur(label, kursor) {
  const rencana = kursor.explain("executionStats")
  const statistik = rencana.executionStats
  print(label)
  print("  tahap      :", rantaiTahap(rencana.queryPlanner.winningPlan))
  print("  nReturned  :", statistik.nReturned)
  print("  docsExamined:", statistik.totalDocsExamined)
  print("  keysExamined:", statistik.totalKeysExamined)
  print("  waktu (ms) :", statistik.executionTimeMillis)
}

function teksRegexVsTextIndex() {
  print("== pencarian 'pensil': regex vs text index ==")
  ukur("regex pada nama + deskripsi (tanpa index):", dbp.katalog
    .find({ $or: [{ nama: /pensil/i }, { deskripsi: /pensil/i }] }))
  ukur("$text + $search (text index):", dbp.katalog
    .find({ $text: { $search: "pensil" } }))
}

function filterDenganVsTanpaIndex() {
  print("== filter kategori + harga: dengan vs tanpa compound index ==")
  const kueri = function () {
    return dbp.katalog
      .find({ kategori: "alat tulis", harga: { $gte: 5000, $lte: 20000 } })
      .sort({ harga: 1 })
  }

  ukur("dengan index kategori_1_harga_1:", kueri())

  dbp.katalog.dropIndex("kategori_1_harga_1")
  ukur("tanpa index (COLLSCAN):", kueri())

  dbp.katalog.createIndex({ kategori: 1, harga: 1 })
  print("index kategori_1_harga_1 dipasang kembali")
}

teksRegexVsTextIndex()
filterDenganVsTanpaIndex()

Jalankan setelah skrip index:

bash
mongosh "mongodb://127.0.0.1:27017/?directConnection=true" --file src/uji-performa.js

Output yang Dihasilkan:

text
== pencarian 'pensil': regex vs text index ==
regex pada nama + deskripsi (tanpa index):
  tahap      : SUBPLAN -> COLLSCAN
  nReturned  : 10
  docsExamined: 60
  keysExamined: 0
  waktu (ms) : 0
$text + $search (text index):
  tahap      : TEXT_MATCH -> FETCH -> IXSCAN
  nReturned  : 10
  docsExamined: 10
  keysExamined: 10
  waktu (ms) : 0
== filter kategori + harga: dengan vs tanpa compound index ==
dengan index kategori_1_harga_1:
  tahap      : FETCH -> IXSCAN
  nReturned  : 17
  docsExamined: 17
  keysExamined: 17
  waktu (ms) : 0
tanpa index (COLLSCAN):
  tahap      : SORT -> COLLSCAN
  nReturned  : 17
  docsExamined: 60
  keysExamined: 0
  waktu (ms) : 0
index kategori_1_harga_1 dipasang kembali

Angka-angka itu bercerita:

  1. Regex vs text index: keduanya mengembalikan 10 hasil yang sama, tetapi regex membuka seluruh 60 document (COLLSCAN) sementara $text membuka tepat 10 (TEXT_MATCH → FETCH → IXSCAN, 10 kunci index diperiksa). Pada 60 produk bedanya tak terasa — pada 6 juta, regex memeriksa 6 juta document setiap kali dicari.
  2. Filter dengan vs tanpa index: dengan compound index, server memeriksa 17 document untuk 17 hasil — sempurna satu-satu. Tanpa index, SORT → COLLSCAN membuka 60 document dan mengurutkan hasilnya di memori. Skrip menutup dengan memasang index kembali agar kondisi katalog utuh.
  3. waktu (ms): 0 di semua baris adalah pengingat yang jujur: pada data sekecil ini, network round-trip dan overhead parsing mendominasi. docsExamined dan keysExamined adalah metrik yang skala mengikuti ukuran data — itulah yang kita pantau.

Suite Tes Penutup #

Empat bagian lengkap; suite tes berikut memverifikasi seluruh perilaku pencarian dalam satu jalan. Sama seperti proyek sebelumnya, tes bersifat mandiri: data uji dibuat sendiri di database proyek_pencarian_tes, lalu dibersihkan:

js
// tests/semua-tes.js — suite tes mandiri proyek pencarian produk
// Tes ini tidak bergantung pada src/: data uji dibuat sendiri lalu dibersihkan.
// Jalankan dari folder proyek:
//   mongosh "mongodb://127.0.0.1:27017/?directConnection=true" --file tests/semua-tes.js

var tes = db.getSiblingDB("proyek_pencarian_tes")

function assertSama(nyata, harapan, pesan) {
  const a = EJSON.stringify(nyata)
  const h = EJSON.stringify(harapan)
  if (a !== h) {
    throw new Error("GAGAL - " + pesan + " | harapan: " + h + " | nyata: " + a)
  }
  print("lulus -", pesan)
}

function idTes(n) {
  return ObjectId("67d2000000000000000000" + n.toString().padStart(2, "0"))
}

function siapkanData() {
  tes.dropDatabase()
  tes.katalog.insertMany([
    { _id: idTes(1), nama: "Pensil Kayu HB", deskripsi: "Inti hitam pekat untuk menulis.", kategori: "alat tulis", harga: 6000, tag: ["sekolah"] },
    { _id: idTes(2), nama: "Pensil Mekanik 0.7", deskripsi: "Isi ulang praktis tanpa rautan.", kategori: "alat tulis", harga: 25000, tag: ["teknis"] },
    { _id: idTes(3), nama: "Pensil Warna 12", deskripsi: "Warna cerah untuk anak.", kategori: "alat tulis", harga: 30000, tag: ["anak"] },
    { _id: idTes(4), nama: "Buku Tulis 38 Lembar", deskripsi: "Kertas mulus untuk pensil.", kategori: "alat tulis", harga: 5500, tag: ["sekolah"] },
    { _id: idTes(5), nama: "Krayon Mini 12", deskripsi: "Aman untuk balita.", kategori: "alat tulis", harga: 12000, tag: ["anak"] },
    { _id: idTes(6), nama: "Teknik Sketsa Pensil", deskripsi: "Panduan seni rupa bergambar.", kategori: "buku", harga: 76000, tag: ["seni"] },
    { _id: idTes(7), nama: "Panduan Menggambar Pemula", deskripsi: "Latihan mewarnai dengan pensil warna dan krayon.", kategori: "buku", harga: 72000, tag: ["seni"] },
    { _id: idTes(8), nama: "Novel Senja", deskripsi: "Fiksi ringan satu duduk.", kategori: "buku", harga: 69000, tag: ["fiksi"] },
  ])
  tes.katalog.createIndex(
    { nama: "text", deskripsi: "text" },
    { default_language: "none", weights: { nama: 10, deskripsi: 1 } }
  )
  tes.katalog.createIndex({ kategori: 1, harga: 1 })
}

function namaHasil(filter) {
  return tes.katalog
    .find(filter, { _id: 0, nama: 1 })
    .sort({ nama: 1 })
    .toArray()
    .map(function (p) { return p.nama })
}

function tesCariKata() {
  assertSama(namaHasil({ $text: { $search: "pensil" } }), [
    "Buku Tulis 38 Lembar",
    "Panduan Menggambar Pemula",
    "Pensil Kayu HB",
    "Pensil Mekanik 0.7",
    "Pensil Warna 12",
    "Teknik Sketsa Pensil",
  ], "pencarian kata 'pensil' memuat tepat enam produk yang diharapkan")
  assertSama(namaHasil({ $text: { $search: "novel" } }), ["Novel Senja"],
    "pencarian kata di deskripsi tetap ditemukan")
}

function tesFrasa() {
  assertSama(namaHasil({ $text: { $search: "\"pensil warna\"" } }), [
    "Panduan Menggambar Pemula",
    "Pensil Warna 12",
  ], "frasa eksak 'pensil warna' hanya memuat dua produk")
}

function tesNegasi() {
  assertSama(namaHasil({ $text: { $search: "pensil -mekanik" } }), [
    "Buku Tulis 38 Lembar",
    "Panduan Menggambar Pemula",
    "Pensil Kayu HB",
    "Pensil Warna 12",
    "Teknik Sketsa Pensil",
  ], "negasi '-mekanik' mengecualikan Pensil Mekanik 0.7")
}

function tesFilterGabungan() {
  const filter = {
    $text: { $search: "pensil" },
    kategori: "alat tulis",
    harga: { $gte: 5000, $lte: 20000 },
  }
  assertSama(namaHasil(filter), ["Buku Tulis 38 Lembar", "Pensil Kayu HB"],
    "filter gabungan teks + kategori + harga mengembalikan dua produk")
  assertSama(tes.katalog.countDocuments(filter), 2,
    "jumlah hasil filter gabungan konsisten")
}

function tesBobot() {
  const peringkat = tes.katalog
    .find(
      { $text: { $search: "pensil" } },
      { _id: 0, nama: 1, skor: { $meta: "textScore" } }
    )
    .sort({ skor: { $meta: "textScore" } })
    .toArray()
  const namaSketsa = peringkat.find(function (p) { return p.nama === "Teknik Sketsa Pensil" })
  const namaBukuTulis = peringkat.find(function (p) { return p.nama === "Buku Tulis 38 Lembar" })
  assertSama(namaSketsa !== undefined && namaBukuTulis !== undefined, true,
    "kedua produk pembanding bobot ditemukan")
  assertSama(namaSketsa.skor > namaBukuTulis.skor, true,
    "kecocokan di nama (bobot 10) mengalahkan kecocokan di deskripsi (bobot 1)")
}

function tesKeysetPagination() {
  function halaman(ukuran, kursor) {
    const filter = { kategori: "alat tulis" }
    if (kursor) {
      filter.$or = [
        { harga: { $gt: kursor.harga } },
        { harga: kursor.harga, _id: { $gt: kursor._id } },
      ]
    }
    const dokumen = tes.katalog
      .find(filter)
      .sort({ harga: 1, _id: 1 })
      .limit(ukuran + 1)
      .toArray()
    const adaLagi = dokumen.length > ukuran
    const isi = adaLagi ? dokumen.slice(0, ukuran) : dokumen
    return {
      nama: isi.map(function (p) { return p.nama }),
      kursorBerikutnya: adaLagi
        ? { harga: isi[isi.length - 1].harga, _id: isi[isi.length - 1]._id }
        : null,
    }
  }

  const hal1 = halaman(2, null)
  assertSama(hal1.nama, ["Buku Tulis 38 Lembar", "Pensil Kayu HB"], "halaman 1 keyset benar")
  const hal2 = halaman(2, hal1.kursorBerikutnya)
  assertSama(hal2.nama, ["Krayon Mini 12", "Pensil Mekanik 0.7"], "halaman 2 keyset benar")
  const hal3 = halaman(2, hal2.kursorBerikutnya)
  assertSama(hal3.nama, ["Pensil Warna 12"], "halaman 3 memuat sisa satu produk")
  assertSama(hal3.kursorBerikutnya, null, "kursor null menandai halaman terakhir")
}

siapkanData()
tesCariKata()
tesFrasa()
tesNegasi()
tesFilterGabungan()
tesBobot()
tesKeysetPagination()
print("SEMUA TES LULUS")
tes.dropDatabase()

Jalankan dari folder proyek:

bash
mongosh "mongodb://127.0.0.1:27017/?directConnection=true" --file tests/semua-tes.js

Output yang Dihasilkan:

text
lulus - pencarian kata 'pensil' memuat tepat enam produk yang diharapkan
lulus - pencarian kata di deskripsi tetap ditemukan
lulus - frasa eksak 'pensil warna' hanya memuat dua produk
lulus - negasi '-mekanik' mengecualikan Pensil Mekanik 0.7
lulus - filter gabungan teks + kategori + harga mengembalikan dua produk
lulus - jumlah hasil filter gabungan konsisten
lulus - kedua produk pembanding bobot ditemukan
lulus - kecocokan di nama (bobot 10) mengalahkan kecocokan di deskripsi (bobot 1)
lulus - halaman 1 keyset benar
lulus - halaman 2 keyset benar
lulus - halaman 3 memuat sisa satu produk
lulus - kursor null menandai halaman terakhir
SEMUA TES LULUS

Dua belas assertion mengulang jalur proyek: kata kunci menemukan produk yang diharapkan baik lewat nama maupun deskripsi, frasa eksak dan negasi memangkas hasil tepat pada batasnya, filter gabungan mengembalikan himpunan yang sama dari dua query berbeda, weights menjaga kecocokan nama di atas deskripsi, dan keyset pagination membelah daftar harga tanpa satu pun produk tercecer.

Penutup Proyek #

Proyek pencarian produk tuntas: schema yang memisahkan teks bebas dari filter terstruktur, query $text lengkap dengan frasa dan negasi, text index berbobot yang menaikkan kecocokan nama, compound index untuk filter, pagination dua jalur, dan angka docsExamined sebagai bukti kerja index. Proyek terakhir Course ini berpindah domain: dari katalog yang dicari ke data yang mengalir — Pencatatan Data Waktu memakai time series collection untuk menyimpan, menganalisis, dan mengatur retensi data sensor.