Elasticsearch и OpenSearch: полнотекстовый поиск
↑ 4.3 Поиск и специализированные БД · → Следующая
Зачем это на собесе
«Как сделать поиск по сайту?» — типичный вопрос. Сильный ответ сначала спрашивает, нужен ли отдельный поисковый движок: часто хватает полнотекстового поиска 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 FTS | Elasticsearch / 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.
Вопросы с ответами
Почему
LIKE '%слово%'плохо подходит для поиска?Он не использует обычный индекс, читает всю таблицу, не понимает морфологию и не ранжирует результаты по релевантности. Полнотекстовые движки строят инвертированный индекс с анализом текста.
Что такое инвертированный индекс?
Структура «слово → список документов, где оно встречается». Запрос разбивается на слова, по ним находятся списки документов и пересекаются.
Чем
filterотличается отmustвbool-запросе?
mustвлияет на оценку релевантности,filterтолько отбирает документы без оценки и хорошо кэшируется. Для точных условий (статус, тег, диапазон дат) используютfilter.
Как синхронизировать поисковый индекс с PostgreSQL?
Через Outbox (событие в одной транзакции, воркер индексирует), CDC (Debezium читает журнал БД) или периодическую пересборку. Двойная запись из приложения приводит к расхождениям при сбоях.
Когда хватит PostgreSQL FTS, а когда нужен Elasticsearch?
FTS достаточно для небольших и средних объёмов и простой релевантности, зато нет отдельной системы. Elasticsearch нужен для больших объёмов, фасетов, подсказок, исправления опечаток, сложной релевантности и горизонтального масштабирования.
Чем OpenSearch отличается от Elasticsearch?
OpenSearch создан как форк Elasticsearch 7.10 с лицензией Apache 2.0 после смены лицензии Elasticsearch в 2021 году. Базовые API совместимы, но продукты развиваются независимо.
Связанные темы
- Полнотекстовый поиск в PostgreSQL: JSONB, массивы, полнотекстовый поиск
- Синхронизация: Transactional Outbox и Inbox
- Выбор хранилища: Polyglot persistence
- Логи в Elastic-стеке: Логи: ELK, Loki