Учебное пособие по Elasticsearch для разработчиков .NET

Опубликовано: 2022-03-11

Должен ли разработчик .NET использовать Elasticsearch в своих проектах? Хотя Elasticsearch построен на Java, я считаю, что он дает много причин, по которым Elasticsearch стоит попробовать для полнотекстового поиска в любом проекте.

Elasticsearch как технология прошла долгий путь за последние несколько лет. Он не только делает полнотекстовый поиск похожим на волшебство, но и предлагает другие сложные функции, такие как автозаполнение текста, конвейеры агрегации и многое другое.

Если мысль о внедрении службы на основе Java в вашу аккуратную экосистему .NET вызывает у вас дискомфорт, не беспокойтесь, поскольку после того, как вы установили и настроили Elasticsearch, вы будете проводить большую часть своего времени с одним из самых крутых пакетов .NET. там: НЕСТ.

В этой статье вы узнаете, как использовать удивительное решение для поисковой системы Elasticsearch в своих проектах .NET.

Установка и настройка

Установка самого Elasticsearch в вашу среду разработки сводится к загрузке Elasticsearch и, при необходимости, Kibana.

При распаковке пригодится вот такой bat-файл:

 cd "D:\elastic\elasticsearch-5.2.2\bin" start elasticsearch.bat cd "D:\elastic\kibana-5.0.0-windows-x86\bin" start kibana.bat exit

После запуска обеих служб вы всегда можете проверить локальный сервер Kibana (обычно доступный по адресу http://localhost:5601), поиграть с индексами и типами и выполнить поиск с использованием чистого JSON, как подробно описано здесь.

Первый шаг

Будучи тщательным и хорошим разработчиком, с полной поддержкой и пониманием со стороны руководства, вы начинаете с добавления проекта модульного тестирования и написания SearchService с покрытием кода не менее 90%.

Первый шаг — это настройка файла app.config для предоставления строки подключения к серверу Elasticsearch.

Отличительной особенностью Elasticsearch является то, что он абсолютно бесплатный. Но я бы все же посоветовал использовать сервис Elastic Cloud, предоставляемый Elastic.co. Размещенный сервис делает все обслуживание и настройку довольно простым. Более того, у вас есть две недели бесплатной пробной версии, которых должно быть более чем достаточно, чтобы опробовать все приведенные здесь примеры!

Поскольку здесь мы работаем локально, ключ конфигурации, подобный этому, должен работать:

 <add key="Search-Uri" value="http://localhost:9200" />

Установка Elasticsearch по умолчанию работает на порту 9200, но вы можете изменить его, если хотите.

ElasticClient и пакет NEST

ElasticClient — приятный маленький помощник, который сделает за нас большую часть работы, и он поставляется с пакетом NEST.

Давайте сначала установим пакет.

Для настройки клиента можно использовать что-то вроде этого:

 var node = new Uri(ConfigurationManager.AppSettings["Search-Uri"]); var settings = new ConnectionSettings(node); settings.ThrowExceptions(alwaysThrow: true); // I like exceptions settings.PrettyJson(); // Good for DEBUG var client = new ElasticClient(settings);

Индексирование и сопоставление

Чтобы иметь возможность что-то искать, мы должны хранить некоторые данные в ES. Используется термин «индексация».

Термин «сопоставление» используется для сопоставления наших данных в базе данных с объектами, которые будут сериализованы и сохранены в Elasticsearch. В этом руководстве мы будем использовать Entity Framework (EF).

Как правило, при использовании Elasticsearch вы, вероятно, ищете решение для поисковой системы для всего сайта. Вы будете либо использовать какую-то ленту или дайджест, либо поиск в стиле Google, который возвращает все результаты от различных объектов, таких как пользователи, записи в блогах, продукты, категории, события и т. д.

Скорее всего, это будет не просто одна таблица или объект в вашей базе данных, а скорее вы захотите агрегировать разнообразные данные и, возможно, извлечь или получить некоторые общие свойства, такие как заголовок, описание, дата, автор/владелец, фотография и т. д. Другое дело, вы, вероятно, не сделаете это в одном запросе, но если вы используете ORM, вам придется написать отдельный запрос для каждой из этих записей в блоге, пользователей, продуктов, категорий, событий или чего-то еще.

Я структурировал свои проекты, создавая индекс для каждого «большого» типа, например, записи в блоге или продукта. Затем можно добавить некоторые типы Elasticsearch для более конкретных типов, которые будут подпадать под тот же индекс. Например, если статья может быть рассказом, видео статьей или подкастом, она все равно будет в указателе «статьи», но у нас будут эти четыре типа в этом указателе. Однако, скорее всего, это будет тот же самый запрос в базе данных.

Имейте в виду, что вам нужен хотя бы один тип для каждого индекса — возможно, тип с тем же именем, что и у индекса.

Для сопоставления ваших сущностей вам потребуется создать несколько дополнительных классов. Обычно я использую класс DocumentSearchItemBase , от которого каждый из специализированных классов будет наследовать BlogPostSearchItem , ProductSearchItem и так далее.

Мне нравится иметь выражения сопоставления внутри этих классов. Я всегда могу изменить выражения, если это необходимо в будущем.

В одном из моих первых проектов с Elasticsearch я написал довольно большой класс SearchService с сопоставлениями и индексированием, выполненным с помощью красивых и длинных операторов switch-case: для каждого типа сущности, который я хочу добавить в Elasticsearch, был переключатель и запрос с сопоставлением, которые сделал это.

Однако на протяжении всего процесса я понял, что это не лучший способ, по крайней мере, не для меня.

Более элегантное решение состоит в том, чтобы иметь какой-то интеллектуальный класс IndexDefinition и определенный класс определения индекса для каждого индекса. Таким образом, мой базовый класс IndexDefinition может хранить список всех доступных индексов и некоторые вспомогательные методы, такие как необходимые анализаторы и отчеты о состоянии, в то время как производные классы, специфичные для индекса, обрабатывают запросы к базе данных и отображают данные для каждого индекса конкретно. Это особенно полезно, когда вам нужно добавить дополнительный объект в ES через какое-то время. Все сводится к добавлению еще одного класса SomeIndexDefinition , который наследуется от IndexDefinition и требует, чтобы вы просто реализовали несколько методов, которые запрашивают данные, которые вам нужны в вашем индексе.

Об Elasticsearch

В основе всего, что вы можете делать с Elasticsearch, лежит язык запросов. В идеале все, что вам нужно для взаимодействия с Elasticsearch, — это знать, как создать объект запроса.

За кулисами Elasticsearch раскрывает свои функциональные возможности как API на основе JSON через HTTP.

Хотя сам API и структура объекта запроса довольно интуитивно понятны, работа со многими реальными сценариями все еще может вызывать затруднения.

Как правило, поисковый запрос к Elasticsearch требует следующей информации:

  • Какой индекс и какие типы ищут

  • Информация о разбиении на страницы (сколько элементов пропустить и сколько элементов вернуть)

  • Выбор конкретного типа (при выполнении агрегации, как мы собираемся сделать здесь)

  • Сам запрос

  • Определение выделения (Elasticsearch может автоматически выделять совпадения, если мы этого захотим)

Например, вы можете захотеть реализовать функцию поиска, при которой только некоторые пользователи могут видеть премиум-контент на вашем сайте, или вы можете захотеть, чтобы некоторый контент был виден только «друзьям» его авторов и т. д.

Возможность создания объекта запроса лежит в основе решения этих проблем, и это действительно может быть проблемой при попытке охватить множество сценариев.

Из всего вышеперечисленного самым важным и самым сложным в настройке является, естественно, сегмент запроса — и здесь мы сосредоточимся в основном на нем.

Запросы — это рекурсивные конструкции, объединенные из BoolQuery и других запросов, таких как MatchPhraseQuery , TermsQuery , DateRangeQuery и ExistsQuery . Этого было достаточно для выполнения любых основных требований, и для начала должно хватить.

Запрос MultiMatch очень важен, поскольку он позволяет нам указать поля, по которым мы хотим выполнить поиск, и еще немного настроить результаты, к которым мы вернемся позже.

MatchPhraseQuery может фильтровать результаты по внешнему ключу в обычных базах данных SQL или статическим значениям, таким как перечисления, — например, при сопоставлении результатов по конкретному автору ( AuthorId ) или сопоставлении всех общедоступных статей ( ContentPrivacy=Public ).

TermsQuery будут переведены как «в» на обычный язык SQL. Например, он может вернуть все статьи, написанные одним из друзей пользователя, или получить товары исключительно от фиксированного набора продавцов. Как и в случае с SQL, не следует злоупотреблять этим и помещать в этот массив 10 000 элементов, поскольку это повлияет на производительность, но обычно он довольно хорошо обрабатывает разумные количества.

DateRangeQuery является самодокументируемым.

ExistsQuery интересен: он позволяет вам игнорировать или возвращать документы, в которых нет определенного поля.

В сочетании с BoolQuery позволяют определить сложную логику фильтрации.

Подумайте, например, о блог-сайте, где записи блога могут иметь поле AvailableFrom , которое указывает, когда они должны стать видимыми.

Если мы применим такой фильтр, как AvailableFrom <= Now , то мы не получим документы, в которых вообще нет этого конкретного поля (мы агрегируем данные, и в некоторых документах это поле может не быть определено). Чтобы решить эту проблему, вы должны объединить ExistsQuery с DateRangeQuery и обернуть его внутри BoolQuery с условием, что хотя бы один элемент в BoolQuery выполнен. Что-то вроде этого:

 BoolQuery Should (at least one of the following conditions should be fulfilled) DateRangeQuery with AvailableFrom condition Negated ExistsQuery for field AvailableFrom

Отрицание запросов — не такая простая готовая работа. Но с помощью BoolQuery все же возможно:

 BoolQuery MustNot ExistsQuery

Автоматизация и тестирование

Чтобы упростить задачу, рекомендуемый метод — писать тесты по ходу дела.

Таким образом, вы сможете экспериментировать более эффективно и, что еще более важно, будете уверены, что любые новые изменения (например, более сложные фильтры) не нарушат существующую функциональность. Я явно не хотел говорить «модульные тесты», так как я не поклонник издевательств над чем-то вроде движка Elasticsearch — макет почти никогда не будет реалистичным приближением к тому, как на самом деле ведет себя ES — следовательно, это могут быть интеграционные тесты, если вы любитель терминологии.

Примеры из реальной жизни

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

В моем последнем проекте я использовал Elasticsearch для предоставления пользовательской ленты: весь контент, собранный в одном месте, упорядоченный по дате создания, и полнотекстовый поиск с некоторыми параметрами. Сама подача довольно проста; просто убедитесь, что где-то в ваших данных есть поле даты, и упорядочите по этому полю.

Поиск, с другой стороны, не будет работать на удивление хорошо из коробки. Это потому, что, естественно, Elasticsearch не может знать, что важно в ваших данных. Допустим, у нас есть некоторые данные, которые (среди прочих полей) имеют поля Title , Tags (массив) и Body . Поле body может быть содержимым HTML (для большей реалистичности).

Орфографические ошибки

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

Чтобы справиться с этим, нам придется познакомиться с анализаторами, токенизаторами, фильтрами символов и фильтрами токенов. Это преобразования, которые применяются во время индексации.

  • Анализаторы должны быть определены. Это может быть определено для каждого индекса.

  • К некоторым полям в наших документах можно применять анализаторы. Это можно сделать с помощью атрибутов или свободного API. В нашем примере мы используем атрибуты.

  • Анализаторы представляют собой комбинацию фильтров, символьных фильтров и токенизаторов.

Для выполнения требования (частичное совпадение слов) создадим анализатор «автозаполнение», который состоит из:

  • Фильтр английских стоп-слов: фильтр, который удаляет все общеупотребительные слова английского языка, такие как «and» или «the».

  • Обрезной фильтр: удаляет пустое пространство вокруг каждого токена.

  • Фильтр нижнего регистра: преобразует все символы в нижний регистр. Это не означает, что когда мы извлекаем наши данные, они будут преобразованы в нижний регистр, но вместо этого включает поиск без учета регистра.

  • Токенизатор Edge-n-gram: этот токенизатор позволяет нам иметь частичные совпадения. Например, если у нас есть предложение «У моей бабушки деревянный стул», при поиске термина «дерево» мы все равно хотели бы найти это предложение. Что делает edge-n-gram, так это сохраняет «woo», «wood», «woode» и «wooden», чтобы найти любое частичное совпадение слова, состоящее как минимум из трех букв. Параметры MinGram и MaxGram определяют минимальное и максимальное количество сохраняемых символов. В нашем случае у нас будет минимум три и максимум 15 букв.

В следующем разделе все они связаны вместе:

 analysis.Analyzers(a => a .Custom("autocomplete", cc => cc .Filters("eng_stopwords", "trim", "lowercase") .Tokenizer("autocomplete") ) .Tokenizers(tdesc => tdesc .EdgeNGram("autocomplete", e => e .MinGram(3) .MaxGram(15) .TokenChars(TokenChar.Letter, TokenChar.Digit) ) ) .TokenFilters(f => f .Stop("eng_stopwords", lang => lang .StopWords("_english_") ) );

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

 public class SearchItemDocumentBase { ... [Text(Analyzer = "autocomplete", Name = nameof(Title))] public string Title { get; set; } ... }

Теперь давайте рассмотрим несколько примеров, демонстрирующих довольно общие требования практически для любого приложения с большим объемом содержимого.

Очистка HTML

Требование: некоторые из наших полей могут содержать HTML-текст внутри.

Естественно, вы бы не хотели, чтобы поиск по «разделу» возвращал что-то вроде «<раздел>…</раздел>» или «тело», возвращая HTML-элемент «<body>». Чтобы избежать этого, во время индексации мы удалим HTML и оставим внутри только содержимое.

К счастью, вы не первый с такой проблемой. Elasticsearch поставляется с полезным фильтром символов для этого:

 analysis.Analyzers(a => a .Custom("html_stripper", cc => cc .Filters("eng_stopwords", "trim", "lowercase") .CharFilters("html_strip") .Tokenizer("autocomplete") )

И применить его:

 [Text(Analyzer = "html_stripper", Name = nameof(HtmlText))] public string HtmlText { get; set; }

Важные поля

Требование: совпадения в заголовке должны быть более важными, чем совпадения в содержании.

К счастью, Elasticsearch предлагает стратегии для повышения результатов, если совпадение происходит в том или ином поле. Это делается в рамках построения поискового запроса с помощью опции boost :

 const int titleBoost = 15; .Query(qx => qx.MultiMatch(m => m .Query(searchRequest.Query.ToLower()) .Fields(ff => ff .Field(f => f.Title, boost: titleBoost) .Field(f => f.Summary) ... ) .Type(TextQueryType.BestFields) ) && filteringQuery)

Как видите, запрос MultiMatch очень полезен в подобных ситуациях, а такие ситуации не так уж и редки! Часто какие-то поля важнее, а какие-то нет — этот механизм позволяет нам это учитывать.

Не всегда просто сразу установить значения буста. Вам нужно будет немного поиграть с этим, чтобы получить желаемые результаты.

Приоритет статей

Требование: некоторые статьи важнее других. Либо автор важнее, либо сама статья набрала больше лайков/репостов/плюсов/и т.д. Более важные статьи должны ранжироваться выше.

Elasticsearch позволяет нам реализовать нашу функцию оценки, и мы упрощаем ее, определяя поле «Важность», которое имеет двойное значение — в нашем случае больше 1. Вы можете определить свою собственную функцию/фактор важности и применить ее. по аналогии. Вы можете определить несколько режимов повышения и подсчета очков — в зависимости от того, что вам больше подходит. Это сработало для нас хорошо:

 .Query(q => q .FunctionScore(fsc => fsc .BoostMode(FunctionBoostMode.Multiply) .ScoreMode(FunctionScoreMode.Sum) .Functions(f => f .FieldValueFactor(b => b .Field(nameof(SearchItemDocumentBase.Rating)) .Missing(0.7) .Modifier(FieldValueFactorModifier.None) ) ) .Query(qx => qx.MultiMatch(m => m .Query(searchRequest.Query.ToLower()) .Fields(ff => ff ... ) .Type(TextQueryType.BestFields) ) && filteringQuery) ) )

У каждого фильма есть рейтинг, и мы вывели рейтинг актеров по среднему рейтингу фильмов, в которых они снимались (не очень научный метод). Мы масштабировали этот рейтинг до двойного значения в интервале [0,1].

Совпадения полных слов

Требование: Полные совпадения должны иметь более высокий рейтинг.

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

Это поле будет соответствовать только в том случае, если точное слово соответствует. Он не будет сопоставлять «дерево» для «деревянного», как это делает анализатор автозаполнения.

Заворачивать

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

Кривая обучения может быть немного крутой, но она того стоит, особенно когда вы правильно ее настроите и начнете получать отличные результаты поиска.

Всегда не забывайте добавлять подробные тестовые примеры с ожидаемыми результатами, чтобы убедиться, что вы не слишком сильно испортите параметры при внесении изменений и экспериментах.

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