Основы Solr и Elasticsearch с интеграцией в C#

Практический курс по изучению популярных поисковых движков с нуля до реализации поиска в .NET приложениях. Вы освоите индексацию, синтаксис запросов и взаимодействие с API через C#.

Введение в поисковые технологии: архитектура Lucene, установка и сравнение возможностей Solr и Elasticsearch

Введение в поисковые технологии: архитектура Lucene, установка и сравнение возможностей Solr и Elasticsearch

Добро пожаловать в курс «Основы Solr и Elasticsearch с интеграцией в C#». Если вы когда-либо пытались реализовать поиск на сайте, используя стандартные возможности SQL (например, оператор LIKE), вы наверняка сталкивались с проблемами производительности и релевантности. Запросы работают медленно на больших объемах данных, а результаты не всегда соответствуют ожиданиям пользователя. Здесь на сцену выходят специализированные поисковые движки.

В этой статье мы разберем фундамент современных поисковых систем — библиотеку Apache Lucene, сравним два самых популярных решения на её основе (Solr и Elasticsearch) и подготовим рабочее окружение для дальнейшей работы с C#.

Почему реляционные базы данных не подходят для поиска?

Реляционные базы данных (RDBMS), такие как PostgreSQL или MS SQL Server, отлично справляются с хранением структурированных данных и поддержанием их целостности. Однако они не оптимизированы для полнотекстового поиска.

Основные ограничения RDBMS в поиске:

  1. Низкая скорость на больших текстах. Поиск подстроки требует сканирования всего текста.
  2. Отсутствие лингвистического анализа. База данных не знает, что «бежал», «бегу» и «бег» — это слова с одним корнем.
  3. Отсутствие ранжирования. SQL вернет все строки, где есть совпадение, но не скажет, какая из них наиболее релевантна запросу.

Для решения этих задач используется инвертированный индекс (Inverted Index) — структура данных, лежащая в основе Apache Lucene.

Архитектура Apache Lucene

Apache Lucene — это высокопроизводительная библиотека для полнотекстового поиска, написанная на Java. Важно понимать: Lucene — это не готовый сервер, к которому можно подключиться по HTTP. Это «мотор», который нужно встроить в приложение. Solr и Elasticsearch — это «автомобили», построенные вокруг этого мотора, предоставляющие удобный API и инструменты управления.

Инвертированный индекс

Представьте, что у нас есть три документа:

  1. Документ 1: «C# — мощный язык»
  2. Документ 2: «Solr использует Lucene»
  3. Документ 3: «Elasticsearch тоже использует Lucene»

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

Процесс создания индекса включает:

  1. Токенизацию: Разбиение текста на отдельные слова (токены).
  2. Нормализацию: Приведение слов к нижнему регистру, удаление окончаний (стемминг) или приведение к начальной форме (лемматизация).
  3. Индексацию: Сохранение списка документов для каждого токена.

В итоге мы получаем структуру, где ключом является слово, а значением — список ID документов, где оно встречается.

Релевантность и оценка (Scoring)

Поисковые движки не просто находят документы, они сортируют их по релевантности. Долгое время стандартом был алгоритм TF-IDF, сейчас чаще используется его улучшенная версия — BM25. Рассмотрим базовую концепцию TF-IDF, чтобы понять принцип.

Формула веса термина ww в документе dd выглядит следующим образом:

wt,d=TF(t,d)×IDF(t)w_{t,d} = TF(t,d) \times IDF(t)

Где:

  • wt,dw_{t,d} — итоговый вес (важность) слова tt в документе dd.
  • TF(t,d)TF(t,d) (Term Frequency) — частота термина. Показывает, сколько раз слово tt встречается в конкретном документе dd. Чем чаще слово встречается в документе, тем оно важнее для этого документа.
  • IDF(t)IDF(t) (Inverse Document Frequency) — обратная частота документа. Показывает, насколько редким является слово tt во всей коллекции документов. Если слово встречается во всех документах (например, предлог «и»), его IDFIDF будет низким, и оно мало повлияет на поиск.

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

Solr vs Elasticsearch: Битва титанов

Оба инструмента используют Lucene, оба работают на Java, и оба предоставляют REST API. Но у них разные философии и области применения.

Apache Solr

Solr — ветеран рынка (появился в 2004 году). Он развивался как поисковая платформа для корпоративных порталов и сайтов электронной коммерции.

  • Преимущества: Огромное количество функций «из коробки», отличная работа с фасетным поиском (фильтры в интернет-магазинах), стабильность.
  • Управление: Традиционно использовал XML для конфигурации, но сейчас поддерживает и JSON API. Управляется через Zookeeper в кластерном режиме (SolrCloud).
  • Экосистема: Часто интегрируется с Hadoop.

Elasticsearch

Elasticsearch появился позже (в 2010 году) и сразу был ориентирован на облака, JSON и распределенную архитектуру.

  • Преимущества: Простота масштабирования, JSON-native (все запросы и ответы в JSON), часть стека ELK (Elasticsearch, Logstash, Kibana), что делает его стандартом де-факто для анализа логов и метрик.
  • Управление: Конфигурация через API, автоматическое перераспределение данных (шардов) при падении узлов.
  • Экосистема: Kibana — мощнейший инструмент визуализации данных.

Сравнительная таблица

Характеристика Apache Solr Elasticsearch
Философия Поиск для сайтов и Enterprise-систем Аналитика, логи, поиск для приложений
Формат данных JSON, XML, CSV, Binary JSON
Масштабирование Требует Zookeeper, сложнее в настройке Встроено (Zen Discovery), проще
Скорость индексации Чуть медленнее (ориентир на точность) Очень быстрая (Real-time search)
Популярность Стабильная, много Legacy проектов Растущая, стандарт для новых проектов

Для C# разработчика Elasticsearch часто выглядит привлекательнее из-за нативного использования JSON, который легко мапится на POCO-классы (Plain Old CLR Objects).

Основные понятия кластера

Независимо от выбора, вам нужно знать терминологию архитектуры:

  1. Node (Узел): Один экземпляр запущенного сервера (один процесс).
  2. Cluster (Кластер): Группа узлов, работающих вместе.
  3. Index (Индекс): Коллекция документов (аналог таблицы в SQL).
  4. Shard (Шард): Часть индекса. Индекс можно разделить на несколько кусков (шардов), чтобы хранить их на разных узлах. Это позволяет масштабировать объем данных.
  5. Replica (Реплика): Копия шарда. Нужна для отказоустойчивости (если узел с основным шардом упадет) и для ускорения чтения.

Установка и запуск (Docker)

Самый простой способ запустить эти системы без установки Java и настройки переменных среды — использовать Docker. Это идеально подходит для разработки и обучения.

Запуск Elasticsearch

Для локальной разработки мы запустим Elasticsearch в режиме одного узла (single-node), отключив сложные проверки безопасности, чтобы упростить старт.

Выполните команду в терминале:

docker run -d --name elasticsearch -p 9200:9200 -e "discovery.type=single-node" -e "xpack.security.enabled=false" docker.elastic.co/elasticsearch/elasticsearch:8.11.1
  • -p 9200:9200: Пробрасываем порт 9200 (стандартный порт для HTTP API).
  • discovery.type=single-node: Указываем, что кластер состоит из одной машины.
  • xpack.security.enabled=false: Отключаем логин/пароль (только для обучения!).

Проверить работу можно в браузере или через curl:

curl http://localhost:9200

Вы должны получить JSON-ответ с информацией о версии.

Запуск Solr

Запустим Solr в стандартном режиме:

docker run -d --name solr -p 8983:8983 solr:9.4
  • -p 8983:8983: Стандартный порт Solr.

После запуска откройте в браузере админ-панель: http://localhost:8983. В отличие от Elasticsearch, у Solr есть полноценный графический интерфейс управления «из коробки».

Взаимодействие через REST API

В следующих статьях мы будем использовать C# для общения с этими сервисами. Однако важно понимать, что под капотом все библиотеки (NEST для Elastic или SolrNet для Solr) используют обычные HTTP-запросы.

Пример того, как выглядит добавление документа в Elasticsearch через сырой HTTP:

POST http://localhost:9200/products/_doc
Content-Type: application/json

{
  "name": "Ноутбук Gaming X",
  "price": 1500,
  "description": "Мощный ноутбук для игр и работы"
}

В ответ сервер вернет JSON с подтверждением создания и присвоенным ID.

Резюме

Мы разобрали, что Solr и Elasticsearch базируются на библиотеке Lucene и используют инвертированный индекс для быстрого поиска. Elasticsearch более популярен в современной разработке благодаря JSON-ориентированности и простоте масштабирования, тогда как Solr остается мощным инструментом для специфических задач электронной коммерции. Мы также развернули оба сервиса в Docker и убедились в их работоспособности.

В следующей статье мы перейдем к практике и создадим наше первое консольное приложение на C# для подключения к Elasticsearch.

Apache Solr: конфигурация схем, управление коллекциями и базовый синтаксис запросов через REST API

Apache Solr: конфигурация схем, управление коллекциями и базовый синтаксис запросов через REST API

В предыдущей статье мы развернули Apache Solr в Docker и убедились, что он запускается. Теперь пришло время превратить этот «пустой ящик» в рабочий инструмент. В отличие от реляционных баз данных, где вы сначала создаете таблицы через CREATE TABLE, а потом вставляете данные, поисковые движки имеют свою специфику определения структуры.

Поскольку ваша конечная цель — взаимодействие с Solr через C#, важно понимать: Solr управляется через HTTP-запросы. Все, что мы будем делать в этой статье (создание индексов, настройка полей, поиск), в дальнейшем будет выполняться из вашего C# кода с помощью HttpClient. Сейчас мы разберем «сырые» запросы, чтобы вы понимали механику процесса.

Иерархия данных в Solr

Прежде чем писать запросы, нужно понять, как Solr хранит данные. В реляционных базах данных (SQL) у нас есть привычная цепочка: Сервер -> База данных -> Таблица -> Строка. В Solr иерархия выглядит иначе:

  1. Cluster (Кластер): Совокупность всех узлов Solr.
  2. Collection (Коллекция): Логический индекс, распределенный по серверам (аналог Таблицы, но более масштабный). В режиме Standalone (одиночный сервер) часто используется термин Core (Ядро).
  3. Document (Документ): Единица информации (аналог Строки в таблице).
  4. Field (Поле): Атрибут документа (аналог Колонки).

Схема данных (Schema)

Схема — это «конституция» вашего индекса. Она определяет, какие поля могут быть в документе и, самое главное, как эти поля должны обрабатываться перед записью.

В современных версиях Solr используется Managed Schema. Это означает, что мы не редактируем XML-файлы вручную, а отправляем Solr команды через API: «Добавь поле», «Измени тип поля».

Типы полей (Field Types)

Тип поля определяет, как Solr «понимает» ваши данные. Это критически важно для поиска. Рассмотрим два основных типа:

  1. String (string): Строка сохраняется «как есть». Если вы сохранили «Apple iPhone», то поиск по слову «iphone» (с маленькой буквы) ничего не найдет. Этот тип используется для ID, артикулов, категорий, тегов — там, где нужно точное совпадение для фильтрации.
  2. Text (text_general): Текст проходит анализ (tokenization). Фраза «Apple iPhone» разбивается на токены [apple, iphone], приводится к нижнему регистру. Поиск «iphone» успешно найдет этот документ. Используется для названий, описаний и полнотекстового поиска.

Атрибуты полей

При объявлении поля мы задаем атрибуты, которые управляют его поведением:

  • indexed=true: Поле попадает в поисковый индекс. По нему можно искать, фильтровать и сортировать. Если поставить false, поле будет просто храниться, но найти по нему ничего нельзя.
  • stored=true: Значение поля хранится в исходном виде и возвращается в результатах поиска. Если поставить false, вы сможете найти документ по этому полю, но само поле в ответе JSON не увидите (полезно для огромных текстов, которые нужны только для поиска).
  • multiValued=true: Поле может содержать массив значений (например, список тегов или категорий).
  • required=true: Документ будет отклонен, если в нем нет этого поля.

Практика: Работа через REST API

Давайте перейдем к практике. Мы будем использовать формат запросов, который вы позже сможете перенести в C#. Представим, что мы создаем каталог электроники.

Шаг 1. Создание коллекции (Core)

Для начала создадим место для хранения данных. В Solr API для этого используется CoreAdmin API.

HTTP запрос: GET http://localhost:8983/solr/admin/cores?action=CREATE&name=electronics&configSet=_default

  • name=electronics: Имя нашей коллекции.
  • configSet=_default: Использование стандартной конфигурации.

Шаг 2. Настройка схемы (Schema API)

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

Мы отправляем POST запрос на адрес http://localhost:8983/solr/electronics/schema с JSON-телом:

{
  "add-field": [
    {
      "name": "product_name",
      "type": "text_general",
      "indexed": true,
      "stored": true
    },
    {
      "name": "category",
      "type": "string",
      "indexed": true,
      "stored": true
    },
    {
      "name": "price",
      "type": "pfloat",
      "indexed": true,
      "stored": true
    }
  ]
}

Здесь мы создали:

  • product_name: для полнотекстового поиска.
  • category: для точной фильтрации (строгий строковый тип).
  • price: число с плавающей точкой для сортировки и диапазонов.

Шаг 3. Индексация документов

Теперь добавим данные. В Solr это называется индексацией.

POST запрос на http://localhost:8983/solr/electronics/update?commit=true.

Параметр commit=true заставляет Solr сразу же сохранить изменения на диск и сделать их доступными для поиска. В высоконагруженных системах коммиты делают реже (автоматически), но для тестов это удобно.

Тело запроса (JSON):

[
  {
    "id": "1",
    "product_name": "Смартфон Apple iPhone 14",
    "category": "smartphones",
    "price": 999.99
  },
  {
    "id": "2",
    "product_name": "Наушники Sony WH-1000XM5",
    "category": "audio",
    "price": 349.50
  }
]

Шаг 4. Базовый поиск

Самое интересное — получение данных. Основной эндпоинт для поиска: /select.

Пример 1: Найти всё GET http://localhost:8983/solr/electronics/select?q=*:*

  • q (Query): Параметр запроса.
  • *:*: Синтаксис «найти все документы» (матчить любое поле по любому значению).

Пример 2: Полнотекстовый поиск GET http://localhost:8983/solr/electronics/select?q=product_name:iphone

Solr найдет документ с ID 1, так как поле product_name имеет тип text_general и слово «iPhone» было проиндексировано в нижнем регистре.

Пример 3: Фильтрация (Filter Query) GET http://localhost:8983/solr/electronics/select?q=*:*&fq=price:[300 TO 1000]

  • fq (Filter Query): Фильтр ограничивает выборку, но не влияет на релевантность (счет) документа. Фильтры работают быстрее и кэшируются Solr.
  • price:[300 TO 1000]: Диапазон цен от 300 до 1000 включительно.

Пример 4: Сортировка и пагинация GET http://localhost:8983/solr/electronics/select?q=*:*&sort=price desc&rows=10&start=0

  • sort: Поле и направление (asc — по возрастанию, desc — по убыванию).
  • rows: Количество документов на странице.
  • start: Смещение (сколько документов пропустить).

Связь с C#

Вы можете спросить: «Зачем мне знать URL-адреса, если я пишу на C#?».

Дело в том, что популярные библиотеки для Solr в .NET (например, SolrNet) под капотом делают именно эти HTTP-запросы. А если вы решите не использовать тяжелые библиотеки, вы будете использовать стандартный HttpClient.

Ваш код на C# будет выглядеть концептуально так:

// Псевдокод для понимания логики
var client = new HttpClient();
var response = await client.GetAsync("http://localhost:8983/solr/electronics/select?q=product_name:sony");
var jsonString = await response.Content.ReadAsStringAsync();
// Далее десериализация JSON в объекты C#

Понимание того, как формируется URL и тело запроса, позволит вам отлаживать любые проблемы, просто скопировав запрос из кода в браузер или Postman.

Заключение

Мы разобрали фундамент Solr: иерархию данных, важность схемы и базовые операции CRUD (Create, Read, Update, Delete) через REST API. Правильно настроенная схема — это 80% успеха быстрого поиска.

В следующих уроках мы углубимся в сложные запросы, фасетный поиск (категоризацию) и начнем писать реальный код на C# для автоматизации этих действий.

Elasticsearch: работа с JSON-документами, маппинг, Query DSL и агрегации данных

Elasticsearch: работа с JSON-документами, маппинг, Query DSL и агрегации данных

В предыдущих статьях мы познакомились с архитектурой Lucene, запустили Elasticsearch в Docker и изучили основы Solr. Теперь пришло время погрузиться в мир Elasticsearch. Если Solr часто воспринимается как строгая система с XML-конфигурациями, то Elasticsearch — это гибкий, JSON-ориентированный движок, который «говорит» на языке современных веб-разработчиков.

В этой статье мы разберем жизненный цикл документа, научимся правильно определять типы данных (маппинг), освоим мощный язык запросов Query DSL и узнаем, как превратить поисковый движок в аналитическую платформу с помощью агрегаций.

Документ как основа данных

В реляционных базах данных (SQL) мы привыкли мыслить строками и столбцами. В Elasticsearch единицей информации является JSON-документ. Это иерархическая структура данных, которая содержит поля и значения.

Когда вы отправляете JSON в Elasticsearch, он сохраняется в специальном системном поле _source. Однако для того, чтобы поиск работал быстро, Elasticsearch должен разобрать этот JSON и построить инвертированный индекс.

Структура документа

Каждый документ при индексации получает метаданные:

  • _index: Имя индекса (аналог таблицы), где лежит документ.
  • _id: Уникальный идентификатор документа (можно задать вручную или сгенерировать автоматически).
  • _version: Номер версии (используется для контроля конкурентного доступа).
  • _seq_no и _primary_term: Служебные поля для синхронизации реплик.

Маппинг (Mapping): Схема данных

Маппинг — это процесс определения того, как документ и его поля сохраняются и индексируются. Это аналог схемы (Schema) в базе данных.

Существует два подхода к маппингу:

  1. Динамический маппинг: Вы просто отправляете JSON, а Elasticsearch сам угадывает типы полей. Например, если он видит "price": 100, он создаст поле типа long. Это удобно для старта, но опасно для продакшена (можно случайно засорить индекс неправильными типами).
  2. Явный маппинг (Explicit Mapping): Вы заранее описываете структуру индекса. Это рекомендуемый подход.

Text vs Keyword

Самая важная концепция, которую нужно понять C# разработчику при переходе на Elasticsearch — это различие между двумя строковыми типами: text и keyword.

  • Text: Используется для полнотекстового поиска. Содержимое поля проходит через анализатор (разбивается на слова, приводится к нижнему регистру). По такому полю нельзя делать точную сортировку или агрегацию.
  • Keyword: Используется для точных совпадений, сортировки и агрегаций. Содержимое сохраняется «как есть». Идеально для ID, статусов (published, draft), email-адресов или тегов.

Пример создания индекса с явным маппингом через REST API:

PUT /products
{
  "mappings": {
    "properties": {
      "name": { "type": "text" },
      "category": { "type": "keyword" },
      "price": { "type": "double" },
      "created_at": { "type": "date" }
    }
  }
}

Query DSL: Язык запросов

Elasticsearch предоставляет мощный JSON-интерфейс для поиска, называемый Query DSL (Domain Specific Language). Запросы делятся на две большие группы: Leaf queries (листовые запросы) и Compound queries (составные запросы).

Листовые запросы (Leaf Queries)

Они ищут конкретное значение в конкретном поле.

  1. Match query: Стандартный запрос для полнотекстового поиска. Он анализирует строку запроса.

    GET /products/_search
    {
      "query": {
        "match": {
          "name": "ноутбук gaming"
        }
      }
    }
    

    Этот запрос найдет документы, содержащие «ноутбук» ИЛИ «gaming» (или оба), даже если они написаны с большой буквы.

  2. Term query: Точный поиск. Не анализирует запрос. Ищет точное совпадение в инвертированном индексе.

    GET /products/_search
    {
      "query": {
        "term": {
          "category": "electronics"
        }
      }
    }
    

    Важно: Никогда не используйте term для полей типа text. Это частая ошибка новичков.

  3. Range query: Поиск по диапазону (числа, даты).

    GET /products/_search
    {
      "query": {
        "range": {
          "price": {
            "gte": 1000,
            "lte": 2000
          }
        }
      }
    }
    

Составные запросы (Compound Queries)

Самый популярный составной запрос — bool. Он позволяет комбинировать листовые запросы, используя логику булевой алгебры. Внутри bool есть четыре секции:

  • must: Документ обязан соответствовать условию (влияет на релевантность/score).
  • filter: Документ обязан соответствовать условию (не влияет на score, работает быстрее, кэшируется). Используйте для точных фильтров (цена, категория).
  • should: Документ может соответствовать (если соответствует — score выше). Аналог оператора OR.
  • must_not: Документ не должен соответствовать.

Пример сложного запроса:

GET /products/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "name": "ноутбук" } }
      ],
      "filter": [
        { "term": { "category": "electronics" } },
        { "range": { "price": { "gte": 500 } } }
      ],
      "should": [
        { "term": { "brand": "apple" } }
      ]
    }
  }
}

Этот запрос означает: «Найди мне электронику (фильтр) дороже 500 (фильтр), где в названии есть слово 'ноутбук' (must). Если это Apple — подними в выдаче выше (should)».

Агрегации: Аналитика данных

Elasticsearch — это не только поиск, но и мощный аналитический движок. Агрегации позволяют собирать статистику по данным. Это похоже на GROUP BY в SQL, но намного гибче.

Агрегации делятся на:

  1. Bucket aggregations (Ведра): Группируют документы в «корзины» (например, по категориям).
  2. Metric aggregations (Метрики): Вычисляют значения на основе документов в корзине (среднее, сумма, мин/макс).

Пример: Средняя цена по категориям

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

xˉ=1n∑i=1nxi\bar{x} = \frac{1}{n} \sum_{i=1}^{n} x_i

Где:

  • xˉ\bar{x} — среднее значение (результат агрегации avg).
  • nn — количество документов в выборке (count).
  • xix_i — значение поля (в нашем случае price) для ii-го документа.
  • ∑\sum — знак суммирования всех значений.

Запрос будет выглядеть так:

GET /products/_search
{
  "size": 0,
  "aggs": {
    "categories_bucket": {
      "terms": { "field": "category" },
      "aggs": {
        "average_price": {
          "avg": { "field": "price" } }
      }
    }
  }
}
  • "size": 0: Нам не нужны сами документы, только цифры.
  • terms: Создает «ведра» для каждой уникальной категории.
  • avg: Считает среднее внутри каждого ведра.

Заключение

Мы разобрали фундамент работы с Elasticsearch: от структуры JSON-документа до сложных аналитических запросов. Понимание разницы между text и keyword, а также умение комбинировать must и filter в bool запросах — это 80% успеха при работе с этой системой.

В следующей статье мы наконец перейдем к практике на C# и подключим библиотеку NEST (или новый Elastic.Clients.Elasticsearch), чтобы выполнять все эти операции из кода вашего приложения.

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

Интеграция Solr с C#: реализация HTTP-клиента и использование библиотеки SolrNet

Интеграция Solr с C#: реализация HTTP-клиента и использование библиотеки SolrNet

В предыдущих статьях мы развернули Apache Solr, изучили его схему данных и научились выполнять запросы через curl и браузер. Теперь пришло время автоматизировать этот процесс. В реальных приложениях пользователи не пишут запросы в адресной строке — они вводят текст в поле поиска, а ваше приложение на C# отправляет запрос к Solr и отображает результаты.

В этой статье мы рассмотрим два способа взаимодействия с Solr:

  1. Низкоуровневый: Использование стандартного HttpClient для отправки «сырых» JSON-запросов.
  2. Высокоуровневый: Использование популярной библиотеки SolrNet, которая берет на себя всю рутину по сериализации и построению запросов.

Архитектура взаимодействия

Прежде чем писать код, важно понять, как наше приложение общается с поисковым движком. Solr предоставляет REST API. Это значит, что любое взаимодействие — это HTTP-запрос (GET, POST, DELETE).

Способ 1: Использование HttpClient

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

Подготовка проекта

Создайте новое консольное приложение:

dotnet new console -n SolrClientDemo
cd SolrClientDemo

Нам понадобится библиотека для работы с JSON. В современных версиях .NET используется System.Text.Json.

Реализация поиска

Предположим, у нас есть коллекция tech_products, и мы хотим найти товары по названию. URL для поиска выглядит так: http://localhost:8983/solr/tech_products/select?q=name:iphone.

Вот пример кода на C#, который выполняет этот запрос:

using System.Text.Json;

var client = new HttpClient();
var solrUrl = "http://localhost:8983/solr/tech_products/select?q=name:solr";

try
{
    var response = await client.GetAsync(solrUrl);
    response.EnsureSuccessStatusCode();

    var jsonString = await response.Content.ReadAsStringAsync();

    // Парсим JSON в динамический объект или JsonDocument для просмотра
    using var doc = JsonDocument.Parse(jsonString);
    var root = doc.RootElement;

    var numFound = root.GetProperty("response").GetProperty("numFound").GetInt32();
    Console.WriteLine($"Найдено документов: {numFound}");

    var docs = root.GetProperty("response").GetProperty("docs");
    foreach (var product in docs.EnumerateArray())
    {
        Console.WriteLine($"ID: {product.GetProperty("id")}");
    }
}
catch (Exception ex)
{
    Console.WriteLine($"Ошибка: {ex.Message}");
}

Недостатки этого подхода:

  • Нужно вручную формировать URL-строку (Query String).
  • Необходимо вручную парсить JSON-ответ.
  • Нет строгой типизации данных.

Способ 2: Библиотека SolrNet

SolrNet — это стандарт де-факто для работы с Solr в мире .NET. Она предоставляет удобный объектно-ориентированный интерфейс, скрывая сложности HTTP и JSON.

Установка пакетов

Для работы нам понадобятся два пакета: основной и расширение для внедрения зависимостей (Dependency Injection).

dotnet add package SolrNet
dotnet add package SolrNet.Microsoft.DependencyInjection

Шаг 1: Описание модели данных (POCO)

SolrNet мапит (сопоставляет) поля из индекса Solr на свойства вашего класса. Для этого используются атрибуты.

Создадим класс Product:

using SolrNet.Attributes;

public class Product
{
    [SolrUniqueKey("id")]
    public string Id { get; set; }

    [SolrField("name")]
    public string Name { get; set; }

    [SolrField("price")]
    public decimal Price { get; set; }

    [SolrField("cat")]
    public List<string> Categories { get; set; } = new List<string>();
}
  • [SolrUniqueKey]: Указывает на поле, являющееся уникальным идентификатором (обычно id).
  • [SolrField("solr_field_name")]: Связывает свойство C# с полем в Solr. Если имена совпадают, атрибут можно не указывать, но для надежности лучше оставить.

Шаг 2: Настройка контейнера (Dependency Injection)

В современном .NET принято использовать DI-контейнер. Настроим его в Program.cs:

using Microsoft.Extensions.DependencyInjection;
using SolrNet;

var services = new ServiceCollection();

// Добавляем SolrNet в контейнер
// Указываем базовый URL к нашему ядру (core) или коллекции
services.AddSolrNet<Product>("http://localhost:8983/solr/tech_products");

var serviceProvider = services.BuildServiceProvider();

// Получаем экземпляр сервиса для работы с Product
var solr = serviceProvider.GetRequiredService<ISolrOperations<Product>>();

Интерфейс ISolrOperations<T> — это главный инструмент, через который выполняются все операции (CRUD).

Шаг 3: Добавление и обновление данных

В Solr операции добавления и обновления идентичны. Если документ с таким ID уже существует, он будет перезаписан.

var newProduct = new Product
{
    Id = "prod-001",
    Name = "Супер Гаджет 3000",
    Price = 999.99m,
    Categories = new List<string> { "electronics", "gadgets" }
};

// 1. Отправляем документ в Solr
await solr.AddAsync(newProduct);

// 2. Фиксируем изменения (Commit)
await solr.CommitAsync();

Console.WriteLine("Документ добавлен и зафиксирован.");

Важно про Commit: Solr не пишет данные на диск мгновенно после команды Add. Они попадают в буфер памяти. Чтобы данные стали доступны для поиска, нужно выполнить Commit. В продакшене часто настраивают autoCommit в конфигурации Solr (solrconfig.xml), чтобы не вызывать его из кода постоянно.

Шаг 4: Поиск данных (Querying)

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

using SolrNet.Commands.Parameters;

// Создаем запрос: найти товары, где в имени есть слово "гаджет"
var query = new SolrQueryByField("name", "гаджет");

// Настройки запроса (пагинация, сортировка)
var options = new QueryOptions
{
    Rows = 10,
    StartOrCursor = new StartOrCursor.Start(0),
    OrderBy = new[] { new SortOrder("price", Order.DESC) }
};

// Выполняем запрос
var results = await solr.QueryAsync(query, options);

Console.WriteLine($"Всего найдено: {results.NumFound}");

foreach (var p in results)
{
    Console.WriteLine($"{p.Name} - {p.Price}$");
}

Пагинация

Для реализации постраничного вывода (пагинации) нам нужно правильно рассчитать параметр Start (смещение). Формула расчета смещения выглядит так:

S=(P−1)×NS = (P - 1) \times N

Где:

  • SS — значение Start (смещение), то есть сколько записей нужно пропустить.
  • PP — номер текущей страницы (начиная с 1).
  • NN — количество записей на странице (Rows).

Например, для 3-й страницы при отображении 10 элементов: S=(3−1)×10=20S = (3 - 1) \times 10 = 20. Мы пропускаем первые 20 записей и показываем следующие 10.

Удаление данных

Удалять можно по ID или по запросу.

// Удаление по ID
await solr.DeleteAsync("prod-001");

// Удаление по запросу (например, удалить все товары дешевле 100)
// await solr.DeleteAsync(new SolrQuery("price:[* TO 100]"));

await solr.CommitAsync();

Обработка ошибок

При работе с SolrNet важно перехватывать исключения SolrConnectionException. Они возникают, если сервер Solr недоступен или вернул ошибку (например, 400 Bad Request из-за неверного синтаксиса запроса).

try
{
    await solr.QueryAsync("name:*");
}
catch (SolrConnectionException ex)
{
    Console.WriteLine($"Ошибка подключения к Solr: {ex.Message}");
}

Сравнение подходов

Характеристика HttpClient SolrNet
Сложность кода Высокая (много ручной работы) Низкая (готовые методы)
Типизация Отсутствует (работа с JSON строками) Строгая (POCO классы)
Гибкость Максимальная Ограничена возможностями библиотеки
Поддержка DI Требует ручной настройки Встроена

Заключение

Мы научились интегрировать Solr в приложение на C#. Использование библиотеки SolrNet значительно упрощает разработку, предоставляя типизированный интерфейс для операций CRUD и поиска. Мы также разобрали, как мапить поля Solr на классы C# с помощью атрибутов и как управлять пагинацией.

В следующей статье мы перейдем к конкуренту Solr и рассмотрим, как подключить Elasticsearch к .NET приложению, используя официальный клиент.

Взаимодействие C# и Elasticsearch: работа через LowLevel клиент и высокоуровневую библиотеку NEST

Взаимодействие C# и Elasticsearch: работа через LowLevel клиент и высокоуровневую библиотеку NEST

В предыдущих статьях мы изучили теорию Elasticsearch, научились писать JSON-запросы и даже сравнили этот процесс с работой в Solr. Мы также рассмотрели, как интегрировать Solr в C# с помощью SolrNet. Теперь пришло время соединить мощь Elasticsearch с экосистемой .NET.

В мире .NET для работы с Elasticsearch исторически сложилась двухуровневая архитектура клиентов. Понимание разницы между ними критически важно для построения производительных приложений.

Архитектура клиентов Elasticsearch для .NET

В отличие от SolrNet, который является единой библиотекой, официальная поддержка Elasticsearch для .NET (до 8-й версии включительно) разделена на два слоя:

  1. Elasticsearch.Net (LowLevel Client): Это низкоуровневый драйвер. Он не знает о ваших классах и типах данных. Он работает со строками, байтами и потоками (Stream). Его задача — эффективно управлять HTTP-соединениями, балансировкой нагрузки и переключением при сбоях (failover).
  2. NEST (HighLevel Client): Это высокоуровневая обертка над LowLevel клиентом. Она добавляет строгую типизацию, маппинг объектов (POCO) в JSON и предоставляет мощный DSL (Domain Specific Language) для написания запросов в стиле LINQ.

Подготовка проекта

Создадим новое консольное приложение и установим необходимые пакеты. Мы будем использовать библиотеку NEST, так как она является стандартом для большинства существующих корпоративных систем.

dotnet new console -n ElasticNestDemo
cd ElasticNestDemo
dotnet add package NEST

Важно: Библиотека NEST (версии 7.x) является наиболее стабильной и функциональной. Для Elasticsearch 8.x существует новый клиент Elastic.Clients.Elasticsearch, но NEST по-прежнему широко используется благодаря своей зрелости.

Работа с LowLevel клиентом (Elasticsearch.Net)

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

LowLevel клиент входит в состав пакета NEST, но его можно использовать отдельно.

Подключение и отправка JSON

using Elasticsearch.Net;

// 1. Настройка соединения
var settings = new ConnectionConfiguration(new Uri("http://localhost:9200"))
    .RequestTimeout(TimeSpan.FromMinutes(2));

var lowLevelClient = new ElasticLowLevelClient(settings);

// 2. JSON документа
var productJson = @"{
    ""name"": ""Смартфон LowLevel"",
    ""price"": 500,
    ""category"": ""electronics""
}";

// 3. Индексация (отправка) документа
// Post<T> возвращает ответ в виде строки или байтов
var indexResponse = await lowLevelClient.IndexAsync<StringResponse>("products", PostData.String(productJson));

if (indexResponse.Success)
{
    Console.WriteLine($"Документ создан! Body: {indexResponse.Body}");
}

Здесь мы вручную указываем имя индекса (products) и передаем сырой JSON. Клиент берет на себя только транспортный уровень.

Работа с HighLevel клиентом (NEST)

В 95% случаев вы будете использовать NEST. Он позволяет работать с Elasticsearch так, будто это обычная коллекция объектов в памяти.

Описание модели (POCO)

Как и в SolrNet, нам нужен класс, отражающий структуру данных. NEST не требует атрибутов, если имена свойств совпадают с полями JSON (с учетом camelCase), но для надежности их можно указать.

using Nest;

[ElasticsearchType(IdProperty = nameof(Id))]
public class Product
{
    public string Id { get; set; }

    [Text(Name = "name")]
    public string Name { get; set; }

    [Number(NumberType.Double, Name = "price")]
    public double Price { get; set; }

    [Keyword(Name = "category")]
    public string Category { get; set; }

    [Date(Name = "created_at")]
    public DateTime CreatedAt { get; set; }
}

Подключение NEST

Настройка соединения в NEST похожа на LowLevel, но используется класс ConnectionSettings.

var settings = new ConnectionSettings(new Uri("http://localhost:9200"))
    .DefaultIndex("products") // Индекс по умолчанию, если не указан явно
    .DisableDirectStreaming(); // Включает возможность читать сырой JSON ответа (полезно для отладки)

var client = new ElasticClient(settings);

Индексация данных

Добавление документа происходит через метод IndexDocumentAsync. NEST сам сериализует объект в JSON.

var product = new Product
{
    Id = "nest-001",
    Name = "Ноутбук NEST Gaming",
    Price = 1500.00,
    Category = "computers",
    CreatedAt = DateTime.UtcNow
};

var response = await client.IndexDocumentAsync(product);

if (response.IsValid)
{
    Console.WriteLine("Документ успешно проиндексирован через NEST.");
}
else
{
    Console.WriteLine($"Ошибка: {response.ServerError.Error.Reason}");
}

Поиск с использованием Fluent API

Самая мощная часть NEST — это Fluent API, который повторяет структуру Query DSL. Вспомним запрос bool / must / filter из прошлой статьи и реализуем его на C#.

Задача: Найти товары категории "computers" (фильтр), в названии которых есть "gaming" (поиск), и отсортировать по цене.

var searchResponse = await client.SearchAsync<Product>(s => s
    .From(0)          // Пагинация: пропустить 0
    .Size(10)         // Пагинация: взять 10
    .Query(q => q
        .Bool(b => b
            .Filter(f => f
                .Term(t => t.Category, "computers") // Строгая фильтрация
            )
            .Must(m => m
                .Match(mt => mt
                    .Field(p => p.Name)
                    .Query("gaming") // Полнотекстовый поиск
                )
            )
        )
    )
    .Sort(sort => sort
        .Descending(p => p.Price)
    )
);

foreach (var item in searchResponse.Documents)
{
    Console.WriteLine($"{item.Name} - {item.Price}$");
}

Обратите внимание на использование лямбда-выражений t => t.Category. Это обеспечивает проверку типов на этапе компиляции. Если вы переименуете свойство в классе Product, код перестанет компилироваться, что защищает от ошибок.

Пагинация

В примере выше мы использовали методы .From() и .Size(). Логика расчета смещения здесь такая же, как и в Solr, и подчиняется формуле:

S=(Npage−1)×NsizeS = (N_{page} - 1) \times N_{size}

Где:

  • SS — значение параметра From (смещение, offset).
  • NpageN_{page} — номер текущей страницы, которую запрашивает пользователь.
  • NsizeN_{size} — количество элементов на одной странице (параметр Size).

Например, для получения 3-й страницы по 20 элементов: S=(3−1)×20=40S = (3 - 1) \times 20 = 40. Мы пропускаем 40 документов.

Агрегации в NEST

Реализуем пример из прошлой статьи: вычисление средней цены по категориям. В JSON это выглядело как вложенность aggs -> terms -> aggs -> avg. В C# структура идентична.

var aggResponse = await client.SearchAsync<Product>(s => s
    .Size(0) // Нам не нужны сами документы
    .Aggregations(a => a
        .Terms("categories_bucket", t => t
            .Field(p => p.Category)
            .Aggregations(childAggs => childAggs
                .Average("average_price", avg => avg
                    .Field(p => p.Price)
                )
            )
        )
    )
);

// Разбор ответа
var categories = aggResponse.Aggregations.Terms("categories_bucket");

foreach (var bucket in categories.Buckets)
{
    // Получаем значение вложенной агрегации
    var avgPrice = bucket.Average("average_price").Value;
    Console.WriteLine($"Категория: {bucket.Key}, Средняя цена: {avgPrice}");
}

Обработка ошибок и отладка

Одна из самых полезных функций NEST — свойство DebugInformation. Если запрос не работает, и вы не понимаете почему, выведите это свойство в консоль.

if (!searchResponse.IsValid)
{
    Console.WriteLine(searchResponse.DebugInformation);
}

Оно покажет:

  1. Успешность запроса.
  2. Оригинальный JSON, который NEST отправил на сервер.
  3. Ответ сервера или текст ошибки (Exception).

Это незаменимый инструмент при разработке сложных запросов.

Сравнение SolrNet и NEST

Теперь, когда мы изучили обе библиотеки, подведем итоги:

Характеристика SolrNet (Solr) NEST (Elasticsearch)
Стиль запросов Объектный (создание экземпляров классов Query) Fluent API (цепочки лямбда-выражений)
Маппинг Атрибуты [SolrField] Автоматический или через атрибуты
Уровень абстракции Средний Очень высокий (скрывает почти весь JSON)
Документация Хорошая, но сообщество меньше Огромное сообщество, отличная документация

Заключение

Мы научились подключать C# к Elasticsearch, используя два подхода: быстрый LowLevel клиент для простых задач и мощный NEST для построения сложных типизированных запросов. Мы перенесли знания о Query DSL и агрегациях из формата JSON в код C#.

В следующей части курса мы займемся продвинутыми темами: настройкой анализаторов текста и реализацией автодополнения (Autocomplete), что является "золотым стандартом" современного поиска.