Appearance
ADR-009: Движок полнотекстового поиска
Дата: 2026-01-04
Статус: Принято
Авторы: Backend Team
Контекст
Проект требует поиска товаров по:
- Названию и описанию (полнотекстовый поиск)
- OEM номерам (точное и fuzzy совпадение)
- Фильтрам (марка, модель, категория, цена, город)
- Автодополнению (suggestions)
Требования из ТЗ
Согласно общее.md:
«При вводе данных в поле, появляются подсказки» «Поиск по OEM номеру» «Year/Make/Model фильтры — КРИТИЧЕСКАЯ фича»
Характеристики нагрузки
| Этап | Товаров | Поисковых запросов/день |
|---|---|---|
| MVP (год 1) | 5-20K | 1-5K |
| Рост (год 2) | 50-100K | 10-50K |
| Масштаб (год 3) | 200-500K | 100-500K |
Рассмотренные альтернативы
| Решение | Производительность | Сложность | Стоимость | Функционал |
|---|---|---|---|---|
| PostgreSQL FTS | До 100K | Низкая | $0 | Базовый |
| Elasticsearch | 1M+ | Высокая | RAM-hungry | Полный |
| OpenSearch | 1M+ | Высокая | RAM-hungry | Полный |
| Meilisearch | 500K+ | Средняя | Умеренная | Хороший |
| Typesense | 500K+ | Средняя | Умеренная | Хороший |
| Algolia | Любой | Низкая | Дорого | Отличный |
Решение
Фазовый подход
┌─────────────────────────────────────────────────────────────┐
│ ФАЗА 1: MVP (0-50K товаров) │
│ ══════════════════════════════ │
│ PostgreSQL Full-Text Search + pg_trgm │
│ • Нулевые дополнительные затраты │
│ • Простота поддержки │
│ • Достаточно для старта │
└─────────────────────────────────────────────────────────────┘
│
│ Триггер: >50K товаров ИЛИ
│ p95 поиска > 500ms ИЛИ
│ нужен fuzzy/typo-tolerance
▼
┌─────────────────────────────────────────────────────────────┐
│ ФАЗА 2: Рост (50K-500K товаров) │
│ ═══════════════════════════════ │
│ Meilisearch │
│ • Typo-tolerance из коробки │
│ • Мгновенные результаты (<50ms) │
│ • Простой API │
│ • 2-4 GB RAM достаточно │
└─────────────────────────────────────────────────────────────┘
│
│ Триггер: >500K товаров ИЛИ
│ сложная аналитика ИЛИ
│ geo-search в радиусе
▼
┌─────────────────────────────────────────────────────────────┐
│ ФАЗА 3: Масштаб (500K+ товаров) │
│ ════════════════════════════════ │
│ Elasticsearch / OpenSearch │
│ • Кластеризация │
│ • Сложные агрегации │
│ • Geo-queries │
│ • ML ranking │
└─────────────────────────────────────────────────────────────┘Фаза 1: PostgreSQL Full-Text Search
Почему PostgreSQL для MVP
- Уже есть — не нужна дополнительная инфраструктура
- Достаточно — до 100K записей работает отлично
- Транзакционность — поиск всегда консистентен с данными
- Простота — один источник истины
Настройка русского языка
sql
-- Проверить доступные конфигурации
SELECT cfgname FROM pg_ts_config;
-- Создать конфигурацию для русского (если нет)
CREATE TEXT SEARCH CONFIGURATION russian_hunspell (COPY = russian);
-- Или использовать встроенную
-- russian уже есть в PostgreSQLИндексы для поиска
sql
-- 1. Полнотекстовый индекс на title + description
ALTER TABLE products ADD COLUMN search_vector tsvector;
CREATE INDEX idx_products_search ON products USING GIN(search_vector);
-- Триггер для автообновления
CREATE OR REPLACE FUNCTION products_search_trigger()
RETURNS TRIGGER AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('russian', COALESCE(NEW.title, '')), 'A') ||
setweight(to_tsvector('russian', COALESCE(NEW.description, '')), 'B');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_products_search
BEFORE INSERT OR UPDATE OF title, description ON products
FOR EACH ROW
EXECUTE FUNCTION products_search_trigger();
-- Обновить существующие записи
UPDATE products SET search_vector =
setweight(to_tsvector('russian', COALESCE(title, '')), 'A') ||
setweight(to_tsvector('russian', COALESCE(description, '')), 'B');Fuzzy search для OEM (pg_trgm)
sql
-- Расширение для триграмм (похожие строки)
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- Индекс для fuzzy поиска OEM
CREATE INDEX idx_oem_numbers_trgm ON oem_numbers
USING GIN(oem gin_trgm_ops);
-- Нормализованный OEM для точного поиска
CREATE INDEX idx_oem_normalized ON oem_numbers(
UPPER(REPLACE(REPLACE(oem, '-', ''), ' ', ''))
);Поисковый сервис
typescript
// src/services/search/product-search.service.ts
export class ProductSearchService {
/**
* Полнотекстовый поиск с фильтрами
*/
async search(params: SearchParams): Promise<SearchResult> {
const {
query,
makeId,
modelId,
generationId,
categoryIds,
condition,
steering,
priceMin,
priceMax,
cityId,
page = 1,
perPage = 20,
sort = 'relevance',
} = params;
// Построение запроса
const qb = this.em.createQueryBuilder(Product, 'p')
.select('p.*')
.where({ status: 'active', deleted_at: null });
// Полнотекстовый поиск
if (query) {
const tsQuery = this.buildTsQuery(query);
qb.andWhere(`p.search_vector @@ to_tsquery('russian', ?)`, [tsQuery]);
if (sort === 'relevance') {
qb.addSelect(
`ts_rank(p.search_vector, to_tsquery('russian', ?)) as rank`,
[tsQuery]
);
qb.orderBy({ rank: 'DESC' });
}
}
// Фильтр по совместимости
if (makeId || modelId || generationId) {
qb.leftJoin('p.compatibility', 'pc');
if (makeId) qb.andWhere({ 'pc.make_id': makeId });
if (modelId) qb.andWhere({ 'pc.model_id': modelId });
if (generationId) qb.andWhere({ 'pc.generation_id': generationId });
}
// Фильтр по категориям
if (categoryIds?.length) {
qb.leftJoin('p.categories', 'pcat');
qb.andWhere({ 'pcat.category_id': { $in: categoryIds } });
}
// Остальные фильтры
if (condition) {
// condition теперь через категории типа 'condition'
qb.leftJoin('p.categories', 'cond_cat');
qb.andWhere({ 'cond_cat.category.category_type': 'condition' });
qb.andWhere({ 'cond_cat.category.slug': condition });
}
if (steering) qb.andWhere({ 'p.steering': steering });
if (priceMin) qb.andWhere({ 'p.price': { $gte: priceMin } });
if (priceMax) qb.andWhere({ 'p.price': { $lte: priceMax } });
if (cityId) qb.andWhere({ 'p.city_id': cityId });
// Сортировка
if (sort !== 'relevance') {
const sortMap = {
price_asc: { 'p.price': 'ASC' },
price_desc: { 'p.price': 'DESC' },
date_desc: { 'p.created_at': 'DESC' },
};
qb.orderBy(sortMap[sort] || { 'p.created_at': 'DESC' });
}
// Пагинация
const offset = (page - 1) * perPage;
const [products, total] = await qb
.limit(perPage)
.offset(offset)
.getResultAndCount();
return {
data: products,
meta: {
total,
page,
perPage,
lastPage: Math.ceil(total / perPage),
},
};
}
/**
* Поиск по OEM номеру
*/
async searchByOem(oem: string): Promise<Product[]> {
// Нормализация: убрать пробелы, дефисы, привести к верхнему регистру
const normalized = oem.replace(/[-\s]/g, '').toUpperCase();
// 1. Точное совпадение (быстро)
let products = await this.em.execute(`
SELECT DISTINCT p.*
FROM products p
JOIN product_oem po ON p.id = po.product_id
JOIN oem_numbers o ON po.oem_number_id = o.id
WHERE UPPER(REPLACE(REPLACE(o.oem, '-', ''), ' ', '')) = $1
AND p.status = 'active'
AND p.deleted_at IS NULL
LIMIT 50
`, [normalized]);
if (products.length > 0) {
return products;
}
// 2. Fuzzy поиск через кросс-референсы
products = await this.em.execute(`
WITH input_oem AS (
SELECT id FROM oem_numbers
WHERE UPPER(REPLACE(REPLACE(oem, '-', ''), ' ', '')) = $1
),
related_oems AS (
SELECT id AS oem_id FROM input_oem
UNION
SELECT analog_oem_id FROM oem_cross_references
WHERE original_oem_id IN (SELECT id FROM input_oem)
UNION
SELECT original_oem_id FROM oem_cross_references
WHERE analog_oem_id IN (SELECT id FROM input_oem)
)
SELECT DISTINCT p.*
FROM products p
JOIN product_oem po ON p.id = po.product_id
WHERE po.oem_number_id IN (SELECT oem_id FROM related_oems)
AND p.status = 'active'
AND p.deleted_at IS NULL
LIMIT 50
`, [normalized]);
if (products.length > 0) {
return products;
}
// 3. Триграммный поиск (похожие номера)
return this.em.execute(`
SELECT DISTINCT p.*, similarity(o.oem, $1) as sim
FROM products p
JOIN product_oem po ON p.id = po.product_id
JOIN oem_numbers o ON po.oem_number_id = o.id
WHERE o.oem % $1 -- триграммное сходство
AND p.status = 'active'
AND p.deleted_at IS NULL
ORDER BY sim DESC
LIMIT 20
`, [oem]);
}
/**
* Автодополнение
*/
async suggest(query: string, limit = 10): Promise<Suggestion[]> {
if (query.length < 2) return [];
// Поиск по началу слова в заголовках
const suggestions = await this.em.execute(`
SELECT DISTINCT
p.title,
p.id,
ts_headline('russian', p.title, to_tsquery('russian', $1 || ':*'),
'StartSel=<b>, StopSel=</b>, MaxWords=10') as highlight
FROM products p
WHERE p.search_vector @@ to_tsquery('russian', $1 || ':*')
AND p.status = 'active'
AND p.deleted_at IS NULL
ORDER BY p.views_count DESC
LIMIT $2
`, [this.escapeTsQuery(query), limit]);
return suggestions.map(s => ({
id: s.id,
title: s.title,
highlight: s.highlight,
}));
}
/**
* Экранирование спецсимволов для ts_query
*/
private buildTsQuery(query: string): string {
// Разбить на слова, экранировать, соединить через &
return query
.trim()
.split(/\s+/)
.filter(word => word.length > 1)
.map(word => this.escapeTsQuery(word))
.join(' & ');
}
private escapeTsQuery(word: string): string {
// Убрать спецсимволы PostgreSQL FTS
return word.replace(/[&|!():*'"\\]/g, '');
}
}Производительность PostgreSQL FTS
sql
-- Анализ плана выполнения
EXPLAIN ANALYZE
SELECT p.*, ts_rank(p.search_vector, query) as rank
FROM products p, to_tsquery('russian', 'фара & bmw') query
WHERE p.search_vector @@ query
AND p.status = 'active'
ORDER BY rank DESC
LIMIT 20;
-- Ожидаемые результаты для 20K товаров:
-- Index Scan using idx_products_search: 5-20ms
-- Sort: 1-5ms
-- Total: 10-30ms ✅Фаза 2: Meilisearch (когда PostgreSQL недостаточно)
Триггеры для миграции
- [ ] p95 поиска > 300ms
- [ ] Объём > 50K товаров
- [ ] Нужен typo-tolerance («фра» → «фара»)
- [ ] Нужны фасеты с подсчётом
Почему Meilisearch, а не Elasticsearch
| Критерий | Meilisearch | Elasticsearch |
|---|---|---|
| RAM на 100K docs | 1-2 GB | 4-8 GB |
| Настройка | 5 минут | 2-4 часа |
| Typo-tolerance | Из коробки | Настройка |
| Русский язык | Есть | Есть |
| Лицензия | MIT | SSPL (спорная) |
| Self-hosted | Просто | Сложно |
Архитектура с Meilisearch
┌─────────────────────────────────────────────────────────────┐
│ APPLICATION │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ ProductSearchService ││
│ │ ┌─────────────────┐ ┌─────────────────────────────┐ ││
│ │ │ searchProducts()│ │ indexProduct() / sync │ ││
│ │ └────────┬────────┘ └──────────────┬──────────────┘ ││
│ └───────────┼──────────────────────────┼──────────────────┘│
└──────────────┼──────────────────────────┼───────────────────┘
│ │
▼ ▼
┌────────────────┐ ┌────────────────┐
│ Meilisearch │◄────────│ PostgreSQL │
│ (search) │ sync │ (source of │
│ │ │ truth) │
└────────────────┘ └────────────────┘Настройка Meilisearch
yaml
# docker-compose.yml
services:
meilisearch:
image: getmeili/meilisearch:v1.6
ports:
- "7700:7700"
environment:
MEILI_MASTER_KEY: ${MEILISEARCH_KEY}
MEILI_ENV: production
volumes:
- meili_data:/meili_dataКонфигурация индекса
typescript
// src/services/search/meilisearch-setup.ts
import { MeiliSearch } from 'meilisearch';
const client = new MeiliSearch({
host: process.env.MEILISEARCH_HOST,
apiKey: process.env.MEILISEARCH_KEY,
});
async function setupProductsIndex() {
const index = client.index('products');
// Настройки индекса
await index.updateSettings({
// Поля для поиска (по приоритету)
searchableAttributes: [
'title',
'oem_numbers',
'description',
'category_names',
],
// Поля для фильтрации
filterableAttributes: [
'status',
'make_id',
'model_id',
'generation_id',
'category_ids',
'condition',
'steering',
'price',
'city_id',
'seller_id',
],
// Поля для сортировки
sortableAttributes: [
'price',
'created_at',
'views_count',
],
// Фасеты для подсчёта
faceting: {
maxValuesPerFacet: 100,
},
// Typo tolerance
typoTolerance: {
enabled: true,
minWordSizeForTypos: {
oneTypo: 4,
twoTypos: 8,
},
},
// Синонимы
synonyms: {
'кпп': ['коробка', 'трансмиссия', 'акпп', 'мкпп'],
'бмв': ['bmw'],
'мерседес': ['mercedes', 'мерс'],
'бу': ['б/у', 'подержанный', 'used'],
},
// Стоп-слова (русские)
stopWords: ['и', 'в', 'на', 'для', 'с', 'по', 'от', 'до'],
});
}Синхронизация данных
typescript
// src/services/search/meilisearch-sync.service.ts
export class MeilisearchSyncService {
private index = this.client.index('products');
/**
* Индексация одного товара
*/
async indexProduct(product: Product): Promise<void> {
const document = this.transformProduct(product);
await this.index.addDocuments([document]);
}
/**
* Удаление из индекса
*/
async removeProduct(productId: string): Promise<void> {
await this.index.deleteDocument(productId);
}
/**
* Полная переиндексация (batch)
*/
async reindexAll(): Promise<void> {
const batchSize = 1000;
let offset = 0;
while (true) {
const products = await this.productRepo.findAll({
where: { status: 'active', deleted_at: null },
populate: ['categories', 'compatibility', 'oems'],
limit: batchSize,
offset,
});
if (products.length === 0) break;
const documents = products.map(p => this.transformProduct(p));
await this.index.addDocuments(documents);
offset += batchSize;
console.log(`Indexed ${offset} products...`);
}
}
/**
* Трансформация для Meilisearch
*/
private transformProduct(product: Product): MeiliDocument {
return {
id: product.id,
title: product.title,
description: product.description,
price: product.price,
status: product.status,
steering: product.steering,
created_at: product.created_at.getTime(),
views_count: product.views_count,
// Денормализованные поля для фильтрации
make_id: product.compatibility?.[0]?.make_id,
model_id: product.compatibility?.[0]?.model_id,
generation_id: product.compatibility?.[0]?.generation_id,
category_ids: product.categories.map(c => c.category_id),
category_names: product.categories.map(c => c.category.name),
condition: product.categories.find(c => c.category.category_type === 'condition')?.category.slug,
city_id: product.city_id,
seller_id: product.seller_id,
// OEM номера для поиска
oem_numbers: product.oems.map(o => o.oem_number.oem),
// Для отображения
thumbnail: product.images?.[0]?.thumbnail_url,
seller_name: product.seller.display_name,
};
}
}Подписчик на события
typescript
// src/subscribers/meilisearch-sync.subscriber.ts
export default async function meilisearchSyncSubscriber({
event,
container,
}: SubscriberArgs<{ id: string }>) {
const syncService = container.resolve<MeilisearchSyncService>("meilisearchSyncService");
const productService = container.resolve<ProductService>("productService");
if (event.name === 'product.deleted') {
await syncService.removeProduct(event.data.id);
} else {
const product = await productService.retrieve(event.data.id, {
relations: ['categories', 'compatibility', 'oems', 'images', 'seller'],
});
await syncService.indexProduct(product);
}
}
export const config: SubscriberConfig = {
event: ['product.created', 'product.updated', 'product.deleted'],
};Поисковый сервис (Meilisearch)
typescript
// src/services/search/meilisearch-search.service.ts
export class MeilisearchSearchService {
async search(params: SearchParams): Promise<SearchResult> {
const { query, page = 1, perPage = 20, sort, ...filters } = params;
// Построение фильтров
const filterClauses: string[] = ['status = "active"'];
if (filters.makeId) filterClauses.push(`make_id = "${filters.makeId}"`);
if (filters.modelId) filterClauses.push(`model_id = "${filters.modelId}"`);
if (filters.categoryIds?.length) {
filterClauses.push(`category_ids IN [${filters.categoryIds.map(id => `"${id}"`).join(', ')}]`);
}
if (filters.priceMin) filterClauses.push(`price >= ${filters.priceMin}`);
if (filters.priceMax) filterClauses.push(`price <= ${filters.priceMax}`);
if (filters.cityId) filterClauses.push(`city_id = "${filters.cityId}"`);
if (filters.condition) filterClauses.push(`condition = "${filters.condition}"`);
if (filters.steering) filterClauses.push(`steering = "${filters.steering}"`);
// Сортировка
const sortMap: Record<string, string[]> = {
price_asc: ['price:asc'],
price_desc: ['price:desc'],
date_desc: ['created_at:desc'],
popular: ['views_count:desc'],
};
const response = await this.index.search(query || '', {
filter: filterClauses.join(' AND '),
sort: sortMap[sort] || undefined,
page,
hitsPerPage: perPage,
facets: ['category_ids', 'condition', 'steering', 'make_id'],
attributesToHighlight: ['title', 'description'],
highlightPreTag: '<mark>',
highlightPostTag: '</mark>',
});
return {
data: response.hits,
meta: {
total: response.estimatedTotalHits,
page: response.page,
perPage: response.hitsPerPage,
lastPage: Math.ceil(response.estimatedTotalHits / perPage),
processingTimeMs: response.processingTimeMs,
},
facets: response.facetDistribution,
};
}
}Фаза 3: Elasticsearch (для масштаба)
Когда переходить
- [ ] > 500K товаров
- [ ] Нужны сложные агрегации (статистика по категориям)
- [ ] Geo-search с полигонами
- [ ] ML-based ranking (learning to rank)
- [ ] Несколько команд работают с поиском
Минимальный кластер
yaml
# Для 500K-1M документов
Elasticsearch Cluster:
- 3 master nodes (2 CPU, 4 GB RAM)
- 3 data nodes (4 CPU, 16 GB RAM, 500 GB SSD)
- 2 coordinating nodes (2 CPU, 4 GB RAM)
Стоимость: ~50-80K ₽/мес (Selectel/Yandex Cloud)Сравнительная таблица
| Аспект | PostgreSQL FTS | Meilisearch | Elasticsearch |
|---|---|---|---|
| Объём | до 100K | до 500K | 1M+ |
| Latency | 20-100ms | 5-20ms | 10-50ms |
| Typo-tolerance | ❌ Нет | ✅ Есть | ⚠️ Настройка |
| Синхронизация | Мгновенная | Near real-time | Near real-time |
| RAM на 100K | 0 (shared) | 1-2 GB | 4-8 GB |
| Фасеты | ❌ Вручную | ✅ Есть | ✅ Есть |
| Geo-search | ✅ PostGIS | ⚠️ Базовый | ✅ Полный |
| Сложность | Низкая | Средняя | Высокая |
Рекомендация для MVP
Начинаем с PostgreSQL FTS:
- Нулевые дополнительные затраты
- Простота (один источник данных)
- Достаточно для 5-20K товаров
- Можно мигрировать позже без breaking changes
Код написан с абстракцией — интерфейс SearchService позволяет подменить реализацию.
typescript
// src/services/search/search.interface.ts
export interface ISearchService {
search(params: SearchParams): Promise<SearchResult>;
searchByOem(oem: string): Promise<Product[]>;
suggest(query: string, limit?: number): Promise<Suggestion[]>;
}
// Реализации
export class PostgresSearchService implements ISearchService { ... }
export class MeilisearchSearchService implements ISearchService { ... }
export class ElasticsearchSearchService implements ISearchService { ... }Последствия
Положительные
- Простой старт: PostgreSQL есть, работает
- Гибкость: фазовый подход, миграция без рефакторинга
- Экономия: не платим за Elastic на MVP
- Typo-tolerance: появится с Meilisearch
Отрицательные
- Ограничения PostgreSQL FTS: нет typo-tolerance, фасеты вручную
- Синхронизация: при переходе на внешний движок нужна
- Две системы: после миграции PostgreSQL и Search Engine
Риски и митигация
| Риск | Митигация |
|---|---|
| PostgreSQL FTS медленный | Мониторинг p95, триггер для миграции |
| Рассинхрон данных | Event-based sync, reconciliation job |
| Сложность миграции | Абстракция SearchService, feature flag |
Связанные решения
- ADR-001: ORM — запросы к PostgreSQL
- ADR-004: Паттерны БД — индексы FTS
- ADR-005: Кэширование — кэш результатов поиска