Перейти к основному содержимому
Перейти к основному содержимому

Точный и приближённый векторный поиск

Задача нахождения N ближайших точек в многомерном (векторном) пространстве для заданной точки называется поиском ближайших соседей или, кратко, векторным поиском. Существуют два общих подхода к решению задачи векторного поиска:

  • Точный векторный поиск вычисляет расстояние между заданной точкой и всеми точками в векторном пространстве. Это обеспечивает максимально возможную точность, то есть возвращаемые точки гарантированно являются фактическими ближайшими соседями. Поскольку векторное пространство просматривается исчерпывающим образом, точный векторный поиск может быть слишком медленным для практического применения.
  • Приближённый векторный поиск относится к группе методов (например, специальные структуры данных, такие как графы и случайные леса), которые вычисляют результаты гораздо быстрее, чем точный векторный поиск. Точность результата обычно «достаточно хороша» для практического использования. Многие приближённые методы предоставляют параметры для настройки компромисса между точностью результата и временем поиска.

Векторный поиск (точный или приближённый) может быть записан на языке SQL следующим образом:

WITH [...] AS reference_vector
SELECT [...]
FROM table
WHERE [...] -- a WHERE clause is optional
ORDER BY <DistanceFunction>(vectors, reference_vector)
LIMIT <N>

Точки в векторном пространстве хранятся в столбце vectors типа массив, например Array(Float64), Array(Float32) или Array(BFloat16). Опорный вектор — это константный массив, задаваемый в виде общего табличного выражения. &lt;DistanceFunction&gt; вычисляет расстояние между опорной точкой и всеми сохранёнными точками. Для этого может быть использована любая из доступных функций расстояния. &lt;N&gt; указывает количество соседей, которое должно быть возвращено.

Точный векторный поиск может быть выполнен, используя выше приведённый запрос SELECT без изменений. Время выполнения таких запросов в общем случае пропорционально количеству сохранённых векторов и их размерности, то есть количеству элементов массива. Кроме того, поскольку ClickHouse выполняет полный перебор (brute-force) всех векторов, время выполнения также зависит от количества потоков, используемых запросом (см. настройку max_threads).

Пример

CREATE TABLE tab(id Int32, vec Array(Float32)) ENGINE = MergeTree ORDER BY id;

INSERT INTO tab VALUES (0, [1.0, 0.0]), (1, [1.1, 0.0]), (2, [1.2, 0.0]), (3, [1.3, 0.0]), (4, [1.4, 0.0]), (5, [1.5, 0.0]), (6, [0.0, 2.0]), (7, [0.0, 2.1]), (8, [0.0, 2.2]), (9, [0.0, 2.3]), (10, [0.0, 2.4]), (11, [0.0, 2.5]);

WITH [0., 2.] AS reference_vec
SELECT id, vec
FROM tab
ORDER BY L2Distance(vec, reference_vec) ASC
LIMIT 3;

возвращает

   ┌─id─┬─vec─────┐
1. │  6 │ [0,2]   │
2. │  7 │ [0,2.1] │
3. │  8 │ [0,2.2] │
   └────┴─────────┘

Индексы векторного сходства

ClickHouse предоставляет специальный индекс векторного сходства (vector similarity) для выполнения приближённого поиска по векторам.

Примечание

Индексы векторного сходства доступны в ClickHouse версии 25.8 и новее. Если вы столкнётесь с проблемами, откройте issue в репозитории ClickHouse.

Создание индекса сходства векторов

Индекс сходства векторов можно создать на новой таблице следующим образом:

CREATE TABLE table
(
  [...],
  vectors Array(Float*),
  INDEX <index_name> vectors TYPE vector_similarity(<type>, <distance_function>, <dimensions>) [GRANULARITY <N>]
)
ENGINE = MergeTree
ORDER BY [...]

В качестве альтернативы можно добавить индекс сходства векторов к существующей таблице:

ALTER TABLE table ADD INDEX <index_name> vectors TYPE vector_similarity(<type>, <distance_function>, <dimensions>) [GRANULARITY <N>];

Индексы сходства векторов — это особый вид пропускающих индексов (см. здесь и здесь). Соответственно, оператор ALTER TABLE, показанный выше, приводит к тому, что индекс будет построен только для новых данных, которые будут добавлены в таблицу в будущем. Чтобы построить индекс также для существующих данных, необходимо его материализовать:

ALTER TABLE table MATERIALIZE INDEX <index_name> SETTINGS mutations_sync = 2;

Функция <distance_function> должна быть

Для нормализованных данных обычно лучше всего подходит L2Distance, в противном случае рекомендуется cosineDistance для компенсации масштаба.

<dimensions> определяет размер массива (число элементов) в базовом столбце. Если ClickHouse обнаружит массив с другим размером во время создания индекса, индекс будет отброшен и будет возвращена ошибка.

Необязательный параметр GRANULARITY <N> относится к размеру гранул индекса (см. здесь). Значение по умолчанию 100 миллионов должно достаточно хорошо работать для большинства сценариев использования, но его также можно настраивать. Мы рекомендуем выполнять настройку только продвинутым пользователям, которые понимают последствия своих действий (см. ниже).

Индексы сходства векторов являются обобщёнными в том смысле, что они могут поддерживать различные методы приблизительного поиска. Фактически используемый метод задаётся параметром <type>. На данный момент единственным доступным методом является HNSW (научная статья) — популярная и современная техника приблизительного векторного поиска, основанная на иерархических графах близости. Если в качестве <type> используется HNSW, пользователи могут дополнительно указать специфичные для HNSW параметры:

CREATE TABLE table
(
  [...],
  vectors Array(Float*),
  INDEX index_name vectors TYPE vector_similarity('hnsw', <distance_function>, <dimensions>[, <quantization>, <hnsw_max_connections_per_layer>, <hnsw_candidate_list_size_for_construction>]) [GRANULARITY N]
)
ENGINE = MergeTree
ORDER BY [...]

Доступны следующие параметры, специфичные для HNSW:

  • <quantization> задаёт квантизацию векторов в графе близости. Допустимые значения: f64, f32, f16, bf16, i8 или b1. Значение по умолчанию — bf16. Обратите внимание, что этот параметр не влияет на представление векторов в базовом столбце.
  • <hnsw_max_connections_per_layer> задаёт число соседей для каждой вершины графа, также известное как гиперпараметр HNSW M. Значение по умолчанию — 32. Значение 0 означает использование значения по умолчанию.
  • <hnsw_candidate_list_size_for_construction> задаёт размер динамического списка кандидатов при построении графа HNSW, также известного как гиперпараметр HNSW ef_construction. Значение по умолчанию — 128. Значение 0 означает использование значения по умолчанию.

Значения по умолчанию для всех параметров, специфичных для HNSW, достаточно хорошо работают в большинстве сценариев использования. Поэтому мы не рекомендуем настраивать параметры, специфичные для HNSW.

Применяются дополнительные ограничения:

  • Индексы сходства векторов могут создаваться только на столбцах типа Array(Float32), Array(Float64) или Array(BFloat16). Массивы с типами Nullable и LowCardinality с плавающей запятой, такие как Array(Nullable(Float32)) и Array(LowCardinality(Float32)), не допускаются.
  • Индексы сходства векторов должны создаваться на одном столбце.
  • Индексы сходства векторов могут быть созданы на вычисляемых выражениях (например, INDEX index_name arraySort(vectors) TYPE vector_similarity([...])), но такие индексы не могут использоваться для приблизительного поиска ближайших соседей в дальнейшем.
  • Индексы сходства векторов требуют, чтобы все массивы в базовом столбце содержали <dimension> элементов — это проверяется во время создания индекса. Чтобы как можно раньше выявлять нарушения этого требования, пользователи могут добавить ограничение для векторного столбца, например, CONSTRAINT same_length CHECK length(vectors) = 256.
  • Аналогично, значения массивов в базовом столбце не должны быть пустыми ([]) или иметь значение по умолчанию (также []).

Оценка потребления памяти и дискового пространства

Вектор, сгенерированный для использования с типичной AI-моделью (например, Large Language Model, LLMs), состоит из сотен или тысяч чисел с плавающей запятой. Таким образом, одно векторное значение может потреблять несколько килобайт памяти. Пользователи, которые хотят оценить объем хранилища, необходимый для базового векторного столбца таблицы, а также объем оперативной памяти, необходимой для индекса сходства векторов, могут использовать две приведенные ниже формулы:

Потребление дискового пространства векторным столбцом в таблице (в несжатом виде):

Storage consumption = Number of vectors * Dimension * Size of column data type

Пример для набора данных DBpedia:

Storage consumption = 1 million * 1536 * 4 (for Float32) = 6.1 GB

Индекс векторного сходства должен быть полностью загружен с диска в оперативную память для выполнения поиска. Аналогично, векторный индекс также полностью формируется в памяти, а затем сохраняется на диск.

Объем памяти, необходимый для загрузки векторного индекса:

Memory for vectors in the index (mv) = Number of vectors * Dimension * Size of quantized data type
Memory for in-memory graph (mg) = Number of vectors * hnsw_max_connections_per_layer * Bytes_per_node_id (= 4) * Layer_node_repetition_factor (= 2)

Memory consumption: mv + mg

Пример для набора данных DBpedia:

Memory for vectors in the index (mv) = 1 million * 1536 * 2 (for BFloat16) = 3072 MB
Memory for in-memory graph (mg) = 1 million * 64 * 2 * 4 = 512 MB

Memory consumption = 3072 + 512 = 3584 MB

Приведённая выше формула не учитывает дополнительную память, необходимую индексам векторного сходства для размещения структур данных времени выполнения, таких как предварительно выделенные буферы и кэши.

Использование индекса сходства векторов

Примечание

Чтобы использовать индексы сходства векторов, настройка compatibility должна быть '' (значение по умолчанию) или '25.1' и новее.

Индексы сходства векторов поддерживают запросы SELECT следующего вида:

WITH [...] AS reference_vector
SELECT [...]
FROM table
WHERE [...] -- a WHERE clause is optional
ORDER BY <DistanceFunction>(vectors, reference_vector)
LIMIT <N>

Оптимизатор запросов ClickHouse пытается сопоставить приведённый выше шаблон запроса и использовать доступные индексы векторного сходства. Запрос может использовать индекс векторного сходства только в том случае, если функция расстояния в запросе SELECT совпадает с функцией расстояния в определении индекса.

Продвинутые пользователи могут задать собственное значение настройки hnsw_candidate_list_size_for_search (также известной как гиперпараметр HNSW "ef_search"), чтобы настроить размер списка кандидатов во время поиска (например, SELECT [...] SETTINGS hnsw_candidate_list_size_for_search = <value>). Значение настройки по умолчанию 256 хорошо работает в большинстве сценариев использования. Более высокие значения настройки означают лучшую точность ценой сниженной производительности.

Если запрос может использовать индекс векторного сходства, ClickHouse проверяет, что LIMIT <N>, указанный в запросах SELECT, находится в разумных пределах. Более конкретно, возвращается ошибка, если <N> больше значения настройки max_limit_for_vector_search_queries со значением по умолчанию 100. Слишком большие значения LIMIT могут замедлить поиск и обычно указывают на ошибку в использовании.

Чтобы проверить, использует ли запрос SELECT индекс векторного сходства, вы можете добавить к запросу префикс EXPLAIN indexes = 1.

В качестве примера, запрос

EXPLAIN indexes = 1
WITH [0.462, 0.084, ..., -0.110] AS reference_vec
SELECT id, vec
FROM tab
ORDER BY L2Distance(vec, reference_vec) ASC
LIMIT 10;

может возвращать

    ┌─explain─────────────────────────────────────────────────────────────────────────────────────────┐
 1. │ Expression (Project names)                                                                      │
 2. │   Limit (preliminary LIMIT (without OFFSET))                                                    │
 3. │     Sorting (Sorting for ORDER BY)                                                              │
 4. │       Expression ((Before ORDER BY + (Projection + Change column names to column identifiers))) │
 5. │         ReadFromMergeTree (default.tab)                                                         │
 6. │         Indexes:                                                                                │
 7. │           PrimaryKey                                                                            │
 8. │             Condition: true                                                                     │
 9. │             Parts: 1/1                                                                          │
10. │             Granules: 575/575                                                                   │
11. │           Skip                                                                                  │
12. │             Name: idx                                                                           │
13. │             Description: vector_similarity GRANULARITY 100000000                                │
14. │             Parts: 1/1                                                                          │
15. │             Granules: 10/575                                                                    │
    └─────────────────────────────────────────────────────────────────────────────────────────────────┘

В этом примере 1 миллион векторов из набора данных dbpedia, каждый размерности 1536, хранятся в 575 гранулах, то есть примерно по 1,7 тыс. строк на гранулу. Запрос ищет 10 соседей, и индекс векторного сходства находит этих 10 соседей в 10 отдельных гранулах. Эти 10 гранул будут прочитаны при выполнении запроса.

Индексы векторного сходства используются, если в выводе присутствует Skip, а также имя и тип векторного индекса (в примере idx и vector_similarity). В этом случае индекс векторного сходства отбросил две из четырёх гранул, то есть 50% данных. Чем больше гранул может быть отброшено, тем эффективнее становится использование индекса.

Совет

Чтобы принудительно использовать индекс, вы можете выполнить запрос SELECT с настройкой force_data_skipping_indexes (укажите имя индекса в качестве значения настройки).

Постфильтрация и префильтрация

Пользователи могут дополнительно указать предложение WHERE с дополнительными условиями фильтрации для запроса SELECT. ClickHouse будет вычислять эти условия фильтрации, используя стратегию постфильтрации или префильтрации. Кратко, обе стратегии определяют порядок, в котором вычисляются фильтры:

  • Постфильтрация означает, что сначала вычисляется индекс векторного сходства, а затем ClickHouse вычисляет дополнительные фильтры, указанные в предложении WHERE.
  • Префильтрация означает, что порядок вычисления фильтров обратный.

У этих стратегий разные компромиссы:

  • Постфильтрация имеет общий недостаток: она может вернуть меньшее количество строк, чем запрошено в выражении LIMIT <N>. Такая ситуация возникает, когда одна или несколько строк результата, возвращённых индексом векторного сходства, не удовлетворяют дополнительным фильтрам.
  • Префильтрация в целом остаётся нерешённой задачей. Некоторые специализированные векторные базы данных предоставляют алгоритмы префильтрации, но большинство реляционных баз данных (включая ClickHouse) прибегают к точному поиску соседей, то есть полному перебору без индекса.

Используемая стратегия зависит от условия фильтра.

Дополнительные фильтры являются частью ключа партиционирования

Если дополнительное условие фильтрации является частью ключа партиционирования, ClickHouse применит отсечение партиций (partition pruning). В качестве примера рассмотрим таблицу с партиционированием по диапазону значений столбца year и запуск следующего запроса:

WITH [0., 2.] AS reference_vec
SELECT id, vec
FROM tab
WHERE year = 2025
ORDER BY L2Distance(vec, reference_vec) ASC
LIMIT 3;

ClickHouse отбросит все партиции, кроме партиции за 2025 год.

Дополнительные фильтры не могут быть вычислены с использованием индексов

Если дополнительные условия фильтрации не могут быть вычислены с использованием индексов (индекс по первичному ключу, пропускающий индекс), ClickHouse применит постфильтрацию.

Дополнительные фильтры могут быть вычислены с использованием индекса по первичному ключу

Если дополнительные условия фильтрации могут быть вычислены с использованием primary key (т. е. они образуют префикс первичного ключа) и

  • условие фильтрации исключает как минимум одну строку внутри части, ClickHouse перейдёт к предфильтрации для «выживших» диапазонов внутри части,
  • условие фильтрации не исключает ни одной строки внутри части, ClickHouse выполнит постфильтрацию для этой части.

На практике второй случай маловероятен.

Дополнительные фильтры могут быть вычислены с использованием пропускающего индекса

Если дополнительные условия фильтрации могут быть вычислены с использованием skipping indexes (minmax-индекс, Set-индекс и т. д.), ClickHouse выполняет постфильтрацию. В таких случаях векторный индекс схожести вычисляется первым, так как ожидается, что он удалит наибольшее количество строк по сравнению с другими пропускающими индексами.

Для более тонкого управления постфильтрацией и предфильтрацией можно использовать две настройки:

Настройка vector_search_filter_strategy (по умолчанию: auto, что реализует описанные выше эвристики) может быть установлена в значение prefilter. Это полезно для принудительного включения предфильтрации в случаях, когда дополнительные условия фильтрации обладают крайне высокой избирательностью. В качестве примера, следующий запрос может выиграть от предфильтрации:

SELECT bookid, author, title
FROM books
WHERE price < 2.00
ORDER BY cosineDistance(book_vector, getEmbedding('Books on ancient Asian empires'))
LIMIT 10

Предположим, что только очень небольшое количество книг стоит меньше 2 долларов, тогда постфильтрация может вернуть ноль строк, поскольку все топ-10 совпадений, возвращённых векторным индексом, могут иметь цену выше 2 долларов. Принудив предварительную фильтрацию (добавьте SETTINGS vector_search_filter_strategy = 'prefilter' к запросу), ClickHouse сначала находит все книги с ценой менее 2 долларов, а затем выполняет векторный поиск полным перебором (brute-force) для найденных книг.

В качестве альтернативного подхода для решения указанной проблемы параметр vector_search_index_fetch_multiplier (по умолчанию: 1.0, максимум: 1000.0) может быть настроен на значение > 1.0 (например, 2.0). Количество ближайших соседей, извлекаемых из векторного индекса, умножается на значение настройки, после чего к этим строкам применяется дополнительный фильтр, чтобы вернуть не более LIMIT строк. Например, мы можем выполнить запрос снова, но с множителем 3.0:

SELECT bookid, author, title
FROM books
WHERE price < 2.00
ORDER BY cosineDistance(book_vector, getEmbedding('Books on ancient Asian empires'))
LIMIT 10
SETTING vector_search_index_fetch_multiplier = 3.0;

ClickHouse извлечёт 3.0 x 10 = 30 ближайших соседей из векторного индекса в каждой части и затем применит дополнительные фильтры. Будут возвращены только десять ближайших соседей. Отметим, что настройка vector_search_index_fetch_multiplier может частично решить проблему, но в крайних случаях (очень селективное условие WHERE) всё ещё возможно, что будет возвращено меньше, чем запрошенное число N строк.

Пересчёт ранжирования

Skip-индексы в ClickHouse, как правило, фильтруют данные на уровне гранул, т.е. поиск в skip-индексе (на внутреннем уровне) возвращает список потенциально подходящих гранул, что уменьшает объем читаемых данных при последующем сканировании. Это хорошо работает для skip-индексов в целом, но в случае индексов векторного сходства возникает «несоответствие по гранулярности». Более подробно, индекс векторного сходства определяет номера строк для N наиболее похожих векторов для заданного опорного вектора, но затем ему необходимо сопоставить эти номера строк с номерами гранул. ClickHouse затем загружает эти гранулы с диска и повторяет вычисление расстояния для всех векторов в этих гранулах. Этот шаг называется пересчётом оценок (rescoring) и, хотя теоретически он может повысить точность — помните, индекс векторного сходства возвращает только приближенный результат, — очевидно, что с точки зрения производительности он не оптимален.

Поэтому ClickHouse предусматривает оптимизацию, которая отключает пересчёт оценок и возвращает наиболее похожие векторы и их расстояния напрямую из индекса. Оптимизация включена по умолчанию, см. настройку vector_search_with_rescoring. На концептуальном уровне это работает так: ClickHouse делает наиболее похожие векторы и их расстояния доступными как виртуальный столбец _distances. Чтобы увидеть это, выполните запрос векторного поиска с EXPLAIN header = 1:

EXPLAIN header = 1
WITH [0., 2.] AS reference_vec
SELECT id
FROM tab
ORDER BY L2Distance(vec, reference_vec) ASC
LIMIT 3
SETTINGS vector_search_with_rescoring = 0
Query id: a2a9d0c8-a525-45c1-96ca-c5a11fa66f47

    ┌─explain─────────────────────────────────────────────────────────────────────────────────────────────────┐
 1. │ Expression (Project names)                                                                              │
 2. │ Header: id Int32                                                                                        │
 3. │   Limit (preliminary LIMIT (without OFFSET))                                                            │
 4. │   Header: L2Distance(__table1.vec, _CAST([0., 2.]_Array(Float64), 'Array(Float64)'_String)) Float64     │
 5. │           __table1.id Int32                                                                             │
 6. │     Sorting (Sorting for ORDER BY)                                                                      │
 7. │     Header: L2Distance(__table1.vec, _CAST([0., 2.]_Array(Float64), 'Array(Float64)'_String)) Float64   │
 8. │             __table1.id Int32                                                                           │
 9. │       Expression ((Before ORDER BY + (Projection + Change column names to column identifiers)))         │
10. │       Header: L2Distance(__table1.vec, _CAST([0., 2.]_Array(Float64), 'Array(Float64)'_String)) Float64 │
11. │               __table1.id Int32                                                                         │
12. │         ReadFromMergeTree (default.tab)                                                                 │
13. │         Header: id Int32                                                                                │
14. │                 _distance Float32                                                                       │
    └─────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Примечание

Запрос, выполняемый без повторного скоринга (vector_search_with_rescoring = 0) и с включёнными параллельными репликами, в итоге может использовать повторный скоринг.

Настройка производительности

Настройка сжатия

Практически во всех сценариях использования векторы в базовом столбце являются плотными и плохо сжимаются. В результате сжатие замедляет операции вставки и чтения из векторного столбца. Поэтому мы рекомендуем отключить сжатие. Для этого укажите CODEC(NONE) для векторного столбца следующим образом:

CREATE TABLE tab(id Int32, vec Array(Float32) CODEC(NONE), INDEX idx vec TYPE vector_similarity('hnsw', 'L2Distance', 2)) ENGINE = MergeTree ORDER BY id;

Настройка создания индексов

Жизненный цикл индексов векторного сходства связан с жизненным циклом частей. Другими словами, всякий раз, когда создаётся новая часть с определённым индексом векторного сходства, создаётся и сам индекс. Обычно это происходит при вставке данных или во время слияний. К сожалению, HNSW известен длительным временем создания индекса, что может существенно замедлять вставки и слияния. Индексы векторного сходства оптимально использовать только в том случае, если данные неизменяемы или редко изменяются.

Для ускорения создания индекса можно использовать следующие приёмы:

Во‑первых, создание индекса можно распараллелить. Максимальное число потоков создания индекса настраивается серверной настройкой max_build_vector_similarity_index_thread_pool_size. Для оптимальной производительности значение настройки следует установить равным количеству ядер CPU.

Во‑вторых, для ускорения команд INSERT пользователи могут отключить создание пропускающих индексов на вновь вставляемых частях с помощью сессионной настройки materialize_skip_indexes_on_insert. Запросы SELECT для таких частей будут использовать точный поиск. Поскольку вставляемые части обычно малы по сравнению с общим размером таблицы, ожидается, что влияние этого на производительность будет пренебрежимо мало.

В‑третьих, для ускорения слияний пользователи могут отключить создание пропускающих индексов на объединённых частях с помощью сессионной настройки materialize_skip_indexes_on_merge. В сочетании с командой ALTER TABLE [...] MATERIALIZE INDEX [...] это даёт явный контроль над жизненным циклом индексов векторного сходства. Например, создание индекса можно отложить до тех пор, пока все данные не пройдут приём, или до периода низкой нагрузки на систему, такого как выходные.

Настройка использования индексов

Запросы SELECT должны загружать индексы векторного сходства в оперативную память, чтобы использовать их. Чтобы один и тот же индекс векторного сходства не загружался в память многократно, ClickHouse предоставляет отдельный кэш в памяти для таких индексов. Чем больше этот кэш, тем реже происходят избыточные загрузки. Максимальный размер кэша настраивается серверной настройкой vector_similarity_index_cache_size. По умолчанию кэш может разрастаться до 5 ГБ.

Примечание

Кэш индексов векторного сходства хранит гранулы векторных индексов. Если отдельные гранулы векторного индекса больше размера кэша, они не будут кэшироваться. Поэтому, пожалуйста, не забудьте вычислить размер векторного индекса (по формуле в разделе «Оценка расхода хранилища и памяти» или с помощью system.data_skipping_indices) и задать соответствующий размер кэша.

Ещё раз подчеркнём, что проверка и, при необходимости, увеличение кэша векторных индексов должны быть первым шагом при исследовании медленных запросов векторного поиска.

Текущий размер кэша индексов векторного сходства отображается в system.metrics:

SELECT metric, value
FROM system.metrics
WHERE metric = 'VectorSimilarityIndexCacheBytes'

Информацию о попаданиях и промахах кэша для запроса с определённым идентификатором запроса можно получить из system.query_log:

SYSTEM FLUSH LOGS query_log;

SELECT ProfileEvents['VectorSimilarityIndexCacheHits'], ProfileEvents['VectorSimilarityIndexCacheMisses']
FROM system.query_log
WHERE type = 'QueryFinish' AND query_id = '<...>'
ORDER BY event_time_microseconds;

Для производственных сценариев мы рекомендуем настраивать кэш так, чтобы он был достаточно большим, чтобы все векторные индексы постоянно помещались в память.

Настройка квантования

Квантование — это метод уменьшения занимаемого векторами объёма памяти и снижения вычислительных затрат на построение и поиск по векторным индексам. Векторные индексы ClickHouse поддерживают следующие варианты квантования:

QuantizationНазваниеПамять на измерение
f32Одинарная точность4 байта
f16Половинная точность2 байта
bf16 (default)Половинная точность (brain float)2 байта
i8Четвертная точность1 байт
b1Бинарный1 бит

Quantization уменьшает точность векторного поиска по сравнению с поиском по исходным значениям полной точности с плавающей запятой (f32). Однако на большинстве наборов данных квантование в формат brain float с половинной точностью (bf16) приводит к пренебрежимо малой потере точности, поэтому векторные индексы сходства по умолчанию используют эту технику квантования. Квантование с четвертной точностью (i8) и бинарное квантование (b1) вызывают заметную потерю точности при векторном поиске. Мы рекомендуем использовать оба варианта квантования только в том случае, если размер векторного индекса сходства значительно превышает доступный объём оперативной памяти (DRAM). В этом случае мы также рекомендуем включить пересчёт оценок (vector_search_index_fetch_multiplier, vector_search_with_rescoring) для повышения точности. Бинарное квантование рекомендуется только для 1) нормализованных эмбеддингов (т.е. длина вектора = 1, модели OpenAI обычно нормализованы) и 2) если в качестве функции расстояния используется косинусное расстояние. Бинарное квантование внутренне использует расстояние Хэмминга для построения и поиска по графу близости. Шаг пересчёта использует исходные векторы полной точности, хранящиеся в таблице, для определения ближайших соседей по косинусному расстоянию.

Настройка передачи данных

Опорный вектор в запросе векторного поиска предоставляется пользователем и, как правило, получается путём обращения к крупной языковой модели (Large Language Model, LLM). Типичный код на Python, выполняющий векторный поиск в ClickHouse, может выглядеть следующим образом:

search_v = openai_client.embeddings.create(input = "[Good Books]", model='text-embedding-3-large', dimensions=1536).data[0].embedding

params = {'search_v': search_v}
result = chclient.query(
   "SELECT id FROM items
    ORDER BY cosineDistance(vector, %(search_v)s)
    LIMIT 10",
    parameters = params)

Векторы встраивания (search_v в приведённом выше фрагменте) могут иметь очень большую размерность. Например, OpenAI предоставляет модели, которые генерируют векторы встраивания с размерностью 1536 или даже 3072. В приведённом выше коде Python-драйвер ClickHouse подставляет вектор встраивания в виде удобочитаемой строки и затем отправляет запрос SELECT целиком в виде строки. Если предположить, что вектор встраивания состоит из 1536 чисел с плавающей запятой одинарной точности, отправляемая строка достигает длины 20 КБ. Это приводит к высокой загрузке CPU при токенизации, разборе и выполнении тысяч преобразований строк в числа с плавающей запятой. Кроме того, в журнале сервера ClickHouse требуется значительный объём места, что также приводит к разрастанию system.query_log.

Обратите внимание, что большинство LLM-моделей возвращают вектор встраивания как список или NumPy-массив обычных чисел с плавающей запятой. Поэтому мы рекомендуем Python‑приложениям привязывать параметр опорного вектора в двоичной форме, используя следующий подход:

search_v = openai_client.embeddings.create(input = "[Good Books]", model='text-embedding-3-large', dimensions=1536).data[0].embedding

params = {'$search_v_binary$': np.array(search_v, dtype=np.float32).tobytes()}
result = chclient.query(
   "SELECT id FROM items
    ORDER BY cosineDistance(vector, reinterpret($search_v_binary$, 'Array(Float32)'))
    LIMIT 10"
    parameters = params)

В этом примере опорный вектор отправляется в двоичном виде без изменений и интерпретируется на сервере как массив чисел с плавающей запятой. Это экономит процессорное время на стороне сервера и предотвращает разрастание серверных логов и system.query_log.

Администрирование и мониторинг

Размер на диске индексов векторного сходства можно узнать из таблицы system.data_skipping_indices:

SELECT database, table, name, formatReadableSize(data_compressed_bytes)
FROM system.data_skipping_indices
WHERE type = 'vector_similarity';

Пример вывода:

┌─database─┬─table─┬─name─┬─formatReadab⋯ssed_bytes)─┐
│ default  │ tab   │ idx  │ 348.00 MB                │
└──────────┴───────┴──────┴──────────────────────────┘

Отличия от обычных индексов пропуска

Как и все обычные индексы пропуска, индексы похожести векторов строятся по гранулам, и каждый индексируемый блок состоит из GRANULARITY = [N] гранул ([N] по умолчанию равно 1 для обычных индексов пропуска). Например, если гранулярность первичного индекса таблицы равна 8192 (настройка index_granularity = 8192) и GRANULARITY = 2, то каждый индексируемый блок будет содержать 16384 строки. Однако структуры данных и алгоритмы для приблизительного поиска ближайших соседей по своей природе ориентированы на строки. Они хранят компактное представление набора строк и также возвращают строки для запросов векторного поиска. Это приводит к некоторым довольно неинтуитивным отличиям в поведении индексов похожести векторов по сравнению с обычными индексами пропуска.

Когда пользователь определяет индекс похожести векторов на столбце, ClickHouse внутренне создаёт «субиндекс» похожести векторов для каждого индексного блока. Субиндекс является «локальным» в том смысле, что он знает только о строках внутри своего индексного блока. В предыдущем примере, если предположить, что столбец содержит 65536 строк, мы получим четыре индексных блока (охватывающих восемь гранул) и по одному субиндексу похожести векторов для каждого индексного блока. Теоретически субиндекс может напрямую возвращать строки с N ближайшими точками внутри своего индексного блока. Однако, поскольку ClickHouse загружает данные с диска в память с гранулярностью гранул, субиндексы расширяют набор совпадающих строк до гранулярности гранул. Это отличается от обычных индексов пропуска, которые пропускают данные на уровне индексных блоков.

Параметр GRANULARITY определяет, сколько субиндексов похожести векторов будет создано. Большие значения GRANULARITY означают меньшее количество, но более крупные субиндексы похожести векторов, вплоть до ситуации, когда столбец (или часть данных столбца) имеет только один субиндекс. В этом случае субиндекс имеет «глобальное» представление всех строк столбца и может напрямую возвращать все гранулы столбца (части) с релевантными строками (таких гранул не более LIMIT [N]). На втором этапе ClickHouse загрузит эти гранулы и определит действительно лучшие строки, выполняя вычисление расстояний полным перебором (brute-force) по всем строкам гранул. При небольшом значении GRANULARITY каждый из субиндексов возвращает до LIMIT N гранул. В результате нужно загрузить и дополнительно отфильтровать больше гранул. Обратите внимание, что точность поиска в обоих случаях одинаково хорошая, различается только производительность обработки. В общем случае рекомендуется использовать большое значение GRANULARITY для индексов похожести векторов и переходить к меньшим значениям GRANULARITY только в случае проблем, например чрезмерного потребления памяти структурами похожести векторов. Если значение GRANULARITY для индексов похожести векторов не указано, значение по умолчанию составляет 100 миллионов.

Пример

CREATE TABLE tab(id Int32, vec Array(Float32), INDEX idx vec TYPE vector_similarity('hnsw', 'L2Distance', 2)) ENGINE = MergeTree ORDER BY id;

INSERT INTO tab VALUES (0, [1.0, 0.0]), (1, [1.1, 0.0]), (2, [1.2, 0.0]), (3, [1.3, 0.0]), (4, [1.4, 0.0]), (5, [1.5, 0.0]), (6, [0.0, 2.0]), (7, [0.0, 2.1]), (8, [0.0, 2.2]), (9, [0.0, 2.3]), (10, [0.0, 2.4]), (11, [0.0, 2.5]);

WITH [0., 2.] AS reference_vec
SELECT id, vec
FROM tab
ORDER BY L2Distance(vec, reference_vec) ASC
LIMIT 3;

возвращает

   ┌─id─┬─vec─────┐
1. │  6 │ [0,2]   │
2. │  7 │ [0,2.1] │
3. │  8 │ [0,2.2] │
   └────┴─────────┘

Дополнительные примеры наборов данных, использующих приближённый векторный поиск:

Квантованный бит (QBit)

Один из распространённых подходов к ускорению точного поиска по векторам — использование вещественного типа данных с пониженной точностью. Например, если векторы хранятся как Array(BFloat16) вместо Array(Float32), размер данных уменьшается вдвое, и ожидается пропорциональное снижение времени выполнения запросов. Этот метод называется квантованием. Хотя он ускоряет вычисления, точность результатов может снизиться, даже несмотря на выполнение исчерпывающего обхода всех векторов.

При традиционном квантовании точность теряется как во время поиска, так и при хранении данных. В приведённом выше примере мы хранили бы BFloat16 вместо Float32, что означает невозможность выполнить более точный поиск в будущем, даже если это потребуется. Один из альтернативных подходов — хранить две копии данных: квантованную и с полной точностью. Хотя это работает, такой подход требует избыточного хранения. Рассмотрим сценарий, когда у нас есть исходные данные типа Float64, и мы хотим выполнять поиск с разной точностью (16-битной, 32-битной или полной 64-битной). Нам пришлось бы хранить три отдельные копии данных.

ClickHouse предлагает тип данных Quantized Bit (QBit), который снимает эти ограничения за счёт:

  1. Хранения исходных данных с полной точностью.
  2. Возможности задавать точность квантования во время выполнения запроса.

Это достигается за счёт хранения данных в виде групп по битам (то есть все i-е биты всех векторов хранятся вместе), что позволяет считывать данные только с запрошенным уровнем точности. Вы получаете преимущество в скорости за счёт уменьшения объёма операций ввода-вывода (I/O) и вычислений благодаря квантованию, при этом все исходные данные остаются доступны при необходимости. При выборе максимальной точности поиск становится точным.

Чтобы объявить столбец типа QBit, используйте следующий синтаксис:

column_name QBit(element_type, dimension)

Где:

  • element_type – тип каждого элемента вектора. Поддерживаемые типы: BFloat16, Float32 и Float64
  • dimension – количество элементов в каждом векторе

Создание таблицы QBit и добавление данных

CREATE TABLE fruit_animal (
    word String,
    vec QBit(Float64, 5)
) ENGINE = MergeTree
ORDER BY word;

INSERT INTO fruit_animal VALUES
    ('apple', [-0.99105519, 1.28887844, -0.43526649, -0.98520696, 0.66154391]),
    ('banana', [-0.69372815, 0.25587061, -0.88226235, -2.54593015, 0.05300475]),
    ('orange', [0.93338752, 2.06571317, -0.54612565, -1.51625717, 0.69775337]),
    ('dog', [0.72138876, 1.55757105, 2.10953259, -0.33961248, -0.62217325]),
    ('cat', [-0.56611276, 0.52267331, 1.27839863, -0.59809804, -1.26721048]),
    ('horse', [-0.61435682, 0.48542571, 1.21091247, -0.62530446, -1.33082533]);

Найдём ближайших соседей к вектору, соответствующему слову 'lemon', используя расстояние L2. Третий параметр в функции расстояния задаёт точность в битах — более высокие значения обеспечивают большую точность, но требуют больше вычислительных ресурсов.

Все доступные функции расстояния для QBit можно найти здесь.

Поиск с полной точностью (64 бита):

SELECT
    word,
    L2DistanceTransposed(vec, [-0.88693672, 1.31532824, -0.51182908, -0.99652702, 0.59907770], 64) AS distance
FROM fruit_animal
ORDER BY distance;
   ┌─word───┬────────────distance─┐
1. │ apple  │ 0.14639757188169716 │
2. │ banana │   1.998961369007679 │
3. │ orange │   2.039041552613732 │
4. │ cat    │   2.752802631487914 │
5. │ horse  │  2.7555776805484813 │
6. │ dog    │   3.382295083120104 │
   └────────┴─────────────────────┘

Поиск с уменьшенной точностью:

SELECT
    word,
    L2DistanceTransposed(vec, [-0.88693672, 1.31532824, -0.51182908, -0.99652702, 0.59907770], 12) AS distance
FROM fruit_animal
ORDER BY distance;
   ┌─word───┬───────────distance─┐
1. │ apple  │  0.757668703053566 │
2. │ orange │ 1.5499475034938677 │
3. │ banana │ 1.6168396735102937 │
4. │ cat    │  2.429752230904804 │
5. │ horse  │  2.524650475528617 │
6. │ dog    │   3.17766975527459 │
   └────────┴────────────────────┘

Обратите внимание, что при 12-битной квантизации мы получаем хорошую аппроксимацию расстояний при более быстром выполнении запроса. Относительный порядок в основном сохраняется, при этом 'apple' по-прежнему остается ближайшим соответствием.

Особенности производительности

Преимущество QBit с точки зрения производительности достигается за счет сокращения количества I/O‑операций, так как при использовании меньшей точности из хранилища нужно считывать меньше данных. Кроме того, когда QBit содержит данные Float32, при значении параметра точности 16 или ниже это дает дополнительный выигрыш за счет уменьшения объема вычислений. Параметр точности напрямую задает компромисс между точностью и скоростью:

  • Более высокая точность (ближе к исходной ширине данных): более точные результаты, более медленные запросы
  • Низкая точность: более быстрые запросы с приблизительными результатами, меньшее потребление памяти

Ссылки

Статьи в блоге: