Перейти до вмісту

L12. Повнотекстовий і семантичний пошук

просунутийспирається на модуль 20

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

Результат: чотири SQL-файли в labs/l12-search/solution/.

  • setup.sql виконується один раз на свіжому датасеті розміру small (Датасет). Тут словник і конфігурація повнотекстового пошуку, колонка tsvector, GIN-індекс і ANN-індекс для векторів.
  • fts.sql повертає до 10 значень product_id (одна колонка, найкращий першим) для тексту запиту, що приходить у змінній :q.
  • semantic.sql повертає до 10 найближчих за змістом product_id для ембединга запиту в змінній :qvec (літерал halfvec).
  • hybrid.sql те саме для обох змінних: злиття двох списків за RRF.

Готово, коли ./labs/l12-search/check.sh друкує усе гаразд: 11 перевірок. Перевірка застосовує setup.sql до чистої копії датасету, проганяє три пошуки по 19 запитах набору й 100 пробних векторах і рахує метрики. Критерій релевантності й склад набору описано в labs/l12-search/README.md, а метрики пояснено нижче.

Чого робити не треба. Змінювати дані й набір запитів; рахувати ембединги описів (вони лежать у dataset/embeddings/small.csv.gz); викликати модель із файлів *.sql: перевірка працює без неї, ембединги запитів набору вже в data/query_embeddings.csv.

Прочитайте модуль 20: розділи про морфологію української, GIN над tsvector, HNSW і RRF. Потрібен Docker і близько 3 ГБ на образи. Модель ембедингів потрібна лише для ваших власних запитів: її образ і ваги (близько 470 МБ) завантажуються з мережі один раз, далі працюють без неї.

Terminal window
./labs/l12-search/up.sh # PostgreSQL зі словником uk_UA, ембединги товарів і набір запитів у базі shop
./labs/l12-search/up.sh embed # те саме плюс сервіс ембедингів (за бажанням)
./labs/l12-search/psql.sh # psql у базі shop

Скрипт створює в shop таблиці product_embeddings (ембединги описів, halfvec(384)), queries, qrels (розмітка) і query_embeddings. Якщо ви запускаєте compose під власною назвою проєкту: COMPOSE_PROJECT_NAME=моя-назва ./labs/l12-search/check.sh. SQL-файли з хоста передавайте через stdin (psql.sh < solution/fts.sql): psql -f шукає файл усередині контейнера.

  1. Подивитися, що є. Перегляньте queries і кількість релевантних товарів у qrels для кількох запитів. Виконайте \dF і переконайтеся, що української конфігурації немає. Порівняйте to_tsvector('simple', 'чохол чохли чохлів') з to_tsvector('russian', …). Перевірка: ви можете сказати, скільки товарів знаходить simple за запитом «чохли» і скільки за «чохол».

  2. Повнотекстовий. У каталозі labs/l12-search/tsearch/ лежать файли словника, у контейнері вони вже змонтовані в tsearch_data як uk_ua (словник і афікси) і ukrainian (стоп-слова). Створіть словник (шаблон ispell), конфігурацію зі словником і запасним simple, обчислювану колонку (generated column) tsvector над назвою й описом та GIN-індекс. Запишіть це в solution/setup.sql, виконайте на вашій shop і напишіть fts.sql із websearch_to_tsquery і ts_rank. Перевірка: ./labs/l12-search/psql.sh -v q='чохли з підвищеними бортиками' < labs/l12-search/solution/fts.sql повертає сім товарів, а EXPLAIN запиту з SET enable_seqscan = off показує Bitmap Index Scan по вашому індексу.

  3. Семантичний. Додайте до setup.sql HNSW-індекс за косинусною відстанню (halfvec_cosine_ops) і напишіть semantic.sql з оператором <=>. Візьміть вектор вашого запиту: ./labs/l12-search/embed.sh "що почитати малюку" (потрібен up.sh embed) або з query_embeddings. Поміряйте recall: порівняйте видачу з HNSW і з точним пошуком (SET enable_indexscan = off) для кількох векторів, змінюючи hnsw.ef_search. Перевірка: у плані Index Scan using … hnsw, recall не нижчий за 0,95.

  4. Гібридний. Візьміть із кожного списку 50–100 кандидатів, дайте їм ранги й додайте 1 / (60 + ранг). Спробуйте іншу вагу повнотекстового списку й порівняйте з рівною. Перевірка: на всьому наборі гібридний не гірший за кращий із двох ваших пошуків.

  5. Здача. ./labs/l12-search/check.sh (близько хвилини). Кожен пункт, що не пройшов, пояснює причину.

Terminal window
./labs/l12-search/check.sh # solution/
./labs/l12-search/check.sh інший/каталог

Перевірка створює в вашому контейнері допоміжні бази l12_base (датасет, ембединги, набір запитів, один раз) і l12_run (свіжа копія на кожну перевірку) і нічого не змінює в shop. Вона виконує setup.sql на чистій копії, тож усе, чого потребують ваші файли, мусить бути в ньому. Що перевіряється:

  • Повнотекстовий: запит може скористатися GIN-індексом (план із вимкненим послідовним скануванням); середній nDCG@10 на запитах з іншою формою слова ≥ 0,80 і на запитах із назвою бренду ≥ 0,90.
  • Семантичний: у плані ANN-індекс (HNSW чи IVFFlat); recall@10 проти точного пошуку на 100 пробних векторах ≥ 0,95; nDCG@10 на запитах без спільних слів ≥ 0,55.
  • Гібридний: на всьому наборі nDCG@10 не гірший за кращий із двох ваших пошуків (допуск 0,01).

Метрика nDCG@10 нормує внесок релевантних товарів на їх місце в першій десятці й на найкращу можливу видачу. Recall@10 тут не підходить: за запитом «слухати музику без проводів» релевантних 157 товарів, і навіть ідеальна видача мала б recall@10 0,06. Окремо міряється recall індексу проти точного пошуку, бо це питання не якості пошуку, а точності індексу. У кінці перевірка друкує таблицю nDCG за групами запитів.

Результат дає лише те, що можна виміряти: якщо check.sh не отримав від контейнера або psql жодного рядка, це «НІ» з поясненням і ненульовий код виходу, а не мовчазне «усе гаразд».

Повнотекстовий нічого не знаходить, хоча словник підключено. У запиті лишились службові слова («з», «від»), які websearch_to_tsquery вимагає від кожного документа. Додайте стоп-слова (StopWords = ukrainian).

setup.sql падає на «could not open dictionary file». Файли словника змонтовано в контейнер із docker compose лабораторії; якщо ви піднімали базу кореневим docker-compose.yml без up.sh, їх там немає.

HNSW є, а в плані Seq Scan. Спробуйте з SET enable_seqscan = off: це показує, що запит МОЖЕ скористатися індексом. Якщо й тоді Seq Scan, у ORDER BY стоїть щось поза відстанню (, product_id), або оператор не відповідає класу індексу (<=> потребує halfvec_cosine_ops).

Recall нижчий за 0,95. За замовчуванням hnsw.ef_search = 40, тож причина, найімовірніше, в значенні, яке ви поставили в semantic.sql. Збільшуйте його, поки recall не стане таким, як потрібно, і дивіться на час. Для IVFFlat те саме з ivfflat.probes.

Гібридний гірший за повнотекстовий або семантичний. Беріть із кожного списку більше десяти кандидатів, а ранг рахуйте row_number() по впорядкованих списках, а не по значеннях ts_rank чи відстані.

Апостроф у словах («об’єм»). Парсер розбиває слово по апострофу. На цьому наборі запитів це не заважає, але для живого пошуку варто прибирати апостроф і з документів, і з запитів.

Завантажте medium (див. setup/README.md), рахуйте ембединги (node dataset/embeddings/embed.mjs --scale medium, близько 25 хвилин) і повторіть вимірювання recall для різних m, ef_construction і lists. Додайте фільтр за ціною до семантичного пошуку й подивіться, коли без hnsw.iterative_scan результатів стає менше десяти. Піднімайте профіль opensearch, ставте analysis-ukrainian і порівняйте його ранжування з вашим ts_rank на запитах набору.