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

IVF: ускорение векторного поиска в Apache Doris с помощью индексов IVF

Определение в одном предложении: IVF (Inverted File Index) — это индекс приближённого поиска ближайших соседей (ANN), который сужает область поиска, разбивая векторное пространство на кластеры. Встроенная поддержка IVF доступна начиная с Apache Doris 4.x.

Эта статья отвечает на следующие вопросы:

  • Что такое индекс IVF? Почему он ускоряет векторный поиск?
  • Как создать, построить и удалить индекс IVF в Apache Doris?
  • Как выбрать ключевые параметры, такие как nlist и nprobe, чтобы сбалансировать полноту поиска и производительность?
  • Какие факторы влияют на полноту поиска? Как избежать снижения производительности?

Быстрая навигация​

Ваша цельПерейти к разделу
Понять основные принципы IVFЧто такое индекс IVF
Создать и использовать индекс IVFИспользование IVF в Apache Doris
Настроить полноту поискаОптимизация полноты поиска
Устранить проблемы с производительностью запросовПроизводительность запросов
Воспроизвести тесты производительностиТест производительности
Найти ответы на частые вопросыЧасто задаваемые вопросы

Что такое индекс IVF​

От инвертированных индексов к векторным инвертированным индексам​

Термин IVF (Inverted File) пришёл из области информационного поиска. Рассмотрим поиск по тексту:

  • Прямой индекс: для каждого документа хранится список слов. При выполнении запроса необходимо просмотреть все документы.

    ДокументСлова
    Документ 1the, cow, says, moo
    Документ 2the, cat, and, the, hat
    Документ 3the, dish, ran, away, with, the, spoon
  • Инвертированный индекс: для каждого слова хранится список документов, содержащих это слово. При выполнении запроса достаточно просмотреть соответствующие списки.

    СловоДокументы
    theДокумент 1, Документ 3, Документ 4, Документ 5, Документ 7
    cowДокумент 2, Документ 3, Документ 4
    saysДокумент 5
    mooДокумент 7

Сегодня текст обычно представляют в виде векторных эмбеддингов. IVF использует идею инвертированного индекса: центроиды кластеров играют роль «словаря», а для каждого центроида хранится список векторов, принадлежащих этому кластеру. При выполнении запроса достаточно проверить небольшое число выбранных кластеров.

Когда набор данных вырастает до миллионов или миллиардов векторов, точный поиск kNN (вычисление расстояния между вектором запроса и каждым вектором в базе данных) становится эквивалентен масштабному матричному умножению, а вычислительные затраты — неприемлемыми.

Приближённый поиск ближайших соседей (ANN) позволяет ускорить поиск на порядок ценой небольшой потери точности. IVF — один из самых распространённых и эффективных методов ANN в отрасли. Его основная идея — «разделяй и властвуй»:

  1. Разбить весь набор векторов на несколько кластеров, каждый из которых представлен центроидом.
  2. При выполнении запроса сначала найти небольшое число кластеров, центроиды которых ближе всего к вектору запроса, а затем искать только в этих кластерах, пропуская остальные данные.

Поиск IVF


Использование IVF в Apache Doris​

Apache Doris поддерживает индексы ANN на основе IVF начиная с версии 4.x. Тип индекса всегда задаётся как ANN, а алгоритм IVF выбирается с помощью параметра index_type=ivf.

Способы построения индекса​

Создать индекс ANN можно двумя способами, подходящими для разных сценариев:

СпособКогда строится индексПреимуществаНедостаткиСценарии применения
Определение индекса при создании таблицыСинхронно во время загрузки данныхЗапросы ускоряются сразу после записи данныхЗамедляет запись; Compaction может вызывать перестроение индекса, расходуя ресурсыПроизводственные среды с уже подобранными параметрами индекса
CREATE INDEX + BUILD INDEXАсинхронно после загрузки данныхНе влияет на загрузку данных; удобен для подбора параметровЗапросы не ускоряются, пока индекс строитсяПодбор параметров, первоначальное заполнение очень больших таблиц

Способ 1: определение индекса при создании таблицы​

CREATE TABLE sift_1M (
id int NOT NULL,
embedding array<float> NOT NULL COMMENT "",
INDEX ann_index (embedding) USING ANN PROPERTIES(
"index_type"="ivf",
"metric_type"="l2_distance",
"dim"="128",
"nlist"="1024"
)
) ENGINE=OLAP
DUPLICATE KEY(id) COMMENT "OLAP"
DISTRIBUTED BY HASH(id) BUCKETS 1
PROPERTIES (
"replication_num" = "1"
);

INSERT INTO sift_1M
SELECT *
FROM S3(
"uri" = "https://selectdb-customers-tools-bj.oss-cn-beijing.aliyuncs.com/sift_database.tsv",
"format" = "csv");

Способ 2: CREATE INDEX + BUILD INDEX​

Шаг 1: создайте таблицу без индекса и загрузите данные.

CREATE TABLE sift_1M (
id int NOT NULL,
embedding array<float> NOT NULL COMMENT ""
) ENGINE=OLAP
DUPLICATE KEY(id) COMMENT "OLAP"
DISTRIBUTED BY HASH(id) BUCKETS 1
PROPERTIES (
"replication_num" = "1"
);

INSERT INTO sift_1M
SELECT *
FROM S3(
"uri" = "https://selectdb-customers-tools-bj.oss-cn-beijing.aliyuncs.com/sift_database.tsv",
"format" = "csv");

Шаг 2: выполните CREATE INDEX, чтобы добавить определение индекса. На этом этапе регистрируются только метаданные индекса; сам индекс для существующих данных ещё не построен.

CREATE INDEX idx_test_ann ON sift_1M (`embedding`) USING ANN PROPERTIES (
"index_type"="ivf",
"metric_type"="l2_distance",
"dim"="128",
"nlist"="1024"
);

SHOW DATA ALL FROM sift_1M;

Ожидаемый результат (LocalIndexSize всё ещё равен 0):

+-----------+-----------+--------------+----------+----------------+---------------+----------------+-----------------+----------------+-----------------+
| TableName | IndexName | ReplicaCount | RowCount | LocalTotalSize | LocalDataSize | LocalIndexSize | RemoteTotalSize | RemoteDataSize | RemoteIndexSize |
+-----------+-----------+--------------+----------+----------------+---------------+----------------+-----------------+----------------+-----------------+
| sift_1M | sift_1M | 10 | 1000000 | 170.093 MB | 170.093 MB | 0.000 | 0.000 | 0.000 | 0.000 |
| | Total | 10 | | 170.093 MB | 170.093 MB | 0.000 | 0.000 | 0.000 | 0.000 |
+-----------+-----------+--------------+----------+----------------+---------------+----------------+-----------------+----------------+-----------------+

Шаг 3: выполните BUILD INDEX, чтобы построить индекс для существующих данных. Эта задача выполняется асинхронно.

BUILD INDEX idx_test_ann ON sift_1M;

Шаг 4: проверьте состояние задачи с помощью SHOW BUILD INDEX.

SHOW BUILD INDEX WHERE TableName = "sift_1M";

После завершения задачи снова проверьте размер данных. Теперь отображается размер построенного индекса (LocalIndexSize):

+---------------+-----------+---------------+-----------------------------------------------------------------------------------------------------------------------------------------------------+-------------------------+-------------------------+---------------+----------+------+----------+
| JobId | TableName | PartitionName | AlterInvertedIndexes | CreateTime | FinishTime | TransactionId | State | Msg | Progress |
+---------------+-----------+---------------+-----------------------------------------------------------------------------------------------------------------------------------------------------+-------------------------+-------------------------+---------------+----------+------+----------+
| 1764392359610 | sift_1M | sift_1M | [ADD INDEX idx_test_ann (`embedding`) USING ANN PROPERTIES("dim" = "128", "index_type" = "ivf", "metric_type" = "l2_distance", "nlist" = "1024")], | 2025-12-01 14:18:22.360 | 2025-12-01 14:18:27.885 | 5036 | FINISHED | | NULL |
+---------------+-----------+---------------+-----------------------------------------------------------------------------------------------------------------------------------------------------+-------------------------+-------------------------+---------------+----------+------+----------+

mysql> SHOW DATA ALL FROM sift_1M;
+-----------+-----------+--------------+----------+----------------+---------------+----------------+-----------------+----------------+-----------------+
| TableName | IndexName | ReplicaCount | RowCount | LocalTotalSize | LocalDataSize | LocalIndexSize | RemoteTotalSize | RemoteDataSize | RemoteIndexSize |
+-----------+-----------+--------------+----------+----------------+---------------+----------------+-----------------+----------------+-----------------+
| sift_1M | sift_1M | 10 | 1000000 | 671.084 MB | 170.093 MB | 500.991 MB | 0.000 | 0.000 | 0.000 |
| | Total | 10 | | 671.084 MB | 170.093 MB | 500.991 MB | 0.000 | 0.000 | 0.000 |
+-----------+-----------+--------------+----------+----------------+---------------+----------------+-----------------+----------------+-----------------+

Удаление индекса​

При подборе параметров часто требуется проверить разные комбинации, чтобы обеспечить нужную полноту поиска. Используйте DROP INDEX для гибкого управления индексами:

ALTER TABLE sift_1M DROP INDEX idx_test_ann;

Выполнение векторных запросов​

Индексы ANN ускоряют как поиск TopN, так и поиск в диапазоне.

Рекомендация для производственной среды: строковое представление векторов высокой размерности создаёт дополнительные накладные расходы при разборе SQL, поэтому при большом числе одновременных запросов не рекомендуется выполнять SQL напрямую. Рекомендуются два варианта оптимизации:

  1. Используйте Prepare Statement для предварительного разбора SQL.
  2. Используйте официальную библиотеку Python для векторного поиска в Doris. Эта библиотека предоставляет обёртку для вызовов Prepare Statement и преобразует результаты запросов непосредственно в pandas DataFrame, что удобно при разработке приложений ИИ.

Пример кода:

from doris_vector_search import DorisVectorClient, AuthOptions

auth = AuthOptions(
host="127.0.0.1",
query_port=9030,
user="root",
password="",
)

client = DorisVectorClient(database="test", auth_options=auth)

tbl = client.open_table("sift_1M")

query = [0.1] * 128 # Example 128-dimensional vector

# SELECT id FROM sift_1M ORDER BY l2_distance_approximate(embedding, query) LIMIT 10;
result = tbl.search(query, metric_type="l2_distance").limit(10).select(["id"]).to_pandas()

print(result)

Ожидаемый результат:

       id
0 123911
1 926855
2 123739
3 73311
4 124493
5 153178
6 126138
7 123740
8 125741
9 124048

Оптимизация полноты поиска​

Основная метрика векторного поиска — полнота. Любой показатель производительности имеет смысл только при приемлемой полноте поиска. Основные факторы, влияющие на полноту:

  1. Параметры индекса IVF (nlist) и параметры запроса (nprobe)
  2. Квантование векторов в индексе
  3. Размер и количество файлов Segment

В этом разделе рассматриваются пункты 1 и 3. Квантование векторов описано в других документах.

Гиперпараметры индекса: nlist и nprobe​

IVF использует ключевые параметры на этапе построения индекса и при выполнении запросов:

Этап построения индекса:

  1. Кластеризация: алгоритм кластеризации (например, k-means) разбивает векторы на nlist кластеров, после чего для каждого кластера вычисляется и сохраняется центроид.
  2. Распределение векторов: каждый вектор назначается кластеру с ближайшим центроидом и добавляется в соответствующий инвертированный список.

Этап выполнения запроса:

  1. Выбор кластеров: вычисляется расстояние от вектора запроса до всех nlist центроидов и выбираются nprobe ближайших кластеров.
  2. Полный перебор внутри кластеров: для поиска ближайших соседей последовательно сравниваются векторы внутри выбранных nprobe кластеров.
ПараметрНазначениеВлияниеЗначение по умолчанию в Doris
nlistКоличество кластеров (инвертированных списков)Чем больше значение, тем мельче разбиение и быстрее поиск, но выше затраты на кластеризацию и вероятность того, что соседние векторы окажутся в разных кластерах1024
nprobeКоличество кластеров, проверяемых при выполнении запросаБольшее значение повышает полноту поиска и задержку; меньшее ускоряет поиск, но повышает вероятность пропуска результатов64

Результаты измерений на наборе данных SIFT_1M:

nlistnproberecall@100
1024640.9542
1024320.9034
1024160.8299
102480.7337
512320.9384
512160.8763
51280.7869

Практический подбор гиперпараметров​

Хотя точные оптимальные параметры нельзя определить заранее, их можно подобрать систематически:

  1. Создайте временную таблицу table_multi_index без индексов, содержащую от 2 до 3 векторных столбцов.
  2. Загрузите данные в эту таблицу с помощью Stream Load или другим способом.
  3. Выполните CREATE INDEX и BUILD INDEX с разными параметрами для каждого векторного столбца.
  4. Сравните полноту поиска для каждого столбца и выберите наиболее подходящую комбинацию параметров.

Пример:

ALTER TABLE tbl DROP INDEX idx_embedding;
CREATE INDEX idx_embedding ON tbl (`embedding`) USING ANN PROPERTIES (
"index_type"="ivf",
"metric_type"="inner_product",
"dim"="768",
"nlist"="1024"
);
BUILD INDEX idx_embedding ON tbl;

Количество строк, охватываемых индексом​

Данные во внутренней таблице Doris организованы иерархически:

  • Таблица → Tablet: данные равномерно распределяются по N Tablets с помощью ключа бакетирования (Tablet — базовая единица миграции и перебалансировки данных).
  • Tablet → Rowset: при каждой загрузке или Compaction добавляется новый Rowset (единица управления версиями).
  • Rowset → Segment: сами данные хранятся в файлах Segment.

Как и инвертированные индексы, векторные индексы работают на уровне Segment. Размер Segment регулируется параметрами конфигурации BE write_buffer_size и vertical_compaction_max_segment_size. Во время загрузки или Compaction, когда размер memtable достигает определённого значения, её содержимое сбрасывается в файл Segment, и для этого Segment строится векторный индекс (для нескольких индексируемых столбцов создаётся несколько индексов).

Каждая комбинация параметров индекса IVF обеспечивает эффективный поиск лишь для ограниченного объёма данных. Когда количество строк в Segment превышает некоторый порог, полнота поиска снижается.

Совет: используйте SHOW TABLETS FROM <table>, чтобы проверить состояние Compaction таблицы. Откройте соответствующий URL, чтобы узнать количество файлов Segment.

Влияние Compaction на полноту поиска​

Compaction объединяет несколько небольших файлов Segment в более крупные. Из-за этого параметры индекса, подобранные для меньшего объёма данных, становятся неэффективными, и полнота поиска снижается.

Рекомендация: запустите FULL COMPACTION перед выполнением BUILD INDEX. Построение индекса после полного объединения файлов Segment даёт два преимущества:

  • Полнота поиска остаётся стабильной.
  • Снижается усиление записи, вызванное построением индекса.

Производительность запросов​

Холодная загрузка файлов индекса​

Индекс ANN в Doris реализован на основе faiss — библиотеки Meta с открытым исходным кодом. Чтобы индекс IVF мог ускорять запросы, его необходимо полностью загрузить в память.

Рекомендация: перед выполнением большого числа одновременных запросов выполните холодный запрос, чтобы загрузить все необходимые файлы индексов Segment в память. В противном случае производительность первого запроса существенно снизится.

Потребление памяти и производительность​

Объём памяти, занимаемый индексом IVF без сжатия с помощью квантования, примерно в 1.02 раза превышает объём индексируемых векторов.

Например, индекс IVF FLAT для набора данных с векторами размерности 128 и 1M строк занимает приблизительно следующий объём памяти:

128 * 4 * 1,000,000 * 1.02 ≈ 500 MB

Справочные значения:

dimКоличество строкРасчётный объём памяти
1281M500 MB
7681M2.9 GB

Для обеспечения производительности запросов на BE должно быть достаточно памяти для размещения всего индекса. В противном случае частые операции IO с файлами индекса приводят к серьёзному снижению производительности запросов.


Тест производительности​

Рекомендация по развёртыванию: условия тестирования должны быть близки к производственной среде. Разверните FE и BE отдельно, а клиент запускайте на отдельной машине.

Средство тестирования: VectorDBBench.

Performance768D1M​

Команды для тестирования:

# load
NUM_PER_BATCH=1000000 python3 -m vectordbbench doris --host 127.0.0.1 --port 9030 --case-type Performance768D1M --db-name Performance768D1M --stream-load-rows-per-batch 500000 --index-prop index_type=ivf,nlist=1024 --skip-search-serial --skip-search-concurrent

# search
NUM_PER_BATCH=1000000 python3 -m vectordbbench doris --host 127.0.0.1 --port 9030 --case-type Performance768D1M --db-name Performance768D1M --search-concurrent --search-serial --num-concurrency 10,40,80 --stream-load-rows-per-batch 500000 --index-prop index_type=ivf,nlist=1024 --session-var ivf_nprobe=64 --skip-load --skip-drop-old

Часто задаваемые вопросы​

Вопрос 1: как выбрать между IVF и HNSW? IVF подходит для больших объёмов данных при достаточном объёме памяти, когда нужно сбалансировать затраты на построение индекса и задержку запросов. HNSW обеспечивает меньшую задержку запросов, но потребляет больше памяти. Подробнее см. в документации HNSW.

Вопрос 2: почему полнота поиска остаётся низкой после BUILD INDEX? Распространённые причины: слишком низкое значение nprobe, слишком большой размер файлов Segment, из-за чего индекс не обеспечивает достаточную полноту поиска, или отсутствие FULL COMPACTION перед BUILD. См. раздел Оптимизация полноты поиска.

Вопрос 3: зачем выполнять холодный запрос перед большим числом одновременных запросов? Чтобы индекс IVF мог ускорять запросы, его необходимо полностью загрузить в память. Холодный запрос прогревает кэш, загружая индекс с диска в память, что позволяет избежать снижения производительности первого рабочего запроса.

Вопрос 4: нужно ли менять значение nlist по умолчанию, равное 1024? По умолчанию Doris использует nlist=1024 и nprobe=64, что подходит для большинства наборов данных среднего размера. Корректируйте параметры с учётом фактического объёма данных и требований к полноте поиска, следуя рекомендациям раздела Практический подбор гиперпараметров.

Вопрос 5: освобождает ли DROP INDEX память немедленно? DROP INDEX удаляет определение индекса, а файлы индекса очищаются позже. При подборе параметров используйте эту команду вместе с последовательностью CREATE INDEX и BUILD INDEX.


Устранение неполадок​

СимптомВозможная причинаРешение
Полнота поиска значительно ниже ожидаемойСлишком низкое значение nprobe / слишком большие файлы Segment / FULL COMPACTION не выполняласьУвеличьте nprobe; выполните FULL COMPACTION перед BUILD; скорректируйте nlist
Первый запрос выполняется с очень большой задержкой, последующие — нормальноИндекс ещё не загружен в память (холодная загрузка)Выполните холодный запрос для прогрева перед большим числом одновременных запросов
На BE не хватает памяти, и производительность запросов снижаетсяИндекс не помещается в память целиком, что вызывает частые операции IOУвеличьте объём памяти BE; рассмотрите сжатие с помощью квантования, чтобы снизить потребление памяти
BUILD INDEX долго не завершаетсяЗадача выполняется асинхронно, а объём данных великПроверьте ход выполнения с помощью SHOW BUILD INDEX WHERE TableName = "<tbl>"
Загрузка данных замедляетсяИндекс, определённый при создании таблицы, строится синхронноПерейдите на способ CREATE INDEX + BUILD INDEX для асинхронного построения