Text index bekerja baik untuk katalog kecil, tetapi semakin banyak kebutuhan yang muncul — pencarian multi-bahasa, logika AND antar kata, integrasi dengan aggregation — semakin sering kita menabrak dinding batasannya. Bab ini membedah batasan-batasan itu satu per satu dengan eksperimen langsung, supaya keputusan “tetap memakai text index atau naik kelas” diambil berdasarkan fakta, bukan tebakan. Kita masih memakai database latihan_geo dan collection buku.

Peta batasan yang akan kita uji:

  • Satu collection hanya boleh memiliki satu text index.
  • Logika antar kata terbatas: OR, frasa, dan negasi — tanpa AND bebas.
  • $text hanya boleh di $match stage pertama pada aggregation pipeline.
  • Stop words dan stemming mengikuti bahasa Inggris.

Satu Text Index Per Collection #

MongoDB menolak text index kedua pada collection yang sama. Semua field teks yang ingin dicari harus digabungkan ke dalam satu index sejak awal — termasuk bobotnya, seperti weights yang kita pakai di Chapter Text Search:

js
use latihan_geo
db.buku.drop()
db.buku.insertMany([
  { judul: "Laut Bercerita", sinopsis: "novel sejarah tentang pelaut muda yang mencari keadilan" },
  { judul: "Sirkus Pohon", sinopsis: "dongeng tentang samudra dan hutan dalam ingatan" },
  { judul: "Warung Tepi Jalan", sinopsis: "kisah warung gorengan dan kopi yang tak pernah sepi" },
  { judul: "The Great Gatsby", sinopsis: "a story of wealth, love and the american dream" }
])
db.buku.createIndex({ judul: "text", sinopsis: "text" })
try {
  db.buku.createIndex({ judul: "text" })
} catch (e) {
  print("Galat: " + e.code)
  print(e.message)
}

Output yang Dihasilkan:

text
switched to db latihan_geo
true
{
  acknowledged: true,
  insertedIds: {
    '0': ObjectId('6ad3e08ee505a46c80c29b4f'),
    '1': ObjectId('6ad3e08ee505a46c80c29b50'),
    '2': ObjectId('6ad3e08ee505a46c80c29b51'),
    '3': ObjectId('6ad3e08ee505a46c80c29b52')
  }
}
judul_text_sinopsis_text
Galat: 85
An equivalent index already exists with a different name and options. Requested index: { v: 2, key: { _fts: "text", _ftsx: 1 }, name: "judul_text", weights: { judul: 1 }, default_language: "english", language_override: "language", textIndexVersion: 3 }, existing index: { v: 2, key: { _fts: "text", _ftsx: 1 }, name: "judul_text_sinopsis_text", weights: { judul: 1, sinopsis: 1 }, default_language: "english", language_override: "language", textIndexVersion: 3 }

Error 85 menampilkan kedua spesifikasi sekaligus: index yang diminta (judul_text) dan index yang sudah ada (judul_text_sinopsis_text). Keduanya sama-sama bertipe text, dan itulah yang ditolak. Konsekuensinya praktis: menambah field baru ke pencarian teks berarti membangun ulang index dengan daftar field lengkap — operasi yang mahal pada collection besar.

Logika AND yang Tidak Ada #

Di Chapter Text Search kita melihat kata biasa di-OR-kan dan hanya frasa yang di-AND-kan. Batas ini terasa nyata ketika pengguna mengetik dua kata yang keduanya wajib ada, tetapi tidak berurutan:

js
db.buku.find({ $text: { $search: "samudra keadilan" } }, { _id: 0, judul: 1 })

Output yang Dihasilkan:

text
[ { judul: 'Laut Bercerita' }, { judul: 'Sirkus Pohon' } ]

Tidak ada satu pun buku yang memuat samudra dan keadilan sekaligus — Sirkus Pohon hanya memuat samudra, Laut Bercerita hanya memuat keadilan. Keduanya tetap lolos karena logikanya OR. Tiga jalan keluar yang tersedia:

  1. Frasa "samudra keadilan" — hanya jika kedua kata memang selalu berurutan.
  2. Menyaring hasil di aplikasi — ambil hasil OR, lalu buang document yang tidak memuat kedua kata.
  3. Naik ke Atlas Search — search engine yang mendukung operator AND antar kata secara native (Chapter berikutnya).

$text Hanya di Stage Pertama #

Pada aggregation pipeline, $match yang memuat $text wajib menjadi stage pertama. Menaruhnya setelah stage apa pun — termasuk setelah $lookup — langsung ditolak server:

js
try {
  db.buku.aggregate([ { $limit: 5 }, { $match: { $text: { $search: "samudra" } } } ]).toArray()
} catch (e) {
  print("Galat: " + e.code + " :: " + e.message)
}
try {
  db.buku.aggregate([
    { $lookup: { from: "buku", localField: "judul", foreignField: "judul", as: "salinan" } },
    { $match: { $text: { $search: "samudra" } } }
  ]).toArray()
} catch (e) {
  print("Galat: " + e.code + " :: " + e.message)
}

Output yang Dihasilkan:

text
Galat: 17313 :: $match with $text is only allowed as the first pipeline stage

Galat: 17313 :: $match with $text is only allowed as the first pipeline stage

Kedua usaha menemui error 17313 yang sama — baik setelah $limit maupun setelah $lookup. Penyebabnya mekanis: $text hanya bisa dilayani lewat text index milik collection asal, sedangkan document yang mengalir setelah $lookup berasal dari collection lain dan tidak lagi terikat pada index itu. Konsekuensinya: pencarian teks harus selalu di depan, dan penggabungan data dari collection lain dilakukan setelahnya.

Stop Words dan Stemming Bahasa Indonesia #

Dua perilaku bahasa Inggris ini sering mengejutkan pengguna bahasa Indonesia. Pertama, stop word Inggris dibuang saat pengindeksan sehingga tidak bisa dicari. Kedua, stemmer hanya mengenal imbuhan Inggris — kata gorengan dan goreng dianggap dua kata berbeda:

js
db.buku.find({ $text: { $search: "the and a of" } }, { _id: 0, judul: 1 }).toArray()
db.buku.find({ $text: { $search: "goreng" } }, { _id: 0, judul: 1 }).toArray()
db.buku.find({ $text: { $search: "gorengan" } }, { _id: 0, judul: 1 })

Output yang Dihasilkan:

text
[]
[]
[ { judul: 'Warung Tepi Jalan' } ]
  1. the and a of → kosong: keempatnya stop word Inggris yang tidak pernah tersimpan di index.
  2. goreng → kosong, gorengan → ketemu: stemmer bahasa Inggris tidak mengenal akhiran -an milik bahasa Indonesia, sehingga gorengan dibiarkan utuh sebagai token. Pencarian menanam vs tanam, penulis vs tulis bernasib sama.
  3. Bahasa index tunggal: default_language ditetapkan sekali untuk seluruh index. Chapter Text Index sudah membuktikan indonesian ditolak karena tidak ada di daftar bahasa yang didukung; opsi praktisnya adalah "none" atau field language per document (lihat language_override di dokumentasi).

Alternatif: Regex dan Kapan Naik Kelas #

Untuk kebutuhan sederhana, regex menutup beberapa celah text index — terutama pencarian bagian kata yang tidak terdeteksi stemmer:

js
db.buku.find({ sinopsis: /goreng/i }, { _id: 0, judul: 1 })
db.buku.find({ judul: /^warung/i }, { _id: 0, judul: 1 })

Output yang Dihasilkan:

text
[ { judul: 'Warung Tepi Jalan' } ]
[ { judul: 'Warung Tepi Jalan' } ]

Regex /goreng/i menemukan gorengan karena mencocokkan substring, bukan token. Regex berprefiks ^warung bahkan bisa memanfaatkan index biasa pada field itu. Namun regex tidak punya peringkat relevansi, tidak memahami frasa, dan regex tanpa prefiks memindai seluruh collection. Tabel berikut merangkum keputusannya:

KebutuhanSolusi tepat
Cari kata/frasa sederhana, satu bahasaText index $text
Cari bagian kata (goreng dalam gorengan)Regex /goreng/i
Awalan kata pada satu fieldRegex prefiks ^... + index biasa
AND antar kata, fuzzy, autocomplete, facetAtlas Search

Rangkuman #

  • Satu collection satu text index; menambah field berarti membangun ulang index.
  • Kata biasa hanya bisa di-OR-kan — AND bebas tidak tersedia, hanya frasa berurutan.
  • $match dengan $text wajib stage pertama; setelah $lookup langsung error 17313.
  • Stop words dan imbuhan mengikuti bahasa Inggris; gorengan ≠ goreng, dan kata the tidak pernah terindeks.
  • Regex menutup kebutuhan bagian kata dan prefiks, tetapi tanpa relevansi.

Batasan-batasan ini bukan cacat perancangan — text index memang dirancang ringan untuk kebutuhan dasar. Ketika aplikasi menuntut lebih, saatnya berkenalan dengan Atlas Search.