آشنایی با الستیک‌سرچ (Elasticseatch)

در این پست به معرفی Elasticsearch می‌پردازیم. سپس به صورت عملی با نحوه کار کردن با آن آشنا می‌شویم.معرفی اولیهدر ساده ترین حالت Elasticsearch یک موتور جست و جوی متن‌باز (open-source) هست که به صورت یک سرویس کاملا مجزا (stand-alone) هست و به ما امکانات زیادی برای سرچ کردن می‌دهد! اما این پایان ماجرا نیست. با این موتور جست‌و‌جو نه تنها قابلیت سرچ روی متن داریم (full-text search) بلکه می‌توانیم روی داده‌هایی از جنس اعداد، زمان و حتی نقاط و محدوده های جغرافیایی فیلتر‌هایی ایجاد کنیم.دلیل محبوبیت فراگیر Elasticsearch سرعت، بهینگی، سادگی یادگیری و مقیاس پذیری آن است.در عین سادگی، این موتور جست و جو از تنظیمات پیشرفته‌ای پشتیبانی می‌کند. با توجه به داده ای که در آن ذخیره می‌کنیم می‌توانیم آن را tune کنیم تا به بهترین کارایی برسیم.الک استک (ELK Stack)سه حرف ELK هر کدام ابتدای اسم سه نرم افزار Elasticsearch، Logstash، Kibana است. به مجموعه این سه با هم Elk Stack می‌گویند.نرم‌افزار Logstash بستری برای جمع‌آوری دادگان از منابع مختلف فراهم می‌کند. سپس می‌تواند آن‌ها را آماده‌سازی کند و به Elasticsearch بفرستد.نرم‌افزار Kibana در کنار Elasticsearch استفاده می‌شود که امکانات قدرتمندی برای مصور‌سازی (visualization) دادگان در اختیار ما قرار می‌دهد. با این نرم‌افزار می‌توانیم در لحظه داده ها را روی نمودار‌های مختلف ببینیم و تحلیل کنیم همچنین داشبورد های مختلفی در اختیار ما قرار می‌دهد که با آن می‌توانیم تنظیمات Elasticsearch را به صورت گرافیکی انجام دهیم که کار را برای ما ساده می‌کند.مصورنمایی دادگان در Kibana در این پست تنها در مورد Elasticsearch است اما با نصب Kibana می‌توانید راحت تر کوئری‌ها را console مربوط به Kibana بنویسید و به Elasticsearch بفرستید (به جای استفاده از curl).کنسول Kibana در یک نگاه!مقایسه با دیتابیساگر بخواهیم Elasticsearch را با یک پایگاه‌داده رابطه‌ای مقایسه کنیم تعریف ها به این شکل می‌شوند.هر index معادل با یک جدول (table) است. یک Document معادل با یک سطر جدول (row) است.هر Document تعدادی field دارد. می‌توانید field ها را ستون های جدول (column) در نظر بگیرید.مقیاس پذیریدر ادامه می‌بینیم که الستیک‌سرچ ذاتا یک سیستم توزیع شده است هر چقدر هم که دادگان ما زیاد باشند می‌توان با توزیع دادگان روی سرور‌های مختلف بازدهی خوبی گرفت.به هر سرور یک node می‌گویند. برای اینکه کل سیستم کار کند باید node ها از طریق شبکه با هم ارتباط داشته باشند. Elasticsearch داده ها را به بهترین شکل ممکن بین node ها توزیع می‌کند.به کل سیستم (متشکل از تعدادی node) یک خوشه (cluster) می‌گویند.هر index معادل با یک table در پایگاه‌داده رابطه‌ای است. در واقع index مجموعه‌ای از document ها است که به هم مرتبط هستند.تعداد document های یک index ممکن است خیلی زیاد باشد! بنابراین Elasticsearch باید هر index را به قطعات مختلفی بکشند تا بتواند آن‌را روی سرور های مختلف توزیع کند. به هر کدام از تکه‌های index یک shard می‌گوییم. ممکن است تمام shard های یک index روی یک node ذخیره شوند. یا ممکن است روی node های مختلف باشند.هر سیستم توزیع شده باید بتواند حتی با وجود خرابی تعدادی از اعضای شبکه به درستی کار کند. در اینجا مفهوم replica به وجود می‌آید. هر shard می‌تواند تعدادی replica داشته باشد که اطلاعات درون replica ها یکسان است. وجود replica ها از این نظر ضروری است که اگر قسمتی از سیستم دچار نقص شد اطلاعاتی از دست ندهیم و سیستم بتواند به کار کردن ادامه بدهد.تعداد replica ها، تعداد shard های هر index و ... قابل تنظیم است. در واقع با توجه به نیاز پروژه باید این تنظیمات را انجام دهیم که به بهترین بازدهی برسیم.بعد از گفتن همه این ها جا داره اعلام کنم از مفاهیم مربوط به مقیاس پذیری قرار نیست در ادامه استفاده کنیم :))برای کار های ساده و معمولی نیاز نمیشه با این تنظیمات درگیر بشیم و تنظیمات default روی یک node کار می‌کنیم!برای شروع آماده اید...؟از سایت اصلی Elasticsearch را نصب کنید. بعد از اجرای برنامه Elasticsearch به صورت پیش‌فرض برنامه روی پورت 9200 بالا می‌آید. (اگر از اینجا Kibana را نصب و اجرا کنید روی پورت 5601 بالا می‌آید که می‌توانید روی مرورگر ببینید).localhost:9200 { &quotname&quot : &quotip520&quot, &quotcluster_name&quot : &quotelasticsearch&quot, &quotcluster_uuid&quot : &quotw7uHLU8eRVq7EXre5asJLA&quot, &quotversion&quot : { &quotnumber&quot : &quot7.14.0&quot, &quotbuild_flavor&quot : &quotdefault&quot, &quotbuild_type&quot : &quottar&quot, &quotbuild_hash&quot : &quotdd5a0a2acaa2045ff9624f3729fc8a6f40835aa1&quot, &quotbuild_date&quot : &quot2021-07-29T20:49:32.864135063Z&quot, &quotbuild_snapshot&quot : false, &quotlucene_version&quot : &quot8.9.0&quot, &quotminimum_wire_compatibility_version&quot : &quot6.8.0&quot, &quotminimum_index_compatibility_version&quot : &quot6.0.0-beta1&quot }, &quottagline&quot : &quotYou Know, for Search&quot }نکته جالب: در واقع Elasticsearch بر پایه یک موتور جست‌و‌جوی قدیمی تر به نام lucene است (هر shard در واقع یک instance از lucene است). در بالا lucene_version که می‌بینید مربوط به همین است!حالا ما یک REST API داریم. برای خواندن داده ها از GET, برای اضافه کردن داده از POST، برای تغییر داده از PUT و برای حذف داده از DELETE استفاده می‌کنیم.اضافه کردن (create)با ایجاد اولین document در یک index آن index خود به خود به وجود می‌آید.POST /movies/_doc { &quottitle&quot: &quotThe Godfather&quot, &quotdirector&quot: &quotFrancis Ford Coppola&quot, &quotyear&quot: 1972 }Response: { &quot_index&quot: &quotmovies&quot, &quot_type&quot: &quot_doc&quot, &quot_id&quot: &quotyBBvyX4B5pg45qr_-Du0&quot, &quot_version&quot: 1, &quotresult&quot: &quotcreated&quot, &quot_shards&quot: { &quottotal&quot: 2, &quotsuccessful&quot: 1, &quotfailed&quot: 0 }, &quot_seq_no&quot: 0, &quot_primary_term&quot: 1 }در اینجا movies نام index ما است. به عنوان json هر object ای را می‌توانیم بنویسیم.هر document یک id یکتا دارد که در اینجا در قسمت id_ و در reponse به ما داده شده است. با داشتن id_ می‌توان document را تغییر داد یا حذف کرد.تغییر دادن (update)PUT /movies/_doc/1 { &quottitle&quot: &quotThe Godfather&quot, &quotdirector&quot: &quotFrancis Ford Coppola&quot, &quotyear&quot: 1972, &quotanother field&quot: &quotnew value&quot }{ &quot_index&quot: &quotmovies&quot, &quot_type&quot: &quot_doc&quot, &quot_id&quot: &quot1&quot, &quot_version&quot: 2, &quotresult&quot: &quotupdated&quot, &quot_shards&quot: { &quottotal&quot: 2, &quotsuccessful&quot: 1, &quotfailed&quot: 0 }, &quot_seq_no&quot: 2, &quot_primary_term&quot: 1 }در اینجا 1 همان id مربوط به document است.اگر document با چنین id وجود داشته باشد مقدار آن تغییر می‌کند. در غیر اینصورت یک document جدید با چنین id ساخته می‌شود.حذف (delete)برای حذف کل index مربوط به moviesDELETE /movies/برای حذف کردن تنها یک documentDELETE /movies/_doc/1که در اینجا 1 همان id است.خواندن اطلاعات (read)GET /movies/_doc/1برای خواندن اطلاعات document با یک id خاص از این کوئری استفاده می‌کنیم.اگر اطلاعات یافت نشود خروجی به صورتResponse: { &quot_index&quot: &quotmovies&quot, &quot_type&quot: &quot_doc&quot, &quot_id&quot: &quot1&quot, &quotfound&quot: false }می‌شود و در صورت پیدا کردن به صورتResponse: { &quot_index&quot: &quotmovies&quot, &quot_type&quot: &quot_doc&quot, &quot_id&quot: &quot1&quot, &quot_version&quot: 1, &quot_seq_no&quot: 3, &quot_primary_term&quot: 1, &quotfound&quot: true, &quot_source&quot: { &quottitle&quot: &quotThe Godfather&quot, &quotdirector&quot: &quotFrancis Ford Coppola&quot, &quotyear&quot: 1972, &quotanother field&quot: &quotnew value&quot } }که در source_ همان document ما قرار دارد.کوئری bulkفرض کنید نیاز داریم تا در ابتدای کار Elasticsearch را با میلیون‌ها document پر کنیم. آیا تنها راه این است که به ازای هر کدام یک request بزنیم؟ قطعا نه!کوئری bulk به ما اجازه می‌دهد که چندین کار را با یک request انجام دهیم. در اینصورت سربار شبکه به حداقل ممکن می‌رسد.در کوئری bulk دو فرمت داریم. در فرمت اول در ابتدا index را مشخص می‌کنیم کوئری ها را روی همان می‌زنیم.کوئری زیر دو document ایجاد می‌کند.POST /movies/_bulk { &quotcreate&quot: { } } { &quottitle&quot: &quotLawrence of Arabia&quot, &quotdirector&quot: &quotDavid Lean&quot, &quotyear&quot: 1962} { &quotcreate&quot: { } } {&quottitle&quot: &quotTo Kill a Mockingbird&quot, &quotdirector&quot: &quotRobert Mulligan&quot, &quotyear&quot: 1962} در فرمت دوم index را در ابتدا مشخص نمی‌کنیم و در هر زیر کوئری به صورت جداگانه مشخص می‌کنیم.POST /_bulk { &quotdelete&quot : { &quot_index&quot : &quottest&quot, &quot_id&quot : &quot2&quot } } { &quotcreate&quot : { &quot_index&quot : &quotmovies&quot, &quot_id&quot : &quot3&quot } } { &quottitle&quot: &quotApocalypse Now&quot, &quotdirector&quot: &quotFrancis Ford Coppola&quot, &quotyear&quot: 1979} { &quotupdate&quot : {&quot_id&quot : &quot1&quot, &quot_index&quot : &quotmovies&quot} } { &quotdoc&quot: {&quottitle&quot: &quotKill Bill: Vol. 1&quot, &quotdirector&quot: &quotQuentin Tarantino&quot, &quotyear&quot: 2003}} این کوئری ابتدا تلاش می‌کند از index ای که وجود ندارد چیزی پاک کند که خطا می‌گیرد و اتفاقی نمی‌افتد.سپس یک document جدید با id مشخص ایجاد می‌کند.سپس یک document که قبلا وجود داشته را تغییر می‌دهد.توجه کنید که در اینجا هنگام create اگر document اگر id مربوطه وجود داشته باشد خطا می‌خوریم. به همین شکل هنگام update اگر document مربوطه وجود نداشته باشد خطا می‌خوریم.فرمت body کوئری به صورت NDJSON است. یعنی هر سطر آن یک JSON است که با new line از هم جدا شده اند. همچنین در انتها نیز به یک new line نیاز داریم.تا اینجا کوئری‌های اولیه CRUD (ایجاد، خواندن، تغییر و حذف) را بررسی کردیم. بحث اصلی از اینجا شروع می‌شود که به سراغ بررسی کوئری های search می‌رویم.قبل از هر چیز یک index جدید به اسم locations می‌سازیم و در آن دادگان زیر را قرار می‌دهیم تا بتوانیم کوئری های search را بررسی کنیم.POST /locations/_bulk { &quotcreate&quot: { } } { &quottype&quot: &quotخیابان&quot, &quotname&quot: &quotآزادی&quot, &quotimportance&quot: 0.9} { &quotcreate&quot: { } } { &quottype&quot: &quotخیابان&quot, &quotname&quot: &quotآزادگان&quot, &quotimportance&quot: 0.4} { &quotcreate&quot: { } } { &quottype&quot: &quotدانشگاه&quot, &quotname&quot: &quotصنعتی شریف&quot, &quotimportance&quot: 0.7} { &quotcreate&quot: { } } { &quottype&quot: &quotکوچه&quot, &quotname&quot: &quotدکتر شریفی&quot, &quotimportance&quot: 0.1} { &quotcreate&quot: { } } { &quottype&quot: &quotدانشگاه&quot, &quotname&quot: &quotامیرکبیر&quot, &quotimportance&quot: 0.7} { &quotcreate&quot: { } } { &quottype&quot: &quotکوچه&quot, &quotname&quot: &quotمحمد خیابانی&quot, &quotimportance&quot: 0.7} از آن‌جایی که با یک ابزار برای بازیابی و تحلیل اطلاعات نیاز داریم کار اصلی ما گرفتن اطلاعات است! کوئری‌های زیر همه از نوع GET هستند.گرفتن اطلاعات indexGET /locations/_searchدر ادامه می‌بینیم که search_ پارامتر‌ های زیادی دارد. در اینجا صرفا بدون هیچ پارامتر یا فیلتری آن را وارد کردیم پس همه داده‌ها را بر می‌گرداند!ضمنا می‌توان این را با match_all نیز نوشت:GET /locations/_search { &quotquery&quot:{ &quotmatch_all&quot: {} } }کوئری matchGET /locations/_search { &quotquery&quot:{ &quotmatch&quot: { &quotname&quot: &quotشریف&quot } } }کوئری بالا کل document هایی که "شریف" در name خود دارند بر می‌گرداند. (name یک field است).تمام متن‌هایی که برای ذخیره به Elasticsearch می‌دهیم به کلمات شکسته می‌شوند (که وظیفه Tokenizer است). سپس زمانی که چیزی سرچ می‌کنیم کلمات سرچ ما با کلمات (نه کل متن) مقایسه می‌شوند.کوئری match در ساده‌ترین حالت خود برابری را چک می‌کند. بنابرین "صنعتی شریف" مطابقت داده می‌شود و "دکتر شریفی" مطابقت داده نمی‌شود.در خروجی در قسمت hits می‌توانید ببینید:&quothits&quot: [ { &quot_index&quot: &quotlocations&quot, &quot_type&quot: &quot_doc&quot, &quot_id&quot: &quotvhAHyX4B5pg45qr_JDvC&quot, &quot_score&quot: 1.5797713, &quot_source&quot: { &quottype&quot: &quotدانشگاه&quot, &quotname&quot: &quotصنعتی شریف&quot, &quotimportance&quot: 0.7 } }, ]مقدار score عددی است که Elasticsearch به ازای هر document خروجی می‌دهد و نشان‌دهنده این است که این document چقدر با شرایطی که ما تعیین کردیم مطابقت دارد (مثلا شاید بخواهیم ۵ نتیجه برتر را نشان بدهیم).در قسمت source هم همان document ما قرار دارد.اطلاعات بیشترکوئری fuzzyدر صورتی‌که بخواهیم جست‌و‌جوی ما به شکل چک کردن برابری نباشد باید از fuzzy search استفاده کنیم. مقدار fuzziness مشخص می‌کند که تا چقدر نزدیکی به رشته مورد نظر، مورد قبول است. منظور از نزدیکی همان Levenshtein distance است.GET /locations/_search { &quotquery&quot:{ &quotmatch&quot: { &quotname&quot: &quotشریف&quot, &quotfuzziness&quot: 1 } } }این کوئری مثل قبل است فقط به دلیل اضافه کردن fuzziness در خروجی document مربوط به "دکتر شریفی" را نیز می‌بینیم.همچین می‌توان مقدار fuzziness را AUTO گذاشت.اطلاعات بیشترکوئری multi-matchاز این کوئری زمانی استفاده می‌شود که بخواهیم سرچ را روی چندین field انجام بدهیم.GET /locations/_search { &quotquery&quot:{ &quotmulti_match&quot: { &quotquery&quot: &quotخیابان&quot, &quotfuzziness&quot: &quotAUTO&quot, &quotfields&quot: [&quottype&quot, &quotname&quot] } } }در اینجا "خیابان آزادی"، "خیابان آزادگان" و "کوچه محمد خیابانی" را در خروجی می‌بینیم (به fuzziness توجه کنید).در field ها از wildcard یا همان * نیز می‌توان استفاده کرد. GET /locations/_search { &quotquery&quot:{ &quotmulti_match&quot: { &quotquery&quot: &quotخیابان&quot, &quotfuzziness&quot: &quotAUTO&quot, &quotfields&quot: [&quot*&quot] } } }کوئری بالا "خیابان" را روی همه field ها جست‌و‌جو می‌کند.یا مثلا اگر دو field به نام های last_name, first_name داشته باشیم آنگاه با قرار دادن &quotfields&quot: [&quot*_name&quot]جست‌و‌جو روی این دو field انجام می‌شود.اطلاعات بیشترکوئری rangeروی field های از جنس عدد یا زمان می‌توان از این کوئری استفاده کرد تا خودمان را محدود به یک بازه بکنیم.GET /locations/_search { &quotquery&quot: { &quotrange&quot: { &quotimportance&quot: { &quotgte&quot: 0.2, &quotlte&quot: 0.6 } } } }تنها خروجی کوئری بالا "خیابان آزادگان" است که importance آن برابر 0.4 است که بین 0.2 و 0.6 است.اطلاعات بیشترکوئری boolبا این کوئری می‌توانیم از ماهیت شرط استفاده کنیم.GET /locations/_search { &quotquery&quot: { &quotbool&quot: { &quotmust&quot: [ { &quotmatch&quot: { &quottype&quot: &quotخیابان&quot } } ], &quotmust_not&quot: [ { &quotmatch&quot: { &quotname&quot: { &quotquery&quot: &quotآزاد&quot, &quotfuzziness&quot: AUTO } } } ] } } }کوئری بالا کل خیابان هایی که نام آن‌ها نزدیک به "آزاد" نیست را بر می‌گرداند. در لیستی که داشتیم تنها "خیابان آزادگان" چنین حالتی دارد (فاصله "آزادگان" از "آزاد" ۳ است که بیشتر از حد auto است).علاوه بر must, must_not دو حالت دیگر should, filter هم وجود دارند که می‌توانید در اطلاعات بیشتر با آن‌ها آشنا شوید.تا اینجا با تعدادی از کوئری‌ها آشنا شدیم. با ترکیب کردن این کوئری‌ها با هم می‌توان منطق‌های سخت و پیچیده‌ای برای سرچ کردن استفاده کرد! منظور از ترکیب کردن این است که در JSON مربوط به request می‌توان منطق هر کدام از کوئری ها را به صورت تو در تو نوشت. برای تمرین با ترکیب range, fuzzy, bool کوئری بزنید که بین تمام خیابان‌هایی که importance آن‌ها حداقل 0.5 است، آن‌هایی که نام‌ آن‌ها شبیه به "امیر" است را برگرداند. برای آشنایی بیشتر با کوئری های متنی Full Text Query را مطالعه کنید.انواع داده‌هایی که Elasticsearch ذخیره می‌کند به عدد و رشته مربوط نمی‌شوند. در اینجا می‌توانید با انواع این type ها و کوئری های خاص خودشان آشنا شوید! به عنوان مثال اگر برای نگه داشتن مکان های جغرافیایی از geo_point استفاده کنیم می‌توانیم با geo_distance کل مناطقی که در فاصله ۱۰‌ کیلومتری یک نقطه خاص هستند را فیلتر کنیم.هر index ساختار مشخصی ندارد. یعنی schema آن به صورت dynamic تغییر می‌کند. می‌توانید یک document اضافه کنید که field های جدیدی داشته باشد و حتما نباید مثل document های قبل باشد!