Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Senior: питання на співбесіді з теми «JSON і повнотекстовий пошук»

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

5 питань

Вбудованої української конфігурації в PostgreSQL немає - серед стандартних (english, russian, german, ...) української мови немає й у PostgreSQL 18. Перевірити: SELECT cfgname FROM pg_ts_config;.

Варіанти:

1. Конфігурація simple - без стемінгу й стоп-слів, лише нижній регістр:

to_tsvector('simple', 'Черги в Laravel')   -- 'черги':1 'в':2 'laravel':3

Працює одразу, але «черга» не знайде «черги»: форми слова не зводяться до основи. Частково рятує префіксний пошук ('черг:*').

2. Hunspell-словник для української - справжня нормалізація форм. Файли словника (.dict, .affix, стоп-слова) кладуть у каталог tsearch_data сервера, а потім:

CREATE TEXT SEARCH DICTIONARY ukrainian_hunspell (
    TEMPLATE = ispell, DictFile = uk_ua, AffFile = uk_ua, StopWords = ukrainian
);

CREATE TEXT SEARCH CONFIGURATION ukrainian (COPY = simple);
ALTER TEXT SEARCH CONFIGURATION ukrainian
    ALTER MAPPING FOR word, hword, hword_part
    WITH ukrainian_hunspell, simple;

Потрібен доступ до файлової системи сервера - на керованих базах (RDS, Cloud SQL) це зазвичай неможливо.

3. unaccent і триграми - доповнення: unaccent прибирає діакритику, а pg_trgm знаходить схожі слова й опечатки, частково компенсуючи відсутність стемінгу.

Пастка, на якій ламається пошук кирилицею, - локаль бази. Приведення до нижнього регістру в повнотекстовому пошуку й у lower() залежить від LC_CTYPE бази. З локаллю C (часто так створюють базу в Docker і для тестів) кирилиця не перетворюється на малі літери:

SELECT lower('Черги');                                      -- 'Черги' при LC_CTYPE = C
SELECT to_tsvector('simple', 'Черги') @@ to_tsquery('simple', 'черги');   -- false

Пошук «тихо» не знаходить слів з великої літери. Рішення - створювати базу з UTF-8 локаллю (uk_UA.UTF-8, ICU чи вбудований провайдер C.UTF-8 з PostgreSQL 17).

Реалістичний вибір: для серйозного пошуку українською часто беруть окремий рушій (Meilisearch, Typesense, Elasticsearch з українським аналізатором) - там морфологія, опечатки й ранжування вже налаштовані. PostgreSQL лишається для фільтрів і простого пошуку.

Докладніше в документації: Словники повнотекстового пошуку

Є три способи зберігати пошуковий вектор, і в кожного свої компроміси.

1. Індекс за виразом - нічого не зберігати:

CREATE INDEX posts_fts_idx ON posts USING gin (to_tsvector('english', title || ' ' || body));

SELECT * FROM posts
WHERE to_tsvector('english', title || ' ' || body) @@ to_tsquery('english', 'queue');
  • Не займає місця в таблиці.
  • Вираз у запиті має дослівно збігатися з виразом в індексі (разом з конфігурацією - функція з одним аргументом нестабільна й в індексі не допускається).
  • ts_rank і перевірки збігу обчислюють вектор заново для кожного рядка - повільно.

2. Згенерована колонка - найпростіший надійний варіант:

ALTER TABLE posts ADD COLUMN search tsvector
    GENERATED ALWAYS AS (
        setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
        setweight(to_tsvector('english', coalesce(body, '')), 'B')
    ) STORED;

CREATE INDEX posts_search_idx ON posts USING gin (search);
  • Завжди актуальна, без коду в застосунку.
  • Вектор обчислено заздалегідь - ранжування швидке.
  • Обмеження: лише колонки того самого рядка.

3. Тригер - коли вектор має включати дані з інших таблиць: назви тегів, ім'я автора, назву категорії.

CREATE FUNCTION posts_search_update() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
    NEW.search := setweight(to_tsvector('english', NEW.title), 'A')
               || setweight(to_tsvector('english',
                      coalesce((SELECT string_agg(name, ' ') FROM tags t
                                JOIN post_tag pt ON pt.tag_id = t.id
                                WHERE pt.post_id = NEW.id), '')), 'B');
    RETURN NEW;
END $$;

Мінус - зміна в пов'язаній таблиці (перейменували тег) сама вектор не оновить: потрібні тригери й там або фонове перерахування.

coalesce обов'язковий: to_tsvector(NULL) дає NULL, а NULL || вектор - теж NULL. Один порожній опис - і весь документ випадає з пошуку.

Рекомендація: згенерована колонка за замовчуванням; тригер чи перерахування в черзі - коли потрібні дані з інших таблиць; індекс за виразом - для простих випадків без ранжування.

Докладніше в документації: Таблиці й індекси для пошуку

pg_trgm розбиває рядок на триграми - послідовності з трьох символів: «laravel» → l, la, lar, ara, rav, ave, vel, el . Схожість двох рядків - частка спільних триграм.

CREATE EXTENSION IF NOT EXISTS pg_trgm;

SELECT similarity('laravel', 'laravle');     -- ~0.45
SELECT 'laravle' % 'laravel';                -- true, якщо схожість вища за поріг (0.3)
SELECT word_similarity('ларав', 'Laravel Україна');

Що вміє:

  • Пошук з опечатками: знайти «Хмельницький» за запитом «Хмельницкий».
  • LIKE '%підрядок%' та ILIKE з індексом - головна практична причина ставити розширення.
  • Найближчі за схожістю: ORDER BY name <-> 'запит' LIMIT 10 з GiST-індексом.
CREATE INDEX companies_name_trgm ON companies USING gin (name gin_trgm_ops);

SELECT * FROM companies WHERE name ILIKE '%софт%';       -- тепер з індексом
SELECT * FROM companies WHERE name % 'Епам' ORDER BY similarity(name, 'Епам') DESC;

GIN проти GiST для триграм: GIN швидший на пошук і LIKE, GiST підтримує сортування за відстанню (<->) для «найближчих».

Обмеження:

  • Короткі запити (1-2 символи) майже не мають триграм - індекс не допомагає, а результатів забагато.
  • Розмір індексу великий - кілька триграм на кожен символ тексту. Для довгих текстів (статті) підходить погано; для назв, імен, адрес - добре.
  • Поріг схожості (pg_trgm.similarity_threshold) треба підбирати під дані: занизький дає сміття, зависокий пропускає опечатки.
  • Регістр і локаль: порівняння без урахування регістру залежить від LC_CTYPE бази - з локаллю C кирилиця не зводиться до малих літер.

Типове поєднання: повнотекстовий пошук для документів + pg_trgm для назв, автодоповнення й підказок «можливо, ви мали на увазі».

Докладніше в документації: pg_trgm

Пошуку PostgreSQL зазвичай достатньо, коли:

  • обсяг - тисячі чи мільйони документів, а не сотні мільйонів;
  • потрібен пошук за словами з базовим ранжуванням і фільтрами за звичайними колонками (категорія, дата, статус) - в одному запиті з транзакційними даними;
  • важлива узгодженість: щойно збережений запис одразу знаходиться, без затримки синхронізації;
  • не хочеться підтримувати ще один сервіс, його синхронізацію, бекапи й моніторинг.

Окремий рушій (Meilisearch, Typesense, Elasticsearch/OpenSearch) потрібен, коли:

  • Якість пошуку - частина продукту: опечатки, синоніми, морфологія мов без готових словників (як українська), пошук під час набору, підказки, «можливо, ви мали на увазі».
  • Фасетний пошук з підрахунком кількості для кожного значення фільтра на великих обсягах.
  • Складне ранжування: популярність, свіжість, персоналізація, бусти полів, налаштування релевантності без зміни коду.
  • Обсяг і навантаження: пошукові запити конкурують з основним навантаженням бази, і їх хочеться винести на окремі ресурси.
  • Аналітика логів і агрегати по величезних обсягах тексту (Elasticsearch/OpenSearch).

Ціна окремого рушія:

  • Синхронізація: дані потрапляють в індекс із затримкою; потрібна черга оновлень і переіндексація при зміні схеми. У Laravel це бере на себе Scout, але проблеми розсинхронізації все одно треба відстежувати.
  • Дві системи правди для пошуку й даних: фільтр за правами доступу треба дублювати в індексі.
  • Інфраструктура: пам'ять, бекапи, оновлення, моніторинг.

Поширений проміжний шлях: почати з PostgreSQL (повнотекстовий пошук + pg_trgm) і перейти на рушій, коли з'явиться конкретна вимога, якої база не закриває. Scout з драйвером database дозволяє почати з бази й пізніше змінити драйвер майже без змін у коді.

Докладніше в документації: Повнотекстовий пошук

Семантичний пошук знаходить документи за змістом, а не за збігом слів: запит «як не втратити завдання при падінні воркера» знайде статтю про повторні спроби в чергах, навіть без спільних слів.

Текст перетворюють на вектор-ембединг (масив з сотень чи тисяч чисел) за допомогою моделі (OpenAI, Voyage, локальні моделі). Схожі за змістом тексти мають близькі вектори.

Розширення pgvector додає тип vector і оператори відстані:

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE documents (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    content text NOT NULL,
    embedding vector(1536)
);

-- 5 найближчих за косинусною відстанню
SELECT id, content
FROM documents
ORDER BY embedding <=> $1
LIMIT 5;

Оператори: <-> - евклідова відстань, <=> - косинусна, <#> - від'ємний скалярний добуток.

Індекси для наближеного пошуку (ANN) - точний пошук перебирає всі вектори, на мільйонах це повільно:

CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops);
  • HNSW - швидкий і точний пошук, але повільніша побудова й більше пам'яті.
  • IVFFlat - швидша побудова, менше пам'яті, але потрібні дані для навчання (створювати після наповнення таблиці) і нижча точність.

Наближені індекси можуть пропустити частину справжніх сусідів - точність регулюється параметрами (hnsw.ef_search).

Переваги вектора в PostgreSQL:

  • Векторний пошук в одному запиті з фільтрами (WHERE tenant_id = ? AND published) і транзакційними даними.
  • Немає окремої векторної бази, її синхронізації й бекапів.

Практичні нюанси:

  • Гібридний пошук: поєднання векторного й повнотекстового пошуку (наприклад, через Reciprocal Rank Fusion) зазвичай дає кращі результати, ніж кожен окремо.
  • Фільтри з HNSW: при дуже вибірковому фільтрі індекс може повернути замало результатів - нові версії pgvector мають ітеративне сканування для цього випадку.
  • Ембединги прив'язані до моделі: змінили модель - перераховуйте всі вектори.
  • Розмір: вектор на 1536 значень - ~6 КБ на рядок, плюс індекс.

Докладніше в документації: pgvector