fts и vss: полнотекстовый и векторный поиск
Аналитический SQL отлично отвечает на вопросы «сколько», «когда», «по каким группам». Но есть два типа поиска, которые обычным WHERE-условием решаются плохо: найти документы по смыслу слов и найти объекты, похожие по содержанию. Первое — задача полнотекстового поиска, второе — векторного. DuckDB закрывает обе расширениями fts и vss. Этот урок про то, как они устроены и почему DuckDB способен совмещать поиск с аналитикой в одном запросе.
Почему LIKE — не поиск
Начнём с того, почему для поиска по тексту недостаточно WHERE text LIKE '%запрос%'.
LIKE '%слово%' ищет точное вхождение подстроки. Он не знает, что «бежал» и «бежать» — одно слово в разных формах. Он не понимает, что в запросе из трёх слов какие-то важнее других. Он не умеет ранжировать: для LIKE документ либо подходит, либо нет, понятия «насколько хорошо подходит» не существует. И он медленный: % в начале шаблона делает индекс бесполезным, остаётся полное сканирование.
Полнотекстовый поиск решает всё это. Он разбивает текст на слова (токенизация), приводит слова к базовой форме (стемминг), строит специальную структуру — инвертированный индекс — и умеет ранжировать документы по релевантности.
fts: полнотекстовый поиск
Расширение fts добавляет в DuckDB полнотекстовый поиск. Его сердце — инвертированный индекс. Идея инвертированного индекса проста и мощна: вместо отображения «документ -> его слова» он хранит обратное — «слово -> список документов, где оно встречается». Чтобы найти документы со словом, не надо сканировать все документы — достаточно взять готовый список из индекса.
Индекс создаётся вызовом PRAGMA create_fts_index. После этого появляется функция match_bm25, которая возвращает оценку релевантности документа запросу:
INSTALL fts;
LOAD fts;
-- Построить полнотекстовый индекс по колонке body таблицы articles
PRAGMA create_fts_index('articles', 'id', 'body');
-- Найти и ранжировать статьи по запросу
SELECT id, title, fts_main_articles.match_bm25(id, 'columnar storage engine') AS score
FROM articles
WHERE score IS NOT NULL
ORDER BY score DESC
LIMIT 5;
┌───────┬──────────────────────────────┬────────────────────┐
│ id │ title │ score │
│ int64 │ varchar │ double │
├───────┼──────────────────────────────┼────────────────────┤
│ 312 │ How columnar engines work │ 4.812... │
│ 87 │ Storage formats compared │ 3.155... │
│ 540 │ Vectorized execution basics │ 1.998... │
└───────┴──────────────────────────────┴────────────────────┘
Функция называется match_bm25, потому что внутри работает алгоритм ранжирования BM25 — индустриальный стандарт. BM25 оценивает релевантность документа, учитывая две вещи: как часто слово запроса встречается в документе и насколько слово редкое в коллекции в целом (редкое слово важнее частого). Чем выше оценка, тем релевантнее документ — отсюда ORDER BY score DESC.
vss: поиск по векторному сходству
Второй вид поиска — по смыслу содержимого, а не по словам. Эту задачу решают эмбеддинги: ML-модель превращает объект (текст, изображение, товар) в вектор из сотен или тысяч чисел так, что похожие по смыслу объекты получают близкие в пространстве векторы. Тогда «найти похожее» сводится к «найти ближайшие векторы».
Расширение vss (vector similarity search) добавляет в DuckDB поиск ближайших векторов. Эмбеддинги хранятся в колонке типа FLOAT[N] — массив фиксированной длины N (длина вектора). Расстояние между векторами считают функции: array_distance (евклидово расстояние), array_cosine_distance (косинусное), array_inner_product.
INSTALL vss;
LOAD vss;
-- Таблица товаров с эмбеддингами длины 768
-- Найти 5 товаров, ближайших к заданному вектору запроса
SELECT name, array_distance(embedding, [0.12, -0.04, ...]::FLOAT[768]) AS dist
FROM products
ORDER BY dist
LIMIT 5;
Запрос «отсортировать по расстоянию и взять K ближайших» называется K nearest neighbors (KNN), поиск K ближайших соседей. Без индекса он работает честным перебором: расстояние считается до каждого вектора в таблице. Для небольших таблиц это нормально и DuckDB делает это быстро благодаря векторизации.
Для больших коллекций перебор дорог, и vss предоставляет индекс HNSW (Hierarchical Navigable Small World). HNSW — это многослойный граф ближайших соседей: поиск спускается по слоям графа, на каждом шаге приближаясь к цели, и находит почти ближайшие векторы, не перебирая всю коллекцию.
-- Построить HNSW-индекс по колонке эмбеддингов
CREATE INDEX prod_hnsw ON products USING HNSW (embedding);
HNSW даёт приближённый ответ — это approximate nearest neighbor search (ANN). Он может пропустить настоящего ближайшего соседа ради скорости. Для рекомендаций и семантического поиска это приемлемо: разница между «самым похожим» и «почти самым похожим» товаром незаметна, а выигрыш в скорости на больших коллекциях огромен. Если же нужен абсолютно точный ответ, используйте честный перебор через ORDER BY array_distance без HNSW-индекса.
| Аспект | fts (полнотекстовый) | vss (векторный) |
|---|---|---|
| Что ищет | Документы со словами запроса | Объекты, близкие по смыслу |
| Представление данных | Инвертированный индекс по словам | Векторы-эмбеддинги FLOAT[N] |
| Алгоритм ранжирования | BM25 | Расстояние между векторами (KNN) |
| Индекс для ускорения | Инвертированный индекс | HNSW (приближённый) |
| Откуда берётся вход | Сам текст | ML-модель, считающая эмбеддинги |
Почему это в SQL-движке — преимущество
Полнотекстовый и векторный поиск обычно живут в отдельных специализированных системах (поисковые движки, векторные базы данных). Что даёт их наличие прямо в DuckDB?
Главное — поиск совмещается с аналитикой и фильтрами в одном запросе. В отдельной векторной базе вы найдёте похожие товары, а потом отдельным шагом в другой системе отфильтруете их по цене, наличию и категории. В DuckDB это один SQL-запрос: векторный поиск, фильтр по обычным колонкам, джойн со складскими остатками, агрегация — всё вместе, одним планом, без перекладывания данных между системами.
-- Векторный поиск + бизнес-фильтр + джойн в одном запросе
SELECT p.name, p.price, array_distance(p.embedding, :query_vec) AS dist
FROM products p
JOIN stock s ON s.product_id = p.id
WHERE p.price < 100 -- обычный фильтр
AND s.quantity > 0 -- джойн со складом
ORDER BY dist -- ранжирование по векторному сходству
LIMIT 10;
Для умеренных объёмов данных это снимает целый класс инфраструктуры: не нужна отдельная поисковая система, не нужна отдельная векторная база, не нужен пайплайн синхронизации между ними. Поиск становится ещё одной возможностью SQL-движка.
fts и vss не делают из DuckDB замену Elasticsearch или специализированной векторной базы для очень больших, постоянно обновляемых поисковых нагрузок с высокой конкурентностью. DuckDB остаётся аналитическим in-process движком: один процесс-писатель, упор на пакетную аналитику. fts и vss блестяще решают задачу «поиск как часть аналитического запроса над умеренным объёмом данных». Для поискового бэкенда веб-сервиса с тысячами запросов в секунду нужна профильная система. Выбирайте инструмент по нагрузке.
Попробуй сам
Обе возможности легко попробовать на учебных данных.
- Создайте таблицу
articles(id, title, body)с десятком текстов. ВыполнитеINSTALL fts; LOAD fts;и постройте индекс черезPRAGMA create_fts_index('articles', 'id', 'body'). - Сделайте поиск через
match_bm25по запросу из двух-трёх слов, отсортируйте поscore DESC. Затем поищите то же самое черезWHERE body LIKE '%слово%'и сравните: LIKE не ранжирует и не находит словоформы. - Создайте таблицу с колонкой
FLOAT[3]и несколькими векторами (короткие векторы удобны для ручной проверки). Найдите ближайшие к заданному вектору черезORDER BY array_distance(...) LIMIT 3. Посчитайте пару расстояний вручную и сверьте. - Напишите один запрос, который совмещает поиск (
match_bm25илиarray_distance) с обычным фильтром по другой колонке и сортировкой. Объясните себе, почему выполнение поиска и фильтра в одном SQL-плане выгоднее, чем два шага в разных системах.