Привет, Хабр! Меня зовут Николай, мне 32 года. По образованию я врач, анестезиолог-реаниматолог — но последние годы развиваюсь в инженерной сфере, и, честно говоря, она затянула меня сильнее. Больше всего — всё низкоуровневое: как устроена память, что происходит под локами, почему индекс быстрый или медленный. Реанимация научила одной вещи, которая неожиданно пригодилась в инженерии: доверять только измеренным показателям, а не ощущениям.
Проект, о котором пойдёт речь, задумывался как образовательный эксперимент — хотелось своими руками пощупать, как базы данных устроены изнутри: event loop, аллокаторы, WAL, графовые индексы. Но чем глубже я копал, тем интереснее становилось, и «эксперимент» незаметно вырос в полноценный движок, которым я хочу поделиться.
Не буду скрывать: моим напарником в этой работе был Claude. Часть вещей он объяснял, я вникал, перепроверял, спорил — и дальше мы творили вместе. Каждое решение и каждую цифру из этой статьи я понимаю и могу защитить, но написано это было в четыре руки, и мне кажется правильным сказать об этом прямо.
Теперь к делу. Полгода назад я начал писать однонодовую in-memory базу с векторным поиском на Go. Без кластеров, без Raft, без «нам нужен Kubernetes-оператор». Один процесс, RESP-протокол (работает обычный redis-cli), HNSW-индекс внутри, WAL на диске.
В этой статье — путь от первой версии, которая выдавала 18 QPS, до движка, который на многопоточном поиске обходит hnswlib на 36–38%, а на высокоразмерных векторах с квантизацией — в 2.5 раза при вчетверо меньшей памяти. Со всеми цифрами, методологическими граблями и честными границами применимости.
Код открыт: Репозиторий на Гитхабе.
Зачем ещё одна векторная БД
Короткий ответ: мне нужен был движок под RAG/семантический поиск, который живёт в одном процессе рядом с данными, а не отдельный кластер на три ноды с координатором.
Тезис, вокруг которого построен проект: подавляющее большинство реальных задач векторного поиска — это единицы миллионов векторов. Миллион векторов по 1536 float32 — это 6 ГБ. С int8-квантизацией — 1.5 ГБ. Это влезает в память ноутбука, не то что сервера. Кластер здесь — не необходимость, а привычка, унаследованная от эпохи, когда «база данных» означало «диск».
Поэтому осознанные проектные решения:
-
Одна нода. Durability и disaster recovery — через WAL + снапшоты + непрерывный шиппинг WAL на S3 (в стиле Litestream), а не через реплики.
-
RESP-протокол. Клиент — любой Redis-клиент на любом языке. Никакого своего SDK.
-
KV/TTL/Pub-Sub-слой вокруг — заморожен. Он существует, чтобы обслуживать векторное ядро (метаданные, payload, RAG-документы), и не пытается быть заменой Redis. Поверхность команд зафиксирована манифестом.
Стек: Go 1.25, вычисление дистанций на AVX2-ассемблере, из внешних зависимостей — практически только wazero для экспериментального WASM-модуля (который выключен из дефолтной сборки).
Архитектура ядра
Векторное ядро устроено как LSM-дерево, только для графов:

-
Дельта — мутабельный HNSW-граф, принимает записи. По достижении порога (
deltaMax) замораживается. -
Frozen-сегменты — иммутабельные графы в CSR-раскладке (плоские массивы, никаких указателей), с опциональной SQ8-квантизацией (float32 → int8, 4× меньше памяти). Иммутабельность даёт лок-фри чтение и дешёвую сериализацию: снапшот сегмента — это по сути
write()пары слайсов с CRC. -
Поиск — fan-out по дельте и всем сегментам, merge top-K. Число сегментов, как выяснится ниже, — главная ручка производительности.
Ключи векторов не хранятся как []string — они интернированы в один байтовый блоб со срезом оффсетов. Причина не в аллокациях на запрос, а в GC: []string на миллион векторов — это миллион указателей, которые GC сканирует каждый цикл. Два плоских слайса без внутренних указателей GC пропускает целиком. Это steady-state цена, которую benchmem не показывает, а p99 долгоживущей базы — показывает.
Методологические грабли, о которых стоит рассказать до цифр
Грабля №1: случайные векторы врут
Первый честный бенчмарк поиска выдал 18 QPS. Паника, профилирование, поиск бага… которого не было. Бенчмарк генерировал случайные равномерные векторы размерности 768 — а в таком пространстве все попарные расстояния почти равны (проклятие размерности), HNSW-граф вырождается, и поиск превращается в брутфорс.
На реальном MNIST-784 тот же движок показал ~1100 QPS. Вывод: бенчмарки ANN-индексов на синтетике не значат ничего. Все цифры ниже — на реальных датасетах (MNIST-784, SIFT-1M, GIST-960, DBpedia/OpenAI-эмбеддинги) с held-out запросами и точным brute-force ground truth.
Грабля №2: перемерь, прежде чем чинить
В какой-то момент я сел «оптимизировать поиск, потому что 1100 QPS мало». Дисциплина потребовала сначала перемерить текущее состояние — и оказалось, что после уже сделанных изменений те же условия дают 3605 QPS. Старое число просто устарело, и я чуть не потратил неделю на решённую проблему. Не доверяйте числам из прошлых сессий.
Грабля №3: сравнивать индексы можно только по iso-recall
Сравнение «мой движок против hnswlib при ef=64» некорректно: при одном и том же ef разные реализации дают разный recall. Честное сравнение — как в ann-benchmarks: строим кривую recall↔QPS для обоих и сравниваем QPS при равном recall. Все head-to-head ниже — iso-recall, на одной и той же машине.
Железо всех замеров: i7-9750H (6 ядер / 12 потоков), ноутбук 2019 года, под нагрузкой троттлит. QPS_1 — один поток, QPS_12 — 12 потоков.
Три оптимизации, которые дали кратный рост
1. Эвристика выбора соседей: ×2.66 бесплатно
Наивный HNSW при вставке выбирает M ближайших кандидатов в соседи. Это создаёт кластеры взаимно-дублирующих рёбер: greedy-поиск застревает в локальном кластере и recall при малом ef проседает. Правильный вариант — Algorithm 4 из статьи Malkov & Yashunin: кандидат принимается, только если он ближе к базовому узлу, чем к любому уже выбранному соседу. Получаются разнообразные, в том числе дальние рёбра — навигация по графу резко улучшается.
Замер на MNIST-784 (60k, M=32, контролируемый seed, чтобы уровни HNSW совпадали):
|
вариант |
efSearch для recall≥0.96 |
QPS_12 |
insert/s |
|---|---|---|---|
|
top-M (наивный) |
200 |
3611 |
561 |
|
эвристика |
50 |
9595 |
834 |
×2.66 QPS при равном recall и одновременно ×1.49 к скорости вставки — вставка ускорилась, потому что по хорошо навигируемому графу и вставочный поиск проходит быстрее. Одна правка build-time логики, формат и поиск не тронуты.
2. Шардированная дельта: ×4.25 к записи
Конкурентные вставки упирались в глобальный лок дельты: потолок ~800 insert/s и отрицательное масштабирование от потоков. Решение — разбить дельту на N независимых шардов (ключ → maphash(key) % N), каждый со своим локом. Add теперь берёт только RLock верхнего уровня, писатели работают параллельно.
MNIST-784, вставка: 1 шард — 906/s, 4 — 2205/s (×2.43), 12 — 3848/s (×4.25). Recall не деградирует.
Цена: поиск по дельте — fan-out по шардам. На проде дельта — 1–2% данных, штраф поиска ~2–5%. Фича gated: дефолт — 1 шард, старое поведение.
3. Консолидация сегментов: ×4–7 к поиску
Главная находка всего проекта. На SIFT-1M мой движок в дефолтной конфигурации проигрывал hnswlib в три раза по многопоточному QPS — и я почти списал это на «архитектурный разрыв». Проверка гипотезы A/B-тестом:
|
Конфигурация |
recall |
QPS_1 |
QPS_12 |
масштабирование |
|---|---|---|---|---|
|
14 сегментов (deltaMax=50k) |
0.996 |
1123 |
2064 |
1.84× |
|
4 сегмента (deltaMax=250k) |
0.993 |
— |
5338 |
— |
|
1 сегмент (deltaMax=N) |
0.991 |
2840 |
14464 |
5.09× |
Мультисегментный fan-out убивал и QPS, и масштабирование по ядрам: каждый запрос платил за поиск в 14 графах и merge. Консолидация в 1–2 крупных сегмента — ×7 QPS одной настройкой, без единой строчки кода. Это и есть классический LSM-tradeoff запись↔чтение: read-heavy профилю — агрессивный merge, write-heavy — больше сегментов.
Head-to-head против hnswlib
hnswlib — референсная C++ реализация HNSW с вылизанным SIMD. Сравнение на одной машине, одинаковые M=16/efC=200, одни и те же 500 held-out запросов, официальный ground truth.

SIFT-1M (1 млн векторов, dim 128, float32)
Iso-recall, консолидированный движок против hnswlib:
|
recall |
наш QPS_1 |
hnswlib QPS_1 |
наш QPS_12 |
hnswlib QPS_12 |
|---|---|---|---|---|
|
~0.96 |
5115 (84%) |
6082 |
25697 (136%) |
18833 |
|
~0.99 |
2840 (86%) |
3300 |
14464 (138%) |
10500 |
Один поток — 84–86% от hnswlib (их ручной SIMD против моего AVX2-ассемблера, честный проигрыш). Но на 12 потоках движок быстрее на 36–38%: масштабирование 5.0× на 6 ядрах против 3.2× у hnswlib. Уже на 128-мерных векторах многопоточный поиск начинает упираться в пропускную способность памяти, и здесь плоская CSR-раскладка отыгрывает.
GIST-960 (500k векторов, dim 960) — целевой кейс
Высокая размерность — это и есть профиль современных эмбеддингов (768–1536). Здесь включается SQ8: наш int8 против float32 hnswlib (у которого квантизации из коробки нет):
|
ef |
наш recall (SQ8) |
hnswlib recall (fp32) |
наш QPS_12 |
hnswlib QPS_12 |
|---|---|---|---|---|
|
64 |
0.815 |
0.808 |
9968 |
3667 |
|
128 |
0.895 |
0.898 |
5512 (×2.54) |
2167 |
|
256 |
0.938 |
0.955 |
3050 (×2.61) |
1167 |
Recall на уровне (эвристика выбора соседей компенсирует потери квантизации), QPS_12 — в 2.5–2.6 раза выше, память — в 4 раза меньше. Механика прозрачна: 960-мерный float32-вектор — это 3.75 КБ, случайный доступ к таким векторам насыщает шину памяти мгновенно (hnswlib масштабируется лишь ×1.8 на 6 ядрах). SQ8-вектор — 960 байт, в 4 раза меньше трафика — масштабирование ×5.2, почти линейное.
Вывод, который переносится на любой ANN-движок: на современных размерностях узкое место — не FLOPS, а memory bandwidth. Квантизация — это не про экономию RAM, это про скорость.
Честная оговорка: у SQ8 есть потолок recall (~0.94 на GIST при M=16). Выше — нужен rerank по float32 или M=32.
Проверка на настоящих эмбеддингах — и два бага в подарок
MNIST/SIFT/GIST — классика, но не трансформерная геометрия. Финальная валидация — DBpedia-эмбеддинги OpenAI ada-002 (1536 измерений, cosine, 100k векторов):
|
режим |
recall@10 |
QPS_12 |
память |
|---|---|---|---|
|
float32 |
0.984 |
1936 |
614 МБ |
|
SQ8 |
0.977 |
4545 |
165 МБ |
На 1536 измерениях SQ8 — очевидный дефолт: −0.7 п.п. recall за ×2.3 QPS и ×3.7 памяти.

А теперь лучшая часть. Именно на этом датасете — и только на нём — всплыли два бага в SQ8-пути для dot-product, невидимые на euclidean-данных:
-
Определение «это dot-метрика?» сравнивало указатель на функцию с внутренней реализацией, а публичные обёртки
CosineDistance/DotProductDistanceимеют другой указатель. Условие не срабатывало никогда — SQ8 всегда считал euclidean-ADC. На нормализованных векторах euclidean и dot дают одинаковый ранг, поэтому recall не проседал, и баг был идеально замаскирован. -
Dot-режим возвращал сходство вместо расстояния — инверсия ранжирования, recall был бы 0. Не стрелял только потому, что из-за бага №1 этот код не исполнялся.
Два бага взаимно прятали друг друга. Фикс дал +17% QPS (dot-ADC дешевле euclidean-ADC: один MAC на измерение). После этого хрупкое сравнение указателей заменено на явный enum метрики — класс багов устранён by design. Мораль старая, но вечная: тестируйте на данных с целевой геометрией, синтетика прощает то, что прод не простит.
Фильтрация и мультитенантность: где живут три порядка
Фильтрованный поиск («найди похожие среди документов тенанта X с price < 500») — известная боль HNSW: post-filtering выкидывает результаты после обхода, фильтрованный обход графа рвёт связность и роняет recall.
Что сделано:
-
Тенант-контигуозная раскладка: при merge векторы сортируются по атрибуту-партиции, блок тенанта становится непрерывным диапазоном ID.
-
Колоночный слой атрибутов: uint-колонки поверх сегментов,
Eq/Range-предикаты за O(1) на кандидата. -
Роутинг по размеру блока: маленький тенант (< ~16k векторов) — точный brute-force по его непрерывному блоку (SIMD по плотному диапазону — это очень быстро); большой — фильтрованный обход графа с range-ограничением.
Замер на SIFT-1M против baseline «обычный движок» (полный обход графа со строковым предикатом), recall = 1.0 у обоих:
|
размер блока |
маршрут |
ускорение (один атрибут) |
ускорение (тенант + регион + range) |
|---|---|---|---|
|
1k |
brute |
×7350 |
×28620 |
|
16k |
brute |
×48.9 |
×735 |
|
50k |
graph |
×1.19 |
×1.28 |
|
200k |
graph |
×1.14 |
×1.23 |
Числа вида «×28620» выглядят рекламно, поэтому сразу честно: это не «мы ускорили поиск в 28 тысяч раз», это «наивная фильтрация на большом графе коллапсирует до 12–158 QPS, а брутфорс по непрерывному блоку остаётся O(размер блока)». Выигрыш растёт сверхлинейно с N именно потому, что деградирует baseline. Но продуктовый вывод железный: для мультитенантного векторного поиска раскладка данных важнее алгоритма индекса. И условие выигрыша то же: консолидированные сегменты (фрагментация дробит блок тенанта).

Абсолютные цифры того же эффекта на SIFT-200k (тенант-роутинг против post-filter, recall = 1.0 у обоих): baseline коллапсирует с ростом графа, брутфорс по блоку тенанта — нет.
Отдельный поучительный баг из этой области: фильтрованный поиск по дельте применял фильтр post-hoc — сначала top-K, потом отсев. На селективном фильтре recall падал до 0.128: топ забивали чужие векторы, которые затем выкидывались. Фикс — фильтрованный brute-force по дельте. Recall 1.0 на всех путях. Если пишете свой движок — проверьте именно этот случай, он легко ускользает: на неселективных фильтрах всё выглядит здоровым.
Обвязка: то, что превращает индекс в базу данных
Алгоритм — половина работы. Вторая половина — скучная и обязательная:
-
Сеть: epoll-сервер с per-worker event-loop, zero-alloc RESP-парсер, кольцевые буферы. Лимиты на всё: max-connections, idle-таймауты (с исключением для pub/sub-подписчиков — об этом ниже), write-дедлайны против slow-reader.
-
Память: tcmalloc-подобный аллокатор для KV (per-worker MCache, лок-фри GET), OOM-гейт на всех путях записи (
--maxmemoryотклоняет записи, а не убивает процесс). -
Durability: WAL с CRC32 и батч-записью, LSN в каждой записи, снапшоты с atomic-load «всё или ничего» (read → CRC → commit; сервер с битым снапшотом громко отказывается и перестраивается из WAL, а не молча поднимается с половиной данных).
-
Disaster recovery: непрерывный шиппинг WAL+снапшотов на
file://илиs3://с периодом 1 секунда (≈RPO при crash, ноль при graceful shutdown). Restore на любой машине флагом--ship-restore. Это осознанная замена репликации для одной ноды. -
Безопасность: AUTH со сравнением за константное время, TLS/mTLS, bind по умолчанию на 127.0.0.1, non-root Docker.
-
Наблюдаемость: Prometheus
/metrics,/health,/ready, структурные логи (slog), Grafana-стек в docker-compose.
Сколько выжимает движок без векторов
Векторный поиск — compute-heavy и живёт в тысячах QPS. Но фундамент под ним — KV-слой, сеть, WAL — работает на совсем других порядках, и его пропускную способность стоит показать отдельно. Сквозные числа через сокет (RESP, loopback, тот же i7-9750H):
|
режим |
GET |
SET |
|---|---|---|
|
без pipeline |
~66K RPS |
~66K RPS |
|
pipeline P=16 |
890K |
874K |
|
pipeline P=64 |
2.2M |
1.49M |
|
pipeline P=128, 200 соединений |
3.06M |
— |
Два честных комментария к таблице. Во-первых, без pipeline всё упирается в TCP round-trip: ~100 мкс на цикл — это потолок ~10K RPS на соединение, какой бы быстрой ни была база. Redis © на таком железе даёт 80–110K, Dragonfly — 80–100K, мой Go — 66K; разница — цена Go-рантайма, и я с ней ок. Во-вторых, 3M GET RPS — это конвейеризованная нагрузка, но это и есть продовый режим: все боевые Redis-клиенты (go-redis, redis-py, Jedis, ioredis) пайплайнят.
История этих цифр — сама по себе урок. Мой аллокатор в микробенчмарке выдавал миллионы операций в секунду, а сервер целиком — 66K. Долго не сходилось, пока не дошло: аллокатор — это 0.05% пути команды, остальное — сетевой стек, syscalls, epoll wakeup. Микробенчмарк меряет одну шестерёнку, а не конвейер. Дальше конвейер чинился послойно: поддержка pipeline (буквально три строки — проверка Buffered(), цикл, один Flush() на пакет) дала ×37; per-worker аллокатор вместо глобального — ещё +19–41% на SET; кольцевой буфер с zero-alloc RESP-парсером (слайсы прямо внутрь read-буфера, ноль копирований и аллокаций на команду) — ещё +27% на глубоком pipeline. До парсера при 77K RPS создавалось ~230K мусорных объектов в секунду — GC такое не прощает.
Микробенчмарки горячих путей, на которых это стоит (все — 0 allocs/op):
|
подсистема |
операция |
время |
|---|---|---|
|
шардированный store |
GET |
17.9 ns |
|
шардированный store |
SET |
39.4 ns |
|
tcmalloc-аллокатор |
выделение |
109 ns |
|
BatchWAL |
запись (горячий путь клиента) |
138 ns (~7M ops/s на ядро) |
|
BatchWAL |
сериализация+CRC в батче |
~82 ns/запись |
|
WAL |
восстановление на старте |
~1.5M записей/с |
|
TTL-менеджер |
set / check |
53–69 ns |
Шардированная хэш-таблица против наивной «мапа под RWMutex» — ×6 на смешанной нагрузке; запись в WAL идёт через неблокирующий канал в фоновый flusher с батчами по 256 записей, поэтому durability почти ничего не стоит воркерам.
Что ещё в коробке
Вокруг векторного ядра есть небольшой, намеренно замороженный набор структур данных — не «Redis-заменитель», а payload-слой, чтобы рядом с векторами жили метаданные и документы:
-
KV + TTL. Обычные
SET/GET/EXPIRE. TTL — куча на 256 шардов с ленивой и активной экспирацией; истечение ключа атомарно накрывает и KV-значение, и связанный вектор. -
Sorted sets на B+Tree (
ZADD/ZRANGEBYSCORE/…). Интересны не сами по себе, а в связке:VSIM.SEARCHRANGEделает гибридный запрос — пересечение диапазона по score из B+Tree с векторной близостью. «Похожие на этот товар, но в ценах от 1000 до 5000» — одной командой. -
Фильтры по метаданным.
VSIM.SEARCHFILTERфильтрует кандидатов по KV-полю или префиксу ключа — для случаев, когда колоночные атрибуты (из раздела про мультитенантность) избыточны. -
Pub/Sub — классический и семантический. Классика как в Redis: каналы, back-pressure, отключение медленных подписчиков. А вот семантический — моя любимая фича:
VSIM.SUBSCRIBE порог v1 … vNподписывает соединение не на канал, а на вектор.VSIM.PUBLISHмаршрутизирует сообщение через HNSW-поиск по векторам подписчиков — его получают те, чья подписка семантически ближе порога. Подписка на смысл, а не на топик: один подписчик слушает «жалобы на оплату», другой — «вопросы про доставку», без единого правила роутинга. -
RAG-демо (опционально). Движок сам по себе BYO-embeddings — векторы приносите от любого провайдера через
VSIM.ADD. Но есть и опциональный слой поверх:docker compose --profile ai upподнимает Ollama и качает модели, после чегоAI.INGEST key textасинхронно считает эмбеддинг и кладёт документ+вектор (с проходом через WAL и OOM-гейт, как любая запись), аAI.ASK вопрос— полный конвейер: эмбеддинг вопроса → векторный топ-3 → подтягивание документов из KV → ответ LLM с контекстом. Сервер подхватывает Ollama фоновым пингом — порядок старта контейнеров не важен.
redis-cli -p 6380> AI.INGEST doc:1 "Go — статически типизированный компилируемый язык"+QUEUED> AI.ASK "Что такое Go?""Go — это статически типизированный компилируемый язык..."
Транзакции MULTI/EXEC тоже есть — про их честный (не редисовый) контракт изоляции см. раздел «Честные границы».
Отдельно — про то, чего в коробке нет, хотя оно написано. За время проекта были сделаны ещё две большие подсистемы: WASM-compute (пользовательские модули на wazero, работающие рядом с данными, с reactor-паттерном для zero-alloc горячего пути) и кластерный режим (hash-slot шардирование, gossip-протокол, живая миграция слотов). Обе работают. Но когда дошло до прод-закалки, аудит показал: дотянуть их до той же планки, что и ядро, — это ещё месяцы работы, которые сейчас не окупаются. Решение было волевое: обе подсистемы заморожены за build-тегом experimental и в дефолтную сборку не входят вообще. Пока ограничиваюсь тем, что доведено до конца; вернусь к ним, когда появится реальный спрос. Признаюсь, удалить фичу из сборки оказалось психологически труднее, чем её написать, — но урок «маленькая честная поверхность лучше большой недоделанной» стоил всего проекта.
Soak-тестирование: как нашлась утечка, которой не было в бенчмарках
Ни один бенчмарк не заменяет многочасовой прогон под смешанной нагрузкой. Стенд: ~6k RPS микса (KV + векторы + TTL + pub/sub + zset — это не замер потолка, как 3M выше, а умеренная постоянная нагрузка без pipeline, под которой ищут дрейф), durability-оракул (n=500 ключей, проверка lost/resurrect/tenant-leak после каждого рестарта), дрейф RSS/p99/goroutines, плюс два дрилла — restore из шиппинга в чистый каталог и намеренная порча снапшота.
Первый же длинный прогон дал рост RSS ~26 МБ/мин, развязанный от объёма данных. Живой pprof на работающем сервере показал: за 6 минут прогона аллоцировано 173 ГБ, из них 118 ГБ — в конструкторе дельта-сегмента. Корень: синхронный flush крутился в цикле «пока дельта не станет полностью пустой», а под непрерывной конкурентной записью она пустой не бывает почти никогда → 2577 flush-ей за 6 минут, ~1.4 вектора на flush, и каждый flush аллоцировал новый дельта-сегмент с ~100 МБ предвыделенных slab-ов и мап.
Два фикса: (1) flush ждёт публикации только того набора, что был в дельте на момент вызова, а не полного опустошения; (2) ленивая аллокация дельта-шарда. A/B на том же миксе:

Финальный 7-часовой soak: 6498/6080 RPS среднего throughput по фазам, p99 стабильный 17–18.6 мс без дрейфа, durability-оракул: lost=0, resurrect=0, tenant-leak=0 во всех verify (graceful-рестарт, kill -9, ship-restore), оба дрилла чистые — сервер отверг снапшот с битым CRC и перестроил 74k векторов из WAL. Дрейф RSS упал в 18 раз.
Бонус-находка оттуда же: ровно 8 ошибок клиентов за фазу. Оказалось — idle-реапер честно закрывал «молчащие» соединения pub/sub-подписчиков (подписчик и должен молчать!). Фикс — подписчики исключены из idle-таймаута, ровно как CLIENT_PUBSUB-исключение в Redis. Такие вещи находятся только soak-ом.
Честные границы
Чтобы статья не превращалась в рекламный буклет — чего движок не делает:
-
Это одна нода. Нет реплик, нет failover за секунды. RTO = время рестарта + restore из шиппинга. Кластерный код существует за build-тегом
experimentalи не production-ready. -
ANN — приблизительный поиск. Recall ≈0.98 на реальных эмбеддингах — это не 1.0; под интенсивным churn ~2% векторов могут выпадать из top-K (есть точный
VSIM.EXISTS). -
MULTI/EXEC— это группировка команд, не изоляция. Движок исполняет команды на шардах параллельно; чужойSETможет вклиниться между командами транзакции. Честно задокументировано, потому что альтернатива — глобальная сериализация всех записей — убила бы всю модель. -
Один поток поиска — 84–86% от hnswlib. Многопоточно быстрее, но если у вас строго однопоточный клиент — hnswlib с ручным SIMD выжмет больше.
-
SQ8 упирается в ~0.94 recall на трудных датасетах при M=16; rerank по float32 в roadmap.
-
Bulk-load медленный относительно параллельного build hnswlib (3006/s против 7755/s на SIFT-1M) — write-path есть куда ускорять.
Не верьте на слово: что можно проверить самому
Репозиторий публичный, и почти каждое утверждение из статьи проверяемо кнопкой:
-
CI на каждый push (вкладка Actions). Прогоняет сборку в обоих вариантах (прод и
-tags experimental), gofmt,go vet, тесты конкурентно-критичных пакетов — сервер, store/tcmalloc, WAL, кластер — под-race, плюс корректностные тесты векторного ядра. Зелёная галочка тут не декорация: гонку в tcmalloc-аллокаторе в своё время поймал именно CI-прогон с-race, а не локальные тесты. -
Soak-workflow запускается вручную (workflow_dispatch → «Soak Test»). Параметры выбираете сами: длительность фаз нагрузки, число параллельных клиентов и режим рестарта между фазами — graceful (SIGTERM, полный flush) или crash (SIGKILL). В логе — вердикт durability-оракула (lost/resurrect/tenant-leak) и дрейф RSS/размера вектор-стора по фазам. Форкните репозиторий — и весь прогон из раздела про soak воспроизводится у вас.
-
Бенчмарки воспроизводимы. Методология, полные таблицы и честные оговорки — в
docs/BENCHMARKS.md; бенч-код лежит в репо (go test -bench), датасеты стандартные и публичные (SIFT/GIST с corpus-texmex, dbpedia-эмбеддинги ada-002 с Hugging Face). -
Релизы — статические бинари amd64/arm64 с sha256-checksums: скачали, сверили, запустили без единой зависимости.
-
Быстрая проверка локально:
go test -short ./...— ~30 секунд на весь прогон (тяжёлые нагрузочные тесты гейтованы за-short). Guided tour на реальных эмбеддингах — один Go-файл вkvstore/examples/quickstart(ему нужен профильai).
Что я вынес из этих месяцев
-
Случайные векторы — не данные. HNSW на равномерной синтетике размерности 768 вырождается в брутфорс. Валидируйте на реальных датасетах с held-out запросами.
-
Перемеряйте перед оптимизацией. Я дважды чуть не начал чинить уже решённую проблему.
-
На современных размерностях всё решает memory bandwidth. Квантизация и раскладка памяти дают кратно больше, чем вылизывание SIMD.
-
Число сегментов — самая дешёвая ручка производительности LSM-подобного индекса: ×7 QPS настройкой merge-политики.
-
Для мультитенантности раскладка данных бьёт алгоритм. Непрерывный блок тенанта + brute-force обгоняет любой фильтрованный обход графа на порядки.
-
Багам нужна целевая геометрия данных. Два взаимно замаскированных бага SQ8 жили до первого cosine-датасета.
-
Soak-тест находит то, что не находит ничто другое: flush-шторм, реапер против подписчиков, поведение при битом снапшоте.
Проект открыт, поднимается одной командой: docker compose up -d (или просто один статический бинарь с Releases — 13MB, без зависимостей), любой Redis-клиент подключается сразу. RAG-команды AI.INGEST/AI.ASK — опциональный слой: docker compose --profile ai up -d добавит Ollama и скачает модели. Буду рад вопросам, критике методологии и чужим цифрам — особенно если у вас есть серверное железо без троттлинга. Не судите строго, так как не являюсь профессиональным разработчиком и все сделано на энтузиазме и постоянном совершенстовании знаний и навыков.
ссылка на оригинал статьи https://habr.com/ru/articles/1057136/