golang

Как я писал in-memory векторный движок на Go — и в каком месте он обогнал hnswilb

  • четверг, 9 июля 2026 г. в 00:00:12
https://habr.com/ru/articles/1057136/

Привет, Хабр! Меня зовут Николай, мне 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-данных:

  1. Определение «это dot-метрика?» сравнивало указатель на функцию с внутренней реализацией, а публичные обёртки CosineDistance/DotProductDistance имеют другой указатель. Условие не срабатывало никогда — SQ8 всегда считал euclidean-ADC. На нормализованных векторах euclidean и dot дают одинаковый ранг, поэтому recall не проседал, и баг был идеально замаскирован.

  2. 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).

Что я вынес из этих месяцев

  1. Случайные векторы — не данные. HNSW на равномерной синтетике размерности 768 вырождается в брутфорс. Валидируйте на реальных датасетах с held-out запросами.

  2. Перемеряйте перед оптимизацией. Я дважды чуть не начал чинить уже решённую проблему.

  3. На современных размерностях всё решает memory bandwidth. Квантизация и раскладка памяти дают кратно больше, чем вылизывание SIMD.

  4. Число сегментов — самая дешёвая ручка производительности LSM-подобного индекса: ×7 QPS настройкой merge-политики.

  5. Для мультитенантности раскладка данных бьёт алгоритм. Непрерывный блок тенанта + brute-force обгоняет любой фильтрованный обход графа на порядки.

  6. Багам нужна целевая геометрия данных. Два взаимно замаскированных бага SQ8 жили до первого cosine-датасета.

  7. Soak-тест находит то, что не находит ничто другое: flush-шторм, реапер против подписчиков, поведение при битом снапшоте.

Проект открыт, поднимается одной командой: docker compose up -d (или просто один статический бинарь с Releases — 13MB, без зависимостей), любой Redis-клиент подключается сразу. RAG-команды AI.INGEST/AI.ASK — опциональный слой: docker compose --profile ai up -d добавит Ollama и скачает модели. Буду рад вопросам, критике методологии и чужим цифрам — особенно если у вас есть серверное железо без троттлинга. Не судите строго, так как не являюсь профессиональным разработчиком и все сделано на энтузиазме и постоянном совершенстовании знаний и навыков.