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/tsvector | MeiliSearch / Typesense | Elasticsearch / OpenSearch |
|---|---|---|---|
| Объём | до ~1M docs комфортно | до ~10M docs | 10M+ без потолка |
| Latency p99 | 50-200ms | 5-50ms | 20-100ms |
| Setup | уже есть, +индекс | один бинарь, час | кластер, неделя |
| Operational cost | ноль (та же БД) | низкий | высокий (heap, JVM, shards) |
| Ranking | tsrank, простой | BM25, typo-tolerant из коробки | BM25 + кастом, function_score |
| Facets / aggregations | можно через GROUP BY | да, нативно | да, мощно |
| Multilang | snowball stemmer | unicode, базовый | плагины, ICU, custom analyzers |
| Synonyms | вручную через словарь | нативно | нативно + multi-word |
| Геопоиск | PostGIS | базовый | сильный |
| Realtime indexing | моментально | секунды | секунды-минуты |
| Reindex cost | блокирует / CONCURRENTLY | rebuild всего индекса | 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'ов:
- Exact match в title → ×10
- Match в title → ×3
- Match в description → ×1
- Recency decay (gauss) → ×0.5..1.0
- 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.enmulti-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 через queue | 100-10K writes/s | +секунды latency, но устойчиво |
| Batch (раз в час reindex) | каталог, slow-changing | Stale данные |
| 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
Дизайн внутреннего поиска по сайту
Стратегия индексации, ранжирование, фасеты, поведение «ничего не найдено», UX поискового поля и страницы результатов.
Cmd+K command bar — спецификация UX
Командная палитра, которая реально ускоряет работу: ranking, секции, shortcuts, recently used, empty state, fuzzy match. Без «поиск с симпатичной анимацией».
Дизайн поискового поля и подсказок
Что в placeholder, как показывать recents, как обрабатывать no-results и did-you-mean, scope-chips. Поиск, который реально находит.