L12. Повнотекстовий і семантичний пошук
Покупці пишуть «чохли», а в каталозі «чохол»; шукають «що почитати малюку», а в описах такого слова немає. Після роботи ви вмієте підключити до PostgreSQL морфологію української, побудувати поруч повнотекстовий і векторний пошуки, злити їх і виміряти, що кожен знаходить, замість того щоб вірити, що «ембединги кращі».
Завдання
Section titled “Завдання”Результат: чотири 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.
Перед початком
Section titled “Перед початком”Прочитайте модуль 20: розділи про морфологію української, GIN над tsvector, HNSW і RRF. Потрібен Docker і близько 3 ГБ на образи. Модель ембедингів
потрібна лише для ваших власних запитів: її образ і ваги (близько 470 МБ) завантажуються з мережі один раз, далі працюють без неї.
./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 шукає файл усередині контейнера.
-
Подивитися, що є. Перегляньте
queriesі кількість релевантних товарів уqrelsдля кількох запитів. Виконайте\dFі переконайтеся, що української конфігурації немає. Порівняйтеto_tsvector('simple', 'чохол чохли чохлів')зto_tsvector('russian', …). Перевірка: ви можете сказати, скільки товарів знаходитьsimpleза запитом «чохли» і скільки за «чохол». -
Повнотекстовий. У каталозі
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по вашому індексу. -
Семантичний. Додайте до
setup.sqlHNSW-індекс за косинусною відстанню (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. -
Гібридний. Візьміть із кожного списку 50–100 кандидатів, дайте їм ранги й додайте
1 / (60 + ранг). Спробуйте іншу вагу повнотекстового списку й порівняйте з рівною. Перевірка: на всьому наборі гібридний не гірший за кращий із двох ваших пошуків. -
Здача.
./labs/l12-search/check.sh(близько хвилини). Кожен пункт, що не пройшов, пояснює причину.
Перевірка
Section titled “Перевірка”./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 жодного рядка, це «НІ» з поясненням і ненульовий код виходу, а не мовчазне «усе гаразд».
Часті помилки
Section titled “Часті помилки”Повнотекстовий нічого не знаходить, хоча словник підключено. У запиті лишились службові слова («з», «від»), які 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 чи відстані.
Апостроф у словах («об’єм»). Парсер розбиває слово по апострофу. На цьому наборі запитів це не заважає, але для живого пошуку варто прибирати апостроф і з документів, і з запитів.
Далі, якщо цікаво
Section titled “Далі, якщо цікаво”Завантажте 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 на запитах набору.