Indeks teks dan wildcard

Menyiapkan indeks teks untuk pencarian kata dan indeks wildcard untuk field yang lentur.

Daftar isi

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.

mongosh
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())
text Keluaran
switched to db kamus

Dokumen tersimpan: 500

Buat indeks teks pada field definisi, lalu cari kata arkais yang hanya muncul pada sepuluh definisi.

mongosh
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)))
text Keluaran
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.

mongosh
print("Dua kata terpisah: " + db.entri.countDocuments({ $text: { $search: "gerakan lambat" } }))
print("Frasa utuh: " + db.entri.countDocuments({ $text: { $search: "\"gerakan yang lambat\"" } }))
text Keluaran
Dua kata terpisah: 196

Frasa utuh: 98

Pencarian 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.

mongosh
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()
text Keluaran
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.

mongosh
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)))
text Keluaran
{"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.

mongosh
const st1 = db.entri.find({ "atribut.etimologi.bahasa": "Sanskerta" }).explain("executionStats")
print("tahap: " + st1.queryPlanner.winningPlan.stage)
print("totalDocsExamined: " + st1.executionStats.totalDocsExamined)
text Keluaran
tahap: COLLSCAN

totalDocsExamined: 500

Buat indeks wildcard, lalu periksa rencana dan statistik query yang sama.

mongosh
db.entri.createIndex({ "atribut.$**": 1 })
db.entri.find({ "atribut.etimologi.bahasa": "Sanskerta" }).explain("queryPlanner").queryPlanner.winningPlan
text Keluaran
atribut.$**_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"]' ]
    }
  }
}
mongosh
const st2 = db.entri.find({ "atribut.etimologi.bahasa": "Sanskerta" }).explain("executionStats").executionStats
print("nReturned: " + st2.nReturned)
print("totalKeysExamined: " + st2.totalKeysExamined)
print("totalDocsExamined: " + st2.totalDocsExamined)
text Keluaran
nReturned: 150

totalKeysExamined: 150

totalDocsExamined: 150

Tahap 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.

mongosh
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 })))
text Keluaran
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.

mongosh
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)
text Keluaran
indeks varian: $**_1

tahap kategori: COLLSCAN

Query 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.

mongosh
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())
}
text Keluaran
{
  acknowledged: true,
  insertedId: ObjectId('…')
}
Kode error: IndexNotFound
Penyebab: text index required for $text query

Kesalahan 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.

mongosh
try {
  db.entri.createIndex({ kata: "text" })
} catch (e) {
  print("Kode error: " + e.codeName)
  print(e.message)
}
text Keluaran
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 $text menelusuri 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 $search dipakai sebagai daftar pilihan, sedangkan frasa yang diberi tanda kutip menuntut rangkaian kata berurutan.
  • Bobot pada opsi weights menentukan 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, dan wildcardProjection membatasi jalur yang ikut diindeks.

Referensi#