Векторный поиск: поле dense_vector и запрос kNN
В предыдущих статьях о полнотекстовом поиске поисковый движок работал со словами: токенизировал текст, строил инвертированный индекс и находил документы с нужными терминами. Векторный поиск устроен принципиально иначе — он ищет по смыслу, а не по словам.
Идея такая: любой объект — текст, товар, изображение — можно представить как точку в многомерном пространстве. Если два объекта схожи по смыслу, их точки окажутся рядом. Такое числовое представление называется эмбеддингом (embedding), а сам массив чисел — вектором. Нейросетевая модель принимает текст и возвращает, например, массив из 384 чисел — координаты этого текста в 384-мерном «семантическом пространстве».
flowchart TD
A["Текст запроса\n'наушники для путешествий'"] --> B["ML-модель\nгенерирует эмбеддинг"]
B --> C["Вектор запроса\n[0.038, -0.117, 0.084, ...]"]
C --> D["kNN-запрос в ES\nfield: embedding, k: 10"]
D --> E["HNSW-граф\nбыстрая навигация по num_candidates"]
E --> F["Ранжирование\nпо метрике cosine / dot_product / l2_norm"]
F --> G["Топ-k документов\n(похожих по смыслу)"]Elasticsearch хранит такие векторы в специальном поле dense_vector и умеет по запросному вектору находить $k$ ближайших соседей — это и есть kNN-поиск (k-Nearest Neighbors).
Поле dense_vector: маппинг
Прежде чем индексировать документы с векторами, нужно описать поле в маппинге. Минимальный вариант:
PUT /products
{
"mappings": {
"properties": {
"name": { "type": "text" },
"description": { "type": "text" },
"embedding": {
"type": "dense_vector",
"dims": 384,
"similarity": "cosine"
}
}
}
}Разберём параметры:
dims— количество измерений вектора. Должно совпадать с размерностью выбранной модели (384, 768, 1536 — зависит от модели). Максимум 4096.similarity— метрика, по которой ES будет считать похожесть. Подробнее ниже.index—trueпо умолчанию, что означает: ES строит граф HNSW для быстрого приближённого поиска. Если выставитьfalse, приближённый kNN недоступен.element_type— тип элементов:float(по умолчанию, 4 байта),byte(1 байт, экономит место, теряет точность),bit(1 бит).
Метрики похожести
Метрика определяет, как ES измеряет «расстояние» между двумя векторами $\mathbf{a}$ и $\mathbf{b}$ из $n$ измерений.
cosine — косинусное сходство (по умолчанию для float и byte):
Показывает угол между векторами. $1$ — идентичны по направлению, $0$ — перпендикулярны, $-1$ — противоположны. Нормирование происходит автоматически, поэтому длина вектора не важна — только направление.
dot_product — скалярное произведение:
Работает корректно только с нормированными векторами (у которых $|\mathbf{a}| = 1$). Быстрее косинуса, потому что не нужно делить на длины. Если ваша модель нормирует выходы — используйте dot_product. Для ненормированных векторов результат будет некорректным.
l2_norm — евклидово расстояние:
Чем меньше расстояние, тем похожее объекты. Хорош, когда важна абсолютная разница в координатах, а не только направление — например, для изображений.
Как выбирать: большинство текстовых моделей (sentence-transformers, OpenAI, многие HuggingFace-модели) возвращают нормированные векторы → берите dot_product или cosine. Если документация модели не уточняет — cosine безопаснее, он нечувствителен к длине вектора.
Индексирование документов с эмбеддингами
Вектор передаётся как обычный массив чисел прямо в теле документа:
POST /products/_doc
{
"name": "Беспроводные наушники Sony WH-1000XM5",
"description": "Наушники с активным шумоподавлением, до 30 часов работы",
"embedding": [0.021, -0.134, 0.057, 0.312, ...]
}Массив должен содержать ровно dims чисел — столько, сколько указано в маппинге. Если длина не совпадает, ES вернёт ошибку.
На практике вектор генерирует ваша модель. Типичная схема в проде: бэкенд отправляет текст во внешнюю ML-модель (или в inference-эндпоинт ES), получает массив чисел, затем кладёт документ в ES. Статьи ELSER и разреженные векторы для семантики и Поле semantic_text и inference-эндпоинты описывают, как это автоматизировать прямо внутри ES.
Точный и приближённый kNN: при чём тут HNSW
Найти $k$ ближайших соседей «честно» означает сравнить запросный вектор с каждым документом в индексе. При миллионе документов и 768 измерениях — это $768 \times 10^6$ умножений за один запрос. Дорого.
Поэтому ES по умолчанию использует приближённый kNN (ANN) — на основе алгоритма HNSW (Hierarchical Navigable Small World). HNSW строит граф, где каждый вектор связан с несколькими «соседями», и во время поиска навигирует по этому графу, не проверяя все документы подряд. Скорость — миллисекунды даже на очень больших индексах. Жертва точностью — минимальная: при правильных параметрах ANN находит те же результаты, что и точный поиск, в 95–99% случаев.
Точный kNN (bruteforce) строится через script_score с функциями cosineSimilarity / dotProduct — он гарантирует правильный ответ, но не масштабируется. Используется редко: для маленьких индексов или финальной верификации.
Запрос kNN: параметры k и num_candidates
Основной способ выполнить kNN-поиск в ES 8 — секция knn в теле запроса _search:
GET /products/_search
{
"knn": {
"field": "embedding",
"query_vector": [0.015, -0.142, 0.063, 0.298, ...],
"k": 5,
"num_candidates": 50
}
}Оба параметра напрямую управляют балансом точности и скорости:
k— сколько результатов вернуть. В примере выше — 5 наиболее похожих товаров.num_candidates— сколько кандидатов собрать с каждого шарда перед финальным ранжированием. ES обходит HNSW-граф, набираетnum_candidatesпретендентов, потом из них выбирает лучшиеk. Чем больше это число — тем точнее результат, но и медленнее запрос. Если не указать, ES по умолчанию берёт $1{,}5 \times k$. На практике разумный диапазон — от $k$ до $10 \times k$.
Важно: num_candidates должен быть не меньше k. Ставить num_candidates = k — значит жертвовать точностью почти полностью. Хорошее эмпирическое правило: начните с $\text{num\_candidates} = 5k$, замерьте качество, затем при необходимости увеличьте.
Пример: семантический поиск по каталогу
Предположим, пользователь вводит запрос «наушники для путешествий». Ваш бэкенд передаёт этот текст в модель и получает вектор. Затем отправляет запрос в ES:
GET /products/_search
{
"knn": {
"field": "embedding",
"query_vector": [0.038, -0.117, 0.084, 0.251, ...],
"k": 10,
"num_candidates": 100
},
"_source": ["name", "description"]
}ES вернёт 10 товаров, чьи эмбеддинги геометрически ближе всего к запросному вектору. В выдаче могут оказаться «шумоподавляющие наушники», «компактные наушники с долгим зарядом», «аудиогид для путешественников» — даже если ни в одном из них нет слова «путешествия».
Фильтрация внутри kNN-запроса
Часто нужно сузить поиск по обычным критериям — например, только товары в наличии. Используйте параметр filter прямо внутри knn:
GET /products/_search
{
"knn": {
"field": "embedding",
"query_vector": [0.038, -0.117, 0.084, 0.251, ...],
"k": 10,
"num_candidates": 100,
"filter": {
"term": { "in_stock": true }
}
}
}Фильтр применяется во время обхода HNSW-графа (pre-filter), а не после. Это значит, что кандидаты, не прошедшие фильтр, вообще не попадают в пул num_candidates. Результат точнее, чем если бы фильтр применялся post-hoc.
Комбинация kNN с обычным поиском
kNN-секцию можно совмещать с обычным Query DSL в одном запросе:
GET /products/_search
{
"knn": {
"field": "embedding",
"query_vector": [0.038, -0.117, 0.084, 0.251, ...],
"k": 10,
"num_candidates": 100
},
"query": {
"match": { "name": "наушники" }
}
}ES выполнит оба поиска, объединит результаты и перемешает скоры. Это базовый вариант гибридного поиска — более мощная его форма с RRF описана в статье Гибридный поиск: retrievers и RRF.
См. также
- ELSER и разреженные векторы для семантики — семантический поиск без своих эмбеддингов
- Поле semantic_text и inference-эндпоинты — упрощённый способ включить семантику
- Гибридный поиск: retrievers и RRF — объединение kNN и BM25 через RRF
- Маппинг: явный, динамический и шаблоны индексов — как описывать схему индекса
- Релевантность и BM25: как считается _score — как устроено ранжирование в полнотекстовом поиске