Elasticsearch и OpenSearch: полнотекстовый поиск

↑ 4.3 Поиск и специализированные БД · → Следующая

Желательно~10 мин чтенияУровень: middle

Зачем это на собесе

«Как сделать поиск по сайту?» — типичный вопрос. Сильный ответ сначала спрашивает, нужен ли отдельный поисковый движок: часто хватает полнотекстового поиска PostgreSQL. Elasticsearch оправдан, когда нужны релевантность, морфология, фасеты, опечатки и большие объёмы.

Подтемы

  • Инвертированный индекс и анализ текста
  • Индекс, документ, шард, реплика
  • Запросы: match, bool, filter, агрегации
  • Синхронизация с основной БД
  • Elasticsearch или OpenSearch
  • Полнотекстовый поиск PostgreSQL как альтернатива

Объяснение

Как устроен поиск

Поиск по LIKE '%слово%' читает всю таблицу и не понимает языка. Поисковые движки строят инвертированный индекс: для каждого слова список документов, где оно встречается. Перед индексацией текст проходит анализатор: разбивка на токены, приведение к нижнему регистру, удаление стоп-слов, стемминг («базы», «базах» → «баз»).

flowchart LR
    A["Документ:<br/>'Как выбрать базу данных'"] --> B["Анализатор:<br/>токены, нижний регистр,<br/>стемминг"]
    B --> C["Инвертированный индекс:<br/>баз → [1, 3]<br/>данн → [1]<br/>выбр → [1]"]
    D["Запрос 'базы данных'"] --> E["Тот же анализатор"] --> C
    C --> F["Совпавшие документы<br/>+ оценка релевантности (BM25)"]

Релевантность считает алгоритм BM25: чем чаще слово в документе и чем оно реже в коллекции, тем выше оценка.

Терминология

ТерминСмыслАналог в SQL
Индекс (index)набор документов одного типатаблица
ДокументJSON-объектстрока
Маппинг (mapping)типы полей и анализаторысхема
Шард (shard)часть индекса, единица масштабированияпартиция
Реплика (replica)копия шарда для отказоустойчивости и чтенияреплика

Данные доступны для поиска не мгновенно: поиск работает в режиме near-real-time, обновление видимости (refresh) по умолчанию раз в секунду.

Запросы

  • match — полнотекстовый по анализируемому полю.
  • term — точное совпадение (для ключевых слов, статусов, тегов).
  • bool — комбинация: must (влияет на оценку), filter (только отбор, кэшируется), should, must_not.
  • Агрегации — группировки и фасеты («сколько документов по категориям»).

Главное правило: это не основная база

Elasticsearch хранит копию данных для поиска. Источник истины остаётся в PostgreSQL. Нет полноценных транзакций и связей, обновления дороже вставок, а потеря индекса лечится пересборкой из основной БД.

Как держать поиск в синхроне

СпособКакМинусы
Двойная запись из приложенияприложение пишет в БД и в индексрасхождения при сбоях
Transactional Outboxсобытие в той же транзакции, воркер индексирует (Outbox)задержка, нужен воркер
CDC (Debezium)изменения читаются из лога БД (CDC)больше инфраструктуры
Периодическая пересборкаполный реиндекс по расписаниюзапаздывание

Elasticsearch или OpenSearch

OpenSearch — форк Elasticsearch 7.10 от AWS под лицензией Apache 2.0, появившийся после смены лицензии Elasticsearch в 2021 году (Elastic License и SSPL; позже Elastic добавил и вариант AGPL). API похожи в базовых сценариях, но со временем расходятся. Клиенты и возможности лучше сверять по версии. Выбирают по лицензии, облаку (у AWS управляемый OpenSearch) и нужным функциям.

PostgreSQL FTS как альтернатива

Для небольших и средних данных встроенный полнотекстовый поиск проще: нет отдельной системы, транзакции и один источник истины.

CREATE TABLE articles (
  id serial PRIMARY KEY, title text, body text,
  tsv tsvector GENERATED ALWAYS AS (
    setweight(to_tsvector('russian', coalesce(title, '')), 'A') ||
    setweight(to_tsvector('russian', coalesce(body,  '')), 'B')) STORED
);
CREATE INDEX ON articles USING gin (tsv);
 
SELECT title, ts_rank(tsv, q) AS rank
FROM articles, websearch_to_tsquery('russian', 'базы данных') q
WHERE tsv @@ q
ORDER BY rank DESC;

websearch_to_tsquery объединяет слова по «И», поэтому строка «Индексы в базах» не найдётся по запросу «базы данных»: слова «данн» там нет. Подробнее: JSONB, массивы, полнотекстовый поиск.

КритерийPostgreSQL FTSElasticsearch / OpenSearch
Операционная сложностьнулевая (уже есть)отдельный кластер
Объём и нагрузкадо миллионов документов комфортномасштабируется горизонтально
Релевантность, опечатки, подсказкибазовыебогатые
Фасеты, агрегацииограниченносильная сторона
Консистентностьтранзакционнаяeventual, near-real-time

Примеры

Маппинг и документ

PUT /articles
{
  "mappings": {
    "properties": {
      "title": { "type": "text", "analyzer": "russian" },
      "body":  { "type": "text", "analyzer": "russian" },
      "tags":  { "type": "keyword" },
      "created_at": { "type": "date" }
    }
  }
}

Поиск: полнотекстовый запрос плюс точный фильтр

GET /articles/_search
{
  "query": {
    "bool": {
      "must":   [ { "match": { "title": "база данных" } } ],
      "filter": [ { "term": { "tags": "db" } } ]
    }
  },
  "size": 10
}

Клиент на .NET

using Elastic.Clients.Elasticsearch;
 
var settings = new ElasticsearchClientSettings(new Uri("http://localhost:9200")).DefaultIndex("articles");
var client = new ElasticsearchClient(settings);
 
public record Article(string Id, string Title, string Body, string[] Tags);
 
await client.IndexAsync(new Article("1", "Как выбрать базу данных", "PostgreSQL или MongoDB?", ["db"]), i => i.Id("1"));
 
var response = await client.SearchAsync<Article>(s => s
    .Query(q => q.Bool(b => b
        .Must(m => m.Match(mm => mm.Field(f => f.Title).Query("база данных")))
        .Filter(f => f.Term(t => t.Field("tags").Value("db")))))
    .Size(10));
 
if (response.IsValidResponse)
    foreach (var hit in response.Hits) Console.WriteLine($"{hit.Score:F2} {hit.Source?.Title}");

Нюансы и подводные камни

  • Поисковый индекс не источник истины. Должна быть возможность полностью пересобрать его из основной БД.
  • Маппинг нельзя менять у существующего поля. Для смены анализатора создают новый индекс и переливают данные (через alias без простоя).
  • Число шардов задаётся при создании. Слишком много мелких шардов тормозят кластер, слишком мало не масштабируются.
  • Глубокая пагинация дорога. from + size на больших смещениях перегружает кластер: используют search_after.
  • Динамический маппинг. Неконтролируемые поля раздувают индекс («mapping explosion»): отключайте или ограничивайте.
  • Память. Кластеру нужна память под кэши и JVM-кучу, не отдавайте её целиком под кучу.
  • Безопасность. Не выставляйте порт 9200 в интернет без аутентификации и TLS.

Вопросы с ответами

Связанные темы