Skip to content
PПромтбук
RUEN
04База данных

Full-text search: pg_trgm vs MeiliSearch vs Elastic

Когда хватит Postgres, когда нужен MeiliSearch, когда только Elastic. Ranking (BM25, custom boost), faceting, multilang, synonyms.

Спроектируй full-text search для {{domain}}. Масштаб: {{scale}}. Языки: {{languages}}.

Базовая аксиома: search — это не LIKE '%query%'. Это отдельная подсистема со своим индексом, ranking-функцией, анализаторами и операционной моделью. Выбор движка определяет следующие 2 года эксплуатации.

1. Выбор движка — матрица решений

КритерийPostgres pg_trgm/tsvectorMeiliSearch / TypesenseElasticsearch / OpenSearch
Объёмдо ~1M docs комфортнодо ~10M docs10M+ без потолка
Latency p9950-200ms5-50ms20-100ms
Setupуже есть, +индексодин бинарь, часкластер, неделя
Operational costноль (та же БД)низкийвысокий (heap, JVM, shards)
Rankingtsrank, простойBM25, typo-tolerant из коробкиBM25 + кастом, function_score
Facets / aggregationsможно через GROUP BYда, нативнода, мощно
Multilangsnowball stemmerunicode, базовыйплагины, ICU, custom analyzers
Synonymsвручную через словарьнативнонативно + multi-word
ГеопоискPostGISбазовыйсильный
Realtime indexingмоментальносекундысекунды-минуты
Reindex costблокирует / CONCURRENTLYrebuild всего индексаrolling, alias swap

Правило большого пальца:

  • < 100K docs, простой поиск, нет бюджета → Postgres
  • 100K-10M, нужен typo-tolerance и instant search → MeiliSearch / Typesense
  • 10M+, сложный ranking, аналитика, логи → Elasticsearch
  • Гибрид: Postgres source-of-truth + Elastic для поиска — нормальный паттерн

2. Postgres FTS: когда хватит

-- tsvector колонка с автообновлением
ALTER TABLE products ADD COLUMN search tsvector
  GENERATED ALWAYS AS (
    setweight(to_tsvector('russian', coalesce(title, '')), 'A') ||
    setweight(to_tsvector('russian', coalesce(description, '')), 'B')
  ) STORED;

CREATE INDEX ON products USING GIN(search);

-- Запрос
SELECT id, title, ts_rank(search, query) AS rank
FROM products, plainto_tsquery('russian', $1) query
WHERE search @@ query
ORDER BY rank DESC LIMIT 20;

Что Postgres даёт:

  • Stemming через snowball (russian, english, etc.)
  • Weighting (A/B/C/D через setweight)
  • Префиксный поиск через :\*
  • Trigram (pg_trgm) для fuzzy / similarity

Что не даёт:

  • Typo-tolerance из коробки (нужен pg_trgm + similarity, и это медленно)
  • BM25 (только tsrank, который проще)
  • Facets без отдельного GROUP BY
  • Synonyms — только через ручной словарь tsearch
  • Хорошее ранжирование multi-field — приходится вручную смешивать

3. MeiliSearch / Typesense — sweet spot

Когда выбирать:

  • Нужен instant search (latency < 50ms)
  • Typo-tolerance критичен (e-commerce, internal search)
  • Команда из 2-5 человек, нет SRE
  • Объём до 10M документов

Что даётся бесплатно:

  • BM25 ranking + typo-tolerance
  • Faceting (фильтры по категориям с counts)
  • Synonyms (iphone = айфон)
  • Highlighting
  • Sort + filter + search в одном запросе

Ограничения:

  • Слабее в кастомном ранжировании (нет function_score как в Elastic)
  • Хуже масштабируется горизонтально
  • Меньше плагинов / интеграций

4. Elasticsearch — когда без вариантов

Сценарии:

  • 10M+ документов
  • Сложное custom ranking (boost по recency × popularity × user_signal)
  • Аналитика поверх поиска (Kibana, dashboards)
  • Логи / метрики (хотя сейчас лучше ClickHouse / Loki)
  • Multi-tenant с per-tenant analyzers

Цена:

  • JVM heap tuning, GC, shards планирование
  • Минимум 3 ноды для prod (master quorum)
  • Версионные миграции через rolling reindex + alias swap
  • Команда минимум 1 человек с опытом

5. Ranking: BM25, tsrank, custom boost

BM25 — стандарт. Учитывает term frequency (TF) и inverse document frequency (IDF) с насыщением (saturation — после 5-го вхождения слова прирост релевантности почти ноль). Доступен в Elastic, MeiliSearch, Typesense из коробки.

Custom boost (Elastic пример):

{
  "function_score": {
    "query": { "match": { "title": "iphone" } },
    "functions": [
      { "filter": { "term": { "in_stock": true } }, "weight": 2.0 },
      { "gauss": { "created_at": { "origin": "now", "scale": "30d" } } },
      { "field_value_factor": { "field": "popularity", "modifier": "log1p" } }
    ],
    "score_mode": "multiply"
  }
}

Иерархия boost'ов:

  1. Exact match в title → ×10
  2. Match в title → ×3
  3. Match в description → ×1
  4. Recency decay (gauss) → ×0.5..1.0
  5. Popularity (log) → ×1..2

Всё это нужно A/B тестировать. Без метрик ranking — это вкусовщина.

6. Faceting (фильтры с counts)

[Категория]
  [x] Электроника (1240)
  [ ] Одежда (892)
  [ ] Книги (340)

[Бренд]
  [x] Apple (412)
  [ ] Samsung (289)
  • Postgres: GROUP BY category, COUNT(\*) после фильтрации — медленно на 1M+ строках
  • MeiliSearch: facets: ["category", "brand"] — встроено
  • Elastic: aggregations: { categories: { terms: { field: "category" } } } — мощно, но прожорливо по памяти

7. Multilang: анализаторы и stemming

Stemming — приведение к корню: бегаю / бежит / бегущий → бег. Без него поиск по бегаю не найдёт бегущий.

// Elastic
"analyzer": {
  "ru_custom": {
    "tokenizer": "standard",
    "filter": ["lowercase", "russian_stop", "russian_stemmer", "icu_folding"]
  }
}

Многоязычные документы:

  • Per-field analyzer: title_ru, title_en с разными analyzers
  • Или title.ru, title.en multi-fields
  • НЕ один analyzer для всех языков — stemming сломается

ICU folding — нормализация диакритики (café → cafe, Müller → muller). Обязательно для европейских языков.

8. Synonyms

iphone, айфон, apple phone
laptop, ноутбук, notebook
ml, machine learning, машинное обучение

Где применять:

  • При индексации: iphone → индекс хранит iphone, айфон, apple phone (раздувает индекс, но запросы быстрее)
  • При запросе: запрос iphone → expand в iphone OR айфон OR apple phone (индекс маленький, запросы медленнее)

Best practice: synonyms при запросе для редко меняющихся, при индексации для горячих.

Multi-word synonyms — отдельная боль. new york → nyc нужно делать на синтаксисе который не сломается на токенизации. В Elastic — synonym_graph filter.

9. Realtime vs batch indexing

ПодходКогдаЦена
Synchronous (запись → индекс)< 100 writes/s, требуется read-your-writeСвязывает запись с поиском
Async через queue100-10K writes/s+секунды latency, но устойчиво
Batch (раз в час reindex)каталог, slow-changingStale данные
CDC (Debezium → search)большой объём, источник истины БДСложная инфра

10. Метрики качества поиска

  • CTR на позицию 1-3 — если < 30%, ranking плохой
  • Zero-result rate — > 10% значит query understanding не работает
  • Reformulation rate — пользователь меняет запрос → плохой результат
  • MRR (Mean Reciprocal Rank) на labeled set — золотой стандарт
  • NDCG@10 — для ranked задач

Без offline eval set из 100-1000 query-document pairs нельзя двигать ranking — улучшения на одних запросах ломают другие.

Anti-patterns

  • WHERE title LIKE '%query%' на миллионе строк — full scan, секунды
  • ❌ Elasticsearch для 50K документов — overkill, операционная нагрузка не оправдана
  • ❌ pg_trgm как primary search на 10M строк — similarity медленный, GIN не спасает
  • ❌ Один analyzer для русского и английского — stemming сломан для обоих
  • ❌ Менять ranking без offline eval — кому-то полегчало, кому-то стало хуже, метрики не покажут
  • ❌ Synonyms списком на 10K записей без тестов — apple → fruit ломает search по iPhone
  • ❌ Реиндексация Elastic через DELETE + CREATE на проде — даунтайм, потерянные доки
  • ❌ Synchronous reindex при каждом UPDATE — связывает запись с поиском, search down кладёт запись
  • ❌ Хранить source-of-truth в Elastic — несогласованность, нет транзакций, потеря данных при reindex
  • ❌ Игнорировать highlighting и snippets — пользователь не понимает почему этот документ нашёлся
  • ❌ Facets с unbounded cardinality (user_id как facet) — память OOM

На выходе

  • Выбор движка с обоснованием по матрице (объём, latency, бюджет, команда)
  • Schema / mapping с analyzer per language
  • Ranking конфиг (BM25 baseline + 2-3 boost-функции)
  • Indexing pipeline (sync / async / CDC) с обоснованием
  • Synonyms словарь стартовый (топ-50 для домена)
  • Eval set: 100 query-document pairs для regression-тестов
  • Дашборд: CTR@3, zero-result rate, latency p50/p99
  • Runbook: что делать когда поиск медленный / выдаёт мусор / down
К подразделу «База данных»
Похожие промты