
Hai semuanya, nama saya Andrey, dan saya seorang pengembang. Dahulu kala - sepertinya, Jumat lalu - tim kami memiliki proyek di mana mereka membutuhkan pencarian bahan-bahan yang membentuk produk. Katakanlah komposisi sosis. Pada awal proyek, tidak banyak yang diperlukan dari pencarian: untuk menunjukkan semua resep di mana bahan yang diinginkan terkandung dalam jumlah tertentu; ulangi untuk bahan-bahan N.
Namun, di masa depan, jumlah produk dan bahan-bahan direncanakan akan meningkat secara signifikan, dan pencarian tidak hanya harus mengatasi peningkatan volume data, tetapi juga memberikan opsi tambahan - misalnya, kompilasi otomatis deskripsi produk berdasarkan bahan yang berlaku.
Persyaratan- Buat pencarian di Elacsticsearch menggunakan database setidaknya 50.000 dokumen.
- Memberikan respons kecepatan tinggi untuk permintaan - kurang dari 300 ms.
- Untuk memastikan bahwa permintaannya kecil, dan layanan itu tersedia bahkan dalam kondisi Internet seluler terburuk.
- Buat logika pencarian seintuitif mungkin dari perspektif UX. Itu pada dasarnya bahwa antarmuka akan mencerminkan logika pencarian - dan sebaliknya.
- Minimalkan jumlah interlayers antara elemen sistem untuk kinerja yang lebih tinggi dan ketergantungan yang lebih sedikit.
- Untuk memberikan peluang kapan saja untuk melengkapi algoritme dengan kondisi baru (misalnya, pembuatan deskripsi produk secara otomatis).
- Buat dukungan lebih lanjut untuk bagian pencarian proyek sesederhana dan senyaman mungkin.
Kami memutuskan untuk tidak terburu-buru dan mulai dari yang sederhana.
Pertama-tama, kami menyimpan semua bahan komposisi produk dalam database, setelah menerima 10.000 entri pertama. Sayangnya, bahkan pada ukuran ini, pencarian basis data terlalu banyak waktu, bahkan dengan mempertimbangkan penggunaan gabungan dan indeks. Dan dalam waktu dekat jumlah catatan seharusnya melebihi 50.000. Selain itu, pelanggan bersikeras menggunakan Elasticsearch (selanjutnya - ES), karena ia menemukan alat ini dan, tampaknya, memiliki perasaan hangat untuknya. Kami tidak pernah bekerja dengan ES sebelumnya, tetapi kami tahu tentang kelebihannya dan setuju dengan pilihan ini, karena, misalnya, direncanakan bahwa kami akan sering memiliki entri baru (menurut berbagai perkiraan dari 50 hingga 500 per hari), yang akan diperlukan segera masalah kepada pengguna.
Kami memutuskan untuk meninggalkan interlayers di tingkat driver dan cukup menggunakan permintaan REST, karena sinkronisasi dengan database dilakukan hanya pada saat membuat dokumen dan tidak lagi diperlukan. Ini adalah keuntungan lain - hingga mengirim permintaan pencarian langsung ke ES dari browser.
Kami mengumpulkan prototipe pertama di mana kami mentransfer struktur dari database (PostgreSQL) ke dokumen ES:
{"mappings" : { "recipe" : { "_source" : { "enabled" : true }, "properties" : { "recipe_id" : {"type" : "integer"}, "recipe_name" : {"type" : "text"}, "ingredients" : { "type" : "nested", "properties": { "ingredient_id": "integer", "ingredient_name": "string", "manufacturer_id": "integer", "manufacturer_name": "string", "percent": "float" } } } } }}
Berdasarkan pemetaan ini, kami mendapatkan kira-kira dokumen berikut (kami tidak dapat menunjukkan pekerja dari proyek karena NDA):
{ "recipe_id": 1, "recipe_name": "AAA & BBB", "ingredients": [ { "ingredient_id": 1, "ingredient_name": "AAA", "manufacturer_id": 3, "manufacturer_name": "Manufacturer 3", "percent": 1 }, { "ingredient_id": 2, "ingredient_name": "BBB", "manufacturer_id": 4, "manufacturer_name": "Manufacturer 4", "percent": 3 } ] }
Semua ini dilakukan dengan menggunakan paket PHP Elasticsearch. Ekstensi untuk Laravel (Elastiquent, Laravel Scout, dll.) Memutuskan untuk tidak menggunakannya karena satu alasan - pelanggan membutuhkan kinerja tinggi, hingga poin yang disebutkan di atas bahwa "300 ms untuk permintaan banyak." Dan semua paket untuk Laravel bertindak sebagai overhead tambahan dan melambat. Itu bisa dilakukan langsung pada Guzzle, tetapi kami memutuskan untuk tidak melakukan ekstrem.
Pertama, pencarian resep yang paling sederhana dilakukan langsung pada array. Ya, semua ini dibawa ke file konfigurasi, tetapi semua permintaan ternyata terlalu besar. Pencarian dilakukan pada dokumen terlampir (bahan yang sama), pada ekspresi Boolean menggunakan "harus" dan "harus", ada juga arahan untuk bagian wajib pada dokumen yang terlampir - sebagai akibatnya, permintaan mengambil dari seratus baris, dan volumenya dari tiga kilobyte.
Jangan lupa tentang persyaratan kecepatan dan ukuran respons - saat itu jawaban dalam API diformat sedemikian rupa untuk meningkatkan jumlah informasi yang bermanfaat: kunci pada setiap objek json dikurangi menjadi satu huruf. Oleh karena itu, pertanyaan dalam ES beberapa kilobyte menjadi kemewahan yang tidak dapat diterima.
Dan pada saat itu, kami menyadari bahwa membangun permintaan raksasa dalam bentuk array asosiatif dalam PHP adalah semacam kecanduan yang sengit. Selain itu, pengendali menjadi benar-benar tidak dapat dibaca, lihat sendiri:
public function searchSimilar() { $conditions[] = [ "nested" => [ "path" => "ingredients", "score_mode" => "max", "query" => [ "bool" => [ "must" => [ ["term" => ["ingredients.ingredient_id" => $ingredient_id]], ["range" => ["ingredients.percent"=>[ "lte"=>$percent + 5, "gte"=>$percent - 5 ]]] ] ] ] ] ]; $parameters['body']['query']['bool']['should'][0]['bool']['should'] = $conditions; $equal_conditions[] = [ "nested" => [ "path" => "flavors", "query" => [ "bool" => [ "must" => [ ["term" => ["ingredients.percent" => $percent]] ] ] ] ] ]; $parameters['body']['query']['bool']['should'][1]['bool']['must'] = $equal_conditions; return $this->client->search($parameters); }
Penyimpangan liris: ketika sampai pada bidang bersarang dalam dokumen, ternyata kami tidak dapat memenuhi kueri formulir:
"query": { "bool": { "nested": { "bool": { "should": [ ... ] } } } }
untuk satu alasan sederhana - Anda tidak dapat melakukan multi-pencarian di dalam filter bersarang. Karena itu, saya harus melakukan ini:
"query": { "bool": { "should": [ {"nested": { "path": "flavors", "score_mode": "max", "query": { "bool": { ... } } }} ] } }
yaitu pertama, array kondisi harus dideklarasikan, dan di dalam setiap kondisi pencarian dipanggil oleh bidang bersarang. Dari sudut pandang Elasticsearch, ini lebih benar dan logis. Akibatnya, kami sendiri melihat bahwa ini logis ketika kami menambahkan istilah pencarian tambahan.
Dan di sini kami menemukan templat
google yang dibangun dalam ES. Pilihannya jatuh pada Kumis - mesin template tanpa logika yang cukup nyaman. Dimungkinkan untuk memasukkan seluruh badan permintaan dan semua data yang dikirim ke dalamnya praktis tanpa perubahan, sebagai akibatnya permintaan terakhir berbentuk:
{ "template": "template1", "params": params{} }
Tubuh templat ternyata agak sederhana dan mudah dibaca - hanya JSON dan arahan dari Moustache itu sendiri. Template disimpan di Elasticsearch itu sendiri dan disebut dengan nama.
/* search_similar.mustache */ { : { : { : [ {: { : {{ minimumShouldMatch }}, : [ {{#ingredientsList}} // mustache ingredientsList {{#ingredients}} // ingredients {: { : , : , : { : { : [ {: {: {{ id }} }}, {: { : { : {{ lte }}, : {{ gte }} }}} ] } } }} {{^isLast}},{{/isLast}} // {{/ingredients}} {{/ingredientsList}} ] }} ] } } } /* */ { : , : { : 1, : { : [ {: 1, : 10, : 5, : true } ] } } }
Sebagai hasilnya, pada output kami mendapatkan template yang kami hanya melewati array bahan yang diperlukan. Secara logis, permintaan tidak berbeda jauh dari, dengan syarat, sebagai berikut:
SELECT * FROM ingredients LEFT JOIN recipes ON recipes.id = ingredient.recipe_id WHERE ingredients.id in (1,2,3) AND ingredients.id not in (4,5,6) AND ingredients.percent BETWEEN 10.0 AND 20.0
tapi dia bekerja lebih cepat, dan itu adalah dasar yang sudah jadi untuk permintaan lebih lanjut.
Di sini, selain pencarian persentase, kami membutuhkan beberapa jenis operasi: pencarian berdasarkan nama di antara bahan, kelompok dan nama resep; cari berdasarkan bahan id dengan mempertimbangkan toleransi kontennya dalam resep; kueri yang sama, tetapi dengan perhitungan hasil di bawah empat kondisi (kemudian dikoreksi untuk tugas lain), serta kueri terakhir.
Permintaan membutuhkan logika berikut: untuk setiap bahan ada lima tag yang menghubungkannya dengan grup mana pun. Menurut kebiasaan, babi dan sapi adalah daging, dan ayam dan kalkun adalah unggas. Setiap tag terletak pada levelnya masing-masing. Berdasarkan tag ini, kami dapat membuat deskripsi kondisional untuk resep, yang memungkinkan kami membuat pohon pencarian dan / atau deskripsi secara otomatis. Misalnya, sosis daging dan susu dengan rempah-rempah, hati dan kedelai, ayam halal. Satu resep dapat memiliki banyak bahan dengan tag yang sama. Ini memungkinkan kami untuk tidak mengisi rantai tag dengan tangan kami - berdasarkan komposisi resep, kami sudah dapat dengan jelas menggambarkannya. Struktur dokumen terlampir juga telah berubah:
{ "ingredient_id": 1, "ingredient_name": "AAA", "manufacturer_id": 3, "manufacturer_name": "Manufacturer 3", "percent": 1, "level_1": 2, "level_2": 4, "level_3": 6, "level_4": 7, "level_5": 12 }
Ada juga kebutuhan untuk menentukan pencarian dengan kondisi "kemurnian" resep. Sebagai contoh, kami membutuhkan resep di mana tidak ada apa pun selain daging sapi, garam, dan lada. Kemudian kami harus menyingkirkan resep yang hanya mengandung daging sapi di tingkat pertama dan hanya rempah-rempah di tingkat kedua (tag pertama untuk rempah-rempah adalah nol). Di sini saya harus menipu: karena kumis adalah templat tanpa logika, tidak mungkin ada pembicaraan tentang perhitungan apa pun; di sini diperlukan untuk mengimplementasikan bagian dari skrip dalam permintaan dalam bahasa skrip ES - Tanpa rasa sakit. Sintaksinya sedekat mungkin dengan Java, jadi tidak ada kesulitan. Akibatnya, kami memiliki JSON kumis-templat yang menghasilkan, di mana bagian dari perhitungan, yaitu penyortiran dan penyaringan, diimplementasikan pada Painless:
"filter": [ {{#levelsList}} {{#levels}} {"script": { "script": " int total=0; for (ingredient in params._source.ingredients){ if ([0,{{tag}}].contains(ingredient.level_{{id}})) total+=1; } return (total==params._source.ingredients.length); " }} {{^isLast}},{{/isLast}} {{/levels}} {{/levelsList}} ]
Selanjutnya, badan skrip diformat agar mudah dibaca, jeda baris tidak dapat digunakan dalam permintaan.
Pada saat itu, kami menghilangkan toleransi untuk kandungan bahan dan menemukan hambatan - kami dapat mempertimbangkan sosis sapi hanya karena bahan ini ditemukan di sana. Kemudian kami menambahkan - semua pada skrip Painless yang sama - menyaring dengan syarat bahwa bahan ini harus berlaku dalam komposisi:
"filter": [ {"script":{ "script": " double nest=0,rest=0; for (ingredient in params._source.ingredients){ if([{{#tags}}{{tagId}}{{^isLast}},{{/isLast}}{{/tags}}].contains(flavor.level_{{tags.0.levelId}})){ nest+= ingredient.percent; }else{ if (ingredient.percent>rest){rest = ingredient.percent} } } return(nest>=rest); " }} ]
Seperti yang Anda lihat, Elasticsearch tidak memiliki banyak hal untuk proyek ini, jadi mereka harus dikumpulkan dari "sarana yang tersedia". Tapi ini tidak mengherankan - proyek ini cukup tidak lazim untuk mesin yang digunakan untuk pencarian teks lengkap.
Pada salah satu tahap menengah dari proyek, kami membutuhkan hal berikut: menampilkan daftar semua kelompok bahan yang tersedia dan jumlah posisi di masing-masing. Masalah yang sama terungkap di sini seperti dalam kueri yang berlaku: dari 10.000 resep, sekitar 10 kelompok dihasilkan berdasarkan konten. Namun, total sekitar 40.000 resep ternyata ada dalam kelompok-kelompok ini, yang sama sekali tidak sesuai dengan kenyataan. Kemudian kami mulai menggali kueri paralel.
Permintaan pertama kami menerima daftar semua grup yang ada di tingkat pertama tanpa jumlah entri. Setelah itu, beberapa permintaan dibuat: untuk setiap kelompok, permintaan dibuat untuk menerima jumlah sebenarnya dari resep sesuai dengan prinsip persentase yang berlaku. Semua permintaan ini dikumpulkan dalam satu dan dikirim ke Elasticsearch. Waktu respons untuk permintaan umum sama dengan waktu pemrosesan permintaan paling lambat. Agregasi massal memungkinkan untuk memparalelkannya. Logika serupa (hanya dengan mengelompokkan berdasarkan kondisi dalam kueri) dalam SQL membutuhkan waktu sekitar 15 kali lebih banyak.
/* */ $params = config('elastic.params'); $params['body'] = config('elastic.top_list'); return (Elastic::getClient()->search($params))['aggregations']['tags']['buckets']; /* */
Setelah itu, kami perlu mengevaluasi:
- berapa banyak resep yang tersedia untuk komposisi saat ini;
- bahan apa lagi yang bisa kita tambahkan ke komposisi (kadang-kadang kita menambahkan bahan dan mendapat sampel kosong);
- bahan mana di antara yang dipilih yang dapat kita tandai sebagai satu-satunya bahan pada level ini.
Berdasarkan tugas tersebut, kami menggabungkan logika permintaan terakhir yang diterima untuk daftar resep dan logika memperoleh angka pasti dari daftar semua grup yang tersedia:
/* */ : { // :{ // :{ : , : { : }, : [ {{#exclude}}{{ id }},{{/exclude}} 0] }, : { : {} } // , } } /* */ foreach ($not_only as $element) { $parameters['body'][] = config('elastic.params'); $parameters['body'][] = self::getParamsBody( $body, collect($only->all())->push($element), $max_level, 0, 0 ); } /* */ $parameters['body'][] = config('elastic.params'); $parameters['body'][] = self::getParamsBody( $body, $only, $max_level, $from, $size') ); /* */ $parameters['max_concurrent_searches'] = 1 + $not_only->count(); return (Elastic::getClient()->msearchTemplate($parameters))['responses'];
Akibatnya, kami menerima permintaan yang menemukan semua resep yang diperlukan dan jumlah totalnya (diambil dari respons ["hit"] ["total"]). Untuk mempermudah, permintaan ini dicatat di tempat terakhir dalam daftar.
Selain itu, melalui agregasi, kami menerima semua bahan id untuk tingkat selanjutnya. Untuk setiap bahan yang tidak ditandai sebagai "unik," kami membuat kueri di mana kami menandainya sesuai, dan kemudian hanya menghitung jumlah dokumen yang ditemukan. Jika lebih besar dari nol, maka bahan tersebut dianggap tersedia untuk menetapkan kunci "tunggal". Saya pikir di sini Anda dapat mengembalikan seluruh templat tanpa saya, yang kami dapatkan pada hasilnya:
{ : {{ from }}, : {{ size }}, : { : { : [ {{#ingredientTags}} {{#tagList}} {: { : [ {: {: {{ tagId }} }} ] }} {{^isLast}},{{/isLast}} {{/tagList}} {{/ingredientTags}} ], : [ {:{ : }} {{#levelsList}}, {{#levels}} {: { : }} {{^isLast}},{{/isLast}} {{/levels}} {{/levelsList}} ] } }, : { :{ :{ : , : { : }, : [ {{#exclude}}{{ id }},{{/exclude}} 0] }, : { : {} } } }, : [ {: {: }} ] }
Tentu saja, kami menyimpan bagian dari tumpukan template dan kueri ini (seperti halaman semua grup yang tersedia dengan jumlah resep yang tersedia), yang menambahkan kami sedikit kinerja di halaman utama. Keputusan ini memungkinkan data utama dikumpulkan dalam 50 ms.
Hasil proyekKami melakukan pencarian di database setidaknya 50.000 dokumen di Elasticsearch, yang memungkinkan Anda untuk mencari bahan dalam produk dan mendapatkan deskripsi produk dengan bahan yang terkandung di dalamnya. Segera basis data ini akan bertambah sekitar enam kali (data sedang dipersiapkan), jadi kami cukup senang dengan hasil kami dan Elasticsearch sebagai alat pencarian.
Pada masalah kinerja, kami memenuhi persyaratan proyek, dan kami sendiri senang bahwa waktu respons rata-rata untuk permintaan adalah 250-300 ms.
Tiga bulan setelah mulai bekerja dengan Elasticsearch, tampaknya tidak lagi membingungkan dan tidak biasa. Dan keuntungan dari templating sudah jelas: jika kita melihat bahwa permintaan menjadi terlalu besar lagi, kita cukup mentransfer logika tambahan ke templat dan lagi mengirim permintaan asli ke server dengan hampir tidak ada perubahan.
"Semua yang terbaik dan terima kasih untuk ikannya!" (c)
PS Pada saat terakhir kami juga perlu menyortir berdasarkan karakter Rusia dalam nama. Dan kemudian ternyata Elasticsearch tidak memahami alfabet Rusia secara memadai. Sosis bersyarat โUltra mega pork 9000 kaloriโ berubah di dalam penyortiran menjadi โ9000โ dan berada di akhir daftar. Ternyata, masalah ini cukup mudah diselesaikan dengan mengubah karakter Rusia menjadi notasi unicode dari bentuk u042B.