Привет Хабр! Меня зовут Владимир сегодня я постараюсь исправить ошибку из моей первой статьи про векторный поиск. Основная претензия к статье (два из трех комментариев 😂) была в том, что тема сисек векторного поиска не раскрыта.
В этом материале постараюсь рассказать, что же такое векторный поиск, зачем он нужен, варианты реализации векторного поиска в PostgreSQL и провести сравнительные тесты времени отклика при различных объёмах данных.
Немного теории
Начнём с азов. Машина (она же ПЭВМ, ПК, PC) не умеет работать с буквами, машине нужны цифры. Для обеспечения работы машины с текстами и придумали векторизацию текста.
Векторизация текста - процесс преобразования текста в числовой формат, который могут понимать и обрабатывать алгоритмы машинного обучения.
Существует множество способов векторизации текста - начиная простейшими, например с помощью One-Hot Encoding (когда каждому слову в словаре присваивается уникальный индекс), и заканчивая продвинутыми - с помощью предобученных нейросетей. Описывать их не буду, т.к. это тема для отдельной статьи. Перейдём к векторному поиску.
Векторный поиск — это технология, которая позволяет искать информацию не по точному совпадению слов, а по смыслу.
Главная идея векторного поиска состоит в том, что вектора слов (они же эмбеддинги) с похожим смыслом располагаются близко в N-мерном пространстве (где N - размерность эмбеддинга), а слова с разным смыслом — далеко. Есть классический пример, взятый из модели Word2Vec:
Формула показывает базовые принципы работы с векторами - разница между эмбеддингами слов Король и Мужчина выделяет смысловое направление "монархия", убирая при этом признак пола из вектора. Когда мы прибавляем вектор Женщина, мы сдвигаем эту точку в пространстве и в итоге новая точка оказывается ближе всего к эмбеддингу слова Королева. Это доказывает, что в векторном пространстве слова выстроены не хаотично, а образуют смысловые кластеры и связи.
Чтобы понять, насколько два вектора похожи, используют математические метрики. Самые популярные из них:
Косинусное сходство (Cosine Similarity) - измеряет угол между двумя векторами. Если векторы смотрят в одну сторону — сходство максимальное. Если векторы перпендикулярны — они не связаны.
Евклидово расстояние - измеряет прямую линию между двумя точками. Чем короче линия, тем объекты ближе.
Стоит отметить, что для нормированных векторов существует однозначное соответствие между косинусным сходством и квадратом евклидова расстояния. Это означает, что ранжирование объектов по этим двум метрикам даст одинаковый результат.
Теперь рассмотрим основные технологии для реализации векторного поиска в PostgreSQL
Технологии векторного поиска в PostgreSQL
pgvector
Судя по количеству звезд репозитория, pgvector является самым популярным расширением с открытым исходным кодом для реализации векторного поиска в PostgreSQL. Расширение написано на языке C и добавляет в PostgreSQL тип данных vector для хранения эмбеддингов текстов. Для векторного поиска в расширении реализована поддержка метрик близости (косинусное сходство, L2, скалярное произведение). Для индексации расширение использует стандартные алгоритмы IVFFlat (алгоритм, основанный на кластеризации векторов по методу KNN) и HNSW (строит многоуровневый граф, где поиск идет от верхних (грубых) слоев к нижним (детальным)), которые позволяют выполнять приближённый поиск ближайших соседей (ANN) прямо внутри СУБД.
pgvectorscale
Расширение от Tiger Data, которое "надстраивается" над pgvector для улучшения производительности на больших объёмах данных. Расширение написано на Rust с использованием фреймворка PGRX. Предлагает собственный индекс StreamingDiskANN (адаптация алгоритма Microsoft DiskANN) и метод сжатия Statistical Binary Quantization (SBQ), что позволяет эффективно хранить векторы на диске и ускорять поиск при ограниченной памяти. Разработчик заявляет прирост пропускной способности поиска в 16 раз при 50 миллионах строк с эмбеддингом размерностью 768. Основное преимущество pgvectorscale заключается в их индексе StreamingDiskANN. В отличие от популярного индекса HNSW, который для работы должен полностью помещаться в оперативной памяти, StreamingDiskANN хранит основную часть индекса на SSD. Это даёт возможность масштабирования без огромных затрат на ОЗУ, сохраняя при этом высокую производительность (алгоритм обеспечивает высокую скорость запросов за счёт продуманной структуры данных и эффективного кэширования).
pgvecto.rs
Расширение pgvecto.rs, так же как и pgvectorscale полностью написано на Rust. Предлагает собственные реализации индексов, оптимизированные под низкую задержку, и поддерживает дополнительные типы векторов (binary, FP16, INT8). Также реализует расширенную поддержку размерности эмбеддинга - 65535 против 2000 у pgvector и pgvectorscale. Ключевой особенность pgvecto.rs является алгоритм поиска VBASE. В отличие от классических ANN-алгоритмов, VBASE оптимизирован для гибридных запросов: векторный поиск + фильтрация + джойны.
VectorChord
VectorChord, так же как и pgvecto.rs, является разработкой компании TensorChord и служит заменой pgvecto.rs (судя по тому,что на момент написания материала VectorChord активно развивается, а последний резиз pgvecto.rs был в ноябре 2024г). Для эффективного хранения векторов (без ущерба для качества поиска) VectorChord использует сжатие RaBitQ (Randomized Binary Quantization) в сочетании с автономным переранжированием, что, по утверждению разработчика, позволяет хранить в 26 раз больше данных, чем в pgvector, при той же цене.
Тип данных vector
Как я написал ранее, тип данных vector изначально был представлен в расширении pgvector. Благодаря своей простоте и открытости он стал общепринятым стандартом, который поддерживают все вышеперечисленные расширения. Это позволяет полностью сохранить уже разработанные миграции и пользовательские запросы. Для нас это означает, что весь ранее написанный код подойдёт для использования с любым расширением. Всё, что останется сделать - выбрать, какой "движок" будет их искать: стандартный HNSW из pgvector, DiskANN из pgvectorscale или RaBitQ из VectorChord.
Думаю хватит теории, перейдём к реализации.
Инфраструктура
Для начала создадим инфраструктуру для будущих тестов производительности - сделаем три отдельных контейнера с базами данных на основе Pgvector (уже существует), pgvectorscale и VectorChord. Начнем с pgvectorscale. Скопируем наш docker-compose.yml с новым именем и изменим образ для развёртывания БД. Дополнительно укажем свой путь к файлу инициализации и прокинем порт наружу, чтобы посмотреть Бобром. Также необходимо заменить путь к данным на /home/postgres/pgdata (вместо стандартного /var/lib/postgresql), т.к. образ timescale/timescaledb-ha:pg18 хранит данные в другом каталоге:
services: postgres: image: timescale/timescaledb-ha:pg18 ports: - "5432:5432" volumes: - pgdata:/home/postgres/pgdata - ./db_init_pgv:/docker-entrypoint-initdb.d
Теперь создадим файл инициализации для нашей новой базы данных:
CREATE EXTENSION IF NOT EXISTS vector; CREATE EXTENSION IF NOT EXISTS vectorscale;
Вызовем docker compose -f docker-compose-pgvs.yml up -d, дождёмся загрузки образа и проверим Бобром, что база данных успешно создалась:

Видим, что в базе данных установлены оба расширения. Можно убедится, что код действительно работает, запустив ранее написанные тесты. Перейдём к созданию контейнера с VectorChord. Аналогично предыдущему, копируем и редактируем docker-compose.yml и папку db_init:
services: postgres: image: tensorchord/vchord-postgres:pg18-v1.1.1 ports: - "5432:5432" volumes: - pgdata:/var/lib/postgresql - ./db_init_vc:/docker-entrypoint-initdb.d
CREATE EXTENSION IF NOT EXISTS vchord CASCADE;
Проверим базу данных:

и запустим тесты.
После того как убедились, что все три варианта контейнеров создаются и инициализируются необходимыми расширениями предлагаю немного облегчить себе запуск контейнеров и создать Makefile:
COMPOSE := docker compose PGV_FILE := docker-compose-pgv.yml PGVS_FILE := docker-compose-pgvs.yml VC_FILE := docker-compose-vc.yml ARGS ?= # === UP === up-pgv: $(COMPOSE) -f $(PGV_FILE) up --build -d $(ARGS) up-pgvs: $(COMPOSE) -f $(PGVS_FILE) up --build -d $(ARGS) up-vc: $(COMPOSE) -f $(VC_FILE) up --build -d $(ARGS) up: up-pgv up-pgvs up-vc
В файле прописываем переменную COMPOSE (вдруг понадобится старый вариант команды) и названия трёх наших контейнеров. Далее делаем короткие команды на запуск контейнеров и одну общую, на запуск всех трех. Для того чтобы она заработала, необходимо немного доработать compose файлы - в каждом указать уникальное имя (например, name: vector_pgv) и указать разные внешние порты для сервиса. Аналогично можно прописать и другие необходимые команды (down, build и т.п.)
Доработка кода
Для записи документов в базу данных дополним код возможностью массового создания документов. Сначала реализуем метод bulk_create в базовом репозитории:
class BaseRepository(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): # ... существующий код async def bulk_create(self, schemas: list[CreateSchemaType]) -> list[ModelType]: instances = [ self.model(**self._normalize_embedding_format(schema.model_dump())) for schema in schemas ] self.session.add_all(instances) await self.session.flush() return instances
Теперь новый эндпоинт. Для начала добавим схему валидации данных для эндпоинта. Схема нужна, чтобы ограничить максимальное число одновременно передаваемых записей. Дополним schemas/document.py:
class BulkCreateRequest(BaseModel): documents: Annotated[ list[DocumentCreate], Field(..., description='Список документов для создания', min_length=1, max_length=500) ]
Сам эндпоинт разместим в файле api/v1/crud.py:
@router.post( path='/bulk', response_model=list[DocumentResponse], status_code=status.HTTP_201_CREATED) async def bulk_create_documents(request: BulkCreateRequest): async with transaction() as session: repo = DocumentRepository(session) created_docs = await repo.bulk_create(request.documents) return created_docs
Переходим к написанию кода для тестирования производительности.
Реализация тестов
Для проведения тестирования нашей БД первым делом нам необходимо наполнить её данными. А так как тестирование планируется на условно большом объеме (1М записей), то встаёт вопрос - где взять тексты? Чтобы не генерировать синтетику воспользуемся готовыми датасетами с сайта Hugging Face, а конкретно - датасетом новостей ag_news. Эмбеддинги будем получать для заголовков новостей, а в качестве метаданных будем использовать поле category датасета.
Установим необходимые зависимости: datasets для загрузки датасета, ollama для получения эмбеддингов, aiohttp - для запросов к нашему API.
uv add --dev datasets ollama aiohttp tqdm
Начнем с плана. Для теста нам понадобится:
Датасет с возможностью получения данных батчами
Функция для получения эмбеддингов
Загрузчик данных в базы данных
Тестировщик, оценивающий скорость выполнения запроса к базе данных
Реализуем класс для датасета speed_test/test_tools/dataset.py:
from datasets import load_dataset class Dataset: def __init__(self): self.dataset = load_dataset(path='contemmcm/ag_news', split='complete') def __len__(self): return len(self.dataset) def get_chunk(self, start_index: int, end_index: int): return self.dataset.select(range(start_index, end_index)) def get_first(self, data_length: int): return self.dataset.select(range(data_length)) dataset = Dataset()
Фактически, класс выполняет одну функцию - обеспечивает, что в метод поиска будут переданы тексты, которые были загружены в нашу базу данных. Класс работает с методом datasets.load_dataset разработки HuggingFace. В методах класса мы возвращаем либо диапазон записей (для Загрузчика), либо первые N записей (для поиска).
Теперь функция получения эмбеддингов текста. Так как у меня установлена Ollama буду использовать её. Данный код чисто для оценки скорости, поэтому можно воспользоваться узкоспециализированной библиотекой и немножко нахардкодить:
# Файл speed_test/test_tools/utils.py import ollama MODEL_NAME = 'qwen3-embedding:0.6b' def generate_embeddings_ollama(texts: list[str]) -> list[list[float]]: try: return ollama.embed(model=MODEL_NAME, input=texts)['embeddings'] except Exception as e: print(f'Ошибка при генерации эмбеддингов: {e}') raise
Так как в данном тесте мне важнее скорость вычисления эмбеддингов, а не точность поиска, возьмём небольшую модель Qwen3 всего на 600 тысяч параметров.
Переходим к загрузчику данных. Он должен получать батч из датасета, преобразовывать в список словарей, подходящий нашему bulk эндпоинту и выполнять POST запрос. Начнем с конца:
from http import HTTPStatus import aiohttp async def send_batch(session: aiohttp.ClientSession, documents: list[dict], url: str): payload = {'documents': documents} try: async with session.post(url, json=payload) as response: status = response.status if status != HTTPStatus.CREATED: print(f'Ошибка {status}: {await response.text()}') return status == HTTPStatus.CREATED except Exception as e: print(f'Исключение: {str(e)}')
Метод делает POST запрос к переданному в поле url эндпоинту, пересылая ему список документов. Контролируем только по коду возврата. Теперь реализуем сам метод загрузки:
async def load_data(batch_size: int, dataset_limit: int, urls: list[str]): async with aiohttp.ClientSession() as session: for i in tqdm(range(0, dataset_limit // batch_size), desc='Загрузка данных в БД'): batch = dataset.get_chunk(start_index=i * batch_size, end_index=(i + 1) * batch_size) embeddings = generate_embeddings_ollama([item['text'] for item in batch]) documents = [] for j, item in enumerate(batch): documents.append({ 'content': item['text'], 'embedding': embeddings[j], 'meta_data': {'category': item['category']} }) for url in urls: await send_batch(session, documents, url)
Для начала создаём асинхронную HTTP-сессию для отправки запросов к нашему эндпоинту. Далее в цикле итерируемся по датасету, получая фрагменты размером batch_size. Для каждого элемента батча считаем эмбеддинг и формируем словарь для загрузки в БД. Так как у нас три базы данных, то send_batch вызывается для каждого URL из списка. Это сделано для того, чтобы не пересчитывать эмбеддинги три раза (это довольно ресурсозатратно).
Проверим работу загрузчика на небольшом пакете данных. Создадим и запустим файл speed_test/main.py:
import asyncio from test_tools import load_data BASE_LOAD_URL = 'http://127.0.0.1:{port}/api/v1/documents/bulk' PORTS = [8001, 8002, 8003] if __name__ == '__main__': asyncio.run(load_data( batch_size=500, dataset_limit=1000, urls=[BASE_LOAD_URL.format(port=p) for p in PORTS]))
Убедимся любым удобным способом, что во всех трёх базах данных создалось по 1000 записей (я просто Бобром посмотрел) и перейдём к созданию Тестировщика. Так как базы у нас асинхронные, при тестировании будем имитировать одновременные запросы, создавая задачи через asyncio.create_task. Сначала напишем код одного запроса:
async def search_request(session, url, query_vector): payload = {'embedding': query_vector, 'meta_filter': None, 'limit': SEARCH_LIMIT} start = time.perf_counter() try: async with session.post(url, json=payload) as response: latency = time.perf_counter() - start return {'latency': latency, 'success': response.status == HTTPStatus.OK} except Exception as e: latency = time.perf_counter() - start return {'latency': latency, 'success': False}
Формируем и отправляем POST запрос к эндпоинту векторного поиска. Возвращаем время и результат операции (положительным считаем просто статус 200). Для оценки времени воспользуемся идеальной для бенчмаркинга (если верить её документации) функцией time.perf_counter(). Тут конечно, получим не чистое время обработки SQL запроса, но для грубой оценки пойдёт. Теперь реализуем наш воркер для тасков. Функция просто пытается получить новый вектор запроса из очереди и, при успехе, отправляет запрос к БД. Результат запроса складируем в массив:
async def worker(queue, session, url, results): while True: try: vector = queue.get_nowait() except asyncio.QueueEmpty: break res = await search_request(session, url, vector) results.append(res) queue.task_done()
Осталась функция-оркестратор. В ней сначала создаём очередь из первых N векторов датасета, затем запускаем заданное количество тасков.
async def search_test(num_requests: int, num_workers: int, url: str): chunk = dataset.get_first(data_length=num_requests) query_vectors = generate_embeddings_ollama([item['text'] for item in chunk]) queue = asyncio.Queue() for vec in query_vectors: await queue.put(vec) results = [] start_time = time.perf_counter() async with aiohttp.ClientSession() as session: workers = [ asyncio.create_task(worker(queue, session, url, results)) for _ in range(num_workers) ] await asyncio.gather(*workers) total_time = time.perf_counter() - start_time successful = [r for r in results if r['success']] if successful: # Расчет статистики..
Дополним наш main.py файл вызовом поисковика и переходим к тестам:
BASE_LOAD_URL = 'http://127.0.0.1:{port}/api/v1/documents/bulk' BASE_SEARCH_URL = 'http://127.0.0.1:{port}/api/v1/documents/search/semantic' PORTS = [8001, 8002, 8003] if __name__ == '__main__': asyncio.run(load_data( batch_size=500, dataset_limit=1_000_000, urls=[BASE_LOAD_URL.format(port=p) for p in PORTS])) for p in PORTS: asyncio.run(search_test( num_requests=1000, num_workers=10, url=BASE_SEARCH_URL.format(port=p)))
Субъективные тесты производительности расширений
1000 строк данных
Начнем с чего-то простенького - 1000 строк и 100 поисковых запросов. Результаты приведены в таблице:
Тестирование скорости доступа на 1000 строк без индексации | |||
Параметр | pgvector | pgvectorscale | VectorChord |
|---|---|---|---|
Всего запросов | 100 | ||
Процент успешных запросов | 100 % | ||
Общее время (сек) | 0,61 | 0,61 | 0,64 |
Запросов в секунду | 164,67 | 162,73 | 157,28 |
Среднее время ответа (мс) | 58,77 | 59,74 | 61,76 |
Минимальное время ответа (мс) | 14,08 | 13,82 | 12,96 |
Максимальное время ответа (мс) | 186,66 | 184,21 | 186,52 |
Если не переводить разницу в проценты, то все базы справились плюс-минус одинаково. Усложняем.
100 000 строк данных и 1000 запросов
Меняем параметры main.py, запускаем и ждём.
Тестирование скорости доступа на 100K строк без индексации | |||
Параметр | pgvector | pgvectorscale | VectorChord |
|---|---|---|---|
Всего запросов | 1 000 | ||
Процент успешных запросов | 100,0% | ||
Общее время (сек) | 29,28 | 29,05 | 29,40 |
Запросов в секунду | 34,16 | 34,42 | 34,01 |
Среднее время ответа (мс) | 290,77 | 288,99 | 292,05 |
Минимальное время ответа (мс) | 153,43 | 143,57 | 144,39 |
Максимальное время ответа (мс) | 595,21 | 565,13 | 565,08 |
Результаты очень похожи. Но в наших базах сейчас нет самого главного - индекса. Индекс необходим для ускорения поиска: без него база вынуждена выполнять полное сканирование таблицы, сравнивая запрос с каждым вектором, что не масштабируется на больших объёмах данных.
Реализация индексации
Добавим индексацию для колонки embedding. Сначала создадим три пустых миграции:
alembic revision -m "add_pgvector_hnsw_index" alembic revision -m "add_pgvectorscale_diskann_index" alembic revision -m "add_vectorchord_index"
Теперь необходимо вручную заполнить созданные Alembic-ом файлы. Начнем с pgvector:
# Файл /migrations/versions/XXXX_add_pgvector_hnsw_index.py import pgvector def upgrade() -> None: op.execute( """ CREATE INDEX documents_embedding_hnsw_idx ON documents USING hnsw (embedding vector_cosine_ops); """ ) def downgrade() -> None: op.execute("DROP INDEX IF EXISTS documents_embedding_hnsw_idx;")
Индекс pgvector создаём на базе технологии HNSW, так как она быстрее альтернативной IVFFlat. Дополнительные параметры не указываем - это уже FineTune.
Аналогично заполняем файлы миграций для pgvectorscale и VectorChord (у каждого свой тип индекса - DiskANN и VChordRQ соответственно):
# Файл /migrations/versions/YYYY_add_pgvectorscale_diskann_index.py import pgvector def upgrade() -> None: op.execute( """ CREATE INDEX documents_embedding_diskann_idx ON documents USING diskann (embedding vector_cosine_ops); """ ) def downgrade() -> None: op.execute("DROP INDEX IF EXISTS documents_embedding_diskann_idx;") # Файл /migrations/versions/ZZZZ_add_vectorchord_index.py import pgvector def upgrade() -> None: op.execute( """ CREATE INDEX documents_embedding_vchord_idx ON documents USING vchordrq (embedding vector_cosine_ops) """ ) def downgrade() -> None: op.execute("DROP INDEX IF EXISTS documents_embedding_vchordrq_idx;")
Теперь необходимо настроить наши контейнеры на новые миграции. Получим список миграций командой alembic history. У меня получилось:

Для каждого контейнера надо применить базовую миграцию (которая init) и его конкретную. Но! Если просто вызвать alembic upgrade ZZZZ, то Alembic дополнительно сделает все миграции ДО указанной. Поэтому придётся его немного обмануть - воспользоваться командой alembic stamp, которая обновляет таблицу alembic_version с указанной версией ревизии без реального применения миграций. Теперь главное не ошибится в миграциях. В моём случае это:
# Файл docker-compose-pgv.yml migration: # Предыдущий код command: > sh -c "alembic upgrade 0b580de05c77 && alembic upgrade 054df1dffa5a && echo 'Migrations completed'" # Файл docker-compose-pgvs.yml migration: # Предыдущий код command: > sh -c "alembic upgrade 0b580de05c77 && alembic stamp 054df1dffa5a && alembic upgrade 6616a918fc97 && echo 'Migrations completed'" # Файл docker-compose-vc.yml migration: # Предыдущий код command: > sh -c "alembic upgrade 0b580de05c77 && alembic stamp 6616a918fc97 && alembic upgrade baec8145fc9a && echo 'Migrations completed'"
Делаем make restart, дожидаемся, когда отработает контейнер с миграциями, ну и на всякий случай проверим, что индекс создался:

Запускаем ещё раз наш тест, предварительно закомментировав загрузку данных.
100 000 строк данных, 1000 запросов и индекс
Сводная таблица: тестирование скорости доступа на 100к строк | ||||||
Параметр | pgvector | pgvectorscale | VectorChord | |||
|---|---|---|---|---|---|---|
Индекс | Нет | Да | Нет | Да | Нет | Да |
Всего запросов | 1 000 | |||||
Процент успешных запросов | 100,0% | |||||
Общее время (сек) | 29,28 | 5,39 | 29,05 | 5,59 | 29,40 | 5,21 |
Запросов в секунду | 34,16 | 185,46 | 34,42 | 179,02 | 34,01 | 191,97 |
Среднее время ответа (мс) | 290,77 | 53,70 | 288,99 | 55,57 | 292,05 | 51,88 |
Мин. время ответа (мс) | 153,43 | 28,61 | 143,57 | 33,81 | 144,39 | 29,10 |
Макс. время ответа (мс) | 595,21 | 189,05 | 565,13 | 242,78 | 565,08 | 205,82 |
Общее время выполнения запросов при включении индексации сократилось в 5–6 раз для всех вариантов базы данных. Однако явного лидера тестов на 100 000 строк нет.
Теперь перейдем к тесту на миллион!
1 000 000 строк данных и 1000 запросов
Для чистоты эксперимента наполним базу снова. Удалим контейнеры с чисткой данных (make down-vol), откорректируем compose файлы, оставив только первую миграцию с созданием таблицы и запустим наш тест.

Думал это будет быстрее. Посмотрим на результат поиска
Тестирование скорости доступа на 1кк строк без индексации | |||
Параметр | pgvector | pgvectorscale | VectorChord |
|---|---|---|---|
Всего запросов | 1 000 | 1 000 | 1 000 |
Процент успешных запросов | 100,0% | 100,0% | 100,0% |
Общее время (сек) | 318,38 | 317,25 | 314,28 |
Запросов в секунду | 3,14 | 3,15 | 3,18 |
Среднее время ответа (мс) | 3 165,10 | 3 158,03 | 3 125,34 |
Минимальное время ответа (мс) | 1 385,49 | 1 334,54 | 1 447,92 |
Максимальное время ответа (мс) | 5 857,14 | 7 804,68 | 6 020,03 |
При увеличении количества данных в 10 раз среднее время поиска так-же увеличилось в 10 раз. При этом без индекса базы данных показывают приблизительно одинаковый результат. Вернем в compose файлы миграции с индексом и перезапустим базы данных.
Параметр | pgvector | pgvectorscale | VectorChord | |||
|---|---|---|---|---|---|---|
Индекс | Нет | Да | Нет | Да | Нет | Да |
Всего запросов | 1 000 | |||||
Процент успешных запросов | 100,0% | |||||
Общее время (сек) | 318,38 | 6,00 | 317,25 | 5,94 | 314,28 | 6,64 |
Запросов в секунду | 3,14 | 166,79 | 3,15 | 168,23 | 3,18 | 150,56 |
Среднее время ответа (мс) | 3 165,10 | 59,62 | 3 158,03 | 59,23 | 3 125,34 | 65,90 |
Минимальное время ответа (мс) | 1 385,49 | 30,80 | 1 334,54 | 26,05 | 1 447,92 | 20,20 |
Максимальное время ответа (мс) | 5 857,14 | 361,45 | 7 804,68 | 304,23 | 6 020,03 | 546,29 |
Индексация ускорила поиск более чем в 50 раз. Все три расширения показали сопоставимые результаты при поиске 5 ближайших соседей. При этом стоит учитывать, что индексация миллиона записей в VectorChord заняла несколько минут, тогда как два других - около часа.
Вместо выводов
Ну что. Мои субъективные тесты показали, что особой разницы на 1М документов нет. Кто-то (если этот кто-то вообще дочитает 🙂) может возразить, что время надо измерять не так, да и тесты надо было делать на 10М с 1000 результатов. Если что, разрабы VectorChord это уже сделали и у них где-то там всё хорошо. Я же хотел проверить производительность под свою задачу - векторизация внешней переписки. Учитывая её интенсивность в моей организации, 1М строк лично мне хватит лет на 25-30.
Для себя же я выбрал VectorChord - хоть QPS и получился плюс-минус одинаковый, но мне понравилать индексация миллиона строк за пару минут. А если покопаться в настройках, то можно ещё и вектора пожать, что сократит потребляемую память. Также мне у него приглянулась ещё одна фишечка, но это уже другая история...
Все тесты проводились на i9 14900k с 64Гб DDR5 4800 и SSD M2 PCIe 4.0. Эмбеддинги считала RTX 5080.
Код проекта доступен тут.
Буду рад обратной связи в комментариях.
