Калина Алексей блог программиста

Elasticsearch найдет все!

Мы продолжаем погружаться в Elasticsearch. Сегодня займемся одной из главных возможностей, предоставляемых этой базой данных. Речь идет о полнотекстовом поиске. Такой тип поиска позволяет находить документы не по точному совпадению имен или применению агрегирования к каким-либо полям, а по текстовому содержимому. Мы разберемся как работает поиск в Elasticsearch и продолжим развлекаться с датасетом Jeopardy.

Немного теории

Полнотекстовый поиск в Elasticsearch основан на понятии инвертированного индекса. Он представляет собой структуру данных, содержащую в себе все слова из датасета и списки с документами, в которых встречаются эти слова. Говоря “слова” я имею ввиду термы, те самые термы, которые выдает анализатор после обработки входного текста. Если вы плохо представляете о чем идет речь, обратитесь к первому посту серии про Elasticsearch.

Так как при полнотекстовом поиске движок обращается только к инвертированному индексу, то он не сможет найти точное совпадение фразы, если только поле не отказывается от анализаторов, храня строки целиком. Поэтому еще на этапе создания маппинга для вашего индекса нужно предусмотреть для каких целей будут использоваться различные поля. Так, например, числа или даты стоит индексировать без применения анализаторов, в то время как к текстовым полям, которые не являются перечислением небольшого набора значений, стоит тщательно выбирать какой анализ применять. От этого напрямую зависят результаты поиска!

Обновление маппинга

Теперь, когда мы еще поднабрались теории, самое время обновить маппинг для нашего индекса. Напомню, что мы работает с датасетом Jeopardy, в котором содержатся данные о вопросах и ответах с различных игр этой популярной американской телевикторины. Итак, мы индексируем следующие поля:

Так выглядит обновленный маппинг:

{
    "properties": {
        "category": {
            "type": "text",
            "analyzer": "phrase"
        },
        "question": {
            "type": "text",
            "analyzer": "english"
        },
        "answer": {
            "type": "text",
            "analyzer": "english"
        },
        "air_date": {
            "type": "date",
            "format": "yyyy-MM-dd",
            "ignore_malformed": true
        },
        "value": {
            "type": "integer"
        },
        "round": {
            "type": "keyword"
        }
    }
}

Query & Filter контексты

В Elasticsearch существует два контекста, в которых работает поиск. Первый из них при выборе документов для формирования результата определяет насколько хорошо документ соответствует запросу. При работе во втором контексте Elasticsearch задается вопросом “Удовлетворяет документ запросу или нет?”.

Первый контекст носит название Query. Именно он работает как полнотекстовый поиск и позволяет находить похожие на запрашиваемые пользователем значения в больших объемах текста. Благодаря применению анализаторов query запросы позволяют искать по словоформам, исключать стоп-слова и многое другое. При выполнении запросов в этом контексте для каждого документа вычисляется рейтинг (score) - численное значение того, насколько документ подходит под запрос. Результаты в выдаче сортируются в порядке убывания этого рейтинга.

Второй контекст называется Filter и позволяет выполнять поиск по точному значению. Этот контекст используется для фильтрации документов. Так как для каждого документа ответом на такие запросы может быть только “да” или “нет”, в выдаче результатов отсутствует поле _score. Filter запросы кешируются и, как правило, отрабатывают быстрее, чем запросы в контексте query.

Поиск по точному значению

Начнем с более привычного для взаимодействующих с реляционными базами людей контекста filter. Сделаем выборку документов, удовлетворяющих следующим условиям:

  1. Игра проходила в 2008 году.
  2. Стоимость вопроса была выше 1000 долларов.
  3. Вопрос прозвучал в первом или втором раунде.

Для того, чтобы строить запросы из нескольких условий, в Elasticsearch используется конструкция bool. Она может включать себя четыре типа условий:

Мы хотим найти документы, которые удовлетворяют набору условий, поэтому будем выполнять поиск в контексте filter. Для этого существует набор запросов уровня термов (полный список приведен в документации). Нам достаточно воспользоваться запросом range, который позволяет делать выборку данных в диапазоне, и term, благодаря которому можно сравнивать содержимое поля с конкретным значением.

{
    "query": {
        "bool": {
            "filter": [
                {
                    "range": {
                        "air_date": {
                            "gte": "2008-01-01",
                            "lte": "2008-12-31"
                        }
                    }
                },
                {
                    "range": {
                        "value": {
                            "gte": 1000
                        }
                    }
                }
            ],
            "must_not": [
                {
                    "term": {
                        "round": "Final Jeopardy!"
                    }
                }
            ]
        }
    },
    "size": 3
}

Ниже частично приведен ответ на запрос. Результаты располагаются в объекте hits. В поле total отображается количество документов, удовлетворивших запросу. Обратите внимание на то, что _score у всех документов равен нулю. Это подтверждает тот факт, что поиск проводился в filter контексте.

{
    "hits": {
        "total": 5004,
        "max_score": 0,
        "hits": [
            {
                "_score": 0,
                "_source": {
                    "category": "WHAT A WEEK",
                    "air_date": "2008-02-05",
                    "question": "Carrie on 'Sex and the City' really enjoyed this event that brings ships & thousands of sailors to NYC",
                    "value": "1000",
                    "answer": "Fleet Week",
                    "round": "Jeopardy!"
                }
            },
            {
                "_score": 0,
                "_source": {
                    "category": "IBLE'S & BITS",
                    "air_date": "2008-02-05",
                    "question": "'14-letter adjective for something that can't be wiped out'",
                    "value": "1600",
                    "answer": "indestructible",
                    "round": "Double Jeopardy!"
                }
            },
            {
                "_score": 0,
                "_source": {
                    "category": "FUNNY FOR NOTHIN'",
                    "air_date": "2008-02-05",
                    "question": "This deadpan comic said, 'I installed a skylight in my apartment... The people who live above me are furious'",
                    "value": "1600",
                    "answer": "Steven Wright",
                    "round": "Double Jeopardy!"
                }
            }
        ]
    }
}

Полнотекстовый поиск

Наконец перейдем к самому интересному. Будем искать документы по содержимому вопросов, используя query контекст.

match

Первоначально запрос match применяет к строке анализатор, который используется полем, в котором ищем совпадения. В результате этой процедуры получается набор термов. Далее формируется boolean запрос, содержащий все эти термы. По умолчанию, оператором запроса является ключевое слово OR, то есть, среди результатов будут все документы, в которых встретился хотя бы один из образованных термов. Однако, эту логику можно изменить, установив оператор AND. При этом, в дефолтном случае все равно более приоритетными документами будут те, в которых встретилось большее число термов из запроса. Это возможно, благодаря тому, что match действует в query контексте и начисляет каждому документу score. Так выглядит простейший запрос:

{
    "query": {
        "match": {
            "question": {
                "query": "fulltext search"
            }
        }
    }
}

fulltextsearch

Теперь изменим поведение match запроса, установив оператор AND. На анимации выше видно, что Elasticsearch не находил термы с опечатками. Мы можем установить параметр fuzziness с числом возможных несовпадающих символов в терме.

{
    "query": {
        "match": {
            "question": {
                "query": "fulltext search", 
                "fuzziness": 1,
                "operator": "and"
            }
        }
    }
}

fulltextsearch

Количество результатов значительно уменьшилось. Это произошло из-за того, что теперь в выдаче присутствуют только те документы, в которых встретились все 3 терма. Кроме того, теперь при поиске позволительна одна опечатка в слове.

match_phrase

Главное отличие match_phrase от предыдущей конструкции в том, что в match порядок термов в запросе не имеет значения. В результаты же нового запроса попадут только те документы, в которых есть все термы из запроса, и они расположены в тексте в том же порядке. Так выглядит запрос:

{
    "query": {
        "match_phrase": {
            "question": {
                "query": "fulltext search"
            }
        }
    }
}

fulltextsearch

Напоследок напишем запрос, который учитывает и документы, содержащие одиночные термы из запроса, и те в которых встречаются несколько слов, но отдает максимальный рейтинг тем, в которых термы стоят в том же порядке, что и в запросе. Для этого необходимо написать bool конструкцию с оператором should, в которую поместить наши match и match_phrase запросы. Чтобы документы с правильным порядком термов имели наибольший приоритет, добавим к запросу match_phrase параметр boost - число, на которое будет умножен score в случае успеха.

{
    "query": {
        "bool": {
            "should": [
                {
                    "match_phrase": {
                        "question": {
                            "query": "full-text search",
                            "boost": 2
                        }
                    }
                },
                {
                    "match": {
                        "question": {
                            "query": "full-text search"
                        }
                    }
                }
            ]
        }
    }
}

fulltextsearch

Заключение

Elasticsearch предоставляет богатые возможности для полнотекстового поиска, в чем мы сегодня убедились. При этом в нем также присутствуют все необходимые конструкции для создания сложных агрегированных запросов.