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

Повнотекстовий і векторний пошук

Покупець шукає в «Крамниці» «чохли». Запит description ILIKE '%чохли%' повертає 0 рядків, хоча на medium (див. Датасет) 786 товарів мають в описі слово «чохол». Каталог написано в однині, а людина пише у множині.

Запит «чохол» знаходить усі 786, але без порядку: поряд із чохлами для телефонів стоять подушки з «чохлом із бавовни». І база читає всю таблицю: на medium це 2760 сторінок, 110–155 мс.

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

Передумови. GIN і ціна індексу на запис: модуль 8. Обчислювані колонки (generated column): модуль 4. EXPLAIN (ANALYZE, BUFFERS): модуль 9. Числа з PostgreSQL 18.6, pgvector 0.8.6, OpenSearch 3.9.0; де розмір не названо, це small (3500 товарів), medium має 35 000.

LIKE '%чохол%' шукає підрядок і має три вади. Він не ранжує: рядок підходить або ні. Він не знає форм слова: «чохли» не містить підрядка «чохол», бо з основи випадає голосна. І він не користується B-деревом, яке впорядковує рядки за початком, а не за серединою. pg_trgm індексує підрядки, але форм слова й ранжування не дає.

Інвертований індекс: слова замість рядків

Section titled “Інвертований індекс: слова замість рядків”

Інвертований індекс зберігає відображення «лексема → список документів, де вона є». Запит читає один список (або перетинає кілька) і не торкається решти документів.

Три документи розбито на лексеми, а індекс зберігає для кожної лексеми список документів, де вона єдокументисловник лексемсписки документів#250Цей чохол від Karpatelстворений для подорожей#74Подушка: чохол із бавовнирозмір 50×70 см#1557Чохол Vivario, чорнийвирізи під камерулемабавовна74камера1557подушка74подорож250чорний1557чохол74, 250, 1557запит «чохли»: нормалізація: чохли → чохолчитається лише цей список
Три документи, нормалізовані лексеми, словник і списки документів. Запит «чохли» спершу стає «чохол» і читає один список.

У PostgreSQL цю роль виконує GIN над tsvector (модуль 8). Перед індексацією текст проходить два кроки. Токенізація розбиває його на слова. Нормалізація зводить форми слова до однієї лексеми: стемінг відрізає закінчення за правилами й дає основу, яка не обов’язково є словом, лематизація шукає початкову форму в словнику. Стоп-слова («для», «від», «і») прибирають: вони є майже в кожному документі й нічим їх не відрізняють. Якість нормалізації вирішує, чи знайде індекс «чохли» за «чохол».

tsvector — відсортований список лексем із позиціями, tsquery — умова над лексемами з &, |, ! і <-> (сусідство); оператор @@ перевіряє відповідність. websearch_to_tsquery читає запит так, як його пише людина: пробіл означає &, лапки задають фразу, - виключає, or дає |. Умова «усі слова» і є причиною, чому «чим зарядити телефон у дорозі» нічого не повертає: жоден опис не містить усіх п’яти слів.

Українська морфологія в PostgreSQL

Section titled “Українська морфологія в PostgreSQL”

У PostgreSQL 18.6 є 30 конфігурацій (\dF): 29 мов і simple. Української серед них немає, найближча russian.

СпробуйPostgreSQLCtrl+Enter — виконати
simple | russian
------------------------------------------+---------------------------------
'чохла':4 'чохли':2 'чохлів':3 'чохол':1 | 'чохл':2,4 'чохлів':3 'чохол':1

simple лише знижує регістр: чотири форми дають чотири лексеми, і «чохли» не знайде «чохол». Стемер russian склеїв «чохли» з «чохла» й залишив «чохол» (голосна, що випадає) та «чохлів» окремими. Стемер іншої мови склеює лише частину форм.

Потрібен словник лем. У tsearch_data стандартного образу лежать лише приклади (hunspell_sample.*), але PostgreSQL читає словники hunspell (.dic з основами, .aff з правилами афіксів) через шаблон ispell. Український словник є в проєкті dict_uk і в LibreOffice (uk_UA, MPL 1.1, 350 тисяч рядків, 9 МБ). Його кладуть у tsearch_data разом зі списком стоп-слів і створюють конфігурацію, де словник стоїть перед simple, щоб невідоме слово (бренд, число) не зникало:

CREATE TEXT SEARCH DICTIONARY uk_hunspell (TEMPLATE = ispell, DictFile = uk_ua, AffFile = uk_ua, StopWords = ukrainian);
CREATE TEXT SEARCH CONFIGURATION uk (COPY = pg_catalog.simple);
ALTER TEXT SEARCH CONFIGURATION uk ALTER MAPPING FOR word, hword, hword_part WITH uk_hunspell, simple;
SELECT w, ts_lexize('uk_hunspell', w) FROM unnest(ARRAY['чохли', 'чохлів', 'зимових', 'Karpatel', 'мила']) AS w;
w | ts_lexize
----------+------------------------------
чохли | {чохол}
чохлів | {чохол}
зимових | {зимовий}
Karpatel |
мила | {мила,милий,мило,милий,мити}

Блок виконується лише в Docker: у пісочниці немає файлів словника, їх у контейнер кладе лабораторна L12. Невідоме слово дає порожній результат, і його підхоплює simple. Словник завантажується в пам’ять сесії при першому зверненні (380–420 мс у новому підключенні), тож пул з’єднань (модуль 6) тут особливо корисний.

Остання пастка: парсер вважає апостроф розділювачем, тож to_tsvector('uk', 'об''єм') дає лексеми 'об' і 'єм', а літера ʼ (U+02BC) лишає слово цілим, але словник її не знає. Просте рішення: прибрати апостроф з тексту й із запиту однаково.

ts_rank впорядковує збіги за частотою лексем у документі, їх позиціями й вагами (setweight, A–D). Він не знає, у скількох документах слово трапляється, і за замовчуванням ігнорує довжину документа:

СпробуйPostgreSQLCtrl+Enter — виконати
product_id | len | rank | rank_len
------------+-----+--------+----------
1396 | 204 | 0.0974 | 0.0200
2014 | 342 | 0.0974 | 0.0168
2066 | 215 | 0.0974 | 0.0190
2317 | 227 | 0.0974 | 0.0188
2984 | 340 | 0.0974 | 0.0170

Описи від 204 до 342 символів мають один ранг. Третій аргумент 1 ділить ранг на логарифм довжини, але це ручне налаштування, а не модель.

Пошукові системи ранжують за BM25: для кожного слова запиту рахують добуток двох множників і додають по словах.

score = Σ IDF(t) · tf / (tf + k1 · (1 − b + b · dl / avgdl))

IDF = log(1 + (N − n + 0,5) / (n + 0,5)) тим більший, чим рідше слово: N документів у колекції, n із цим словом. Другий множник насичується: двадцяте входження майже не додає до першого. Параметр b (типово 0,75) вмикає довжину: слово в короткому описі (dl менша за середню avgdl) важить більше, ніж у довгому, де воно губиться. Типове k1 — 1,2.

OpenSearch показує ці числа в _explain. Для товару 250 і запиту «чохол гарантія»: «чохол» є в n = 88 із N = 3500 описів, IDF = 3,678; «гарантія» в 435, IDF = 2,084. Довжина поля 20 слів проти середніх 26,045 дає множник tf 0,502 для обох, тож внесок «чохла» 3,678 · 0,502 = 1,847, «гарантії» 1,047. Слово, яке рідше, важить майже вдвічі більше, а ts_rank цього не бачить.

Обчислювана колонка (generated column, модуль 4) тримає tsvector готовим, а GIN індексує лексеми. На medium:

ALTER TABLE products ADD COLUMN search_vec tsvector
GENERATED ALWAYS AS (to_tsvector('uk', title || ' ' || description)) STORED;
CREATE INDEX products_search_gin ON products USING gin (search_vec);
EXPLAIN (ANALYZE, BUFFERS) SELECT product_id FROM products
WHERE search_vec @@ websearch_to_tsquery('uk', 'чохли з підвищеними бортиками');
Bitmap Heap Scan on products (actual time=0.063..0.198 rows=75.00 loops=1)
Recheck Cond: (search_vec @@ '''чохол'' & ''підвищений'' & ''бортик'''::tsquery)
Heap Blocks: exact=75
Buffers: shared hit=82
-> Bitmap Index Scan on products_search_gin (actual time=0.047..0.048 rows=75.00 loops=1)
Buffers: shared hit=7
Execution Time: 0.215 ms

Запит розібрано у три лексеми (стоп-слово «з» зникло), 75 рядків і 82 буфери замість 2760, 0,2 мс замість 110–155. Слово «чохол» (786 рядків) читає 753 буфери за 3,0 мс. Для частого «гарантія» (4374 рядки) це вже 3169 буферів: індекс виграє, коли слово рідкісне. Платити доводиться за запис: додавання колонки тривало 14 с (словник нормалізує кожен із 35 000 описів), таблиця виросла з 22 до 45 МБ, а GIN займає 4,2 МБ.

Профіль opensearch кореневого docker-compose.yml піднімає OpenSearch 3.9.0. Ідея та сама (інвертований індекс на файлах Lucene, розподілений по шардах), а аналіз тексту описує аналізатор: токенізатор і ланцюжок фільтрів. Вбудовані аналізатори мов (english, russian, german…) української не мають. Вона є в офіційному плагіні analysis-ukrainian, його ставлять у контейнер і перезапускають вузол (перевірено на 3.9.0):

Terminal window
docker compose --profile opensearch up -d --wait
docker compose exec opensearch bin/opensearch-plugin install --batch analysis-ukrainian
docker compose restart opensearch
curl -s localhost:9200/_analyze -H 'content-type: application/json' --data-binary @- <<'EOF'
{"analyzer": "ukrainian", "text": "Чохли для телефонів, чохлів і чохол. Термобілизна для зимової риболовлі"}
EOF
standard чохли для телефонів чохлів і чохол термобілизна для зимової риболовлі
russian чохл телефонів чохлів і чохол термобілизн зимової риболовлі
ukrainian чохол телефон чохол чохол термобілизна зимовий риболовля

Після docker compose down контейнер створюється заново без плагіна, тож його ставлять знову або збирають власний образ. Індекс з полями text і "analyzer": "ukrainian" завантажують через _bulk; multi_match об’єднує слова через OR і ранжує за BM25. OpenSearch потрібен, коли пошук має жити окремо від бази (власне масштабування, фасети, підказки); ціна — друга система, яку треба синхронізувати з першою. Якщо пошук лише один зі способів прочитати таблицю, tsvector у тій самій транзакції обходиться без синхронізації.

Ембединги і семантичний пошук

Section titled “Ембединги і семантичний пошук”

Ембединг — вектор фіксованої довжини, який модель ставить тексту у відповідність так, що близькі за змістом тексти дають близькі вектори. Тут використано intfloat/multilingual-e5-small (MIT, 384 числа, близько сотні мов, зокрема українська): відкрита модель, що працює на CPU локально через text-embeddings-inference. Ембединги описів small лежать у dataset/embeddings/small.csv.gz (3,3 МБ) із скриптом і описом моделі. Модель просить для запиту префікс query: , для документа passage: .

Близькість міряє косинусна відстань 1 − cos(кут); для нормованих векторів це 1 − скалярний добуток. У pgvector це оператор <=> (<-> дає евклідову, <#> від’ємний скалярний добуток). Запит «чим зарядити телефон у дорозі» не має спільних слів із жодним описом зарядного пристрою:

SELECT p.product_id, p.title, round((e.embedding <=> q.embedding)::numeric, 3) AS dist
FROM query_embeddings AS q, product_embeddings AS e JOIN products AS p USING (product_id)
WHERE q.query_id = 10
ORDER BY e.embedding <=> q.embedding
LIMIT 6;
product_id | title | dist
------------+------------------------------------------+-------
925 | Зарядний пристрій Neveron V952, червоний | 0.165
2978 | Смартфон Zenitro S577, 128 ГБ, білий | 0.167
3303 | Зарядний пристрій Ozerno M603 | 0.168
1116 | Смартфон Sigmatek K59, 256 ГБ, білий | 0.171
1932 | Відеореєстратор Avtomir V526 | 0.171
1261 | Зарядний пристрій Lumenta X155, чорний | 0.172

Блок лише для Docker: у пісочниці немає pgvector, а таблиці query_embeddings і product_embeddings є в лабораторній L12. Три зарядні пристрої знайдено, а смартфони й відеореєстратор (про «телефон» і «дорогу») ні. Відстані лежать у вузькому діапазоні 0,165–0,172: e5-small стискає шкалу, тож порогу «підходить» не поставити, лише порядок.

Вектори помиляються там, де слово вирішує справу. Запит «чохли з підвищеними бортиками» ставить на перше місце берці (відстань 0,144): вектор усереднює зміст опису, і «підвищені» в ньому ближче до взуття, ніж деталь «підвищені бортики» до чохла. Артикул, бренд і число краще знаходить інвертований індекс, перефразований запит краще знаходить вектор.

pgvector: типи й точний пошук

Section titled “pgvector: типи й точний пошук”

vector(384) зберігає числа з одинарною точністю (4 × 384 + 8 = 1544 байтів), halfvec(384) з половинною (776 байтів): для ембедингів різниця в якості не помітна. halfvec індексується до 4000 вимірів, vector до 2000. Без індексу ORDER BY embedding <=> $1 LIMIT 10 порівнює запит з усіма векторами: 7,5 мс на 35 000 векторів, пропорційно кількості. Це точний пошук, але на мільйонах рядків він надто повільний.

Наближений пошук: HNSW і IVFFlat

Section titled “Наближений пошук: HNSW і IVFFlat”

Наближений пошук найближчих сусідів (ANN) може пропустити сусіда. Це плата за швидкість, і її міряє recall: яку частку з десяти справжніх найближчих повернув індекс (recall@10).

HNSW будує граф із шарів. На нижньому лежать усі вектори, кожен зв’язано з m близькими. Верхні містять рідку випадкову частку вузлів і дають довгі стрибки. Пошук входить зверху, жадібно йде до сусіда, ближчого до запиту, спускається шаром нижче й на нижньому тримає список із ef_search кандидатів.

Три шари графа HNSW: на верхньому кілька вузлів, на нижньому всі. Пошук входить зверху й спускається до запитушар 2: кілька вузлів, довгі стрибкишар 1шар 0: усі вектори, найближчі сусідивхідзапитшлях пошуку
Три шари HNSW. Лінія показує шлях: стрибок на верхньому шарі, спуск, а на нижньому крок до найближчого вузла.

Параметри HNSW: m (типово 16) і ef_construction (64) задають під час CREATE INDEX … WITH (…), hnsw.ef_search (40) задає кожна сесія. IVFFlat ділить вектори на lists кластерів і шукає в probes найближчих (типово 1). Він будується швидше й займає менше місця, але потребує даних на момент побудови, а центри кластерів фіксовано, тож після великих змін індекс перебудовують. Recall і час на medium (500 пробних запитів, точні сусіди з послідовного сканування; машина була навантажена, тож порівнюйте відношення):

Конфігурація Побудова Розмір Recall@10 мс на запит
без індексу 1,000 7,49
HNSW m=16, ef_construction=64, ef_search=10 2,2 с 39 МБ 0,860 0,18
те саме, ef_search=40 (типово) 0,980 0,26
те саме, ef_search=100 0,992 0,50
HNSW m=8, ef_construction=32, ef_search=40 0,8 с 34 МБ 0,945 0,18
HNSW m=32, ef_construction=128, ef_search=40 9,0 с 46 МБ 0,999 0,39
IVFFlat lists=200, probes=1 0,9 с 28 МБ 0,924 0,12
те саме, probes=5 0,991 0,28
те саме, probes=100 0,999 4,80

Типовий HNSW у 29 разів швидший за точний пошук і втрачає 2% сусідів; підняти ef_search з 40 до 100 дає ще 1,2% recall майже за вдвічі більший час. Побудова з m=32 виходила за maintenance_work_mem, і PostgreSQL попередив, що це уповільнить її. Дані шаблонні й добре кластеризуються, тож на живому каталозі recall при тих самих параметрах буде нижчою. На small точний пошук займає 1,04 мс, ANN там не потрібен. В обох випадках ORDER BY має бути лише за відстанню: додатковий ключ (, product_id) відбирає в планувальника індекс.

Додамо умову: лише кавоварки й чайники (category_id = 24, 87 із 3500 товарів):

SELECT p.product_id FROM product_embeddings e JOIN products p USING (product_id)
WHERE p.category_id = 24
ORDER BY e.embedding <=> (SELECT embedding FROM query_embeddings WHERE query_id = 18) LIMIT 10;
Limit (actual rows=0.00 loops=1)
-> Nested Loop (actual rows=0.00 loops=1)
-> Index Scan using emb_hnsw on product_embeddings e (actual rows=40.00 loops=1)
Order By: (embedding <=> (InitPlan 1).col1)
-> Index Scan using products_pkey on products p (actual rows=0.00 loops=40)
Filter: (category_id = 24)

Нуль рядків замість десяти й жодної помилки. Індекс повернув ef_search = 40 кандидатів, фільтр викинув усіх, а умову застосовано після сканування. У pgvector 0.8.0 з’явилося ітеративне сканування: коли після фільтра результатів замало, індекс сканує далі, до hnsw.max_scan_tuples (20 000). SET hnsw.iterative_scan = relaxed_order повертає 10 рядків, але індекс обійшов 1045 вузлів: 8435 буферів проти 747 і 9,2 мс проти 2,1. relaxed_order дозволяє трохи порушити порядок за відстанню, strict_order його зберігає. Якщо фільтр лишає менше відсотка рядків, ітерації дорожчають, і тоді краще точний пошук серед відібраних (індекс за умовою) або партиція з окремим індексом.

Оцінки BM25 і відстані мають різні шкали, тож додавати їх не можна. Reciprocal rank fusion (RRF) додає місця: документ отримує Σ 1 / (k + ранг) по кожному списку, де він є, k зазвичай 60 (Cormack, Clarke, Büttcher, 2009).

СпробуйPostgreSQLCtrl+Enter — виконати
product_id | rrf | lists
------------+---------+-----------
250 | 0.03252 | {fts,sem}
2188 | 0.03227 | {fts,sem}
1557 | 0.01613 | {fts}
925 | 0.01587 | {sem}

У справжньому запиті рангами служать row_number() двох упорядкувань, кожне з LIMIT 50–100: з десяти кандидатів документ, що не потрапив у чужу десятку, не встигне збільшити суму. У L12 є 19 запитів з розміткою релевантності за описами. Якість міряє nDCG@10: внесок релевантного товару на місці r дорівнює 1/log2(r + 1), сума ділиться на найкращу можливу. Recall@10 тут не годиться: запит про безпровідну музику має 157 релевантних товарів. Середні на small:

Група запитів simple uk (AND) OpenSearch (OR) pgvector RRF
інша форма слова (7) 0,000 1,000 0,988 0,887 0,984
назва бренду (2) 1,000 1,000 1,000 1,000 1,000
без спільних слів (8) 0,000 0,000 0,488 0,790 0,790
слова збігаються й зміст (2) 0,500 0,931 0,965 0,836 0,931
усі 19 0,158 0,572 0,776 0,853 0,898

Гібридний кращий у середньому, хоча в окремих групах його випереджає хтось один; жоден метод не виграє скрізь. Набір малий, а тексти шаблонні, тож цифри оптимістичніші за живий каталог, і різницю 0,965 проти 0,988 тлумачити не варто. uk із AND нічого не знаходить у групі «без спільних слів»; з OR повнотекстовий хоч щось повертає (OpenSearch має 0,488).

У RAG документи розбивають на фрагменти, рахують ембединги, шукають найближчі до запиту й передають моделі як контекст: архітектуру описано в модулі 14 курсу «Хмарні технології». Фрагменти лежать у таблиці поруч із метаданими, пошук іде тим самим ORDER BY embedding <=> $1. Фільтр за правами доступу застосовується разом з ANN і потрапляє в ту саму пастку: модель отримає менше фрагментів, ніж просили, і не побачить жодної помилки. Спеціалізовані векторні бази мають сенс, коли векторів десятки мільйонів, потрібні розподілені індекси або масштабувати пошук треба окремо від основної бази. Поки вектор лише одна з колонок, його зручніше тримати в PostgreSQL: той самий SQL, транзакції й фільтри. Межу в цифрах не наведено: її міряють на своїх даних.

Усе, крім пісочниць, виконується в Docker. Скрипт лабораторної піднімає PostgreSQL зі словником uk_UA, завантажує ембединги товарів, набір запитів і (за бажанням) сервіс моделі, а embed.sh перетворює ваш запит на вектор, який підставляють у psql змінною qvec:

Terminal window
./labs/l12-search/up.sh embed
V=$(./labs/l12-search/embed.sh "що почитати малюку")
docker compose exec -T postgres psql -U shop -d shop -v qvec="$V" <<'SQL'
SELECT p.title FROM product_embeddings e JOIN products p USING (product_id)
ORDER BY e.embedding <=> :'qvec'::halfvec LIMIT 5;
SQL

Recall індексу міряють проти точного пошуку: спершу десять сусідів із SET enable_indexscan = off (послідовне сканування), потім видача індексу, і порівнюють множини. Ембединги medium рахуються node dataset/embeddings/embed.mjs --scale medium близько 25 хвилин на CPU.

Типові помилки розуміння

Section titled “Типові помилки розуміння”

«simple — це повнотекстовий пошук». Він лише знижує регістр: на наборі L12 знаходить 0 із 7 запитів про форму слова.

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

«Вектори замінять повнотекстовий пошук». Вони розмивають деталі: запит про «підвищені бортики» повернув берці.

«ANN повертає те саме, що точний пошук». З ef_search = 10 recall на medium 0,86. Її міряють проти точного пошуку на своїх запитах.

«WHERE разом з ORDER BY embedding <=> … просто фільтрує». У HNSW умову застосовано після сканування індексу, тож результатів може бути менше за LIMIT, навіть нуль.

Перевір себе

1. Запит `description ILIKE '%чохли%'` на `medium` повернув 0 рядків, хоча в описах є «чохол». Чому?
2. Чому для української мало `simple`, а `russian` допомагає лише частково?
3. Два описи мають по одному входженню слова запиту, але один на 200 слів, другий на 20. Що їх розрізнить за замовчуванням?
4. `WHERE category_id = 24 ORDER BY embedding <=> $1 LIMIT 10` повертає 0 рядків, а план показує `Index Scan using … hnsw`. Що сталося?
5. HNSW із `ef_search = 10` дав recall 0,86, зі `100` — 0,992. Яке твердження правильне?
6. Один список має оцінки BM25 від 0,2 до 14, другий косинусну відстань від 0,14 до 0,18. Як їх злити без вигаданих ваг?

L12. Повнотекстовий і семантичний пошук: три пошуки над товарами small (повнотекстовий зі словником uk_UA, семантичний у pgvector з HNSW і гібридний на RRF) і перевірка на 19 запитах: nDCG@10, recall індексу проти точного пошуку, план виконання.