Повнотекстовий і векторний пошук
Навіщо це
Section titled “Навіщо це”Покупець шукає в «Крамниці» «чохли». Запит 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
Section titled “Чому не LIKE”LIKE '%чохол%' шукає підрядок і має три вади. Він не ранжує: рядок підходить або ні. Він не знає форм слова: «чохли» не містить
підрядка «чохол», бо з основи випадає голосна. І він не користується B-деревом, яке впорядковує рядки за початком, а не за серединою.
pg_trgm індексує підрядки, але форм слова й ранжування не дає.
Інвертований індекс: слова замість рядків
Section titled “Інвертований індекс: слова замість рядків”Інвертований індекс зберігає відображення «лексема → список документів, де вона є». Запит читає один список (або перетинає кілька) і не торкається решти документів.
У PostgreSQL цю роль виконує GIN над tsvector (модуль 8). Перед індексацією текст проходить два кроки. Токенізація розбиває його
на слова. Нормалізація зводить форми слова до однієї лексеми: стемінг відрізає закінчення за правилами й дає основу, яка не
обов’язково є словом, лематизація шукає початкову форму в словнику. Стоп-слова («для», «від», «і») прибирають: вони є майже
в кожному документі й нічим їх не відрізняють. Якість нормалізації вирішує, чи знайде індекс «чохли» за «чохол».
tsvector і tsquery
Section titled “tsvector і tsquery”tsvector — відсортований список лексем із позиціями, tsquery — умова над лексемами з &, |, ! і <-> (сусідство);
оператор @@ перевіряє відповідність. websearch_to_tsquery читає запит так, як його пише людина: пробіл означає &, лапки задають
фразу, - виключає, or дає |. Умова «усі слова» і є причиною, чому «чим зарядити телефон у дорозі» нічого не повертає: жоден опис не містить усіх п’яти слів.
Українська морфологія в PostgreSQL
Section titled “Українська морфологія в PostgreSQL”У PostgreSQL 18.6 є 30 конфігурацій (\dF): 29 мов і simple. Української серед них немає, найближча russian.
simple | russian------------------------------------------+--------------------------------- 'чохла':4 'чохли':2 'чохлів':3 'чохол':1 | 'чохл':2,4 'чохлів':3 'чохол':1simple лише знижує регістр: чотири форми дають чотири лексеми, і «чохли» не знайде «чохол». Стемер 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
Section titled “Ранжування: ts_rank”ts_rank впорядковує збіги за частотою лексем у документі, їх позиціями й вагами (setweight, A–D). Він не знає, у скількох документах
слово трапляється, і за замовчуванням ігнорує довжину документа:
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 цього не бачить.
GIN над tsvector
Section titled “GIN над tsvector”Обчислювана колонка (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 productsWHERE 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
Section titled “OpenSearch”Профіль opensearch кореневого docker-compose.yml піднімає OpenSearch 3.9.0. Ідея та сама (інвертований індекс на файлах Lucene, розподілений по шардах),
а аналіз тексту описує аналізатор: токенізатор і ланцюжок фільтрів. Вбудовані аналізатори мов (english, russian, german…) української не мають.
Вона є в офіційному плагіні analysis-ukrainian, його ставлять у контейнер і перезапускають вузол (перевірено на 3.9.0):
docker compose --profile opensearch up -d --waitdocker compose exec opensearch bin/opensearch-plugin install --batch analysis-ukrainiandocker compose restart opensearchcurl -s localhost:9200/_analyze -H 'content-type: application/json' --data-binary @- <<'EOF'{"analyzer": "ukrainian", "text": "Чохли для телефонів, чохлів і чохол. Термобілизна для зимової риболовлі"}EOFstandard чохли для телефонів чохлів і чохол термобілизна для зимової риболовлі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 distFROM query_embeddings AS q, product_embeddings AS e JOIN products AS p USING (product_id)WHERE q.query_id = 10ORDER BY e.embedding <=> q.embeddingLIMIT 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: 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) відбирає в планувальника індекс.
Фільтр і ANN
Section titled “Фільтр і ANN”Додамо умову: лише кавоварки й чайники (category_id = 24, 87 із 3500 товарів):
SELECT p.product_id FROM product_embeddings e JOIN products p USING (product_id)WHERE p.category_id = 24ORDER 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 його зберігає. Якщо фільтр лишає менше відсотка рядків, ітерації дорожчають, і тоді краще точний пошук серед відібраних (індекс за умовою) або партиція з окремим індексом.
Гібридний пошук
Section titled “Гібридний пошук”Оцінки BM25 і відстані мають різні шкали, тож додавати їх не можна. Reciprocal rank fusion (RRF) додає місця: документ отримує Σ 1 / (k + ранг) по кожному списку,
де він є, k зазвичай 60 (Cormack, Clarke, Büttcher, 2009).
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 і векторні бази
Section titled “RAG і векторні бази”У RAG документи розбивають на фрагменти, рахують ембединги, шукають найближчі до запиту й передають моделі як контекст: архітектуру описано в
модулі 14 курсу «Хмарні технології». Фрагменти лежать у таблиці поруч із метаданими, пошук іде тим самим
ORDER BY embedding <=> $1. Фільтр за правами доступу застосовується разом з ANN і потрапляє в ту саму пастку: модель отримає менше фрагментів, ніж просили, і не побачить жодної помилки.
Спеціалізовані векторні бази мають сенс, коли векторів десятки мільйонів, потрібні розподілені індекси або масштабувати пошук треба окремо від основної бази. Поки вектор лише одна з колонок, його зручніше тримати
в PostgreSQL: той самий SQL, транзакції й фільтри. Межу в цифрах не наведено: її міряють на своїх даних.
Як це насправді
Section titled “Як це насправді”Усе, крім пісочниць, виконується в Docker. Скрипт лабораторної піднімає PostgreSQL зі словником uk_UA, завантажує ембединги товарів, набір запитів і (за бажанням) сервіс моделі,
а embed.sh перетворює ваш запит на вектор, який підставляють у psql змінною qvec:
./labs/l12-search/up.sh embedV=$(./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;SQLRecall індексу міряють проти точного пошуку: спершу десять сусідів із 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, навіть нуль.
Перевір себе
Лабораторна
Section titled “Лабораторна”L12. Повнотекстовий і семантичний пошук: три пошуки над товарами small (повнотекстовий зі словником uk_UA, семантичний у pgvector з HNSW і гібридний на RRF) і перевірка на 19 запитах: nDCG@10, recall індексу проти точного пошуку, план виконання.
Джерела
Section titled “Джерела”- PostgreSQL 18, розділ 12 «Full Text Search»: словники,
ispell, ранжування, GIN. - pgvector, README і CHANGELOG: типи, оператори, HNSW, IVFFlat, ітеративні скани (0.8.0).
- OpenSearch, аналізатори і додаткові плагіни.
- S. Robertson, H. Zaragoza, The Probabilistic Relevance Framework: BM25 and Beyond, 2009.
- Yu. Malkov, D. Yashunin, Efficient and Robust Approximate Nearest Neighbor Search Using Hierarchical Navigable Small World Graphs, 2016.
- G. Cormack, C. Clarke, S. Büttcher, Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods, SIGIR 2009.
- Картка моделі
intfloat/multilingual-e5-small(MIT). - Український словник hunspell: проєкт dict_uk, пакет
uk_UAу LibreOffice dictionaries (MPL 1.1).