Реалізація Full-Text Search для веб-застосунку

Наша компанія займається розробкою, підтримкою та обслуговуванням сайтів будь-якої складності. Від простих односторінкових сайтів до масштабних кластерних систем, побудованих на мікро сервісах. Досвід розробників підтверджено сертифікатами від вендорів.

Розробка та обслуговування будь-яких видів сайтів:

Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Реалізація Full-Text Search для веб-застосунку
Середня
~2-3 робочих дні
Часті питання

Наші компетенції:

Етапи розробки

Останні роботи

  • image_website-b2b-advance_0.png
    Розробка сайту компанії B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    874
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Розробка веб-сайту для компанії ФІКСПЕР
    851

Реалізація повнотекстового пошуку для веб-додатків

Повнотекстовий пошук — пошук за сенсом слів, а не точним збігом. LIKE '%запит%' не масштабується та не розуміє морфологію: "купити", "купив", "куплений" — різні рядки. FTS обробляє всі три.

PostgreSQL FTS: вбудований варіант

Для більшості проектів вбудований FTS PostgreSQL закриває завдання без зовнішніх сервісів.

Підготовка схеми:

ALTER TABLE products
    ADD COLUMN search_vector TSVECTOR
                             GENERATED ALWAYS AS (
        to_tsvector('russian',
            coalesce(title, '') || ' ' ||
            coalesce(description, '') || ' ' ||
            coalesce(brand, '')
        )
    ) STORED;

CREATE INDEX idx_products_fts ON products USING GIN (search_vector);

GENERATED ALWAYS AS ... STORED — PostgreSQL 12+. Колонка оновлюється автоматично при INSERT/UPDATE, не потрібен триггер.

Для мультимовного пошуку та різних ваг полів:

-- Без GENERATED (гнучка настройка):
UPDATE products SET search_vector =
    setweight(to_tsvector('russian', coalesce(title, '')), 'A') ||
    setweight(to_tsvector('russian', coalesce(brand, '')), 'B') ||
    setweight(to_tsvector('russian', coalesce(description, '')), 'C');

CREATE OR REPLACE FUNCTION products_search_update() RETURNS TRIGGER AS $$
BEGIN
    NEW.search_vector :=
        setweight(to_tsvector('russian', coalesce(NEW.title, '')), 'A') ||
        setweight(to_tsvector('russian', coalesce(NEW.brand, '')), 'B') ||
        setweight(to_tsvector('russian', coalesce(NEW.description, '')), 'C');
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER products_search_trigger
    BEFORE INSERT OR UPDATE ON products
    FOR EACH ROW EXECUTE FUNCTION products_search_update();

Пошук:

-- Простий запит
SELECT id, title,
       ts_rank(search_vector, query)   AS rank,
       ts_headline('russian', description, query,
           'MaxWords=30, MinWords=15, StartSel=<b>, StopSel=</b>'
       ) AS excerpt
FROM products,
     plainto_tsquery('russian', 'бездротові навушники') AS query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT 20;

-- websearch_to_tsquery: підтримує "фрази", -виключення, OR
SELECT id, title
FROM products
WHERE search_vector @@ websearch_to_tsquery('russian', '"бездротові навушники" -дротові')
ORDER BY ts_rank(search_vector, websearch_to_tsquery('russian', '"бездротові навушники" -дротові')) DESC;

ts_headline генерує сніппет із підсвіченими збігами.

Elasticsearch: коли потрібен зовнішній механізм

PostgreSQL FTS обмежен: немає fuzzy search, немає синонімів з коробки, немає агрегацій за фасетами. Якщо потрібно все три — Elasticsearch або OpenSearch.

Схема індексу:

PUT /products
{
  "settings": {
    "analysis": {
      "analyzer": {
        "russian_analyzer": {
          "type": "custom",
          "tokenizer": "standard",
          "filter": ["lowercase", "russian_stop", "russian_stemmer"]
        }
      },
      "filter": {
        "russian_stop": {
          "type": "stop",
          "stopwords": "_russian_"
        },
        "russian_stemmer": {
          "type": "stemmer",
          "language": "russian"
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "title":       { "type": "text", "analyzer": "russian_analyzer", "boost": 3 },
      "brand":       { "type": "text", "analyzer": "russian_analyzer", "boost": 2 },
      "description": { "type": "text", "analyzer": "russian_analyzer" },
      "category_id": { "type": "keyword" },
      "price":       { "type": "double" },
      "status":      { "type": "keyword" },
      "created_at":  { "type": "date" }
    }
  }
}

Пошук із фасетами:

POST /products/_search
{
  "query": {
    "bool": {
      "must": {
        "multi_match": {
          "query": "бездротові навушники",
          "fields": ["title^3", "brand^2", "description"],
          "type": "best_fields",
          "fuzziness": "AUTO"
        }
      },
      "filter": [
        { "term":  { "status": "published" } },
        { "range": { "price": { "gte": 1000, "lte": 15000 } } }
      ]
    }
  },
  "aggs": {
    "by_brand": {
      "terms": { "field": "brand.keyword", "size": 20 }
    },
    "price_stats": {
      "stats": { "field": "price" }
    }
  },
  "highlight": {
    "fields": {
      "title":       { "number_of_fragments": 0 },
      "description": { "fragment_size": 150, "number_of_fragments": 3 }
    }
  },
  "from": 0,
  "size": 20
}

Синхронізація з PostgreSQL:

# Варіант 1: синхронно в сервісі
async def create_product(data: ProductCreate, db: AsyncSession) -> Product:
    product = Product(**data.dict())
    db.add(product)
    await db.flush()          # отримуємо id

    await es.index(
        index='products',
        id=str(product.id),
        document=product_to_es_doc(product),
    )

    await db.commit()
    return product

# Варіант 2: через CDC (Change Data Capture)
# Debezium читає WAL PostgreSQL та публікує события в Kafka
# Consumer підписується та оновлює Elasticsearch

Варіант із CDC надійніший: дані попадуть в ES навіть якщо сервіс упав в момент запису.

Типізований клієнт (Python)

from elasticsearch import AsyncElasticsearch
from pydantic import BaseModel
from typing import Any

class SearchResult(BaseModel):
    id: str
    score: float
    title: str
    price: float
    highlight: dict[str, list[str]] = {}

async def search_products(
    query: str,
    category_id: int | None = None,
    price_min: float | None = None,
    price_max: float | None = None,
    page: int = 1,
    per_page: int = 20,
) -> tuple[list[SearchResult], int]:

    es = AsyncElasticsearch(hosts=['http://localhost:9200'])

    filters: list[dict[str, Any]] = [{"term": {"status": "published"}}]
    if category_id:
        filters.append({"term": {"category_id": category_id}})
    if price_min or price_max:
        filters.append({"range": {"price": {
            **({"gte": price_min} if price_min else {}),
            **({"lte": price_max} if price_max else {}),
        }}})

    body = {
        "query": {
            "bool": {
                "must": {"multi_match": {
                    "query": query,
                    "fields": ["title^3", "brand^2", "description"],
                    "fuzziness": "AUTO",
                }},
                "filter": filters,
            }
        },
        "highlight": {"fields": {"title": {}, "description": {"fragment_size": 150}}},
        "from": (page - 1) * per_page,
        "size": per_page,
    }

    resp = await es.search(index="products", body=body)
    total = resp["hits"]["total"]["value"]
    hits = [
        SearchResult(
            id=h["_id"],
            score=h["_score"],
            highlight=h.get("highlight", {}),
            **h["_source"],
        )
        for h in resp["hits"]["hits"]
    ]
    return hits, total

Вибір рішення

PostgreSQL FTS Elasticsearch/OpenSearch Meilisearch
Настройка Хвилини Години–дні Хвилини
Fuzzy search Через розширення Вбудований Вбудований
Фасети Складно Вбудований Вбудований
Синхронізація Не потрібна CDC або sync CDC або sync
Інфраструктура Вже є +JVM сервер +Go сервер

Графіки

PostgreSQL FTS (триггер, індекс, запити, highlight): 1 день. Elasticsearch з російським анал вер, фасетами та CDC-синхронізацією через Debezium: 3–4 дні.