Търсене по смисъл
Накратко: Векторното търсене превръща текста във вектор от числа (embedding) и намира най-близките по смисъл записи чрез косинусово сходство.
Проблем
„Нещо за закуска“
Клиент пише в търсачката на магазина „нещо за закуска“. В имената на продуктите думата я няма:
Пълнотекстовото търсене от предишния урок намира само отзива „Пълнозърнестият хляб е чудесен за закуска“. А мляко, кифла, кафе и баница са точно това, което клиентът иска. Той търси смисъл, а не дума. Синоними, преводи и „нещо за…“ не се хващат с думи.
Идеята
Текстът като точка
Езиков модел (embedding model) превръща текст във вектор: списък от числа. Обучен е така, че текстове с близък смисъл дават близки вектори. „Мляко“, „кифла“ и „нещо за закуска“ са близо, а „прах за пране“ е далеч.
Истинските модели дават от няколкостотин до няколко хиляди числа, и никое от тях няма име. Тук, за да се вижда какво става, векторите са измислени и имат само четири измерения с имена: закуска, сладко, готвене и чистене. Таблицата product_vectors пази вектора на всеки продукт като JSON масив:
Кифлата е много „закуска“ и малко „сладко“. Картофите са само „готвене“, а прахът за пране само „чистене“.
По-точно, за любопитните
Векторът се смята веднъж, когато продуктът се добави или описанието му се смени, и се пази в базата. Въпросът на клиента се превръща във вектор със същия модел при всяко търсене. Двата вектора трябва да са от един и същ модел: вектори от различни модели не могат да се сравняват. Модели има отворени (например от семейството sentence-transformers, които вървят и на обикновен компютър) и платени услуги.
Идеята
Колко близо: косинус
Два вектора са близки, когато сочат в една и съща посока. Мярката е косинусовото сходство: 1 значи една посока, 0 значи нищо общо. Смята се така: умножаваш числата на едни и същи места и ги събираш (скаларно произведение), после делиш на дължините на двата вектора.
json_each разгъва JSON масив в редове: key е мястото, а value е числото. Сирене срещу въпроса „нещо за закуска“ ([1, 0.2, 0.1, 0], както би го дал моделът):
Сборът на последната колона е 0.66. Делен на дължините (0.849 на сиренето и 1.025 на въпроса), дава 0.759. Същото за всички продукти, от най-близкия надолу:
WITH q(v) дава вектора на въпроса. Двата json_each се свързват по мястото, а GROUP BY събира произведенията за всеки продукт. Нито един от тези продукти няма думата „закуска“ в името си.
Пробвай
Сладко за закуска
Моделът е превърнал въпроса „нещо сладко за закуска“ във вектора [0.7, 0.9, 0, 0]. Намери трите най-близки продукта (колони name и similarity, закръглено до 0.001), от най-близкия надолу.
Решение
WITH q(v) AS (SELECT '[0.7, 0.9, 0, 0]')
SELECT p.name,
ROUND(SUM(a.value * b.value)
/ (sqrt(SUM(a.value * a.value)) * sqrt(SUM(b.value * b.value))), 3) AS similarity
FROM product_vectors pv
JOIN products p ON p.id = pv.product_id
CROSS JOIN q
JOIN json_each(pv.embedding) a
JOIN json_each(q.v) b ON b.key = a.key
GROUP BY p.id, p.name
ORDER BY similarity DESC
LIMIT 3;Същата заявка с '[0.7, 0.9, 0, 0]' в WITH q(v). С ORDER BY similarity без DESC излизат най-далечните: продуктите за чистене и за кухнята, със сходство 0. А векторът на „нещо за закуска“ дава кафе и баница, които не са сладки.
Идеята
Филтър преди подреждането
Често търсенето по смисъл има и обикновени условия: под някаква цена, само в наличност, само в категория. Условието е в WHERE, тоест преди GROUP BY, ORDER BY и LIMIT. Ако първо вземеш най-близките три и после филтрираш, може да не остане нищо: те са кафе за 14,90 €, баница за 2,50 € и мляко за 1,60 €.
По-точно, за любопитните
Векторните индекси (следващата карта) имат точно този проблем. Приблизителното търсене връща например най-близките 100, и ако филтърът е след него, а евтините продукти не са сред тези 100, резултатът е празен. Затова векторните бази имат филтри, които се прилагат по време на търсенето, а pgvector има настройки като hnsw.ef_search и итеративно сканиране (hnsw.iterative_scan).
Упражнение
Евтино и подобно
1. Клиентът е избрал „до 1,50 €“.
Намери трите продукта, най-близки до „нещо за закуска“ ([1, 0.2, 0.1, 0]), с цена под 1,50 € (колони name и similarity), от най-близкия надолу.
Решение
WITH q(v) AS (SELECT '[1, 0.2, 0.1, 0]')
SELECT p.name,
ROUND(SUM(a.value * b.value)
/ (sqrt(SUM(a.value * a.value)) * sqrt(SUM(b.value * b.value))), 3) AS similarity
FROM product_vectors pv
JOIN products p ON p.id = pv.product_id
CROSS JOIN q
JOIN json_each(pv.embedding) a
JOIN json_each(q.v) b ON b.key = a.key
WHERE p.price < 1.5
GROUP BY p.id, p.name
ORDER BY similarity DESC
LIMIT 3;WHERE p.price < 1.5 преди GROUP BY. Ако първо вземеш най-близките три и после филтрираш по цена, от тях не остава нито един: кафето, баницата и млякото са над 1,50 €.
2. Страницата на продукта показва „подобни продукти“. Векторът на въпроса е векторът на самия продукт.
Намери трите продукта, най-подобни на сиренето (продукт 7), без самото сирене (колони name и similarity), от най-близкия надолу.
Решение
WITH q(v) AS (SELECT embedding FROM product_vectors WHERE product_id = 7)
SELECT p.name,
ROUND(SUM(a.value * b.value)
/ (sqrt(SUM(a.value * a.value)) * sqrt(SUM(b.value * b.value))), 3) AS similarity
FROM product_vectors pv
JOIN products p ON p.id = pv.product_id
CROSS JOIN q
JOIN json_each(pv.embedding) a
JOIN json_each(q.v) b ON b.key = a.key
WHERE p.id <> 7
GROUP BY p.id, p.name
ORDER BY similarity DESC
LIMIT 3;WITH q(v) AS (SELECT embedding FROM product_vectors WHERE product_id = 7) и WHERE p.id <> 7. Без условието първото място е на самото сирене със сходство 1.
Идеята
Милиони вектори: приблизителен индекс
Заявката сравнява въпроса с всеки продукт. Планът показва SCAN p:
При 23 продукта с по 4 числа това е нищо. При 10 милиона документа с по 1536 числа всяко търсене са 15 милиарда умножения. Обикновеният индекс не помага: той подрежда по една стойност, а „близо“ във вектори е в стотици посоки наведнъж.
Затова векторните бази ползват приблизителни индекси (ANN, approximate nearest neighbours). Най-разпространеният е HNSW: граф, в който всеки вектор е свързан с близките си, на няколко нива. Търсенето започва от горното, рядко ниво и слиза, като върви все към по-близки съседи. Намира най-близките за милисекунди, но понякога изпуска някой от тях. Делът на истинските най-близки, които индексът намира, е recall, и се настройва срещу скоростта.
Идеята
Къде живеят векторите
Най-простото е векторите да стоят до данните. pgvector е разширение на PostgreSQL:
CREATE EXTENSION vector;CREATE TABLE product_vectors ( product_id integer PRIMARY KEY REFERENCES products(id), embedding vector(384));CREATE INDEX ON product_vectors USING hnsw (embedding vector_cosine_ops);
-- <=> е косинусовото разстояние (1 минус сходството)SELECT p.nameFROM product_vectors pv JOIN products p ON p.id = pv.product_idWHERE p.price < 1.5ORDER BY pv.embedding <=> $1LIMIT 3;Отделни векторни бази (Qdrant, Weaviate, Milvus, Pinecone) си струват при стотици милиони вектори или когато търсенето по смисъл е главната работа. Elasticsearch, OpenSearch и Redis също имат векторно търсене.
Най-честата употреба днес е RAG (retrieval-augmented generation): чатботът на магазина превръща въпроса във вектор, намира най-близките откъси от описания, отзиви и правила и ги дава на езиковия модел, за да отговори по тях, а не по памет. Често търсенето по смисъл се смесва с пълнотекстовото: думите хващат точните имена и кодове, а векторите смисъла.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Отчет на стъпки: WITH · Индекси · EXPLAIN: планът на заявката · Търсене в текст
- Продължава в: Как се избира база
- Задачи по темата: Видове бази данни