Dua jenis indeks khusus melengkapi indeks kesamaan dan rentang, yaitu indeks teks (text index) untuk pencarian kata di dalam string dan indeks wildcard untuk field yang nama maupun strukturnya beragam antardokumen. Topik ini membahas pembuatan indeks teks beserta pencarian kata dan frasa, pengaturan bobot field pada indeks teks, perilaku indeks wildcard pada field bersarang, pembatasan jalurnya melalui wildcardProjection, serta batasan satu indeks teks per koleksi. Demonstrasi memakai basis data kamus dengan koleksi entri yang setiap dokumennya memuat definisi kalimat dan atribut etimologi. Setelah mengikuti topik ini, kebutuhan pencarian kata dan field lentur dapat dipetakan ke indeks yang tepat.
Indeks teks untuk pencarian kata#
Operator $text menelusuri kata di dalam nilai string, tetapi operator ini menuntut adanya indeks teks pada koleksi yang dituju. Indeks teks dibuat dengan nilai spesifikasi "text", dan server memecah setiap string menjadi token, yaitu potongan kata utuh, lalu menyimpannya sebagai kunci. Setiap token ditempatkan dalam struktur kunci terurut yang terpisah dari dokumen, sehingga pencarian kata melalui kunci seperti pemindaian indeks pada umumnya. Query kemudian menyebut kata yang dicari tanpa perlu tahu kalimat lengkapnya.
Tanamlah data contoh berikut. Field definisi memuat kalimat bebas, sedangkan field atribut menyimpan data etimologi yang strukturnya bersarang.
use kamus
const sukuAwal = ["ba", "ta", "ka", "me", "pa", "sa", "ni", "go", "lu", "wi"]
const sukuAkhir = ["tah", "rin", "sup", "kan", "del", "mus", "pel", "rong", "wis", "dak"]
const daftarKategori = ["nomina", "verba", "adjektiva"]
const daftarFrasa = ["gerakan yang cepat", "gerakan yang lambat", "bentuk yang sederhana", "cara yang rapi", "suara yang nyaring"]
const daftarBahasa = ["Melayu", "Sanskerta", "Arab", "Belanda"]
const daftarVarian = ["baku", "lisan", "klasik"]
const docs = []
const tambah = function (i, j, k) {
const definisi = (i === 0 && k === 0) ? "Kata arkais yang jarang dipakai" : "Menyatakan " + daftarFrasa[(i + j + k) % 5]
docs.push({
kata: sukuAwal[i] + sukuAkhir[j],
homonim: k + 1,
kategori: daftarKategori[(i + j + k) % 3],
definisi: definisi,
atribut: {
etimologi: { bahasa: daftarBahasa[i % 4], tahun: 1900 + ((i * 7 + j) % 60) },
varian: daftarVarian[j % 3]
}
})
}
for (let i = 0; i < 10; i++) {
for (let j = 0; j < 10; j++) {
for (let k = 0; k < 5; k++) {
tambah(i, j, k)
}
}
}
const hasil = db.entri.insertMany(docs)
print("Dokumen tersimpan: " + db.entri.countDocuments())switched to db kamus
Dokumen tersimpan: 500Buat indeks teks pada field definisi, lalu cari kata arkais yang hanya muncul pada sepuluh definisi.
db.entri.createIndex({ definisi: "text" })
print("Hasil arkais: " + db.entri.countDocuments({ $text: { $search: "arkais" } }))
db.entri.find({ $text: { $search: "arkais" } }, { _id: 0, kata: 1 }).sort({ kata: 1 }).limit(3).forEach(d => print(JSON.stringify(d)))definisi_text
Hasil arkais: 10
{"kata":"badak"}
{"kata":"badel"}
{"kata":"bakan"}Server mengembalikan sepuluh dokumen yang definisinya memuat kata tersebut. Pencarian menyaring lewat kunci token pada indeks teks, sehingga kata tidak perlu menjadi keseluruhan nilai field untuk ditemukan. Pengurutan menurut kata dan limit tetap berlaku seperti query biasa.
Pencarian beberapa kata dan frasa#
Nilai $search yang memuat beberapa kata dianggap sebagai daftar kata, dan dokumen yang memuat salah satu kata tersebut ikut dikembalikan. Artinya, beberapa kata pada satu nilai pencarian diperlakukan sebagai pilihan dengan makna “atau”, bukan syarat yang harus terpenuhi semuanya. Untuk menuntut kata-kata itu berdampingan sesuai urutan, tulis frasa di dalam tanda kutip pada nilai pencariannya. Pada contoh berikut, tanda kutip tersebut ditulis dengan garis miring terbalik agar tidak menutup string nilai pencariannya. Kedua bentuk ini menghasilkan kelompok dokumen yang berbeda pada data yang sama.
print("Dua kata terpisah: " + db.entri.countDocuments({ $text: { $search: "gerakan lambat" } }))
print("Frasa utuh: " + db.entri.countDocuments({ $text: { $search: "\"gerakan yang lambat\"" } }))Dua kata terpisah: 196
Frasa utuh: 98Pencarian dua kata mengembalikan 196 dokumen, yaitu seluruh entri yang definisinya memuat gerakan atau lambat. Pencarian frasa hanya mengembalikan 98 dokumen yang definisinya memuat rangkaian gerakan yang lambat secara berurutan. Pilih bentuk pencarian sesuai maksud query, karena selisih hasilnya signifikan pada data yang sama.
Bobot field pada indeks teks#
Indeks teks yang mencakup beberapa field menerima opsi weights, yaitu bobot yang menentukan seberapa besar kontribusi setiap field terhadap skor pencocokan. Skor (text score) adalah angka yang dihitung server untuk setiap dokumen hasil pencarian teks, dan nilainya diambil melalui ekspresi $meta: "textScore" pada proyeksi maupun pengurutan. Field yang diberi bobot lebih besar menghasilkan skor lebih tinggi untuk kecocokan yang sama, sehingga urutan hasil dapat mengutamakan kecocokan pada field utama.
Ganti indeks teks satu field dengan indeks teks gabungan pada kata dan definisi yang memberi bobot 10 pada kata.
const hapus = db.entri.dropIndex("definisi_text")
print("definisi_text dihapus: " + (hapus.ok === 1))
db.entri.createIndex({ kata: "text", definisi: "text" }, { weights: { kata: 10, definisi: 1 } })
db.entri.getIndexes()definisi_text dihapus: true
kata_text_definisi_text
[
{ v: 2, key: { _id: 1 }, name: '_id_' },
{
v: 2,
key: { _fts: 'text', _ftsx: 1 },
name: 'kata_text_definisi_text',
weights: { definisi: 1, kata: 10 },
default_language: 'english',
language_override: 'language',
textIndexVersion: 3
}
]Keluaran getIndexes menampilkan field weights berisi { definisi: 1, kata: 10 } pada indeks kata_text_definisi_text, sehingga bobot yang diterapkan dapat diverifikasi tanpa menebak. Urutkan hasil pencarian dua kata berdasarkan skornya untuk melihat efek bobot tersebut.
db.entri.find({ $text: { $search: "batah lambat" } }, { _id: 0, kata: 1, skor: { $meta: "textScore" } }).sort({ skor: { $meta: "textScore" } }).limit(5).forEach(d => print(JSON.stringify(d))){"kata":"batah","skor":11.625}
{"kata":"batah","skor":11}
{"kata":"batah","skor":11}
{"kata":"batah","skor":11}
{"kata":"batah","skor":11}Lima hasil teratas seluruhnya dokumen dengan kata batah, yaitu kecocokan pada field kata yang berbobot 10 dan mencetak skor di sekitar 11. Dokumen yang kecocokannya hanya pada field definisi mencetak skor 0.625 karena bobotnya 1, sehingga seluruhnya berada di bawah dokumen dengan kecocokan kata. Skor yang dihitung dari bobot dengan demikian menjadi dasar pengurutan hasil pencarian teks.
Indeks wildcard untuk field yang lentur#
Dokumen MongoDB boleh menyimpan field bersarang yang namanya tidak seragam, dan indeks pada field tunggal tidak dapat mengantisipasi semua nama tersebut. Indeks wildcard dibuat dengan pola "$**" pada sebuah field, misalnya "atribut.$**", sehingga server menuliskan kunci untuk setiap field daun, yaitu field paling dalam yang tidak berisi subdokumen, di bawah atribut apa pun namanya. Query pada jalur bersarang mana pun di bawah field itu dapat memakai indeks yang sama.
Ukur dahulu pekerjaan server sebelum indeks wildcard ada untuk query pada jalur atribut.etimologi.bahasa.
const st1 = db.entri.find({ "atribut.etimologi.bahasa": "Sanskerta" }).explain("executionStats")
print("tahap: " + st1.queryPlanner.winningPlan.stage)
print("totalDocsExamined: " + st1.executionStats.totalDocsExamined)tahap: COLLSCAN
totalDocsExamined: 500Buat indeks wildcard, lalu periksa rencana dan statistik query yang sama.
db.entri.createIndex({ "atribut.$**": 1 })
db.entri.find({ "atribut.etimologi.bahasa": "Sanskerta" }).explain("queryPlanner").queryPlanner.winningPlanatribut.$**_1
{
isCached: false,
stage: 'FETCH',
inputStage: {
stage: 'IXSCAN',
keyPattern: { '$_path': 1, 'atribut.etimologi.bahasa': 1 },
indexName: 'atribut.$**_1',
isMultiKey: false,
multiKeyPaths: { '$_path': [], 'atribut.etimologi.bahasa': [] },
isUnique: false,
isSparse: false,
isPartial: false,
indexVersion: 2,
direction: 'forward',
indexBounds: {
'$_path': [ '["atribut.etimologi.bahasa", "atribut.etimologi.bahasa"]' ],
'atribut.etimologi.bahasa': [ '["Sanskerta", "Sanskerta"]' ]
}
}
}const st2 = db.entri.find({ "atribut.etimologi.bahasa": "Sanskerta" }).explain("executionStats").executionStats
print("nReturned: " + st2.nReturned)
print("totalKeysExamined: " + st2.totalKeysExamined)
print("totalDocsExamined: " + st2.totalDocsExamined)nReturned: 150
totalKeysExamined: 150
totalDocsExamined: 150Tahap IXSCAN memakai indeks atribut.$**_1 dengan field $_path sebagai penunjuk nama field daun yang sedang dicari. Server memeriksa 150 kunci dan 150 dokumen untuk 150 dokumen hasil, sedangkan sebelum indeks ada seluruh 500 dokumen diperiksa. Field bersarang lain seperti atribut.varian dilayani oleh indeks yang sama tanpa pembuatan indeks baru.
Pembatasan jalur dengan wildcardProjection#
Indeks wildcard yang membungkus seluruh field dapat dipersempit melalui opsi wildcardProjection, yaitu daftar jalur yang ikut diindeks atau yang dikeluarkan. Indeks { "$**": 1 } dengan wildcardProjection berisi { atribut: 1 } hanya menyimpan kunci untuk field daun di bawah atribut, sehingga ukuran indeksnya jauh lebih kecil daripada indeks wildcard penuh. Query pada jalur yang tidak termasuk dalam daftar tidak menemukan kunci yang cocok dan kembali ke pemindaian koleksi.
Ganti indeks wildcard pada atribut dengan indeks $** yang diproyeksikan, lalu tampilkan daftar indeksnya dalam bentuk ringkas.
const hapusW = db.entri.dropIndex("atribut.$**_1")
print("atribut.$**_1 dihapus: " + (hapusW.ok === 1))
db.entri.createIndex({ "$**": 1 }, { wildcardProjection: { atribut: 1 } })
db.entri.getIndexes().forEach(ix => print(JSON.stringify({ name: ix.name, wildcardProjection: ix.wildcardProjection })))atribut.$**_1 dihapus: true
$**_1
{"name":"_id_"}
{"name":"kata_text_definisi_text"}
{"name":"$**_1","wildcardProjection":{"atribut":1}}Indeks wildcard baru bernama $**_1 dan hanya entri tersebut yang membawa field wildcardProjection berisi { atribut: 1 }. Periksa rencana query pada jalur yang diindeks dan pada jalur yang dikeluarkan dari daftar.
const pA = db.entri.find({ "atribut.varian": "baku" }).explain("queryPlanner").queryPlanner
print("indeks varian: " + pA.winningPlan.inputStage.indexName)
const pK = db.entri.find({ kategori: "verba" }).explain("queryPlanner").queryPlanner
print("tahap kategori: " + pK.winningPlan.stage)indeks varian: $**_1
tahap kategori: COLLSCANQuery pada atribut.varian memakai indeks $**_1 karena jalurnya termasuk dalam proyeksi, sedangkan query pada kategori jatuh ke COLLSCAN karena kategori tidak diindeks. wildcardProjection dengan demikian menyeimbangkan kelenturan indeks wildcard dengan ukurannya, yaitu hanya jalur yang benar-benar diquery yang dibayar biaya indeksnya.
Kesalahan umum#
Operator $text menolak dijalankan pada koleksi yang belum memiliki indeks teks, dan pesan errornya menunjuk kebutuhan tersebut secara langsung. Koleksi draft berikut sengaja dibuat tanpa indeks teks untuk menampilkan penolakannya.
db.draft.insertOne({ teks: "kata uji" })
try {
db.draft.find({ $text: { $search: "uji" } }).toArray()
} catch (e) {
print("Kode error: " + e.codeName)
print("Penyebab: " + e.message.split(":: caused by :: ").pop())
}{
acknowledged: true,
insertedId: ObjectId('…')
}
Kode error: IndexNotFound
Penyebab: text index required for $text queryKesalahan kedua adalah mencoba menumpuk indeks teks. Koleksi hanya boleh memiliki satu indeks teks, dan percobaan membuat indeks teks kedua pada field lain ditolak server dengan pesan yang merinci indeks yang sudah ada. Kesalahan ketiga adalah memperlakukan pencarian teks sebagai pencarian potongan kata. Pencocokan berlangsung pada token utuh hasil pemecahan string, sehingga nilai pencarian seperti gera tidak menemukan dokumen yang tokennya gerakan.
try {
db.entri.createIndex({ kata: "text" })
} catch (e) {
print("Kode error: " + e.codeName)
print(e.message)
}Kode error: IndexOptionsConflict
An equivalent index already exists with a different name and options. Requested index: { v: 2, key: { _fts: "text", _ftsx: 1 }, name: "kata_text", weights: { kata: 1 }, default_language: "english", language_override: "language", textIndexVersion: 3 }, existing index: { v: 2, key: { _fts: "text", _ftsx: 1 }, name: "kata_text_definisi_text", weights: { definisi: 1, kata: 10 }, default_language: "english", language_override: "language", textIndexVersion: 3 }Perbaikan kesalahan kedua bukan menumpuk indeks teks, melainkan memasukkan seluruh field yang ingin dicari ke dalam satu indeks teks yang sama, seperti indeks gabungan kata dan definisi yang dipakai pada bagian bobot. Bobot tiap field diatur pada opsi weights agar field utama lebih menentukan urutan hasil. Perencanaan field teks sejak awal menghindari pembangunan ulang indeks.
Ringkasan#
- Operator
$textmenelusuri kunci token dari indeks teks yang wajib ada, dan satu koleksi hanya boleh memuat satu indeks teks untuk seluruh field yang dicari. - Beberapa kata pada
$searchdipakai sebagai daftar pilihan, sedangkan frasa yang diberi tanda kutip menuntut rangkaian kata berurutan. - Bobot pada opsi
weightsmenentukan kontribusi tiap field terhadap skor, sehingga kecocokan pada field utama mengurutkan hasil lebih tinggi. - Indeks wildcard dengan pola
"$**"mengindeks field daun di bawah field yang dituju, danwildcardProjectionmembatasi jalur yang ikut diindeks.
Referensi#
- Text Indexes — MongoDB Manual — Dokumentasi indeks teks beserta aturan pembuatannya.
- Wildcard Indexes — MongoDB Manual — Dokumentasi indeks wildcard untuk field yang lentur.
- $text — MongoDB Manual — Spesifikasi operator pencarian teks beserta sintaks frasa.
- db.collection.createIndex() — MongoDB Manual — Spesifikasi pembuatan indeks teks dan wildcard.