Реализация поиска: PG full-text vs MeiliSearch vs Algolia vs Elasticsearch
Критерии выбора engine, индексация, ranking, faceting, multi-language, как мерить релевантность.
Действуй как senior search engineer. Выбери и спроектируй search для корпуса {{corpus_size}}, паттерны запросов {{query_patterns}}, языки {{languages}}.
1. Decision matrix: какой engine
| Engine | Брать когда | НЕ брать когда |
|---|---|---|
PostgreSQL FTS (tsvector/tsquery) | < 1M документов, простые запросы, уже есть PG, нет typo tolerance | Нужен typo tolerance / faceting / instant search / много языков с native ranking |
| MeiliSearch | 100K-50M, typo tolerance из коробки, нужен self-hosted, простая операция | Сложная агрегация / nested / vector search крупного масштаба |
| Typesense | Аналог Meili, чуть лучше с faceting, instant search | То же что Meili |
| Algolia | < 50M, нужно "просто работает", готовы платить, prefix-search первоклассный, instant search | $$$ при росте; data sovereignty (US-hosted); ограничения на сложные queries |
| Elasticsearch / OpenSearch | > 10M, сложная агрегация, нужна гибкость scoring, log analytics одновременно | Маленький проект (операционная сложность непропорциональна) |
| Vector DB (pgvector / Qdrant / Pinecone) | Semantic search, RAG, embeddings | Lexical/keyword search в чистом виде |
Гибрид (recommended на средних масштабах): PG для structured filtering + dedicated search engine для full-text. Или Meili + pgvector для lexical + semantic.
2. Что измерить ДО выбора
Не выбирай engine "по ощущениям". Замерь:
- Object count сейчас и через 12 месяцев (с темпом роста).
- Avg document size — 200 байт title? 50KB body? меняет storage план.
- Query QPS — 10/s? 10000/s? влияет на need в шардинге.
- p95 latency target — instant search хочет < 50ms; "найти и подождать" терпит 500ms.
- % queries с typo — если > 10%, FTS без typo tolerance даст плохой UX.
- Index update lag tolerance — real-time (< 1s) или eventual (несколько минут)?
3. Индексация
Стратегии
- Live indexing (write-through): любой write в primary store → событие в search engine. Через outbox pattern + worker, не из request thread.
- Batch reindex (eventual): cron / job каждые N минут читает изменённое (
updated_at > last_seen) и upsert'ит в индекс. Проще, но lag. - Full reindex — для schema changes / новых полей. Должен быть online (новый индекс параллельно, alias swap), не downtime.
Что хранить в индексе
- Searchable fields — text, отдельно по полям с весами (title × 3, body × 1).
- Filter fields —
status,category_id,priceдля facets / refinements. - Display fields — то что нужно показать в результатах без обращения в primary DB (snippet, thumbnail_url). Денормализация — это норма для search index.
- Sort fields —
created_at,popularity. Не сортируй на лету по непроиндексированному полю.
Atomic reindex без downtime
1. Create index v2 (новая schema / analyzer)
2. Backfill: stream все docs из primary DB → v2
3. Live writes идут в **оба** v1 и v2
4. Когда v2 caught up — swap alias `current → v2`
5. Stop writes в v1, drop v1 через N часов
4. Ranking
Дефолтный BM25 — хороший старт, но почти всегда надо bias'ить:
- Field weights —
title^3, tags^2, body^1. - Function score / boosting — recent (decay function по
created_at), popular (views), premium content (boost: 1.5). - Personalization (если есть): user history → re-rank top-N после initial retrieval, не в самом BM25.
Не оптимизируй ranking на основе ощущений. См. п. 7 — мерь.
5. Faceting
- Counts on filtered set: "Brands (12), Categories (8)" обновляются по результатам текущего фильтра.
- Multi-select within facet — OR внутри, AND между (
brand IN (Nike, Adidas) AND price < 100). - Facet limit — top-N по count, "show more" подгружает остальное (не присылай 50K брендов сразу).
PG FTS — facets надо считать отдельным query, медленно. Meili/Algolia/ES — нативно быстро.
6. Multi-language
- Per-language analyzer — stemming / stopwords специфичны для языка. Английский Porter stemmer на русском тексте даёт мусор.
- Detection vs тэгинг — храни
langполе явно (определи при загрузке через franc / cld3), не пытайся определять на лету. - Стратегии индекса:
- One index per language — чисто, легко тюнить, но запрос "поверх языков" сложнее.
- Single index, multi-field —
title_ru,title_enс разными analyzer'ами в одном документе. Проще операционно.
- Кросс-язычный поиск — embeddings (semantic): "running shoes" находит "беговые кроссовки". Lexical здесь не работает.
7. Как мерить релевантность
Без измерения — нет работы над ranking, есть гадание.
Offline (готовая разметка)
- Набор (query, expected top results) — 50-200 пар, размеченных вручную или из логов "что юзер кликнул".
- Метрики: NDCG@10, MRR, Recall@K, Precision@K.
- Запускай после каждого изменения ranking → не ушли ли метрики вниз.
Online (на живых пользователях)
- CTR на позицию —
clicks_at_position_i / impressions. Если CTR на топ-3 падает после изменения — стало хуже. - Zero-result rate — % запросов с 0 результатами. > 10% — проблема (либо корпус мал, либо typo handling плох).
- Refinement / abandonment rate — юзер переформулировал / ушёл без клика. Высокий — релевантность плоха.
- A/B-тест для крупных изменений ranking — двух недель достаточно для значимых сигналов.
Quality канарейка
- Smoke set из ~20 "контрольных" queries прогоняется на каждый deploy. Если "best coffee maker" перестал возвращать ваш топ-product — alert.
8. Operational concerns
- Backup / restore time — снапшоты индекса. Для ES — snapshot to S3. Для Meili — file copy. Тест restore раз в квартал.
- Index size budget — индекс обычно 30-100% от primary data. Запланируй disk + monitoring.
- Hot data vs cold — для > 100M доков — partition по времени (ES ILM).
Формат вывода
## Choice
**Engine:** <chosen>
**Why:** <2-3 bullets tied to corpus_size / query_patterns / languages>
**Alternatives considered:** <table compact>
## Index schema
```json
{ ... }
Ranking config
- Field weights: ...
- Boosts: ...
Indexing strategy
<live / batch / hybrid, latency target>
Quality measurement plan
- Offline set: <N queries, кто размечает, какие метрики>
- Online: <CTR @position, zero-result rate, A/B>
Migration plan (если меняем с существующего)
<5-7 шагов с rollback>
## Anti-patterns
- ❌ Elasticsearch для < 100K документов — оверкилл, ops cost > benefit.
- ❌ PG FTS для > 10M + typo tolerance — будет медленно и плохо.
- ❌ `LIKE '%query%'` как "поиск" в production — full table scan каждый раз.
- ❌ Reindex с downtime — пользователи видят пустоту во время миграции.
- ❌ Без quality measurement — каждое изменение ranking — лотерея, регрессии незаметны.
- ❌ Same analyzer для всех языков — русский ищется как набор латинских символов.
- ❌ Real-time index updates через synchronous write из request thread — write latency растёт + search падает с primary.
- ❌ Хранить только id в индексе и hydrate из БД для каждого hit — теряешь весь смысл search engine.
- ❌ Без zero-result monitoring — % "пусто" растёт незаметно, юзеры уходят.
- ❌ A/B-тест ranking на 100 юзерах — стат-незначимо, шум.
Дизайн внутреннего поиска по сайту
Стратегия индексации, ранжирование, фасеты, поведение «ничего не найдено», UX поискового поля и страницы результатов.
Cmd+K command bar — спецификация UX
Командная палитра, которая реально ускоряет работу: ranking, секции, shortcuts, recently used, empty state, fuzzy match. Без «поиск с симпатичной анимацией».
Дизайн поискового поля и подсказок
Что в placeholder, как показывать recents, как обрабатывать no-results и did-you-mean, scope-chips. Поиск, который реально находит.