יסודות Elasticsearch

Elasticsearch הוא מנוע חיפוש עם json rest api, המשתמש ב-Lucene וכתוב ב-Java. תיאור של כל היתרונות של מנוע זה זמין ב האתר הרשמי. בהמשך נתייחס אל Elasticsearch כאל ES.

מנועים דומים משמשים לחיפושים מורכבים במסד נתונים של מסמכים. לדוגמה, חיפוש תוך התחשבות במורפולוגיה של השפה או חיפוש לפי קואורדינטות גיאוגרפיות.

במאמר זה אדבר על היסודות של ES באמצעות הדוגמה של אינדקס פוסטים בבלוג. אני אראה לך איך לסנן, למיין ולחפש מסמכים.

כדי לא להיות תלוי במערכת ההפעלה, אגיש את כל הבקשות ל-ES באמצעות CURL. יש גם תוסף לגוגל כרום שנקרא תחושה.

הטקסט מכיל קישורים לתיעוד ולמקורות נוספים. בסוף יש קישורים לגישה מהירה לתיעוד. ניתן למצוא הגדרות של מונחים לא מוכרים ב מילוני מונחים.

התקנת ES

לשם כך, אנו זקוקים תחילה ל-Java. מפתחים להמליץ התקן גרסאות Java חדשות יותר מעדכון Java 8 20 או Java 7 עדכון 55.

הפצת ES זמינה בכתובת אתר למפתחים. לאחר פירוק הארכיון עליך לרוץ bin/elasticsearch. זמין גם חבילות עבור apt ו-yum. יש תמונה רשמית עבור docker. עוד על התקנה.

לאחר ההתקנה וההשקה, בואו נבדוק את הפונקציונליות:

# для удобства запомним адрес в переменную
#export ES_URL=$(docker-machine ip dev):9200
export ES_URL=localhost:9200

curl -X GET $ES_URL

נקבל משהו כזה:

{
  "name" : "Heimdall",
  "cluster_name" : "elasticsearch",
  "version" : {
    "number" : "2.2.1",
    "build_hash" : "d045fc29d1932bce18b2e65ab8b297fbf6cd41a1",
    "build_timestamp" : "2016-03-09T09:38:54Z",
    "build_snapshot" : false,
    "lucene_version" : "5.4.1"
  },
  "tagline" : "You Know, for Search"
}

אינדקס

בואו נוסיף פוסט ל-ES:

# Добавим документ c id 1 типа post в индекс blog.
# ?pretty указывает, что вывод должен быть человеко-читаемым.

curl -XPUT "$ES_URL/blog/post/1?pretty" -d'
{
  "title": "Веселые котята",
  "content": "<p>Смешная история про котят<p>",
  "tags": [
    "котята",
    "смешная история"
  ],
  "published_at": "2014-09-12T20:44:42+00:00"
}'

תגובת השרת:

{
  "_index" : "blog",
  "_type" : "post",
  "_id" : "1",
  "_version" : 1,
  "_shards" : {
    "total" : 2,
    "successful" : 1,
    "failed" : 0
  },
  "created" : false
}

ES נוצר באופן אוטומטי - מדד בלוג ו тип הודעה. אנו יכולים לצייר אנלוגיה מותנית: אינדקס הוא מסד נתונים, וסוג הוא טבלה במסד נתונים זה. לכל סוג יש סכמה משלו - מיפוי, ממש כמו טבלה יחסית. המיפוי נוצר באופן אוטומטי כאשר המסמך מתווסף לאינדקס:

# Получим mapping всех типов индекса blog
curl -XGET "$ES_URL/blog/_mapping?pretty"

בתגובת השרת, הוספתי את ערכי השדות של המסמך המאונדקס בהערות:

{
  "blog" : {
    "mappings" : {
      "post" : {
        "properties" : {
          /* "content": "<p>Смешная история про котят<p>", */ 
          "content" : {
            "type" : "string"
          },
          /* "published_at": "2014-09-12T20:44:42+00:00" */
          "published_at" : {
            "type" : "date",
            "format" : "strict_date_optional_time||epoch_millis"
          },
          /* "tags": ["котята", "смешная история"] */
          "tags" : {
            "type" : "string"
          },
          /*  "title": "Веселые котята" */
          "title" : {
            "type" : "string"
          }
        }
      }
    }
  }
}

ראוי לציין ש-ES אינו מבדיל בין ערך בודד למערך של ערכים. לדוגמה, שדה הכותרת פשוט מכיל כותרת, ושדה התגים מכיל מערך של מחרוזות, למרות שהן מיוצגות באותו אופן במיפוי.
נדבר יותר על מיפוי מאוחר יותר.

בקשות

אחזור מסמך לפי המזהה שלו:

# извлечем документ с id 1 типа post из индекса blog
curl -XGET "$ES_URL/blog/post/1?pretty"
{
  "_index" : "blog",
  "_type" : "post",
  "_id" : "1",
  "_version" : 1,
  "found" : true,
  "_source" : {
    "title" : "Веселые котята",
    "content" : "<p>Смешная история про котят<p>",
    "tags" : [ "котята", "смешная история" ],
    "published_at" : "2014-09-12T20:44:42+00:00"
  }
}

מפתחות חדשים הופיעו בתגובה: _version и _source. באופן כללי, כל המפתחות מתחילים ב _ מסווגים כרשמיים.

מפתח _version מציג את גרסת המסמך. זה נחוץ כדי שמנגנון הנעילה האופטימי יפעל. לדוגמה, אנו רוצים לשנות מסמך שיש לו גרסה 1. אנו מגישים את המסמך ששונה ומציינים כי מדובר בעריכה של מסמך עם גרסה 1. אם מישהו אחר ערך גם מסמך עם גרסה 1 והגיש שינויים לפנינו, אז ES לא תקבל את השינויים שלנו, כי הוא מאחסן את המסמך עם גרסה 2.

מפתח _source מכיל את המסמך שהוספנו לאינדקס. ES לא משתמש בערך זה עבור פעולות חיפוש מכיוון אינדקסים משמשים לחיפוש. כדי לחסוך במקום, ES מאחסנת מסמך מקור דחוס. אם אנחנו צריכים רק את המזהה, ולא את כל מסמך המקור, אז נוכל להשבית את אחסון המקור.

אם איננו זקוקים למידע נוסף, נוכל לקבל רק את התוכן של _source:

curl -XGET "$ES_URL/blog/post/1/_source?pretty"
{
  "title" : "Веселые котята",
  "content" : "<p>Смешная история про котят<p>",
  "tags" : [ "котята", "смешная история" ],
  "published_at" : "2014-09-12T20:44:42+00:00"
}

אתה יכול גם לבחור רק שדות מסוימים:

# извлечем только поле title
curl -XGET "$ES_URL/blog/post/1?_source=title&pretty"
{
  "_index" : "blog",
  "_type" : "post",
  "_id" : "1",
  "_version" : 1,
  "found" : true,
  "_source" : {
    "title" : "Веселые котята"
  }
}

בואו נוסיף לאינדקס עוד כמה פוסטים ונפעיל שאילתות מורכבות יותר.

curl -XPUT "$ES_URL/blog/post/2" -d'
{
  "title": "Веселые щенки",
  "content": "<p>Смешная история про щенков<p>",
  "tags": [
    "щенки",
    "смешная история"
  ],
  "published_at": "2014-08-12T20:44:42+00:00"
}'
curl -XPUT "$ES_URL/blog/post/3" -d'
{
  "title": "Как у меня появился котенок",
  "content": "<p>Душераздирающая история про бедного котенка с улицы<p>",
  "tags": [
    "котята"
  ],
  "published_at": "2014-07-21T20:44:42+00:00"
}'

Сортировка

# найдем последний пост по дате публикации и извлечем поля title и published_at
curl -XGET "$ES_URL/blog/post/_search?pretty" -d'
{
  "size": 1,
  "_source": ["title", "published_at"],
  "sort": [{"published_at": "desc"}]
}'
{
  "took" : 8,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "failed" : 0
  },
  "hits" : {
    "total" : 3,
    "max_score" : null,
    "hits" : [ {
      "_index" : "blog",
      "_type" : "post",
      "_id" : "1",
      "_score" : null,
      "_source" : {
        "title" : "Веселые котята",
        "published_at" : "2014-09-12T20:44:42+00:00"
      },
      "sort" : [ 1410554682000 ]
    } ]
  }
}

בחרנו בפוסט האחרון. size מגביל את מספר המסמכים שיונפקו. total מציג את המספר הכולל של המסמכים התואמים את הבקשה. sort בפלט מכיל מערך של מספרים שלמים שלפיהם מתבצע המיון. הָהֵן. התאריך הומר למספר שלם. מידע נוסף על מיון ניתן למצוא ב תיעוד.

מסננים ושאילתות

ES מאז גרסה 2 לא מבחין בין מסננים לשאילתות, במקום זאת מושג ההקשרים מוצג.
הקשר שאילתה שונה מהקשר מסנן בכך שהשאילתה מייצרת ציון _ ואינה מאוחסנת במטמון. אני אראה לך מה זה _ציון מאוחר יותר.

סנן לפי תאריך

אנו משתמשים בבקשה רכס בהקשר של מסנן:

# получим посты, опубликованные 1ого сентября или позже
curl -XGET "$ES_URL/blog/post/_search?pretty" -d'
{
  "filter": {
    "range": {
      "published_at": { "gte": "2014-09-01" }
    }
  }
}'

סינון לפי תגים

השתמש שאילתת מונח כדי לחפש מזהי מסמכים המכילים מילה נתונה:

# найдем все документы, в поле tags которых есть элемент 'котята'
curl -XGET "$ES_URL/blog/post/_search?pretty" -d'
{
  "_source": [
    "title",
    "tags"
  ],
  "filter": {
    "term": {
      "tags": "котята"
    }
  }
}'
{
  "took" : 9,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "failed" : 0
  },
  "hits" : {
    "total" : 2,
    "max_score" : 1.0,
    "hits" : [ {
      "_index" : "blog",
      "_type" : "post",
      "_id" : "1",
      "_score" : 1.0,
      "_source" : {
        "title" : "Веселые котята",
        "tags" : [ "котята", "смешная история" ]
      }
    }, {
      "_index" : "blog",
      "_type" : "post",
      "_id" : "3",
      "_score" : 1.0,
      "_source" : {
        "title" : "Как у меня появился котенок",
        "tags" : [ "котята" ]
      }
    } ]
  }
}

חיפוש טקסט מלא

שלושה מהמסמכים שלנו מכילים את הדברים הבאים בשדה התוכן:

  • <p>Смешная история про котят<p>
  • <p>Смешная история про щенков<p>
  • <p>Душераздирающая история про бедного котенка с улицы<p>

השתמש שאילתת התאמה כדי לחפש מזהי מסמכים המכילים מילה נתונה:

# source: false означает, что не нужно извлекать _source найденных документов
curl -XGET "$ES_URL/blog/post/_search?pretty" -d'
{
  "_source": false,
  "query": {
    "match": {
      "content": "история"
    }
  }
}'
{
  "took" : 13,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "failed" : 0
  },
  "hits" : {
    "total" : 3,
    "max_score" : 0.11506981,
    "hits" : [ {
      "_index" : "blog",
      "_type" : "post",
      "_id" : "2",
      "_score" : 0.11506981
    }, {
      "_index" : "blog",
      "_type" : "post",
      "_id" : "1",
      "_score" : 0.11506981
    }, {
      "_index" : "blog",
      "_type" : "post",
      "_id" : "3",
      "_score" : 0.095891505
    } ]
  }
}

עם זאת, אם נחפש "סיפורים" בשדה התוכן, לא נמצא דבר, כי האינדקס מכיל רק את המילים המקוריות, לא את גבעוליהן. על מנת לבצע חיפוש באיכות גבוהה, עליך להגדיר את הנתח.

שדה _score מופעים רלוונטיות. אם הבקשה מבוצעת בהקשר מסנן, אז ערך ה-_score תמיד יהיה שווה ל-1, כלומר התאמה מלאה למסנן.

מנתחים

מנתחים נחוצים כדי להמיר את טקסט המקור לסט של אסימונים.
מנתחים מורכבים מאחד טוקניזר וכמה אופציונליים TokenFilters. ניתן להקדים לטוקנייזר כמה מסנני Char. Tokenizers מפרקים את מחרוזת המקור לאסימונים, כגון רווחים ותווי פיסוק. TokenFilter יכול לשנות אסימונים, למחוק או להוסיף חדשים, למשל להשאיר רק את גזע המילה, להסיר מילות יחס, להוסיף מילים נרדפות. CharFilter - משנה את כל מחרוזת המקור, למשל, גוזר תגי html.

ל-ES יש כמה מנתחים סטנדרטיים. לדוגמה, מנתח רוסי.

בואו נשתמש API ובואו נראה כיצד המנתחים הסטנדרטיים והרוסים הופכים את המחרוזת "סיפורים מצחיקים על חתלתולים":

# используем анализатор standard       
# обязательно нужно перекодировать не ASCII символы
curl -XGET "$ES_URL/_analyze?pretty&analyzer=standard&text=%D0%92%D0%B5%D1%81%D0%B5%D0%BB%D1%8B%D0%B5%20%D0%B8%D1%81%D1%82%D0%BE%D1%80%D0%B8%D0%B8%20%D0%BF%D1%80%D0%BE%20%D0%BA%D0%BE%D1%82%D1%8F%D1%82"
{
  "tokens" : [ {
    "token" : "веселые",
    "start_offset" : 0,
    "end_offset" : 7,
    "type" : "<ALPHANUM>",
    "position" : 0
  }, {
    "token" : "истории",
    "start_offset" : 8,
    "end_offset" : 15,
    "type" : "<ALPHANUM>",
    "position" : 1
  }, {
    "token" : "про",
    "start_offset" : 16,
    "end_offset" : 19,
    "type" : "<ALPHANUM>",
    "position" : 2
  }, {
    "token" : "котят",
    "start_offset" : 20,
    "end_offset" : 25,
    "type" : "<ALPHANUM>",
    "position" : 3
  } ]
}
# используем анализатор russian
curl -XGET "$ES_URL/_analyze?pretty&analyzer=russian&text=%D0%92%D0%B5%D1%81%D0%B5%D0%BB%D1%8B%D0%B5%20%D0%B8%D1%81%D1%82%D0%BE%D1%80%D0%B8%D0%B8%20%D0%BF%D1%80%D0%BE%20%D0%BA%D0%BE%D1%82%D1%8F%D1%82"
{
  "tokens" : [ {
    "token" : "весел",
    "start_offset" : 0,
    "end_offset" : 7,
    "type" : "<ALPHANUM>",
    "position" : 0
  }, {
    "token" : "истор",
    "start_offset" : 8,
    "end_offset" : 15,
    "type" : "<ALPHANUM>",
    "position" : 1
  }, {
    "token" : "кот",
    "start_offset" : 20,
    "end_offset" : 25,
    "type" : "<ALPHANUM>",
    "position" : 3
  } ]
}

המנתח הסטנדרטי חילק את המחרוזת לפי רווחים והמיר הכל לאותיות קטנות, המנתח הרוסי הסיר מילים לא חשובות, המיר אותן לאותיות קטנות והשאיר את גזע המילים.

בואו נראה באילו Tokenizer, TokenFilters, CharFilters משתמש המנתח הרוסי:

{
  "filter": {
    "russian_stop": {
      "type":       "stop",
      "stopwords":  "_russian_"
    },
    "russian_keywords": {
      "type":       "keyword_marker",
      "keywords":   []
    },
    "russian_stemmer": {
      "type":       "stemmer",
      "language":   "russian"
    }
  },
  "analyzer": {
    "russian": {
      "tokenizer":  "standard",
      /* TokenFilters */
      "filter": [
        "lowercase",
        "russian_stop",
        "russian_keywords",
        "russian_stemmer"
      ]
      /* CharFilters отсутствуют */
    }
  }
}

בואו נתאר את המנתח שלנו על בסיס רוסית, שיחתוך תגיות HTML. בואו נקרא לזה ברירת מחדל, כי מנתח בשם זה ישמש כברירת מחדל.

{
  "filter": {
    "ru_stop": {
      "type":       "stop",
      "stopwords":  "_russian_"
    },
    "ru_stemmer": {
      "type":       "stemmer",
      "language":   "russian"
    }
  },
  "analyzer": {
    "default": {
      /* добавляем удаление html тегов */
      "char_filter": ["html_strip"],
      "tokenizer":  "standard",
      "filter": [
        "lowercase",
        "ru_stop",
        "ru_stemmer"
      ]
    }
  }
}

ראשית, כל תגי ה-HTML יוסרו ממחרוזת המקור, לאחר מכן תקן הטוקנייזר יפצל אותה לאסימונים, האסימונים שיתקבלו יעברו לאותיות קטנות, מילים לא משמעותיות יוסרו, והאסימונים הנותרים יישארו גזע המילה.

יצירת אינדקס

למעלה תיארנו את מנתח ברירת המחדל. זה יחול על כל שדות המחרוזת. הפוסט שלנו מכיל מערך של תגים, כך שהתגים יעובדו גם על ידי המנתח. כי אנו מחפשים פוסטים לפי התאמה מדויקת לתג, ואז עלינו להשבית את הניתוח עבור שדה התגים.

בואו ניצור אינדקס blog2 עם מנתח ומיפוי, שבו הניתוח של שדה התגים מושבת:

curl -XPOST "$ES_URL/blog2" -d'
{
  "settings": {
    "analysis": {
      "filter": {
        "ru_stop": {
          "type": "stop",
          "stopwords": "_russian_"
        },
        "ru_stemmer": {
          "type": "stemmer",
          "language": "russian"
        }
      },
      "analyzer": {
        "default": {
          "char_filter": [
            "html_strip"
          ],
          "tokenizer": "standard",
          "filter": [
            "lowercase",
            "ru_stop",
            "ru_stemmer"
          ]
        }
      }
    }
  },
  "mappings": {
    "post": {
      "properties": {
        "content": {
          "type": "string"
        },
        "published_at": {
          "type": "date"
        },
        "tags": {
          "type": "string",
          "index": "not_analyzed"
        },
        "title": {
          "type": "string"
        }
      }
    }
  }
}'

בואו נוסיף את אותם 3 פוסטים לאינדקס הזה (blog2). אני אשמיט את התהליך הזה כי... זה דומה להוספת מסמכים לאינדקס הבלוג.

חיפוש טקסט מלא עם תמיכה בביטויים

בואו נסתכל על סוג אחר של בקשה:

# найдем документы, в которых встречается слово 'истории'
# query -> simple_query_string -> query содержит поисковый запрос
# поле title имеет приоритет 3
# поле tags имеет приоритет 2
# поле content имеет приоритет 1
# приоритет используется при ранжировании результатов
curl -XPOST "$ES_URL/blog2/post/_search?pretty" -d'
{
  "query": {
    "simple_query_string": {
      "query": "истории",
      "fields": [
        "title^3",
        "tags^2",
        "content"
      ]
    }
  }
}'

כי אנו משתמשים בנתח עם גזירה רוסית, אז בקשה זו תחזיר את כל המסמכים, למרות שהם מכילים רק את המילה 'היסטוריה'.

הבקשה עשויה להכיל תווים מיוחדים, לדוגמה:

""fried eggs" +(eggplant | potato) -frittata"

תחביר בקשה:

+ signifies AND operation
| signifies OR operation
- negates a single token
" wraps a number of tokens to signify a phrase for searching
* at the end of a term signifies a prefix query
( and ) signify precedence
~N after a word signifies edit distance (fuzziness)
~N after a phrase signifies slop amount
# найдем документы без слова 'щенки'
curl -XPOST "$ES_URL/blog2/post/_search?pretty" -d'
{
  "query": {
    "simple_query_string": {
      "query": "-щенки",
      "fields": [
        "title^3",
        "tags^2",
        "content"
      ]
    }
  }
}'

# получим 2 поста про котиков

תזכור

PS

אם אתם מעוניינים במאמרים-שיעורים כאלה, יש לכם רעיונות למאמרים חדשים או הצעות לשיתוף פעולה, אשמח לקבל הודעה בהודעה אישית או בדוא"ל m.kuzmin+habr@darkleaf.ru.

מקור: www.habr.com