Skip to content

ADR-009: Движок полнотекстового поиска

Дата: 2026-01-04
Статус: Принято
Авторы: Backend Team


Контекст

Проект требует поиска товаров по:

  • Названию и описанию (полнотекстовый поиск)
  • OEM номерам (точное и fuzzy совпадение)
  • Фильтрам (марка, модель, категория, цена, город)
  • Автодополнению (suggestions)

Требования из ТЗ

Согласно общее.md:

«При вводе данных в поле, появляются подсказки» «Поиск по OEM номеру» «Year/Make/Model фильтры — КРИТИЧЕСКАЯ фича»

Характеристики нагрузки

ЭтапТоваровПоисковых запросов/день
MVP (год 1)5-20K1-5K
Рост (год 2)50-100K10-50K
Масштаб (год 3)200-500K100-500K

Рассмотренные альтернативы

РешениеПроизводительностьСложностьСтоимостьФункционал
PostgreSQL FTSДо 100KНизкая$0Базовый
Elasticsearch1M+ВысокаяRAM-hungryПолный
OpenSearch1M+ВысокаяRAM-hungryПолный
Meilisearch500K+СредняяУмереннаяХороший
Typesense500K+СредняяУмереннаяХороший
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                                               │
└─────────────────────────────────────────────────────────────┘

Почему PostgreSQL для MVP

  1. Уже есть — не нужна дополнительная инфраструктура
  2. Достаточно — до 100K записей работает отлично
  3. Транзакционность — поиск всегда консистентен с данными
  4. Простота — один источник истины

Настройка русского языка

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

КритерийMeilisearchElasticsearch
RAM на 100K docs1-2 GB4-8 GB
Настройка5 минут2-4 часа
Typo-toleranceИз коробкиНастройка
Русский языкЕстьЕсть
ЛицензияMITSSPL (спорная)
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 FTSMeilisearchElasticsearch
Объёмдо 100Kдо 500K1M+
Latency20-100ms5-20ms10-50ms
Typo-tolerance❌ Нет✅ Есть⚠️ Настройка
СинхронизацияМгновеннаяNear real-timeNear real-time
RAM на 100K0 (shared)1-2 GB4-8 GB
Фасеты❌ Вручную✅ Есть✅ Есть
Geo-search✅ PostGIS⚠️ Базовый✅ Полный
СложностьНизкаяСредняяВысокая

Рекомендация для MVP

Начинаем с PostgreSQL FTS:

  1. Нулевые дополнительные затраты
  2. Простота (один источник данных)
  3. Достаточно для 5-20K товаров
  4. Можно мигрировать позже без 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

Связанные решения