Как я писал in-memory векторный движок на Go — и в каком месте он обогнал hnswilb
- четверг, 9 июля 2026 г. в 00:00:12
Привет, Хабр! Меня зовут Николай, мне 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 долгоживущей базы — показывает.
Первый честный бенчмарк поиска выдал 18 QPS. Паника, профилирование, поиск бага… которого не было. Бенчмарк генерировал случайные равномерные векторы размерности 768 — а в таком пространстве все попарные расстояния почти равны (проклятие размерности), HNSW-граф вырождается, и поиск превращается в брутфорс.
На реальном MNIST-784 тот же движок показал ~1100 QPS. Вывод: бенчмарки ANN-индексов на синтетике не значат ничего. Все цифры ниже — на реальных датасетах (MNIST-784, SIFT-1M, GIST-960, DBpedia/OpenAI-эмбеддинги) с held-out запросами и точным brute-force ground truth.
В какой-то момент я сел «оптимизировать поиск, потому что 1100 QPS мало». Дисциплина потребовала сначала перемерить текущее состояние — и оказалось, что после уже сделанных изменений те же условия дают 3605 QPS. Старое число просто устарело, и я чуть не потратил неделю на решённую проблему. Не доверяйте числам из прошлых сессий.
Сравнение «мой движок против 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 потоков.
Наивный 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 логики, формат и поиск не тронуты.
Конкурентные вставки упирались в глобальный лок дельты: потолок ~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 шард, старое поведение.
Главная находка всего проекта. На 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 — больше сегментов.
hnswlib — референсная C++ реализация HNSW с вылизанным SIMD. Сравнение на одной машине, одинаковые M=16/efC=200, одни и те же 500 held-out запросов, официальный ground truth.

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-раскладка отыгрывает.
Высокая размерность — это и есть профиль современных эмбеддингов (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 и в дефолтную сборку не входят вообще. Пока ограничиваюсь тем, что доведено до конца; вернусь к ним, когда появится реальный спрос. Признаюсь, удалить фичу из сборки оказалось психологически труднее, чем её написать, — но урок «маленькая честная поверхность лучше большой недоделанной» стоил всего проекта.
Ни один бенчмарк не заменяет многочасовой прогон под смешанной нагрузкой. Стенд: ~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 и скачает модели. Буду рад вопросам, критике методологии и чужим цифрам — особенно если у вас есть серверное железо без троттлинга. Не судите строго, так как не являюсь профессиональным разработчиком и все сделано на энтузиазме и постоянном совершенстовании знаний и навыков.