Выпуск 41 · подкаст «Тысяча фичей»

#41: Qdrant: Векторная база данных на Rust

2:41:18
↓ скачать mp3

Александр Пахомов и Андрей Васнецов (CTO и сооснователь Qdrant) разбирают, как устроен векторный поиск и почему Qdrant — это поисковый движок, а не «векторная база данных». По пути: откуда берутся эмбеддинги (word2vec, BERT, ONNX) и какими свойствами обладают векторы; почему точный индекс не работает в размерности полторы тысячи и как вместо него применяется приближённый `HNSW`; в чём киллер-фича Qdrant — фильтруемый `HNSW` со вторичными индексами; как всё это шардируется и реплицируется поверх `RAFT`; и большой финальный блок про Rust — от безопасного рефакторинга и `cargo` до вставок на ассемблере, квантизации и `io_uring`.

Главное

  • Эмбеддинг — это машинно-читаемое представление объекта (текста, картинки, аудио): близость векторов по выбранной метрике означает семантическую близость, и сравнивать всегда нужно относительно (скор `0.7` интереснее `0.6`), а не по абсолютному значению.
  • Метрику (`cosine`, евклидово расстояние, `dot product`) выбирают под то, на чём обучалась нейросеть; на практике удобнее всего `dot product` — он самый дешёвый в вычислении.
  • Один эмбеддинг OpenAI — это ~1500 float'ов ≈ 6 КБ; на миллионе записей это уже ~6 ГБ, которые ради скорости приходится держать в RAM.
  • Точный индекс (`B-tree`, геохэш) ломается о «проклятие размерности», поэтому применяется приближённый поиск `ANN` на графе `HNSW` (Hierarchical Navigable Small Worlds) — жадный обход с несколькими лучами, сложность ~`O(log n)`.
  • Киллер-фича Qdrant — фильтруемый `HNSW`: векторный индекс знает про вторичные индексы (цена, локация) и достраивает дополнительные рёбра между отфильтрованными вершинами, что дешевле, чем join-индекс, и не ломается на низкой кардинальности (там просто `fullscan`).
  • Qdrant — поисковый движок, а не source-of-truth: его гарантии — скорость, масштабирование и отказоустойчивость (как у Elasticsearch), а в `RAFT`-консенсусе хранится только маленькая, но важная мета-информация о шардах, не сами векторы.
  • Rust выбран потому, что это system programming language для программ, которыми пользуются другие программы; компилятор даёт безопасный рефакторинг (паттерн-матчинг находит все места правки) и гарантии против data race и segfault ценой высокого порога входа и медленной компиляции.
  • Самый горячий код — вычисление `dot product` — написан на ассемблере под каждую архитектуру (даёт ~10× к наивной реализации); а `io_uring` позволяет на этапе рескоринга слать тысячи параллельных чтений к диску одним процессом, снижая latency одного запроса.

В выпуске

  • Андрей ВаснецовCTO и сооснователь Qdrant — векторного поискового движка на Rust; ранее ML-инженер в поисковых командах. GitHub ↗ qdrant.tech ↗
Расшифровка

[00:00] Андрей: И если бы я начал писать Qdrant на C++, я бы до сих пор сегфолты искал — на каждый десятый запуск, наверное.

[00:12] Александр: Здарова! Меня зовут Саша Пахомов, и я инженер, который любит своё дело. Это сорок первый выпуск подкаста «Тысяча фичей». Тут мы разговариваем про всякое интересное из мира разработки — последнее время это базы данных. И с самого первого выпуска про базы данных я очень сильно хотел записать подкаст про векторную базу данных. И желательно, чтобы она была написана на Rust. И вот, наконец, это свершилось: у меня в гостях CTO векторной базы данных Qdrant — Андрей Васнецов. В этом выпуске вы узнаете, что такое векторный поиск, как на самом деле устроен Qdrant и почему это не векторная база данных. Ну и, конечно, поговорим про Rust. Поехали!

[01:17] Александр: Кстати, интересно, что ты несколько раз подмечал: Qdrant ты не хочешь называть векторной базой данных, ты всё-таки считаешь, что это векторный engine, поиск.

[01:27] Андрей: «Векторная база данных» — это такой маркетинговый термин, с которым мы вынуждены существовать, потому что иначе люди про нас просто не узнают. То есть он уже прижился, но он не вполне корректный. А объяснить, почему он не вполне корректный, наверное, правильнее будет во второй части нашего диалога — там вся эта жесть про гарантии и прочее. Начать хочется с простого.

[01:51] Александр: Да, давай с простого начнём, пожалуйста, начинай.

[01:57] Андрей: С простого тогда так. Самый визуальный пример, который я знаю, — это Google Lens, такое приложение. Ты словно наставляешь камеру на какой-то объект, и он тебе говорит: вот это такая-то штука — и показывает ссылку на Википедию. Это самый каноничный, типичный пример векторного поиска, причём мультимодального: у тебя есть некая нейронка, которая может сделать такой embedding, или вектор, как мы его ещё называем. И сравнивая этот вектор с кучей других векторов, мы можем сказать, какой из них больше похож, а какой меньше похож на оригинальные объекты. По сути, если не вдаваться в подробности с оптимизацией и прочим, то Google Lens работает так: ты извлекаешь вектор из картинки, ищешь его по всей своей коллекции всяких разных объектов, которые ты знаешь, — на Википедии, ещё где-нибудь. И тот, который лучше всего совпал, — это и есть твой результат, ответ на поисковый запрос.

[02:56] Александр: То есть мы не можем это никак текстом описать — ну, или даже не хотим никак это текстом описывать.

[03:02] Андрей: Вместо описания текстом мы используем вот такое машинно-читаемое представление данных. И интерес в том, что мы, по сути, можем любые абсолютно данные представить в виде таких векторов. Мы можем представить в виде векторов тексты, картинки, аудио, историю транзакций пользователя в каком-нибудь онлайн-банке — тоже как вектор. И если у нас есть достаточно умная нейронка, которая может этот вектор генерировать, то мы можем находить похожие в какой-то большой коллекции.

[03:39] Александр: Мы, кстати, приложение типа Google Lens на третьем курсе в универе разрабатывали — на курсе по iOS. У нас его мой друг Никита Покидышев вёл, шаут-аут Никите. Мы делали следующее: у нас была некоторая обученная модель, которая могла картинку трансформировать в некоторое представление. Возможно, это был не вектор, но сейчас я думаю, что вектор — я тогда не сильно въехал.

[04:09] Андрей: Да, раньше — если мы говорим лет десять назад — вместо векторов использовались бы какие-нибудь LSH, Locality-Sensitive Hashing. Но LSH может найти тебе похожую картинку, которая визуально выглядит похожей, а вот про семантику он мало что тебе скажет. То есть если у тебя есть фотография собаки — скажем, фотография пуделя и фотография какого-нибудь добермана, — с точки зрения LSH это совершенно разные картинки: цвета разные, формы разные. А с точки зрения вектора, обученного по современным стандартам, всякая собака будет ближе к собакам — и неважно, как она выглядит.

[04:58] Александр: Независимо от того, какой там фон, на ходу это снято или статично.

[05:01] Андрей: Да, если это правильно обучено — именно на извлечение семантики из картинки, — то да, всё будет работать вне зависимости от визуальных аспектов.

[05:12] Александр: Ага. Ну, получается, глобально подобные задачи решаются как бы с двух сторон, да? Первая сторона — нам нужно иметь модель, которая может представлять картинки, текст, всё что угодно в векторы. И это прям deep learning, это работа инженеров машинного обучения, правильно?

[05:31] Андрей: Правильно, но, к счастью, эта работа для больших типичных задач уже сделана. Эти модели можно пойти скачать, использовать готовые — они, большинство из них, с хорошими лицензиями. Ничего не мешает нам использовать их в наших приложениях.

[05:47] Александр: Да. Вспоминая то iOS-приложение Google Lens, которое мы писали, — мы как раз чуть ли не с официального сайта или откуда-то скачали себе апку. Даже не апку, это была, по-моему, как крейт в Rust, только в Swift. Библиотека, мы её себе просто заимпортировали, и в неё прям бинарное представление картинки с камеры слали — каждый, не знаю, третий фрейм, насколько могли себе позволить. И она нам отвечала — но настолько высокоуровневая была, что отвечала словом: банан, хот-дог и так далее.

[06:22] Андрей: Банан и хот-дог — это, наверное, всё-таки какой-то классификатор. То есть модель знает какое-то количество объектов и выполняет классификацию по этому списку. Она тебе говорит: вот с такой вероятностью это банан, вот с такой вероятностью это яблоко — и ты берёшь топовое. А векторы не настолько бинарные — они семантику учитывают в современных подходах. Вместо того чтобы сказать, на сколько процентов это банан, а на сколько яблоко, такая нейронка выдаст тебе просто набор чисел, с которым ты можешь пойти в свою собственную коллекцию овощей — которую можно независимо поддерживать — и с помощью этого вектора узнать, к какому овощу или фрукту эта картинка ближе. То есть тебе не нужно заранее знать все возможные классы, не нужно заранее обучаться на конкретный список типичных овощей: можно иметь этот список отдельно и обновлять его независимо от нейронки. Это большое, кстати, преимущество, и это то, почему я вообще начал работать с векторным поиском — как с инструментом улучшить классификацию, скажем так.

[07:47] Андрей: У нас была немножко другая задача — не про картинки, а скорее про тексты. Мы классифицировали резюме человека, который ищет работу. Изначально, когда я пришёл в компанию — такой немецкий стартап, — там был как раз такой же классификатор, как ты описываешь. Он с какой-то вероятностью говорил: этот человек — фронтендер, а с такой-то вероятностью он продакт-менеджер. На 50% продакт-менеджер, на 50% фронтендер.

[08:19] Александр: А давай для людей, которые совсем не понимают или немножко путаются: у вас было резюме, допустим PDF-ки, вы могли эти PDF-ки представить, условно, строкой в программе, передать эту строку в модель, и модель говорила тебе — вот это, короче, джуниор Java, скорее всего. То есть почему она классифицировала — по роли, я правильно понимаю?

[08:45] Андрей: По тайтлу, в нашем случае по тайтлу. То есть человек пишет «JavaScript-ниндзя», например — такие случаи бывали. Пишет он «JavaScript-ниндзя» — а что такое «ниндзя»? Обычным contains, или starts with, или обычным матчингом, regex, это сложно сделать.

[09:01] Александр: Да, это можно 80% решить, но вся сложность в последних 20%, как обычно.

[09:09] Андрей: Вот тут как раз нейронки хорошо работали — они говорили, что вот этот человек что-то умеет делать. Но была проблема: количество таких возможных профессий было заложено при обучении модели, их там было двадцать или что-то такое. А хотелось расширить сервис в тысячу раз — добавить докторов, водителей автобусов. А для этого, во-первых, нет обучающих данных заранее: сервисом не пользуются врачи, потому что классификатор не может их классифицировать, а чтобы научить классификатор их классифицировать, нужны примеры данных. Проблема курицы и яйца: ты не можешь добавить новые категории, потому что нет примеров, и примеров не появится, потому что у тебя нет новых категорий. Вот такую проблему мы хотели решить, и это было основным мотиватором начать смотреть в сторону векторного поиска. Потому что поиск как технология не обязательно должен быть применён как строчка в гугле, где мы пишем запрос и получаем результат, — его можно в том числе и для классификации применить.

[09:17] Александр: А как ты классы отправляешь — как поисковый запрос? И таким образом разделяешь данные, или как это работает?

[10:27] Андрей: У меня есть большой набор разных классов — там, вместо 120 я добавляю 2000 всяких более хитрых профессий. По сути, ищу среди них ту, которая лучше всего описывается тем, что пользователь про себя назвал. Он назвал себя хирургом — значит, это ближе к врачам.

[10:51] Александр: Ну, то есть это поиск ближайших соседей, грубо говоря, по смыслу: ты берёшь ближайший класс из этих 2000, и он им и является.

[10:58] Андрей: Поиск ближайших соседей — это хороший кейворд, надо его запомнить. Это алгоритм; то, что мы делаем, — это именно поиск ближайших соседей.

[11:13] Александр: Хорошо. Давай, если с картинками, плюс-минус представить само приложение проще: ты взял, навёл, круто обученная сетка смогла представить эти векторы — с чиселками, там, не знаю, тысяча чисел. А свойство этих чисел какое? Чем они отличаются от просто массива данных, в чём смысл этих векторов?

[11:37] Андрей: Они отличаются тем, что, сравнивая пару таких векторов — с помощью, например, косинусного расстояния, — мы можем понять, насколько они близки в векторном пространстве. А близость в векторном пространстве, благодаря нашей хорошо обученной нейронке, означает и близость с точки зрения человека: это похожие лица, например, или это одинаковые по семантике объекты. Все фотографии собак будут сгруппированы в этом векторном пространстве близко друг к другу — ближе, чем, например, фотография кошки и собаки.

[12:15] Александр: То есть получаются такие кластеры.

[12:16] Андрей: Но всё ещё довольно близко друг к другу, если мы говорим про кошек, собак и дерево. Тут важно понимать, что мы переходим от абсолютных чисел — когда мы говорим, что это дерево на 40%, — к относительным. И если векторный поиск нам даёт какую-то чиселку — например, 0.6, результат косинусного произведения, — это не значит, что результат похож на 60%. Само по себе это число особо смысла не имеет. При поиске важна именно относительная похожесть: вот эта картинка похожа на 0.6, другая на 0.7 — та, что на 0.7, нам более интересна. Абсолютные значения если и имеют какой-то смысл, то скорее случайно — это не то, на что мы специально обучаем нейросети.

[13:13] Александр: Понял, окей. А давай прям картинку нарисуем. Если мы берём самый примитивный вектор, допустим размерности 3, — это точка в трёхмерном пространстве, правильно? Проводим x, y и z — вот такой кубик получился, я думаю, все более-менее представляют. И вектор из трёх чисел, например 1, 1, 1, — это точка где-то внизу; а 20, 20, 20 — по диагонали ровно вверх, чуть подальше.

[13:45] Андрей: Тут интересный нюанс возникает: когда мы меряем именно косинусное расстояние, вектор 1, 1, 1 и 20, 20, 20 — это одно и то же, потому что углы совпадают.

[13:56] Александр: Потому что это один вектор, по сути. Если мы говорим не про точку, а именно про вектор, который идёт от 0, 0, 0.

[14:03] Андрей: Есть разные подходы. Косинусное расстояние — не единственный правильный способ сравнивать векторы. Можно сравнивать с помощью евклидова расстояния, и тогда это два совершенно разных вектора. Это корень квадратный из суммы квадратов разностей.

[14:21] Александр: Да, да, вот эта формула, пифагоровы штаны.

[14:25] Андрей: Можно использовать даже просто dot product — это сумма поэлементных произведений внутри вектора. Это, на самом деле, зависит от того, как нейросеть была обучена. Она была обучена с помощью какой-то из этих метрик, и желательно использовать ту же самую метрику, с которой она обучалась, — тогда результаты более точные.

[14:46] Александр: Ну, то есть метрика сама по себе является метрикой — в отрыве от обучения. Возьмёшь два вектора, померишь расстояние — оно тебе ничего не скажет, потому что по сути метрику мы используем так: вот, нейронная сеть, мы тебя сейчас будем обучать, пожалуйста, расположи ближайшие друг к другу векторы так, чтобы эта метрика была максимальной для ближайших, а для неближайших — минимальной. И это, грубо говоря, линейка, по которой нейронка ходит и сверяет результаты. То есть сама метрика не даёт ни лучше, ни хуже — в каких-то ситуациях что-то работает лучше, что-то хуже, но в целом нейронка решает вот эту формулу.

[15:31] Андрей: Да, именно так.

[15:35] Андрей: На самом деле тут типичный кейс по выбору метрики возникает: поскольку нейронка уже всё за нас решила, нам эффективнее выбрать ту метрику, которую проще всего считать. А проще всего считать именно dot product — это самый простой из всех возможных.

[15:52] Александр: А если она обучалась не на dot product?

[15:55] Андрей: Тогда мы так не можем сделать. Но, как правило, они обучаются либо на dot product, либо на косинусном произведении. А косинусное произведение — это почти то же самое, что dot product, только с препроцессингом, который нам нужно один раз выполнить. В результате мы всё равно выбираем очень простую функцию сравнения. Отчасти поэтому это довольно быстро работает.

[16:21] Александр: Окей. Давай завершим эту нашу трёхмерную картинку, чтобы совсем у всех вопросы отпали, о чём мы тут говорим. Получается, в этом трёхмерном кубике мы берём из центра, проводим векторы по направлению 20, 20, 20 или 1, 1, 1, что то же самое, — это чёткая диагональ под 45 градусов относительно каждой оси. И проводим ещё с десяток таких векторов, но направление чуть-чуть разное у всех. И близость с помощью той или иной метрики: визуально они тоже будут примерно в одном направлении смотреть.

[17:03] Андрей: Вот те, что визуально смотрят примерно в одном направлении, семантически, скорее всего, будут близки — как, например, две собаки. Там где-то будет кошка чуть дальше, рядом с ней много других кошек или более мелких животных с шерстью и четырьмя лапами. А вот автомобиль будет смотреть вообще в другую сторону, потому что он никак не связан с этими. Хотя, если мы обучили сетку так, что, например, все, кто может передвигаться со скоростью выше 20 км/ч, будут близки, — то и автомобиль окажется где-то рядом с собакой. То есть всё зависит от того, какую цель мы перед сеткой поставили.

[17:41] Александр: Какая обучающая выборка у неё будет.

[17:43] Андрей: И выборка, да.

[17:45] Александр: И потом эти нейросети на GPU супер-пупер обучаются — получается, некоторые программы: бинарник, библиотека, всё что угодно, в зависимости от языка программирования.

[18:00] Андрей: У нейросетей есть несколько стандартов того, как они могут быть представлены. Если ты их разрабатываешь, ты можешь использовать PyTorch, какой-нибудь TensorFlow. Но когда ты её уже обучил, ты обычно хочешь её экспортировать в некий формат, который уже независимо от твоего фреймворка позволит её инференсить. Один из самых крутых стандартов — это ONNX, Open Neural Network Exchange, который, по сути, делает нейросети независимыми от языка. Ты можешь одно и то же запустить на Python, на Rust, на Java, на чём угодно. Это будет такой файл, который описывает просто слои, которые есть внутри сетки, — и этого уже достаточно, чтобы любой рантайм смог его подцепить и начать выполнять.

[18:54] Александр: Инференсить — это, получается…

[18:56] Андрей: Выполнять.

[18:57] Александр: Выполнять, да. Грубо говоря, метод мы написали, а теперь мы его вызываем. Окей. Получается, что аргументы этого метода — нейронной сети — это векторы, и выходные тоже векторы, массив векторов?

[19:13] Андрей: Аргументы, если мы говорим про картинки, — это будут такие матрицы.

[19:17] Александр: Матрицы, ну, типа пиксели.

[19:19] Андрей: Да. А выходные — это векторы.

[19:29] Александр: Давай, кстати, про слова. Интересно, как происходит токенизация слов, как они вообще представляются векторами и какие слова в этом же пространстве будут рядом, а какие далеко.

[19:41] Андрей: Ну смотри, есть довольно старые методы про то, как отдельные слова векторизовать. Например, word2vec или fastText — есть такая штука. Это не то, что сейчас используется в современных нейросетях, но интересно рассказать для общего понимания. По сути, что происходит, когда мы говорим про word2vec? Это нейросеть, которая обучается предсказывать контекст слова по самому слову — или наоборот, предсказывать слово по его контексту. Там есть два варианта обучения: одно применяется в одних случаях, другое — в других. Но идея такая: у нас есть предложение, взятое абсолютно из любого текста, мы берём в нём произвольное слово, заменяем на такую маску и говорим нейросети — предскажи, что это было за слово, по всем остальным словам вокруг него. Нейросеть это делает внутри себя, и промежуточный результат этого предсказания, промежуточный слой, — это как раз и будет word2vec-эмбеддинг, или вектор. Как ни странно, он тоже обладает интересным свойством: похожие слова оказываются близко в каком-то векторном пространстве. Типичный пример, который во всех статьях иллюстрирует это, — про то, что мы можем взять слово «король», слово «королева», и если мы из «королевы» вычтем слово «женщина», то получим слово «король». Вот такой типичный академический пример того, как word2vec организует внутри себя структуру этих векторов. То есть у нас сама собой получается такая структура векторного пространства, которая позволяет делать такие манипуляции со смыслами слов.

[21:38] Александр: По сути, «король» и «королева» — это два вектора, и они обладают таким свойством, что, построив вектор «женщина» и вычтя его из «королевы», ты меняешь направление, и оно совпадёт или будет очень близко к «королю».

[21:53] Андрей: Да, да.

[21:55] Александр: Это вообще довольно прикольно. Для людей, кто, возможно, первый раз слышит, может и голову подорвать немного.

[22:02] Андрей: Это прикольно, и эта идея продолжает развиваться. Основная проблема word2vec — и почему его сейчас не применяют — в том, что он предлагает строить эмбеддинги для слов отдельно. Вот есть слово «король», оно соответствует единственному эмбеддингу. Но в естественном языке смысл слов на самом деле зависит от контекста: «король» в контексте Burger King — это совсем другое слово, нежели «король» в контексте, скажем, короля Англии. Word2vec не знает про такие особенности. А вот про них знает BERT — это такое семейство моделей, которые уже не просто генерируют по вектору для каждого слова, а генерируют по вектору для каждого слова внутри его контекста. То есть на вход подаётся уже не одно слово, а всё предложение, и на выходе получается n векторов — по количеству слов в предложении.

[23:08] Александр: Так, давай немножко остановимся. До этого мы говорили про word2vec, который просто с одним словом работал: одно слово — один вектор, и он обладал некоторыми свойствами. А как он задачу решал?

[23:20] Андрей: Разные задачи он решал. Само по себе его для поиска использовать было сложно, потому что на каждое слово не напасёшься векторов — слишком большой объём. Но он был такой первой стадией инференса моделей. Если в модели есть несколько слоёв, то первый слой — это обычно был набор векторов, обученных из word2vec, которые заменяли собой слова из предложения. И вместо предложения у нас получалась такая матрица векторов, которую дальше какая-то более умная нейросеть продолжала обрабатывать.

[24:04] Александр: Препроцессинг такой текста, получается.

[24:06] Андрей: Да. Вот это то, что было лет десять назад.

[24:10] Александр: Так, а BERT?

[24:11] Андрей: А BERT — это немножко другая идея, которая заключается в том, что мы не хотим делать это в несколько стадий, не хотим отдельно обучать эти word2vec-модели и отдельно — модель, которая целиком с предложением работает. Мы возьмём предложение целиком и обучим такую модель от начала и до конца. Она сама себе придумывает эмбеддинги для отдельных слов, сама себе придумывает эмбеддинги для контекста — то есть у нас есть не просто эмбеддинг для слова, а эмбеддинг для слова внутри контекста, в котором оно используется. И на выходе, через несколько этапов преобразования… Самая маленькая, при этом используемая, BERT-модель имеет где-то 6 слоёв, насколько я помню. Бывают, конечно, больше, но 6 слоёв — это такой bare minimum, который при этом даёт работающие результаты. На выходе ты получишь опять же векторы для каждого слова, но которые будут обладать этими умными свойствами, про которые мы говорили, уже в контексте каждого предложения в отдельности.

[25:19] Александр: То есть, по сути, word2vec — это будут векторы, но у них нет контекста, потому что они обучались представлению, которое не знает о контексте. А когда ты даёшь на вход модели слова в предложении, само окружение слова является контекстом и влияет на свойства векторов на выходе.

[25:39] Александр: Но ты говоришь, что это всё ещё старые штуки.

[25:42] Андрей: BERT — это уже довольно нормальная штука. Та штука, которая сейчас больше всего работает. Мы не знаем, конечно, как это устроено у OpenAI, но, скорее всего, примерно по такому же принципу. Получается, у тебя есть набор векторов для каждого слова в предложении, но ты хочешь сравнивать не каждое слово в отдельности, а предложение целиком. Есть разные подходы, как объединить эти векторы отдельных слов в один общий, но самое простое — просто их усреднить. Ты берёшь и говоришь: теперь вектор для предложения — это средний вектор всех слов, которые в него входят.

[26:25] Александр: Типа это, грубо говоря, смысл этого предложения.

[26:28] Андрей: Да. И вот мы наконец пришли к тому представлению, которое полезно для поиска. Когда у нас есть эмбеддинг для предложения целиком — тогда мы его можем использовать.

[26:41] Александр: Прикольно. То есть, по сути, есть у нас какой-то запрос: «Хочу купить себе шубу где?» Грубо говоря, «шубу купить», возможно, по словам ещё разберёшь, но «где» — я уже хочу место, а не что-то. То есть смысл не самый очевидный, если мы просто слова по отдельности берём. Соответственно, мы строим вот этот смысловой вектор всех слов, условно усреднённый, и потом идём в нашу какую-нибудь векторную engine и говорим: найди мне, пожалуйста, три предложения, про которые ты знаешь, самые близкие к этому вектору. И он тебе по смыслу что-то вытаскивает — «базар, шубы в…», не знаю. Ну, там ещё дать контекст моей геолокации — вообще замечательно.

[27:29] Андрей: Контекст геолокации — это немножко отдельная тема, про это тоже интересно было бы поговорить, потому что не встроишь ты геолокацию внутрь нейронки, это слишком сложно. Но это интересная проблема.

[27:48] Александр: Давай так. Получается, как раз подходим к векторам. Мы разобрали, я думаю, подробно, откуда эти векторы берутся, какими свойствами обладают и для чего используются. В чём проблема, собственно? Ну, допустим, есть куча баз данных — я одну из таких разрабатываю, — которая вполне может хранить очень большие объёмы данных, и векторов в том числе. Какую проблему решает замечательный продукт Qdrant в этой области?

[27:58] Андрей: Во-первых, давай представим типичный вектор. Типичный вектор — это то, что генерирует OpenAI. Он самый популярный. Я не скажу, что он самый лучший, не скажу, что самый точный, — я просто скажу, что он самый популярный. И вот этот OpenAI-вектор — это полторы тысячи флоат-пойнтов. Если представить смысл одного предложения, нам нужно получить полторы тысячи флоатов. Полторы тысячи флоатов по 4 байта на каждый — это 6 килобайт информации.

[28:47] Александр: 6 килобайт информации?

[28:50] Андрей: Это больше, чем страница операционной системы. Во-первых, это больше, чем страница операционной системы. Во-вторых, если у нас есть, скажем, какая-нибудь табличка в миллион записей, и мы хотим её векторизовать… Что такое миллион записей? Открой любой free-tier-деплой Postgres — один миллион записей он спокойно проживёт. Это очень маленькие данные для современных машин.

[29:18] Александр: Да, это очень маленькие.

[29:18] Андрей: Но как только мы применим эти 6-килобайтные эмбеддинги к одному миллиону записей, у нас внезапно окажется, что нужно 6 гигабайт памяти. Причём RAM. Потому что сравнение векторов очень… Мы про это тоже поговорим, как ускорить сравнение в RAM, но если мы хотим сделать поиск абсолютно точным — выбрать из этого миллиона самые правильные топовые значения, и мы не готовы ни на какой компромисс по точности, — то нам нужно будет взять вектор запроса и пройтись по всем 6 гигабайтам наших данных, сравнить каждый с каждым. А 6 гигабайт, если это на диске, — чтение 6 гигабайт просто очень долгое, никто не дождётся ответа на такой запрос. Поэтому тут нам RAM поможет очень сильно.

[30:17] Александр: Ну, то есть миллион записей мы храним в RAM — эти векторы. И наивный подход: берём вектор запроса — запрос человека, это предложение, с помощью той же модели где-то на клиенте или бэкенде превращаем в вектор, — суём этот вектор в нашу базу данных и говорим: найди мне самые ближайшие два вектора. Наивный подход: мы берём брутфорсом, линейным поиском каждый вектор сравниваем с исходным, считаем, например, евклидово расстояние, находим минимум и возвращаем два — поиск двух минимумов. Эта задача потребует доступа к 6 гигабайтам данных, где бы они ни находились — в RAM или на диске. Это очень долго.

[31:04] Александр: Так, но современные базы данных — да даже не современные, все базы данных — такую проблему решают с помощью индексов. В чём проблема проиндексировать векторы?

[31:12] Андрей: Во-первых, не всякий индекс подойдёт. Если мы хотим индекс типа B-tree, который будет давать точный результат, то это очень плохо работает для данных большой размерности. Эти полторы тысячи флоатов, которые содержатся в эмбеддинге OpenAI, создают такой эффект под названием «проклятие размерности». Ты пытаешься увеличить dimensionality, и она растёт линейно, а объём пространства растёт экспоненциально. Поэтому простые индексы типа деревьев очень плохо работают, они очень неэффективны для данных большой размерности.

[32:00] Александр: Ну и к тому же, если мы берём простой индекс, то он определён под поиск конкретного матча — или, если это хеш-индекс или ренджа, отсюда досюда, — что предполагает отношение порядка в ключах. А в векторах порядок, может, и есть, но он, наверное, не такой тривиальный.

[32:20] Андрей: Могу привести пример геоиндекса. Когда у нас есть карта, и на этой карте есть точки, у нас есть две координаты — latitude и longitude. Обычно, если мы хотим построить быстрый поиск по геоданным, мы строим так называемый геохэш. Мы всю карту разбиваем на квадратики: есть крупные квадратики, внутри каждого крупного — более мелкие, и в зависимости от того, какой длины наш геохэш, мы можем углубляться в каждый конкретный квадратик — всё дальше и дальше. И это работает только потому, что пространство размерности 2 не очень объёмное. То же самое сделать для пространства размерности 3 — как это по-русски сказать, dimensionality? — размерности 3 — уже намного сложнее. А с размерностью полторы тысячи практически невозможно: все квадратики будут пустые, потому что очень большой объём. Поэтому такой абсолютно точный индекс применительно к векторам и эмбеддингам непрактичен. Теоретически его можно сделать, но он будет ещё медленнее, чем fullscan. Поэтому то, что применяем мы — и что применяет большинство подобных решений, — это приближённый индекс, так называемый ANN, approximate nearest neighbors. То есть вместо KNN — это ANN, приближённый. В чём суть? Мы не гарантируем, что найдём самый ближайший вектор, но даём возможность покрутить всякие параметры, чтобы подвигать баланс между скоростью поиска и точностью. И в большинстве реальных примеров точности 99.9% вполне бывает достаточно, и это ускоряет всё в тысячу раз по сравнению с fullscan. Трейд-офф такой: да, ребят, возможно, в нашем пространстве векторов есть более близкий вектор, но вероятность того, что мы его не найдём, настолько мала, что ею в большинстве продакшн-систем можно пренебречь в угоду скорости.

[34:43] Александр: Да, да.

[34:43] Андрей: Не то чтобы она настолько мала — эта вероятность случается, на практике мы можем что-то потерять. Но мы говорим: уж лучше мы сделаем так, чем ждать по 20 минут каждого ответа.

[34:59] Александр: Так, хорошо, на примере геоиндексов — кстати, ты хорошо меня в нужное русло направил, потому что я сразу начал про классическое B-дерево. Геоиндекс — это хороший пример точного поиска по размерности, равной 2, — примерно можно даже погуглить, понять, как он работает.

[35:22] Александр: А как же тогда осуществляется этот поиск ANN по размерности полторы тысячи?

[35:28] Андрей: Существует другой класс индексов, который не основан на размерности, а основан на близости векторов друг к другу. Нам перестаёт быть важно, какой они размерности, — мы просто говорим, что вот есть вектор, а вот есть вектор, который к нему ближе, чем твой. Я точно не знаю, как называется этот класс алгоритмов и индексов, но тот алгоритм, который используем мы, называется HNSW.

[35:57] Александр: Так, расшифруй, пожалуйста.

[36:00] Андрей: Расшифровывается как Hierarchical Navigable Small Worlds. Такое очень хитрое название. Но если мы говорим про интуитивное представление, можно представить такой граф близости: каждый вектор в этом индексе представляется как нода графа, а рёбра графа соединяют те векторы, которые близки между собой. Получаем такой proximity-граф. Каждый сосед знает про своих ближайших соседей, а те, соответственно, знают про своих ближайших соседей. Его можно даже иногда нарисовать на плоскости. В чём фишка таких графов? В том, что, чтобы найти приблизительно ближайшего соседа к твоему исходному запросу, мы можем начать с абсолютно любого места, с любой вершины, и произвести такой жадный поиск. На каждой вершине спросим: а какой из соседей ближе к нашему запросу, чем все остальные? И, переходя в этого соседа, повторяем процедуру — в какой-то момент доходим до вершины, где мы уже не можем улучшить результат. Все остальные соседи уже не ближе, чем та, которую мы сейчас проверяем. Тогда мы говорим: вот это наш ответ, это та вершина, которая будет результатом поиска. Естественно, нет никаких гарантий, что это будет не локальный минимум, а абсолютный минимум. Поэтому вместо одного луча при жадном поиске мы можем сделать несколько лучей — поиск в глубину, но с элементом поиска в ширину. И в зависимости от ширины луча мы можем балансировать между точностью и скоростью поиска.

[38:04] Александр: То есть можно такую картинку в голове представить — не знаю, насколько она точная, ты меня подкорректируешь. Мы, скажем так, в древнерусском государстве, где не существует карт, нет Яндекс.Карт и поездов, и мы пешим способом пытаемся найти царя-батюшку. Мы оказались где-то в Сибири, дошли до первого дома, говорим: а как попасть к царю? Он говорит: о, это тебе надо в Москву идти. Даже лучше: мы приходим в одну деревню и спрашиваем — а где у вас деревня богаче? Говорят: иди на север. Идёшь на север, спрашиваешь — а где деревня богаче? Говорят: идите дальше на север. Идёшь, идёшь, пока тебе не скажут, что у нас тут самая богатая деревня, — и если тебе повезло, ты оказался в Москве.

[39:02] Андрей: Да, хорошее сравнение.

[39:02] Александр: А как задаётся направление вот этого луча? Ты говоришь, что они знают друг про друга, соседи рядом, кто ближе. Что за метрика в вертексе?

[39:21] Андрей: В вертексе — сам вектор.

[39:25] Александр: А в эдже?

[39:25] Андрей: А в эдже — связь с ближайшим. У тебя есть деревня, из неё три дороги, и ты можешь выбрать одну — тогда у тебя ширина луча один. Если у тебя группа из десяти человек, ты говоришь: окей, пять человек идёт на одну дорогу, пять — на вторую по лучшести дорогу. Нет гарантии, что вторая дорога не придёт в тупик, но за счёт того, что ты разделился, кто-то из десяти человек в итоге до Москвы дойдёт.

[39:55] Александр: Так, а метрика та же самая, да? Условно евклидово пространство: ты берёшь и сравниваешь векторы с текущим.

[40:02] Андрей: Да, да. У тебя есть твой идеальный вектор, который ты ищешь. И когда ты сравниваешь текущий вектор с идеальным, ты, по сути, говоришь, насколько тебе интересна очередная деревня.

[40:08] Александр: И получается, что, придя в какую-то среднюю деревню, которая знает про 10 других деревень, я у каждой из этих десяти меряю расстояние, смотрю, какая наиболее близка мне нужна, и туда иду. Окей, в какой-то момент я окажусь там, где самое близкое среди всех вокруг.

[40:39] Андрей: Ты придёшь в локальный максимум. Нет гарантии, что локальный максимум окажется глобальным, поэтому тебе нужно разделяться — в каждый момент выбирать не одну дорогу, а две как минимум.

[40:52] Александр: Окей, ну смотри, мы просто уже пришли в граф, который уже построен. А вопрос: как он образуется из ничего?

[41:02] Андрей: Образуется он на самом деле просто. Чтобы добавить новую деревню на нашу карту, мы сначала доходим до самой лучшей деревни, а потом рядом с ней строим.

[41:13] Александр: Нормально. Ну, это, я думаю, так и происходит: вначале это граф из одной деревни, потом рядом с ней вторая, третья — и так разрастается.

[41:22] Андрей: Да. И там есть ограничение: мы можем иметь не больше какого-то определённого количества дорог, то есть не можем построить дороги от каждой деревни к каждой. Поэтому, если у нас появилась более лучшая пара деревень, то дорогу до старой деревни, которая не очень нам интересна, мы убираем. Таким образом, мы поддерживаем более-менее константное число дорог в каждую деревню и из каждой — но сохраняем свойство, что мы всё ещё можем путешествовать в нужном нам направлении.

[41:59] Александр: Хорошо. А какая сложность поиска одного вектора в такой структуре?

[42:04] Андрей: Нет каких-то строгих оценок, но приблизительно логарифм.

[42:08] Александр: От?

[42:08] Андрей: От числа вершин.

[42:12] Александр: Ага. И, соответственно, вставка примерно так же, только плюс ещё какие-то перестроения.

[42:15] Андрей: Вставка примерно в полтора раза дороже, чем поиск. Чтобы вставить, нужно сначала найти, плюс ещё поменять.

[42:25] Александр: Окей. То есть, по сути, перформанс плюс-минус такой же, как у B-дерева, но точность немножко страдает, зато можно сделать так, чтобы оно работало.

[42:36] Андрей: Точность страдает. И когда мы говорим, что у нас есть O(n), нужно понимать, что n в данном случае — это умножение вектора. Если в B-дереве мы два числа сравним, то в этом графе одна элементарная операция — это перемножение довольно большого вектора, вот эти 6 килобайт. Поэтому на практике выходит так, что построение индекса — довольно дорогая операция.

[43:04] Александр: Ну, то есть из голых данных, когда мы много строим, это довольно дорого.

[43:08] Андрей: Да.

[43:13] Александр: Окей, хорошо. По-моему, отлично мы обрисовали ситуацию. Давай тогда так. В целом-то базы данных уже вроде как умеют это делать — в плане построения всяких разных индексов. Postgres геоиндексы точно умеет делать, я знаю. И вроде бы там есть pgvector, что-то такое.

[43:30] Андрей: Да, он и HNSW умеет делать.

[43:33] Александр: То есть в чём, собственно, киллер-фича?

[43:36] Андрей: Вот это интересно. Тут нужно упомянуть про то, что происходит, когда ты пытаешься объединить поиск по нескольким полям. Например, есть какой-то список товаров, и ты хочешь одновременно и по локации поискать, и по цене пофильтровать. У тебя есть, по сути, два варианта. Либо ты говоришь: у меня один индекс более строгий — по локации я отфильтрую тысячу товаров, а по цене десять тысяч. Тогда я сначала применю более строгий индекс, а потом сверху него — фильтр по цене, который менее строгий, но он уже будет всего на тысячу товаров, это будет быстро. Это первый подход. Второй подход: я построю join-индекс сразу по двум полям — такое B-дерево, где каждый элемент это такой тюпл. Особенность HNSW и векторного поиска в том, что ты вообще не можешь ограничить результат. Каждый вектор похож на каждый другой вектор. Ты не можешь сказать: если вектор меньше, чем 0.1, то он не похож, — потому что это будет неправда. Как мы уже говорили, важен не абсолютный скор между парой векторов, а относительный скор между двумя разными парами. Поэтому такое ограничение результатов за счёт применения нескольких индексов с HNSW не работает.

[45:08] Александр: То есть я не могу отсечь какую-то часть, чтобы потом её применить, потому что я могу потерять коннекции, или как?

[45:17] Андрей: Это тоже отдельный немножко вопрос. Что ты можешь сделать: если одно из твоих условий достаточно строгое, ты можешь сначала применить его, а потом векторный индекс — это всё нормально. Но это далеко не типичный случай. Типичный случай: у тебя есть HNSW, который даёт тебе результат — по сути, весь датасет в какой-то степени результат, — и есть какое-то условие, тоже недостаточно строгое, которое, например, половину данных отфильтровывает. Как объединить такие штуки? Вот это то, почему мы, собственно, Qdrant начали делать. Мы видели, что эта проблема существует и мешает нам внедрять векторный поиск в настоящий продакшн. Потому что, если у тебя нет никаких специализированных инструментов, ты можешь сделать две вещи. Либо префильтр с помощью строгого индекса, а потом реранкинг векторов; либо постфильтринг, когда ты выбираешь топ-100 ближайших векторов с помощью HNSW, а потом применяешь фильтры. Оба подхода достаточно ограниченные. Первый будет медленный, а второй — неточный, потому что нет никакой гарантии, что фильтр не уберёт всё.

[46:36] Александр: Ну да, среди этих 100 может не оказаться нужного, тебе нужны следующие 100, следующие — и так далее.

[46:41] Андрей: Да. То есть нет никакой гарантии, что ты когда-то закончишь поиск и что этот поиск не будет настолько же сложен, как полный перебор.

[46:49] Александр: Ну условно, например, я по всей планете хочу найти самую качественную машину, производимую в России. Очевидно, что, если ты будешь использовать индекс HNSW, российская машина будет там на тысячном месте, и ты перед этим, по сути, делаешь брутфорс по всем машинам. Это неэффективно.

[47:14] Андрей: Это неэффективно. Поэтому наш пропозал в том, что мы немножко меняем подход к векторному индексу. Мы не рассматриваем векторный индекс как просто какой-то индекс, один из многих. В нашем случае векторный индекс — это центральное место всей системы, и он знает про то, какие могут быть фильтры. И чтобы объяснить, как это работает, нужно сделать два замечания. Первое замечание — про то, что, когда мы обсуждали proximity-граф, в котором мы жадно ищем следующую вершину, мы можем в момент этого поиска применять и фильтры. Условно говоря, деревни, в которых нет почтового отделения, — туда мы не идём, даже если они ближе. Мы идём только в те деревни, где есть почтовое отделение. Таким образом, мы в результате всегда окажемся в той деревне, где почтовое отделение есть, потому что в другие места мы просто не ходили. Но есть проблема: если у нас не так много почтовых отделений, то мы очень быстро можем обнаружить себя в ситуации, где нет никаких путей, и мы не нашли ничего хорошего — даже близко не подошли к нашему результату. И то, как мы это делаем: мы дополнительно строим дороги между теми деревнями, где есть почтовое отделение. У нас получается такой большой граф всех деревень и маленький граф деревень с почтовыми отделениями. Поэтому поиск с этим условием гарантированно приведёт к результату лучшему, чем если бы таких дополнительных дорог не было. Условно говоря, мы строим не обычную дорогу, а железную, но только между теми деревнями, где есть почта.

[49:14] Александр: Это же тогда должно учитываться при построении графа, правильно?

[49:17] Андрей: Да. И это то, что мы делаем, — то, почему мы считаем, что для векторного поиска нужны специализированные решения. Это не просто какая-то очередная колонка внутри таблицы, это центральный элемент, который знает про все остальные данные, которые мы храним вместе с векторами.

[49:36] Александр: Слушай, а это же какие-то ограничения на использование вводит? Потому что, по сути, я должен как-то ограничить количество фильтров, которые могу применять.

[49:46] Андрей: Ну, ты применяешь те фильтры, для которых сделал этот вторичный индекс. Условно, ты построил дерево для цены, и вот это построение дерева для цены влияет на построение дополнительных дорог внутри нашего графа.

[50:06] Александр: Это где-то, условно, в метаданных коллекции записано, то есть это как реально вторичный индекс.

[50:11] Андрей: Да, да. Если я ищу по полю, на котором нет вторичного индекса, — ну, сорян.

[50:15] Александр: Да.

[50:17] Андрей: И прелесть в том, что это намного дешевле, чем строить join-индекс. Если в базах данных мы можем построить join-индекс между двумя полями — ладно, окей. Если между тремя, четырьмя — это будет совсем больно, потому что у нас, по сути, снова появляется проклятие размерности, только немножко в другом виде. В случае HNSW это намного приятнее делать. Мы можем построить 20 дополнительных полей, и это будет очень незначительный overhead на размер графа.

[50:53] Александр: Ну, потому что векторы сами по себе не маленькие — что-то там положил рядом несколько полей.

[50:57] Андрей: Даже нет. Просто если дорога между двумя вершинами существует, и между ними же существует почтовое отделение, как мы их называем, то нам не нужно вторую дорогу рядом строить — мы переиспользуем старую дорогу.

[51:11] Александр: А, ну то есть ты переиспользуешь и достраиваешь только новые дороги, которых нет.

[51:15] Андрей: Да, это первое. Второе — нам не нужно заморачиваться, если какой-то payload слишком маленький. Допустим, у нас есть тысяча уникальных значений какого-то фильтра, и, как бы ни выглядел наш граф, нам всегда будет эффективнее сделать fullscan в этот момент, чем достраивать какие-то вершины. Поэтому есть такая зона размерности фильтра — мы называем это кардинальностью фильтра, — которая требует достроения дополнительных связей. И она должна быть довольно большой. Если она слишком большая, то это тоже не нужно; слишком маленькая — не нужно. Есть такая золотая середина, которую мы достраиваем, и она не даёт дополнительно сильных проблем в размерности графа.

[51:57] Александр: А сколько, интересно, типичных запросов с продакшена попадает вот в этот индекс, а какие идут по левую и по правую сторону от него?

[52:20] Андрей: Зависит от продакшена, сложно сказать.

[52:23] Александр: По порядку — могут быть и 10 процентов, и 99, вообще разные?

[52:27] Андрей: Да, могут быть любые. Есть юзкейсы, когда, допустим, чат-боты: если мы обсуждаем контекст чат-ботов, то там длина одного диалога максимум 20 сообщений, и если мы хотим найти внутри одного диалога, это 20 сообщений, — то мы всегда используем вторичный индекс. Мы вообще HNSW даже не трогаем.

[52:50] Александр: Давай немножко разберём этот пример, потому что много кто сейчас прикручивает себя к RAG — интеграцию с OpenAI — и делает память для чат-ботов и так далее. В плане Qdrant и интеграции с OpenAI: какую роль там занимает он, какие запросы, какую информацию он хранит во время взаимодействия меня и его чат-бота, построенного поверх OpenAI?

[53:18] Андрей: Ну, это всё зависит от приложения. Типичное, как люди обычно делают: у них есть какая-то база знаний, специфичная для их приложения. Например, документация какого-то продукта — такой источник знаний, который используется, чтобы подсказать какому-нибудь ChatGPT, как правильнее ответить на вопрос пользователя. Это первый вариант. Второй вариант — это действительно память диалога. Если у тебя есть пользователь, который общается с машиной на протяжении условной недели, то машина должна знать, про что они говорили в понедельник. И тут довольно просто: у нас много пользователей, каждый пользователь имеет очень небольшой кусочек данных, и мы, в принципе, используем только вторичный индекс. Если мы говорим про такие вещи, то нужно понимать, что на маленьких масштабах, когда у нас один диалог, вообще неважно, что использовать. Использовать Qdrant или Postgres — действительно будет всё равно. Преимущества Qdrant возникают, когда мы начинаем говорить про масштабирование таких систем. Когда данные уже не влезают на одну машину, когда данные нужно шардировать, нужно делать репликации, чтобы у нас была high availability. Вот в этот момент возникает вопрос: а сможет ли Postgres сделать шарды? Потому что это нетривиальная задача для Postgres — как сделать join между двумя машинами. Это как бы не работает из коробки. А для векторного поиска это самое банальное, что можно сделать: объединить результат двух шардов.

[55:05] Александр: Например, грубо говоря, если мы говорим про распределение этого индекса: некоторые вершины будут лежать в одном шарде, некоторые в другом. И вот переход от одного шарда к другому — посчитать, туда заглянуть — это, по сути, межнодовое взаимодействие, и оно довольно частое в таком поиске, правильно?

[55:31] Андрей: Оно не просто частое, оно возникает всегда. Если у нас данные разделены на два шарда, то, чтобы сказать результат поиска, нужно каждый шард спросить и результат объединить.

[55:43] Александр: Мы, грубо говоря, запускаем на два шарда вот этих гонцов, по несколько штук, они пробегают, достают какие-то самые ближайшие деревни, а потом из тех, что получились, ещё разок это делаем, правильно?

[55:54] Андрей: Из тех, что получились, — у нас довольно ограниченные списки, — мы их просто самым банальным fullscan переранжируем.

[56:00] Александр: Ну да, ранжируете, и сколько нужно, столько и отдаёте. А может ли быть такое, что с одного шарда перебежал на другой вот этот поиск?

[56:10] Андрей: Нет, индексы независимые.

[56:13] Александр: В шардах. То есть это абсолютно независимые штуки сами в себе.

[56:14] Андрей: Да. Ну, условно, в один шард это, не знаю, центральная Россия, другой шард — это Север, Урал и так далее. Это может быть случайно — по дефолту это случайно, — но можно сделать и по регионам, можно по времени сделать разделение. Типичный пример, почему мы хотим по времени: у нас есть какая-нибудь социальная сеть, и мы не сможем проиндексировать все данные за всё время, но хотим горячий индекс для, скажем, последних 7 дней — того, что пользователи создавали. Тогда мы можем создать 7 шардов, на каждый день, и каждый день удалять самый старый и добавлять новый. У нас будет такое rolling window, где мы имеем индекс последней недели и очень быстро и эффективно удаляем всё, что было старше.

[57:07] Александр: Окей, то есть получается это шардированный индекс, каждый шард которого, я так понимаю, может быть и на одной ноде, и на разных, — мы можем несколько шардов на одной ноде иметь, и это всё такая абстракция, я могу предположить.

[57:19] Андрей: Да, да, да.

[57:22] Александр: А в Postgres — тут же сейчас кто-нибудь скажет: слушай, Postgres вообще тоже шардируется, чего ты мне рассказываешь? Но всё-таки он шардируется по-другому, это другое, что называется.

[57:31] Андрей: Очень просто: join между машинами — это очень дорогая вещь, и практически никто это не делает. Если нужно взять таблицу, которая частично на одной машине, и таблицу, которая частично на другой машине, и сджойнить эти две таблицы, — это будет очень дорого.

[57:52] Александр: Ну окей, use case, преимущество поняли. Давай, если ты не против, немножко уже в технические детали нырнём. Раз уж мы сейчас в распределённом контексте говорим — давай проговорим следующее. В распределённых системах какая проблема? Договориться между собой, как правило. Потому что разные ноды: у них может немножко системное время отличаться, оно может на некоторых нодах чуть пойти назад, сеть может моргать, и куча проблем возникает сразу, как только мы распределяем данные. И самый капитанский очевидный совет: если вы можете не распределять свои данные — пожалуйста, не делайте этого. Но если вы это сделали, то надо как-то договариваться — нам нужен консенсус между нодами, чтобы, условно, при удалении каких-то данных они удалились отовсюду. Если есть репликация, то нам надо обе реплики удалить: мы не можем одну удалить, а вторая из-за того, что сетка моргнула, всё ещё существует. Это неконсистентность, а мы хотим консистентности. Как вы этого достигаете? Какие инсайды по поводу распределения?

[58:58] Андрей: Поскольку систему, которую мы строим… тут, наверное, нужно вернуться к разговору про базу данных versus поисковый движок. Гарантии и цели того, что мы проектируем и разрабатываем, могут отличаться. Если мы говорим про базу данных, которая должна проворачивать финансовые транзакции, хранить пароли пользователей, учётные записи, — это один тип базы данных, и у неё должны быть строгие гарантии. Она должна быть source of truth для всей системы. Если мы делаем обновление, то оно должно быть транзакционным: либо применилось целиком, либо не применилось вообще. И это довольно дорого. Если мы хотим такие гарантии, то мы получаем Postgres со всеми вытекающими проблемами Postgres. У поисковых движков, к которым я отношу векторную базу данных, гарантии немножко другие, и use case другой. Навряд ли вы захотите хранить финансовые транзакции внутри векторной базы данных. С другой стороны, что вы захотите делать — это иметь быстрый поиск, причём как можно быстрее. Вы захотите иметь масштабирование. Вот мы говорили про 6 гигабайт эмбеддингов из всего миллиона строк, — а представьте, что у вас не миллион строк, а миллиард: сколько вам нужно разных машин. Мы опускаем сейчас всякие техники про компрессию. Миллиард честных эмбеддингов — это очень много, очень дорого, на одну машину не влезет никогда. Значит, вам нужно масштабирование. А раз нужно масштабирование, то вы, наверное, хотите, чтобы, если какая-то нода умерла в вашем кластере, вся система в принципе продолжила работать. Если из 10 машин умерла одна — ничего страшного, это типичный мейнтенанс, на пользователя он влиять не должен. Это совсем другой набор гарантий и другой набор целей для оптимизации. И это то, в чём я вижу различие между базами данных и поисковыми движками. База данных — для консистентности, для персистенса, для транзакций. А поисковые движки — для скорости, масштабирования и отказоустойчивости в плане того, что что-то может упасть и не должно влиять на поиск.

[1:01:34] Андрей: При этом поисковые движки редко являются оригинальными хранилищами данных. Обычно есть какая-то ещё первичная база данных, честная база данных, которая хранит оригинальные данные, — а в поисковый движок они попадают в результате какого-то процесса трансформации. И для векторных поисковиков это особенно актуально, потому что векторное представление по определению вторично. Изменилась модель, OpenAI выпустила новую версию эмбеддингов — будь добр, создай по новой, потому что иначе это не будет работать. Это специфичный для векторных поисковиков процесс, когда нам нужно всё пересобрать, и тут нет никаких воркараундов. Из этих требований, из этих целей мы, соответственно, выбираем разные решения — eventual consistency или строгую консистентность прямо сразу с транзакциями. Мы здесь не исключение, ничего особенного не изобрели. То, что делаем мы, довольно сильно похоже на Elasticsearch: там тоже есть шарды, есть репликации, это тоже можно настраивать. При этом, если мы говорим про консенсус, — алгоритмы консенсуса недешёвые. Есть такая база данных, называется etcd, — есть даже классный мем с ней. Она полностью построена на консенсусе: каждое добавление значения — это операция консенсуса, на неё должны согласиться кворум участников, и только после этого она применяется, записывается в write-ahead log. И, в общем-то, хранить много данных в etcd — это больно. Она рассчитана на очень маленькое подмножество данных. С векторной базой данных, в общем-то, та же история: мы хотим в консенсусе хранить только самое важное. Это, собственно, конфигурация — всякая мета-дата про то, где каждый шард находится, в каком он состоянии, живой он или нет, какие параметры коллекции у нас есть, какие ноды есть. Это мета-информация, она довольно маленькая, но очень важная. Маленькую по объёму, мы можем позволить себе хранить её в консенсусе. А хранить в консенсусе все векторы, которые пользователь добавляет, — очень дорого, очень больно, мы этого не делаем. В нашем случае все операции внутри шарда, которые происходят непосредственно с векторами, с данными, с записями, идут напрямую на шарды. А консенсус работает как механизм, который следит за тем, чтобы всё выполнилось. Если, допустим, какая-то операция на запись не была успешна, то мы сообщим в консенсус, что вот этот шард неполный и ему требуется дополнительная синхронизация. По сути, мы говорим, что надо что-то исправить. Если исправлять ничего не надо — консенсус просто работает независимо.

[1:05:05] Александр: Какой алгоритм используется для достижения консенсуса?

[1:05:07] Андрей: Ну, у нас это RAFT, такой типичный выбор. Требует, чтобы больше 51% нод было онлайн, чтобы принять изменения.

[1:05:18] Александр: Ну, в кворуме.

[1:05:20] Андрей: Да, да. Как только у нас 51% нод, мы можем применять операции. Иначе у нас бы возникла ситуация, когда кластер мог развалиться на две части, и каждая независимо друг от друга начала бы принимать решения, которые нам потом нужно было бы как-то мержить. У нас такого нет.

[1:05:38] Александр: Ну, по сути, вот происходит отказ.

[1:05:45] Андрей: Да. Но при этом поиск всё ещё работает.

[1:05:48] Александр: То есть на запись только отказ.

[1:05:51] Андрей: На запись, причём на запись вот этих мета-операций. Если в рамках одного шарда ничего не нужно менять — то и окей, всё продолжает работать.

[1:05:59] Александр: Довольно неплохо. Ну, про распределённые поговорили: RAFT на метаданные — классическая история. Все базы данных так или иначе распределённые хранят только метаданные в централизованном консистентном хранилище. Некоторые базы, которые поддерживают распределённые транзакции, координируют транзакции через commit/abort, а данные, естественно, лежат просто на нодах.

[1:06:28] Александр: Хорошо, давай тогда так. Мы говорим, что в целом векторные базы данных или векторные движки поиска редко когда являются single source of truth. Скорее всего, это какое-то вторичное хранилище — типа аналитической базы данных. Есть такой паттерн: есть OLTP базы данных…

[1:06:49] Андрей: Это хороший пример.

[1:06:51] Александр: …есть аналитическая база данных, в которую как-то асинхронно, каким-то процессом заливаются данные, и потом их аналитики считывают. Примерно так же, наверное, данные заливаются в этом большом проценте векторов.

[1:07:04] Андрей: В хорошо спроектированной системе да, нужно делать такой процесс, который из source-of-truth-базы данных обновит векторный поиск.

[1:07:15] Александр: Единственная обратная сторона медали такой архитектуры в том, что нет ACID-гарантии. Когда мы смотрим на всю систему целиком — берём, например, систему Spotify, — я смотрю на неё сверху как пользователь: я лайкнул пять песен за секунду, потом пошёл, обновил и жду, что рекомендации подстроятся под эти пять лайкнутых, потому что я же лайкнул. А так как рекомендации и мои лайки могут находиться в разных вообще системах, то это асинхронный процесс, и я не могу сразу увидеть в рекомендациях что-то похожее на мои лайки.

[1:07:55] Андрей: Ну, рекомендации, наверное, тут немножко другая причина, почему они не обновляются онлайн. Но вообще хороший пример — это счётчик лайков на YouTube: он не абсолютно точный.

[1:08:08] Александр: Ну, это да, но давай конкретно с векторным поиском другой пример. У меня есть векторный поиск в интернет-магазине, я добавляю новый товар, абсолютно новый, и не могу его тут же по векторному поиску найти. В какое-то время такое может случаться?

[1:08:22] Андрей: В случае нормальной работы системы, если ничего не является bottleneck, если всё отвечает в правильный тайм-аут, — результат будет доступен сразу. Все эти нюансы транзакционности, нюансы eventual consistency возникают, когда что-то в системе идёт не так. И это уже более интересный детальный разговор: в каких случаях, что мы гарантируем, что-то если отвалилось, то узнает ли пользователь об этом или нет, а если узнает — что он может сделать. В нормальной системе всё окей, но мы не можем гарантировать, что в 100% случаев будет окей, — в отличие от транзакционных баз данных.

[1:09:05] Александр: Да, но зато у транзакционных баз данных, если что-то пошло не так, — допустим, это реплицированный мастер-слейв, вот эта версия Postgres, — то, если не работают 100% машин, ты уже не можешь записать ничего.

[1:09:24] Андрей: Ну, там не 100. Если мы про Postgres говорим, то мастер — это single point of failure в случае записи: нет мгновенного переключения на слейва, и поэтому это совсем нештатная ситуация, из которой тоже нужно делать recovery. В случае нашего кластера все реплики равноценные, можно писать в любую ноду, и внутри себя распределение произойдёт как надо. Например, даже если я пишу в ту ноду, на которой нет шарда для моих данных, она примет запрос, сделает внутренний роутинг куда следует и отдаст результат. Более того, если из двух реплик одна упала — просто машина перезагрузилась, — то продолжать запись всё ещё можно. Можно писать в одну из оставшихся реплик, и когда другие машины восстановятся, будет произведена синхронизация — данные докинутся на те реплики, которые отставали.

[1:10:26] Александр: Понял, хорошо. Смотри, такой вопрос. Раз мы говорим, что векторные search engine, как правило, вторичные системы, — но ты вначале упомянул, что всё-таки Qdrant это такое центральное понятие векторной базы данных, вокруг неё что-то строится. Что ты имел в виду?

[1:10:50] Андрей: Уточню, что я имел в виду: внутри базы данных векторный индекс — это центральный элемент. И все остальные индексы, все остальные операции в нашем случае построены вокруг векторного поиска. А в случае распределённой системы это не очень важно, это уже out of scope, другой уровень абстракции немножко.

[1:11:12] Александр: Всё так. Да, я это задним умом держал, хотел бы спросить.

[1:11:21] Александр: Хорошо, слушай, когда мы говорим про распределённые системы, всегда встаёт вопрос тестирования всего этого добра. Это довольно нетривиальная задача.

[1:11:31] Андрей: Нетривиальная.

[1:11:32] Александр: Как вы вообще тестируете систему с точки зрения того, что она распределённая?

[1:11:37] Андрей: У нас есть несколько таких… Во-первых, много функциональных тестов, где мы запускаем кластер из трёх машин, делаем kill -9 на одну ноду, при этом параллельно загружая данные, и смотрим, что данные после того, как всё восстановилось, тоже восстановились как надо. Это, по сути, ограничено нашей фантазией — что мы можем придумать как плохой сценарий. У нас есть ещё более дикий тест, который создаёт кластер и кидает в него произвольные операции — произвольно удаляет разные ноды, — и мы смотрим, что опять же ничего не дедлочит, ничего не теряется, никаких данных. Такой хаос-тестинг.

[1:12:24] Александр: Можем так называть, хаос-манки ещё.

[1:12:26] Андрей: Да, да. Такая хаос-манки, которая не убивает слишком много нод, чтобы они не перестали работать, но воспроизводит типичные ситуации, когда в пределах наших гарантий что-то отказывает. Есть графаны, куда можно смотреть, которые говорят: вот это всё консистентно, консистентно — оп, не консистентно — значит, надо разбираться.

[1:12:50] Александр: А эти тесты каждый раз на CI прогоняются или отдельный какой-то процесс?

[1:12:54] Андрей: Функциональные прогоняются на CI, а те, которые хаос, — это просто отдельный кластер, который постоянно работает и постоянно следит за дев-бранчей.

[1:13:07] Александр: Найтли-билды такие, можно сказать, чаще.

[1:13:10] Андрей: Найтли-билды, да.

[1:13:11] Александр: Я понял. Хорошо. Я, кстати, посмотрел в код немножко — репозиторий склонировал, собрал себе локально. Я на Rust не пишу фултайм продакшн, я Java-программист, но энтузиаст. И мне понравилось, что некоторые тесты — это, во-первых, просто bash-скрипт запускается, а во-вторых, что там, по-моему, на Python есть некоторые сценарии.

[1:13:31] Андрей: Да, интеграционные тесты на Python, ну, потому что это проще как-то соркестрировать — нужно взаимодействовать с несколькими системами. У нас есть тесты и на Rust целиком, есть такие питонистские. Если мы говорим про тестирование, то у нас есть интересный момент — такая штука, которую мы называем local mode. Это, по сути, имплементация всей функциональности Qdrant в рамках одной машины — не распределённой, а в рамках одной машины, — которую можно запустить из чистого Python. Все те же функции — добавление данных, удаление данных, поиск — всё это можно запустить из чистого Python. Там вообще нет зависимости на Rust, ванильная имплементация алгоритмов на Python. Там даже нет алгоритмов, там всегда fullscan происходит. Но это полезно, чтобы сделать какие-то маленькие эксперименты, запустить Jupyter-ноутбуки или, например, CI: если не хочется в CI запускать докер, можно взять вот такую штуку в интеграционных тестах. Это, по сути, имплементация ровно того же интерфейса, но простая и на Python. И у нас есть прям огромное количество тестов, которые проверяют идентичность того, как работает серверная версия, и того, как работает вот эта локальная версия, local mode. То есть у нас такой процесс, который, во-первых, требует функционально каждую API имплементировать дважды: один раз оптимально внутри Rust, и другой — просто чтобы работало внутри Python. И поскольку у нас есть две имплементации, тестировать их друг против друга становится очень удобно.

[1:15:21] Александр: На Python?

[1:15:23] Андрей: Да, на Python. Мы просто создаём кучу случайных данных, случайный запрос туда, случайный запрос сюда, и смотрим, чтобы результат был одинаковый. И это нам позволило очень много всяких проблем найти. Когда у тебя есть вторая имплементация того же самого — это прям полезно для тестов.

[1:15:41] Александр: Круто, да, такая идея не сильно распространена. Но у вас, видишь, целевые юзеры — большинство людей на Python пишут, и вам прям нужно сильно тестировать.

[1:15:53] Андрей: Это трудоёмко. Имплементировать фичи в этом local mode — считай, каждая фича плюс от 10 до 20 процентов времени дольше требует, если это фича, связанная именно с функциональностью, с чистой оптимизацией. Это дорого с одной точки зрения, но с другой — довольно полезно. И юзерам полезно, которым это упрощает жизнь, и для нас полезно, потому что такие тест-кейсы возникают. Ну и на Python, очевидно, всякие верхнеуровневые тесты писать проще, удобнее — там не нужна вот эта строгость Rust. Сильные стороны Rust — в бэкенде, в оптимизациях, а в тестировании Python сильно удобнее.

[1:16:37] Александр: Вообще классное решение. У нас в Java-мире есть что-то подобное: иногда люди пишут тесты на Spock — это фреймворк на Groovy, очень похожий на Python язык, тоже динамически типизированный. Это иногда прям сильно увеличивает качество тестов, потому что, когда человек хочет написать тесты и написать их просто, он с большей вероятностью их напишет, чем если написать сложно. А то — да ну нафиг эти тесты, их вроде особо никто не смотрит, вот так пройдёт. И качество софта иногда действительно от таких банальных вещей может страдать.

[1:17:17] Александр: Классно. Ну смотри, по поводу Python давай тогда дальше. А как вот этот интерфейс, который взаимодействует с Rust, — через что они коммуницируют?

[1:17:25] Андрей: Если мы говорим про нормальную серверную версию Qdrant, то у нас есть два варианта, как поговорить с сервером. Это либо REST API — то есть JSON, вот это всё, как мы любим, — либо gRPC. JSON более гибкие, их проще менять — в каких-то случаях проще менять, — и они, в общем-то, полезны для разработчиков. Разработчик может открыть условный браузер. У нас, кстати, есть такой Web UI интересный, где можно писать JSON-запросы, REST-запросы с автокомплитом.

[1:18:01] Александр: Что-то типа Kibana у Elasticsearch, да?

[1:18:03] Андрей: Да, ну попроще.

[1:18:05] Александр: Ну понятно, что попроще.

[1:18:07] Андрей: С одной стороны, Kibana больше на графики ориентирована.

[1:18:11] Александр: Ну, там можно писать запросы, у них там свой язык. У нас — знаешь, есть этот dev-режим в Kibana, где первый плейн-экран — вводишь запрос, второй плейн-экран — результат? Вот у нас такая штука есть.

[1:18:23] Андрей: И можно там REST-запросы писать, автокомплит, всё такое. Если нужна скорость, если нужно тысячи запросов в секунду отправлять к поиску, то REST-интерфейс медленный, потому что он слишком эксплицитный. Особенно это заметно, когда мы векторы используем. Вот эти тысячи флоатов кодируются как текст, отправляются, сериализуются, десериализуются — очень большие накладные расходы. Поэтому у нас есть второй интерфейс, он практически все функции дублирует, — это gRPC. И с одного интерфейса на другой можно переключиться в Python буквально одним флагом: говоришь «хочу gRPC» — получаешь gRPC. По-моему, такая фишка есть только в Python. В остальных клиентах у нас всегда либо REST — если мы говорим про Java и TypeScript, то там REST, — либо, если про .NET, там всегда gRPC используется.

[1:19:27] Александр: А, то есть нет варианта поменять?

[1:19:29] Андрей: Нет.

[1:19:30] Александр: Но всё равно это, мне кажется, очень удобно, когда есть два интерфейса. Ну вообще это неплохое решение — даже очень хорошее, потому что иногда один из камней преткновения: используют ли gRPC? Говорят: блин, мне разрабатывать тяжко, дебажить тяжко — эти бинарные протоколы, протобафы, это не так удобно, как JSON, который я взял и увидел у себя в Wireshark. Действительно, JSON удобнее. И если у нас нет бутылочного горлышка в виде самой этой сериализации-десериализации в JSON — то, собственно, зачем вам gRPC, вы себе жизнь усложняете. Но как раз в вашем юзкейсе есть это бутылочное горлышко, и вот за этим gRPC.

[1:20:16] Андрей: Да. Мы не ищем простых путей: мы имплементируем два интерфейса, два режима — вот у нас есть local. Мы идём сложным путём, так скажем. The hard way.

[1:20:31] Александр: Классно. А вот прям интересный вопрос: есть ли у вас какие-то бенчмарки на тему того, как сильно хуже REST в каких моментах по сравнению с gRPC?

[1:20:42] Андрей: Когда мы его только релизили, мы делали бенчмарк — что там типа в три раза быстрее загрузка. Но я не уверен, что это именно связано с тем, что протокол настолько быстрый, потому что сериализация в Python тоже отжирает своё. То есть, возможно, за счёт того, что долгая сериализация, а не за счёт того, что сеть меньше.

[1:21:02] Александр: Получается, что реализация на клиенте сериализации тоже влияет, не только на стороне сервера. Логично, да. Ну, прикольно. То есть порядки такие — три-два раза? Это хорошие порядки, это не бесполезная работа, поэтому вы продолжаете поддерживать оба. На самом деле, OpenAPI-спека мне очень близка, потому что я тот человек, который для базы данных весь REST написал вместе с CLI, и, конечно, мы там тоже использовали OpenAPI. Это очень удобно в плане того, что ты уже поддерживаешь всех клиентов языков программирования, на которых можно сгенерировать клиентов. Есть нюанс.

[1:21:42] Андрей: Есть нюанс.

[1:21:44] Александр: Есть нюанс, да. Нюанс заключается…

[1:21:44] Андрей: …что это OpenAPI.

[1:21:46] Александр: Да, нюанс в том, что OpenAPI — там типа десяток разных версий OpenAPI, десяток разных генераторов, один поддерживает то, другой не поддерживает.

[1:21:55] Андрей: В общем, в плане именно генерации клиента gRPC намного более стабильный. Вот прям намного более стабильный.

[1:22:02] Александр: Ещё один плюс.

[1:22:04] Андрей: Да. На Python нам вот эту коробочную генерацию не удалось сделать, мы написали…

[1:22:08] Александр: Не удалось?

[1:22:10] Андрей: Да, мы как-то форкнули какой-то там хитрый генератор, который Pydantic использует. Pydantic — это такая библиотечка, которая делает прикольные структуры с валидациями и прочей интересной машинерией. Мы форкнули генератор клиентов, который использует Pydantic, и постоянно его допиливаем. Примерно один раз из трёх оказывается, что этот клиент какой-то новый тип не поддерживает, его надо допиливать. В общем, он настолько кривой и макаронный, что мы его даже не open-source-им — он у нас лежит, нам немножко стыдно за него. То есть клиент получается хороший в результате, но вот этот сам процесс генерации клиента в Python — это кошмар, если честно.

[1:23:04] Александр: Ну, смотри, я тебе сейчас расскажу, что в Java абсолютно такая же ситуация, вот один в один — с OpenAPI-генерацией клиентов. Давай поясним проблему, с которой мы оба столкнулись и боль у нас общая. Может быть, кто-то думает, что всё работает идеально, как пишут на сайтах и рассказывают на конференциях, — но в жизни реально не так. OpenAPI что декларирует? Говорит: смотрите, ребята, у вас есть YAML-спецификация, как правило, или JSON — по сути, схема вашего REST. И по этой схеме мы говорим, что есть некоторые процессы, которые валидируют, что бэкенды этой схеме соответствуют: если в какой-то ручке в body есть поле-строка, то ты туда не сможешь засунуть int, и это гарантируется, если у тебя всё правильно построено. И также есть способы генерации REST-клиентов почти для каждого языка программирования из этой спецификации. Как красиво всё на бумаге звучит. У тебя бэкенд берёт — например, вот Java, как мы берём (у меня даже целый выпуск подкаста про это, сделаю ссылку), — мы аннотируем наши контроллеры, которые отвечают за REST, некоторыми аннотациями. Я думаю, в Rust это какие-нибудь директивы.

[1:24:18] Андрей: Кстати говоря, когда я это начинал делать, такой штуки не было в Rust, к сожалению. Поэтому мы накостыляли свой генератор OpenAPI. Он наполовину руками создаётся: если нужна новая API, то её нужно создать руками. Но структуры в самих API, дата-тайпы, генерируются. Поэтому у нас такой небольшой франкенштейн. С тех пор уже появились проекты, которые умеют приблизительно генерировать автоматически из серверного кода спеку, но для нас это было слишком поздно — у нас уже была довольно глубокая интеграция того, что было, а переписывать на новое мы не нашли в себе отваги. Ну и как бы не топ-1 фича, которая стоит ресурсы. У нас есть кавердж из тестов, поэтому мы можем гарантировать просто тестами, что мы что-нибудь не забыли.

[1:25:16] Александр: Да, продолжая про кривости всей этой схемы: в Python, как Андрей рассказывает, и в Java, как рассказываю я, для большого комплексного продукта не получится сгенерировать клиент, который просто будет работать. Даже компилироваться он в Java не будет, и будут всякие странные ошибки в рантайме, которые вы даже иногда не поймёте. То есть код, по сути, генерируется нерабочим.

[1:25:39] Андрей: Сам код довольно страшный, если на него посмотреть.

[1:25:41] Александр: Ну, он страшный, да, это правда. У меня всё руки чешутся просто сесть и за выходные всё переписать на просто красивый, аккуратненький клиент.

[1:25:52] Андрей: Но в этом плане у нас докфудинг — у нас есть CLI, который генерирует клиент, и мы его используем. То есть мы проверяем хотя бы, что для Java оно работает. И то там есть, конечно, некоторые костылики. Это сильно тормозит. Я сейчас ретроспективно смотрю — конечно, принимая решение о генерации своего клиента, я бы не стал этого делать.

[1:26:11] Александр: OpenAPI-спека ещё чем полезна, не только же ради клиентов? Она генерирует Swagger.

[1:26:13] Андрей: Это да, ну не только Swagger — в нашем случае это документация Redoc.

[1:26:21] Александр: Документация, да.

[1:26:24] Андрей: В плане документации это, пожалуй, самое лучшее её применение: на основе кода построить ту версию API, в которой можно посмотреть разработчику и понять, что вообще происходит.

[1:26:34] Александр: Кстати, OpenAPI-спеку ещё использует интеграция OpenAI с твоим REST: ты можешь ей дать спеку твоего сервиса, и она будет генерировать запросы и ходить в твой сервер. Так что тоже ещё один плюс. Ну и, соответственно, в некоторых языках всё-таки поддержка лучше. Допустим, в JavaScript, в TypeScript использовать OpenAPI-спеку намного приятнее, чем gRPC.

[1:27:03] Андрей: Ну да.

[1:27:03] Александр: Там она нативная и работает, можно сказать, в real-time даже — как-то language-сервер её… Тебе не нужны датаклассы, у тебя JSON, это JavaScript Object Notation.

[1:27:15] Андрей: Да, поэтому для некоторых языков мы основным протоколом используем REST. В Python — потому что мы написали вот этот свой генератор, в JavaScript — потому что он там в принципе хорошо поддерживается. Но для всего остального, если мы будем делать новые клиенты, мы, скорее всего, будем начинать с gRPC. Он тоже не идеальный, но он, по крайней мере, генерируется всегда — вот это его преимущество. Он генерирует некрасивые структуры данных часто, но с этими структурами по крайней мере можно работать, их можно улучшать, над ними можно производить какие-то надстройки, делать из них что-то более-менее удобоваримое. С REST так не получится.

[1:28:00] Александр: С REST, наверное, полностью переделывать. Вообще такой интересный способ интеграции. Он хороший — я знаю некоторые базы данных, например, старая RethinkDB, она тоже с помощью REST работала. Ну, тут тоже: базы данных, не базы данных — search engine. По сути, чтобы положить объект, достать объект и что-то сделать, мы коммуницируем с помощью REST или gRPC. А рассматривал ли ты ещё какие-то способы интеграции с другими языками — нативные коллы и так далее? Или тебе достаточно gRPC?

[1:28:36] Андрей: Мы не рассматриваем себя как встраиваемую базу данных. Например, если мы говорим про SQLite — SQLite может нативными вызовами общаться с твоей программой. Мы просто не рассматриваем Qdrant как такую систему. У нас много чего сделано, что заточено под серверный деплоймент, поэтому мы предполагаем, что в правильной инсталляции Qdrant находится на отдельной машине. Ну или на отдельном поде, если мы про Kubernetes говорим. Он не часть процесса.

[1:29:13] Александр: Окей. Кстати, насколько я знаю, ClickHouse на днях выпустили chDB, который как раз может встраиваться. Но это, ладно, это уже про аналитические штуки.

[1:29:23] Андрей: Это валидный кейс. Если мы будем это делать, то отдельным продуктом, который, возможно, будет использовать часть кода, каких-то внутренних крейтов, что у нас есть. Но это будет именно отдельный проект, отдельный репозиторий.

[1:29:39] Александр: Актуально, надо будет подписаться.

[1:29:41] Андрей: Это, опять же, небольшой приоритет. Мы знаем, что есть векторные search engine, которые делают это своим основным юзкейсом, и у некоторых из них тоже написано на Rust, кстати говоря. У нас просто другой юзкейс — серверные деплойменты, high availability. А если хочешь просто поиграться и посмотреть, как это работает, можешь использовать local mode, но он не оптимизирован. Вообще не оптимизирован, by design. Там всё… Вот если больше 10 тысяч векторов, то это будет уже очень медленно.

[1:30:17] Александр: 10 тысяч — это такой трешхолд fullscan.

[1:30:19] Андрей: Ну да.

[1:30:28] Александр: Окей, хорошо. Смотри, раз мы немножко про перформанс говорим — тоже интересная штука, которую я подметил. Естественно, база данных написана… давай уж буду говорить «база данных», но имея в виду векторный search engine, — написана на Rust. И в большинстве вещей, которые написаны на Rust, они by design, by nature…

[1:30:46] Андрей: Blazingly fast.

[1:30:49] Александр: Blazingly fast, да. И вот что мне понравилось в твоём репозитории — так это то, что там есть бенчмарки, которые подтверждают: типа, за слова отвечаем. Это не всегда так подкрепляют. И вот по поводу бенчмарков мне бы хотелось узнать, как вообще ты пришёл к тому, что нужно бенчмаркать, потому что не все это делают. Какие вообще челленджи, какие инсайды ты из этого извлёк?

[1:31:13] Андрей: Вообще говоря, традиция бенчмаркать векторный поиск появилась задолго до векторных баз данных. Там было множество алгоритмов, и вообще эта область вплоть до, не знаю, трёх лет назад в основном развивалась в сторону таких крупных корпораций типа Google, которым нужно искать среди 10 миллиардов векторов очень быстро, за наносекунду условную. И весь академический ресёрч, все инженеры, которые работали с векторным поиском, решали вот эту одну проблему: как масштабировать векторный поиск на максимально большое количество векторов. Это не совсем про нас. Наша изначальная идея была не в том, чтобы сделать самый быстрый векторный поиск, а в том, чтобы как-то подружить эти фильтры и вторичные индексы с векторным поиском. Это то, чего не было, и то, что на самом деле нужно большинству компаний нормального размера, которые работают над своим проектом: они привыкли так делать в эластике, и они должны так же делать для векторного поиска в том числе. Поэтому бенчмарки не были основной целью. Мы хотели показать, конечно, что мы быстрые, но соревноваться с этими библиотеками, которые встраиваются в процесс и заоптимизированы уже до самого последнего слоя, — это, во-первых, неправильно, потому что разные юзкейсы. Тот же gRPC-интерфейс, какой бы он хороший бинарный ни был, по скорости не сравнится ни с какой библиотекой, которая просто память копирует — в лучшем случае копирует, иногда просто указатель даёт, говоря «нá». Поэтому мы бенчмаркаем только относительно сравнимых проектов. Мы не бенчмаркаем против этих библиотек, не бенчмаркаем против алгоритмов, потому что мы не изобретаем алгоритмы, — мы делаем систему целиком и предпочитаем бенчмаркать её тоже целиком. Бенчмаркаем против open-source-проектов, потому что нам нужно иметь одинаковое железо и гарантировать, что это не bias внутри железа. Мы даже не просто на одинаковом железе, а на одной и той же машине, в одном и том же дата-центре это бенчмаркаем.

[1:33:39] Александр: С какими продуктами?

[1:33:42] Андрей: Ну, есть всякие конкуренты, я их не очень хочу называть, но вы можете посмотреть.

[1:33:45] Александр: Ну, Elasticsearch уже был.

[1:33:47] Андрей: Elasticsearch, да, один из них, раз назвали. Они все улучшаются, все постепенно приходят к общему знаменателю, поэтому я бы даже сказал, что те бенчмарки, которые мы делали год назад, сейчас уже не очень интересны, потому что все стали примерно одинаковыми. Все примерно делают один и тот же HNSW, и интерес теперь не в том, чтобы бенчмаркать, у кого более канонично правильная имплементация, а в том, чтобы смотреть, как ещё мы можем пооптимизировать сам подход к работе с векторами. И тут возникают уже такие темы, как квантизация: как хранить вместо флоатов байты, а если не байты, то даже иногда биты можно хранить. Это не прямолинейная замена, потому что приводит к потере точности, но эту точность мы можем компенсировать в реалтайме. Как сделать, чтобы память удобно использовалась? То есть бенчмарки усложняются. Те, которые у нас сейчас на сайте, были актуальны, наверное, год назад. Сейчас нам уже нужны другие бенчмарки, с более хитрыми сценариями, потому что иначе это просто скучно становится: у всех один и тот же алгоритм, какие-то небольшие проценты разнятся. Тут нужно подчеркнуть, что мы не исследователи, мы не пишем научные работы, мы инженеры и делаем решения. Так что сравнивать векторную базу данных с какой-то определённой имплементацией определённого алгоритма, написанного на C++, — это непрактично.

[1:35:27] Александр: Это правда, кстати, ты хорошо подметил. Есть эти академические бенчмарки, которые, как правило, какой-нибудь профессор в университете: выцепил себе абитуриента-студента, вот он сидит, пишет, имплементирует, чтобы эти бенчмарки нарисовать, что оно работает. Но ты не возьмёшь это как готовую программу и не заиспользуешь. Ни о каком там поддержке REST или деплойменте речи, конечно, не идёт, но даже просто взять код — ты его даже адаптировать не сможешь. Потому что это просто узкая штука, чтобы проверить, что, когда мы так оптимизируем хранение флоатов, получаем вот такой-то перформанс.

[1:36:06] Андрей: Ну типа да. Ну ты попробуй в продакшене теперь оптимизируй эти флоаты так же. Это будет означать, что мы много чего другого не можем. Например, динамически добавлять новые значения не можем.

[1:36:16] Александр: А, ну да, то есть на поиск оно быстрее, а вот добавлять — сорян.

[1:36:20] Андрей: Ну да.

[1:36:22] Александр: Разница такая довольно существенная, хорошо, что ты её подметил. Итак, про бенчмарки — вот такие производственные. То есть вы, по сути, берёте свои gRPC-клиенты, засовываете туда сколько-то данных и смотрите скорость ответа.

[1:36:35] Андрей: Да, вот то, как оно будет работать в продакшене.

[1:36:36] Александр: Давай, а какие цифры в плане — столько-то записей, такой-то респонс-тайм?

[1:36:42] Андрей: Мы в основном меряем RPS, request per second. Ну, скажем так, на 5 миллионов векторов можно тысячу RPS-ов получить.

[1:36:55] Александр: Тысячу RPS-ов на чтение, да, на 5 миллионов векторов — на скольких машинах?

[1:36:59] Андрей: На одной машине.

[1:37:00] Александр: На одной.

[1:37:02] Андрей: И это горизонтально масштабировано. Если мы добавляем 10 машин, мы можем получить в 10 раз больше — либо в 10 раз больше векторов, либо в 10 раз быстрее.

[1:37:10] Александр: А, то есть там линейная в двух направлениях зависимость даже.

[1:37:14] Андрей: Да, да.

[1:37:16] Александр: Прикольно. А есть какие-то разумные пределы или их не существует, грубо говоря?

[1:37:20] Андрей: Всегда есть предел железа: не у всех есть 10 машин. 10 машин — это серьёзный кластер, так скажем. Но при этом, с другой стороны, есть крупные клиенты, которые запускают больше чем на 10 машинах, и у них тоже всё работает. У них есть другой челлендж — про конкретику я рассказывать не буду. Но у одного из них самый высоконагруженный юзкейс, который мы знаем so far. Это много параллельных записей, много параллельных чтений, и им нужно, чтобы вставленные новые значения быстро появлялись в поиске. То есть им batch indexing не всегда помогает.

[1:38:08] Александр: Прикольно, тоже интересный кейс.

[1:38:17] Александр: Давай, наверное, к самому сладенькому, к самой мякушке подойдём. Во-первых, для тех, кто уже почти два часа нас прослушал, — огромный респект, ребята. Вы заслужили сейчас слушать ещё час про Rust. Я специально не стал это в начало вставлять, чтобы всё-таки про Rust послушали. Давайте вот для терпеливых. В общем-то, база данных написана на Rust, да? И давай с самого начала. Вот ты, когда был инженером, который ещё не создал Qdrant, работал в компании, — как ты понял, что тебе… Ну, понятно, что был такой юзкейс: хотелось создать систему, которая поддерживает такой поиск, не только с векторным индексом, но и с некоторой фильтрацией внутри него. Было какое-то понимание возможности с точки зрения алгоритма, как это можно приблизительно сделать. И вот потом ты решил, какой язык?

[1:39:09] Андрей: У меня был прототип на Python, который демонстрировал принципиальную возможность, что это применимо. Этот прототип был близок к академическому коду, потому что на практике его было очень сложно применять. Самый большой инсайт, который я из него извлёк, — это то, что всё-таки надо хранить данные (вот эти payload’ы, как мы их называем) рядом с самими векторами, иначе это не работает. Совсем отдельно положить векторный индекс и совсем отдельно все остальные индексы — этот сценарий не срабатывает. Нужно их всех комбинировать вместе.

[1:39:48] Александр: Ну, вот эта доп-фильтрация должна быть на payload. А раз их надо хранить вместе, то это естественным образом больше похоже на отдельный сервис или отдельную базу данных, нежели на библиотеку. Потому что изначально у меня тоже была мысль: почему бы не сделать встраиваемую, такую типа SQLite-индекс, который можно вызвать напрямую из Python, а он бы тебе построил.

[1:40:12] Андрей: Ну вот нет, нельзя, потому что нужно данные тоже хранить. Иначе это становится слишком сложно, это неатомарный продукт, он не ограничен скоупом только векторов. Он должен больше всего в себя включать, и логично было сделать это именно как отдельный сервис. У меня к тому времени уже был очень разнообразный опыт написания всяких разных сервисов — и на плюсах, и на Scala, и на Java, и на Python. Так что мне ничего из этого не нравилось. Было понятно, что Python слишком медленный — вот на этом примере он не годился. Java мне не нравилась сама по себе, как экосистема, вот это всё. Scala к тому моменту уже умирала — я не знаю, умерла до конца или нет, но кажется, что она к тому моменту уже начала умирать. А C++ было просто страшно, потому что себе ногу отстрелить очень просто на C++, и если бы я начал писать Qdrant на C++, я бы до сих пор сегфолты искал — на каждый десятый запуск, наверное. Поэтому возникло желание совместить приятное с полезным — какой-то новый язык и новый проект. В основном выбор был между Go и Rust. Я бы ни того, ни другого не знал к тому моменту. Но Go, во-первых, внешне не очень приятный лично для меня: там много вот этих эксплицитных проверок на nil, очень простой язык без дженериков. В общем, мне это не очень понравилось, поэтому я решил, что Rust больше подходит. И в Rust есть очень хорошая формулировка, что это system programming language — язык, на котором пишутся программы, используемые другими программами. А это как раз определение того, что мне было нужно: vector search engine — это то, чем будут пользоваться не пользователи, а другие программы. Второй бэкенд общается с vector search engine. Вот из-за этого я, собственно, выбрал Rust. Мне потом говорили, что можно проследить эволюцию моих навыков в Rust, если смотреть в код достаточно глубоко: где-то он начинался с наивного, а потом вроде стал получше.

[1:42:39] Александр: Интересно, да, потому что, мне кажется, у Rust есть такой же флейм, как у Scala, — что там очень разный код можно писать. От примитивного, императивного, как на Java — циклы, — и до супер-забористого, где просто не разберёшься.

[1:42:55] Андрей: На мой вкус, на Scala гораздо проще сделать нечитаемый код, гораздо. Всеми этими имплицитными параметрами в Scala, функциональщиной, которая там выкручена на максималке. Код на Scala очень просто испортить. На Rust не так просто. И то, что мне нравится в Rust, — это, по сути, как происходит рефакторинг. Допустим, в Rust нужно добавить новое поле в структуру данных. Код можно организовать таким образом, что, добавляя новое поле, компилятор тебе автоматически найдёт все места, где нужно что-то исправить. Тебе не нужно искать это по всему проекту: ты добавил поле, и везде, везде, где оно должно было быть использовано, компилятор тебе скажет, что оно должно быть использовано. Это работает с enum, это работает с трейтами. Очень полезно.

[1:43:58] Александр: Ну, то есть паттерн-матчинг. Паттерн-матчинг — это вообще киллер-фича. Когда у тебя компилятор настолько продвинутый, что способен отследить все места использования какого-то closed enum, например, и сказать, что вот здесь ты должен использовать новую веточку, которую добавил, а она там не используется. По сути, в Java недавно завезли sealed-классы, решает ту же самую проблему, но там она решается совершенно не так, и лучше бы они её вообще не решали, но они пытаются. Ну вот, в Rust это, кажется, из коробки работает. И реально рефакторинг может получиться большой по объёму: если поменялась одна штука, это может полсотни разных файлов за собой притащить. Но ты можешь быть уверен, что как только ты это сделал, компилятор найдёт все места, и когда ты поправишь все ошибки компиляции, у тебя будет программа, которая будет работать. Ну а если я, например, где-то использую нижнее подчёркивание, условно дефолтную ветку, — или это уже на уровне код-стайлов решается?

[1:44:57] Андрей: Да, мы просто такое не пропускаем в ревью. Это можно сделать, естественно, и ты это обнаружишь очень скоро — что-то забудешь. Там есть некоторые моменты, которым нужно заставить себя и команду следовать. Например, int-конверсии: если ты будешь просто использовать везде into, into, into, то это очень сильно портит навигацию по коду, потому что ни rust-analyzer, который в VS Code, ни джетбрейновский парсер языка не могут понять, какую именно конверсию ты хочешь сделать. Поэтому все конверсии мы теперь предпочитаем делать явно.

[1:45:41] Александр: Ну, в то же время компилятор как-то понимает.

[1:45:44] Андрей: Компилятор как-то понимает, но инструмент не идеальный, и иногда в угоду удобства работы с инструментами надо заставлять разработчиков чуть более эксплицитно писать там, где можно.

[1:45:58] Александр: Получается, что как раз в этом немножко Scala мне напоминает — что есть некоторые неявные штуки. Кажется, что в Scala стандартная библиотека построена по такому принципу: например, если нужен компаратор при сортировке списка…

[1:46:10] Андрей: Ну, там в этом и фича, что «неявно значит круто». Ну, по факту получается «неявно значит больно».

[1:46:18] Александр: Да, это жизнь показала, и на примере Scala мы видим, что случилось с этим подходом.

[1:46:24] Андрей: Да.

[1:46:26] Александр: Да, я тоже писал на этом замечательном языке и думал, что лучше не существует. Но нет. Окей, значит, в Rust из-за того, что компилятор нормальный, можно довольно классно, безопасно рефакторить код — например, с добавлением поля, супер. Что ещё такого в Rust, что делает его преимущественным перед другими?

[1:46:46] Андрей: Стандартные, везде рекламируемые фишки Rust — это безопасная многопоточность, borrow checker, lifetime, вот эти все вещи, которые практически в чистом виде были придуманы в Rust. В науке, конечно, были какие-то прототипы, но в единую и юзабельную систему они были собраны в Rust таким образом, что, в принципе, если ты сам себе не устроишь такую ловушку, ты не можешь получить segfault никогда, если пишешь safe code. Ты не можешь получить data race никогда. Ты не можешь получить использование неаллоцированной памяти. Ты не можешь получить memory leak. Все эти штуки в Rust очень сложно сделать. Если ты это сделаешь, то тебе нужно реально потратить время, чтобы убедить компилятор, что ты хочешь это сделать. И компилятор очень упрямый: пока явно не скажешь, что ты готов согласиться на отказ от всех гарантий и использовать unsafe code, — тогда ладно. Но все эти примитивы — типа mutex, каких-то RW-локов, какой-то параллельности — обложены ростовыми гарантиями, и ты не можешь, даже если сильно захочешь, залочить mutex на запись дважды. Просто компилятор не пропустит. Что можно сделать в Rust — это deadlock получить. Вот это очень просто сделать. Такой компилятор проверить не может, но, к слову говоря, в любом другом языке его тоже очень просто получить.

[1:48:28] Александр: Это ты всё говоришь, да, как и все говорят про Rust, что всё замечательно. Я сам в целом на нём пописываю иногда, но не продакшн. И вот что я сильно заметил — это, для меня лично (и, думаю, из комьюнити у многих) прям огромный порог входа. Благодаря этим гарантиям ты не всегда можешь сделать штуки, которые в остальных языках привычны. Например, вернуть итератор из класса — это вообще нетривиальная задача в Rust. Итератор по каким-то залоченным данным — просто лучше даже не начинать это пробовать делать. Я там потратил несколько дней на то, чтобы понять, что мой подход в принципе неправильный, хотя в той же Scala это изи-пизи, дефолтное поведение. По-моему, есть пример тоже замечательный: попробуй реализуй linked list на Rust — удачи тебе. Если ты начинаешь, это очень сложно, потому что компилятор очень часто бьёт по рукам. Например, потому что ты не всё в кучу аллоцируешь, много чего аллоцируется на стеке, и там вот этот бороуинг, точнее ownership, — кто чем владеет, какой стек уже ушёл. Ну реально вот этот порог входа высокий, и в этом плане я расцениваю это как минус языка. Если мы с Go сравниваем — в Go порога почти нет, как и в Python.

[1:49:52] Андрей: Но, как кто-то мне сказал в один из моментов моей старой работы, Java была спроектирована таким образом, чтобы код сеньора с 20-летним опытом и код джуна выглядели примерно одинаково.

[1:50:06] Александр: Финал.

[1:50:08] Андрей: Наверное, на Go примерно такой же принцип: что сеньор, что джун будет писать nil-чеки после каждой функции. В Rust такого нет. В Rust компилятор тебе много чего запрещает, и основная сложность с порогом входа — это как бы принять, что окей, компилятор это запрещает, наверное, for a good reason, — и в итоге с ним согласиться и сделать так, как компилятору хочется, а не как тебе хочется. Как только ты научишься делать так, как хочет компилятор, сразу всё станет гораздо проще, уйдут все эти проблемы с memory leak’ами. Основные проблемы сразу возникают, когда разработчики считают себя умнее компилятора, пишут unsafe, а потом оказывается, что нифига они не умнее. Точнее, наверное, так: это как проблема выжившего. Те, кто оказались умнее, по дефолту молчат, а те, кто не умнее, громко кричат, — и мы наблюдаем только тех, кто не умнее.

[1:51:02] Александр: Ну, круто. То есть ты трансформировал эту проблему высокого порога входа: условно, не надо — просто следуй тому, как компилятор тебя просит. Но я имел в виду немножко другой высокий порог входа. Вот ты говоришь, что в Java код сеньора будет выглядеть примерно так же, как код джуна, — вследствие чего джун может прийти на проект и довольно быстро начать хотя бы баги фиксить. А в Rust, мне кажется, это не так. Я даже сейчас не могу прийти в Rust-проект, open-source, где ребята супер-пупер используют типы, и результаты у них какие-то страшные — ну, страшные для меня. В плане, что они настолько эту систему типов прожали, что уже начинают какие-то вещи программировать на ней, которые я даже не пойму, как работают. И, наверное, не победю в схватке с компилятором в этом проекте.

[1:51:58] Андрей: Да, если мы говорим про порог входа в существующий проект — с одной стороны, да: если ты не знаешь Rust, тебе нужно побороться, и простую какую-нибудь фичу, которую в Java сделал бы за полчаса, ты будешь несколько дней понимать, что от тебя компилятор хочет. С другой стороны, зато ревью проще. Вот мне, например, у нас иногда мы делаем такие эксперименты, когда на всякие issue в GitHub баунти навешиваем, и туда приходят совершенно сторонние разработчики и делают нам что-то. И если компилятор прошёл, тест прошёл — я более-менее уверен, что оно правильно. Если бы такое в Python было, я бы вообще не был уверен, я бы 100500 тестов попросил написать.

[1:52:46] Александр: То есть это как бы, да, ты больше воюешь с компилятором у себя локально на машине — ну, зато меньше воюешь с ревьюером.

[1:52:53] Андрей: Да, либо просто ревьюер может даже и не посмотреть на твой код и не смержит, потому что, блин, это надо вникать, а у меня у самого работы много.

[1:53:02] Александр: Мне надо будет проинтерпретировать этот код, понять, что там нет багов и что те тесты, которые есть, все кейсы покрывают. Ну, ты такой — блин, нафиг надо. А тут CI зелёный, значит, компилируется, тесты есть, значит, оно работает, вероятно, всё окей. Надо проверить только минимум — что оно делает то, что нужно, и всё. Как бы подытоживая в сторону Rust — в этом плане он тоже подходит: эти современные языки нового поколения учатся на ошибках старых языков, как ни крути. Нельзя спроектировать язык с нуля, чтобы он был идеальным, если это не Lisp. Ещё мне нравится инфраструктура, которая вокруг Rust есть, — я имею в виду Cargo, тул, который помогает нам отвечать за сборку, тестирование, добавление зависимостей. Там есть команда cargo add, например, которой нет в Java-комьюнити. Ну нельзя вот взять и написать gradle add — и что? Ну, или если можно, то это какие-то плагины тебе надо ставить. Нет этого by design, поэтому, даже если что-то есть стороннее от комьюнити, оно ненатурально, и никто это не использует. Соответственно, форматинг тоже — rustfmt, замечательная тема: код всегда отформатирован так, как он отформатирован. И его можно даже форматировать на CI. То есть у тебя есть tooling вокруг языка, и это замечательно. Тот же самый LSP: в Java вот есть LSP, но они все очень плохие, потому что большая компания, которая разрабатывает IDE для Java, не написала LSP — точнее, написала, но свою версию, и, конечно, не будет это open-source-ить, потому что иначе тогда все на Neovim перейдут с их LSP. В плане tooling — офигенный, я очень кайфую с этого. Может быть, есть у тебя какой-то tooling, которым ты прям был восхищён в Rust, из продакшн-опыта?

[1:54:55] Андрей: Ну, ты правильно всё назвал — стандартный cargo. Мы используем ещё Clippy, это такой более-менее стандартный линтер для Rust, который всякие типичные антипаттерны кода отлавливает. Но самое интересное, что у cargo есть система плагинов. Ты можешь поставить совершенно разные утилиты — начиная от проверки орфографии, проверки того, что ты случайно не подключил какую-нибудь GNU GPL-зависимость, которая испортит твою лицензию. И одна из интересных таких тулз, которыми мы пользовались, — это автоматический детектор специального кейса, который приводит к дедлоку, когда ты дважды лочишь на запись в одном треде.

[1:55:53] Александр: Ну, это если у тебя есть какой-нибудь mutex, и ты делаешь лок на запись, а потом ещё раз лок на запись, не отпустив предыдущий. Но некоторые, по идее, разрешают так делать, они просто пропускают — ну, тот же самый поток, пожалуйста, бери.

[1:56:09] Андрей: Да, но если при этом у тебя есть поток на запись, другой поток, который будет пытаться лочить на запись, и он попадёт между вот этими двумя ридлоками, — то у тебя будет дедлок.

[1:56:17] Александр: А, ну тогда да.

[1:56:19] Андрей: И такие штуки не отлавливаются стандартным компилятором, но отлавливаются вот этой тулзой. Это то, что нас пару раз спасло от дедлока.

[1:56:29] Александр: Вносит ли она какие-то false positive, то есть палки в колёса не вставляет?

[1:56:33] Андрей: Вставляет, поэтому мы запускаем её только руками, не всегда. Довольно сложно интерпретировать её вывод. Но она лучше, чем просто глазами смотреть.

[1:56:43] Александр: А вот по поводу тестирования. Я смотрел, у вас там используется не стандартный cargo test, а nextest?

[1:56:50] Андрей: Да, nextest у нас используется, потому что они немножко быстрее, а во-вторых, потому что позволяют всякие ретраи делать. Это наш, к сожалению, техдолг — что у нас некоторые тесты такие флаки. И вместо того чтобы их чинить, мы просто сказали: ну, три раза запускай.

[1:57:10] Александр: Классика.

[1:57:10] Андрей: Это не очень, конечно, вообще не горжусь этим.

[1:57:15] Александр: Ну, много кто так делает, правда.

[1:57:17] Андрей: Но иногда некоторые тесты очень сложно сделать стабильными. Особенно те, которые зависят от таймингов, или тесты, которые зависят от параллельности: им нельзя, условно говоря, сид фиксированный для рандома включить, и поэтому на каком-то медленном CI оно начинает давать непредсказуемые результаты. Это неприятный момент, именно с параллельностью связан, но мы его пытаемся потихоньку искоренять.

[1:57:43] Александр: А по поводу параллельности в Rust вы какую-то библиотеку используете тоже, или нативные треды?

[1:57:49] Андрей: У нас есть несколько вариантов. Для той части, которая связана с нетворкингом, с консенсусом и API, — это Tokio, async. А то, что связано с построением индекса, — это Rayon. Там немножко разные workload’ы. Tokio всё-таки завязан на асинхронность и ожидание, когда данные готовы из I/O, а Rayon больше про то, как загрузить CPU.

[1:58:17] Александр: Перемолоть побольше, да?

[1:58:19] Андрей: Да.

[1:58:19] Александр: Прикольно, что есть такие библиотеки, которые можно вот прям настолько узкоспециализированно делать. Потому что, если мы возьмём ту же самую пресловутую Java, там в целом не так сильно разделяется: есть просто экзекьюторы, эти сервисы, ты можешь немножко повлиять на то, как они будут расти, но на этом, наверное, и всё. А тут, видишь, прям можно целую библиотеку. В этом плане мне тоже импонирует идея Rust, что они сделали ядро языка максимально маленьким, а всё остальное просто как батареечки снаружи в качестве зависимости притягивается.

[1:58:56] Андрей: Tokio — это, пожалуй, стандарт де-факто в экосистеме. Я не видел альтернативы, сравнимой по размеру: она на несколько порядков более зрелая, чем всё остальное, что там есть.

[1:59:10] Александр: А она какую концепцию использует вообще — Event Loop или Thread Pool?

[1:59:16] Андрей: Фьючерсы. Мы создаём рантаймы под разные виды workload’ов: допустим, консенсус у нас один рантайм с одним thread pool, какой-нибудь поиск — это другой рантайм с другим thread pool.

[1:59:32] Александр: По сути, фьючерсы, да? Какой предел? Вот сколько параллельно можно на один рантайм нагрузить коннекций?

[1:59:41] Андрей: Коннектов-то можно много, но мы видели, что, например, на машинах с большим числом CPU — скажем, 150 CPU, 128 CPU — производительность заметно снижается. То есть больше 64 CPU если использовать…

[1:59:57] Александр: Да, да.

[1:59:58] Андрей: …это, наверное, то, что нам следует подебажить на один рантайм. Я не могу сказать, что именно является причиной таких тормозов, но нужно иметь в виду, что больше 64 CPU на одной машине, наверное, не стоит иметь, и нужно уже тогда в этот момент разносить на разные. Возможно, это из-за того, что атомики мы там используем, возможно, ещё что-то, — в общем, не пойму, пока не выяснил. Но это не такая серьёзная проблема, потому что всё-таки в основном никто не использует такие большие тачки: все начинают разделять раньше.

[2:00:35] Александр: Ну, то есть горизонтальное масштабирование наступает раньше, чем упирается вертикальное?

[2:00:40] Андрей: Да, да, да.

[2:00:40] Александр: Окей, супер. Ну, про Rust немного поговорили с хорошей стороны — с плохой ты ничего не хочешь сказать?

[2:00:53] Андрей: С плохой? То, что он очень медленно компилируется. Прям очень медленно компилируется. Мы делаем очень много танцев с бубном вокруг того, чтобы эту компиляцию ускорить. То есть ладно на CI — на CI мы можем подождать, у нас там несколько разных профилей. Какой-то самый долгий профиль с нуля будет компилировать пару часов, и это мы запускаем только когда…

[2:01:16] Александр: С максимальными оптимизациями?

[2:01:18] Андрей: Да, да. Это мы запускаем только когда уже новую версию релизим. Например, раз в месяц можем запустить. Но даже если мы говорим про dev-билды, вот со всеми кэшами, со всем-всем-всем — это всё равно, ну, секунд 30 надо подождать, пока бинарь соберётся, даже если я одну строчку поменяю. И специальный линкер мы используем, используем все возможные кэши, в докере тоже свои кэши — там докер разделён на несколько этапов: первый строит все зависимости, второй строит уже из кода. У нас много таких этапов, и даже со всеми ними скорость компиляции оставляет желать лучшего.

[2:02:03] Александр: Это ещё зависит, наверное, от тачки, потому что я вот по себе скажу: я на Java работаю, и у меня на интеловском Mac’е, на старом, это тоже была проблема. Как только я купил себе нормальный Mac на M3 Max, я прям перестал вообще ощущать какие-то проблемы в разработке. Более того, я даже на i9 начал разрабатывать фреймворк для тестирования нашего продукта, и я там упарывался по оптимизациям, у меня уже был roadmap, — а пересел на M3 и просто забил на этот проект, потому что у меня больше этой проблемы не стало. Я такой: ну, пускай кто-нибудь на i9 это сделает.

[2:02:46] Андрей: Ну да, это, конечно, проще гораздо решить, чем runtime: просто купи разработчикам хорошие машины. У нас просто, знаешь, компания эта распределённая, все работают из каких-то съёмных квартир, мало у кого есть десктоп. Все собирают с ноутбука, и у меня тоже нет десктопа — я, конечно, хочу его в какой-то момент получить, но пока нету. И всё, что я компилирую, я компилирую с лаптопа, и это вносит свои ограничения. Проблема медленной компиляции не в том, что она сама по себе медленная, а в том, что это увеличивает feedback loop при разработке.

[2:03:25] Александр: Да, именно так. И ты просто выпадаешь из флоу, начинаешь отвлекаться, смотреть YouTube. Как раз поэтому люди любят всякие Clojure, реплы и так далее — потому что ты там постоянно в этом feedback loop. JavaScript.

[2:03:40] Андрей: JavaScript, да: открыл браузер — вот тут же изменения, тут же она у тебя отобразилась.

[2:03:48] Александр: В этом плане, конечно, да. И есть, может быть, у тебя какие-то лайфхаки, как эту прокрастинацию 30-секундную побороть? То есть чем ты занимаешься, код смотришь?

[2:03:55] Андрей: Ну, лайфхак в том, чтобы редко его запускать. Компилятор всё-таки достаточно умный, чтобы подсветить проблемы раньше: мне не нужно, как в Python, запускать каждые 5 секунд, чтобы удостовериться, что там синтаксис правильный, тип правильно использую. Я запускаю всё-таки когда нужно реально что-то протестировать. Но это, конечно, не спасает от отладки, когда нужно именно отлаживать. Когда ты разрабатываешь новую фичу, то сильно часто запускать, в общем-то, и не нужно: пишешь, пишешь, пишешь — у тебя все типы готовы. Я не могу сказать, что это хороший рецепт, я всё ещё отвлекаюсь, если компиляция долгая.

[2:04:33] Александр: Окей, ну, это можно рассматривать и как плюс, и как минус — с какой стороны посмотреть. Супер. Ну, блин, мне понравилось очень, как с обоих сторон про Rust поговорили. Но мы говорим не просто про Rust, а про Rust в контексте — ну, как ни назови — довольно системной штуки, близкой к базе данных, которая довольно нагружена, и требования к перформансу там выше, чем от какого-нибудь обычного веб-сервиса, который сам идёт в базу данных, что-то её спрашивает и большее время просто ждёт в while. Там есть специфика. Какие, может быть, есть технологии, которые конкретно специфичны для базы данных, вы используете?

[2:05:14] Андрей: Ну, конкретно у нас самое специализированное, что мы делаем, — это вычисление вот этого dot product, который в нашем…

[2:05:24] Александр: То есть умножение.

[2:05:27] Андрей: Да, умножение векторов, которое настолько специализированное, что мы его, в принципе, даже не на Rust написали — оно написано на ассемблере. Причём оно на ассемблере написано несколько раз, под каждую архитектуру. Есть машина с AVX — у нас есть имплементация для машины с AVX, есть машина там…

[2:05:46] Александр: Там прям кусок ассемблера на макросах в Rust?

[2:05:48] Андрей: Прям кусок ассемблера, в некоторых случаях даже не на макросах в Rust, а просто файл .s, который содержит себе ассемблер. Ну, мы это выпиливаем постепенно, так что в итоге будут макросы в Rust. Просто проблема в том, что не на все инструкции есть макросы. Некоторые новые инструкции, особенно для half-precision флоатов, их просто ещё не включили в сборку. Они в Nightly доступны, но мы делаем на Stable-release Rust. Поэтому тут иногда приходится делать такие хитрости — писать на C или на ассемблере и линковать в Rust.

[2:06:33] Александр: Ну, interop у Rust с C довольно приятный.

[2:06:35] Андрей: Да, да. Там с этим никаких проблем нет. Просто появляются файлы на C, которые не гарантируются компилятором, — там нужно быть внимательным.

[2:06:46] Александр: Unsafe.

[2:06:49] Андрей: Кстати, это одно из немногих мест, где у нас реально есть unsafe. Это как раз работа с ассемблером и ещё работа с memory map, где мы делаем трансмутацию данных.

[2:07:00] Александр: На Rust вставки, окей. Что ещё интересного? Ой, господи, на ассемблере вставки.

[2:07:03] Андрей: Да, да. Ну, это такой самый горячий цикл, самое горячее место кода, которое там 60% всего занимает, поэтому мы его как могли уже оптимизировали.

[2:07:15] Александр: А насколько переписывание этого горячего места на ассемблер — какой порядок бустапа перформанса был?

[2:07:23] Андрей: Ну, по сравнению с наивной имплементацией — в десяток раз. Достаточно много времени заняло, но сначала у нас был BLAS. Мы использовали библиотеку BLAS, и у неё были свои проблемы: она компилировалась, по-моему, дольше, чем весь остальной проект, раз в пять. Мы её выпилили только потому, что из неё нам нужен был очень маленький кусочек, и это сильно-сильно сократило время компиляции. Помимо процессорной тормознутости, которую мы решаем с помощью ассемблера, есть ещё тормознутость, которая генерируется I/O-операцией. Вот мы говорили, что нам нужно 6 гигабайт RAM, чтобы использовать векторный поиск. Почему нам нужен RAM, даже в случае использования индекса? Это потому, что у индекса есть неприятная особенность: он требует очень много random access. Как мы этот граф построили — мы совершенно не можем предсказать, в какое место в памяти он должен сходить, чтобы получить очередной вектор. Поэтому, когда мы говорим про HNSW, то, к сожалению, даже с использованием индекса нам нужно всё ещё хранить векторы на быстрой памяти. И это проблематично по многим параметрам, потому что память дорогая, векторы большие, нужно что-то придумывать.

[2:08:51] Андрей: Наша типичная оптимизация, которую мы предлагаем сделать, — это: а зачем хранить full-precision векторы, все эти 32 бита? Почему бы не сделать какой-нибудь маленький вектор с precision там 8 бит вместо 32? В лучшем случае можно меньше, можно сделать 1 бит precision, и разбить этот процесс на 2 этапа. На первом этапе мы поищем много кандидатов, но с этим плохим precision, а на втором этапе мы из этих кандидатов сделаем топ-10 хороших, но уже с полным precision. И тут возникает интересный момент. Когда мы ищем векторы внутри графа — вот с этим поиском в глубину, — мы делаем последовательные случайные чтения, рандом-риды последовательные. Мы сначала в одну вершину, потом перешли в другую, сделали запрос к памяти, потом ещё в одну перешли. И таким образом именно latency памяти — это основной критерий, который тормозит весь поиск. То есть один за другим должны отработать несколько запросов. А когда у нас есть, скажем, тысяча кандидатов и нужно выбрать из них 10, нам уже нет нужды читать последовательно эту тысячу векторов. Мы можем отправить тысячу параллельных запросов к условной файловой системе, получить результат пачкой и отранжировать всё сразу. Особенно это явно видно, когда в качестве памяти мы используем network attached storage.

[2:10:30] Александр: Ну короче, такой сетевой диск. S3 типа или что-то такое?

[2:10:31] Андрей: S3 — это такой радикальный пример, но в данном случае мы больше на EBS смотрим. В Амазоне есть такие диски, которые с обычной файловой системой, но при этом они находятся чуть дальше, чем локальные SSD. К ним может быть большая пропускная способность канала, но при этом latency индивидуальных запросов довольно медленная — нужно ждать условно миллисекунду, чтобы получить результат. Но если тебе нужно тысячу результатов, ты можешь их все сразу отправить и за ту же самую миллисекунду получить.

[2:11:06] Александр: Вот это, кстати, очень классный пример, о котором очень редко кто вообще задумывается — когда у тебя есть latency просто физическая, когда сигнал доходит из одной системы памяти в более низкую память, и ты не можешь это оптимизировать в каком-то пределе. То есть есть предел, и всё, физически. Но что ты можешь сделать? Ты можешь больше данных отправить одновременно по этому каналу и за то же самое время в этом же пределе получить больше результатов.

[2:11:35] Андрей: Да.

[2:11:35] Александр: И вот это как раз и есть latency против throughput.

[2:11:38] Андрей: Да, да. И тут возникает вопрос: а как, собственно, сделать тысячу запросов к диску параллельно? Мы же не можем создать тысячу потоков и в каждом потоке подождать результат. И чтобы сделать это правильно, канонично, и использовать все возможные оптимизации, нам нужно async I/O. И это async I/O в самом каноничном, правильном виде реализовано, пожалуй, только в последних версиях Linux — называется io_uring. И это тот момент, где мы его действительно используем. Если ваш Linux позволяет вам это делать, то вы можете включить эту опцию в Qdrant, и когда будет происходить вот этот второй этап рескоринга векторов, вы можете их получить все параллельно. Таким образом, latency ваших запросов будет существенно ниже.

[2:12:29] Александр: Интересная тема очень, потому что, во-первых, с такими проблемами действительно редко кто сталкивается, а даже если сталкивается — не всегда все могут понять, что именно в этом проблема. Типа, ты можешь сказать: ну, упёрся в сеть, в диск, всё, мои лапки. А есть ещё варианты, как это можно оптимизировать?

[2:12:47] Андрей: Это как раз один из тех вариантов, где даже с плохим диском… Типичное решение было бы: ну, окей, не использовать сетевой диск, давайте использовать локальный диск. Это решение действительно решит проблему. Но если у нас нет возможности, если мы хотим использовать сетевой диск и при этом не хотим ждать, — то это вот такой один из вариантов, можно сделать async I/O. То есть мы всё равно должны получить этот список кандидатов с помощью RAM, но в RAM мы гораздо более компактное представление храним. Там уже не нужен full-precision вектор, мы храним там одну тридцать вторую всех векторов, а кандидатов переранжируем с помощью диска.

[2:13:29] Александр: Круто. Крутой юзкейс, и он работает в рамках как бы одного запроса, да? То есть вы скалируете запрос одного пользователя.

[2:13:35] Андрей: Да, да.

[2:13:36] Александр: А есть возможность скалировать много запросов, чтобы они между собой тоже так проходили?

[2:13:40] Андрей: Ну, такого мы не делали. Всё-таки у нас есть некая изоляция между объектами: пользователь создаёт — для него создаётся эта io_uring-структура.

[2:13:52] Александр: То есть per-user.

[2:13:53] Андрей: Да, да. Между пользователями мы не пробовали. Возможно, будет, возможно, не будет — не могу сказать, потому что между пользователями, опять же, latency индивидуального запроса влияет. Может, наоборот, хуже будет, если мы попытаемся это забатчевать.

[2:14:08] Александр: Давай, может быть, чуть-чуть поподробнее раскроем, как это работает, потому что ты вот первый, с кем я разговариваю, кто прям знает, трогал это хорошенько — про io_uring я имею в виду. Может быть, просто стандартный способ, как без io_uring идёт чтение с диска, и в чём принципиальная разница?

[2:14:28] Андрей: Принципиальная разница в том, что, когда мы читаем с диска, у нас есть вот этот объект-файл: мы говорим file open, такой-то путь, такой-то permission, и у этого файла мы можем сделать read, можем сделать seek, можем сделать close. Но когда мы делаем read, это означает, что текущий процесс в операционной системе ждёт этого результата чтения. Поэтому важно иметь async, важно иметь несколько потоков, если ты работаешь с файлами. И, например, в Tokio это такой типичный мем — что работа с файлами в Tokio это такой фейк. Они имеют асинхронный интерфейс, но внутри себя всё равно синхронные. Если ты в Tokio будешь много разных файлов читать, он всё равно будет их ждать — ты всё равно будешь ждать, неважно, асинхронный интерфейс или нет. А io_uring работает по-другому. Он работает такими, можно сказать, батчами, где внутрь одного батча мы передаём список того, что нужно получить, и передаём этот батч внутрь операционной системы. Вот этот io_uring — это такой кольцевой буфер. Есть кольцевой буфер запросов, есть кольцевой буфер ответов. Этот буфер существует внутри операционной системы — то есть ядро Linux решает, в какой момент его заполнить, в какой момент подвинуть. И когда мы отдаём внутрь операционной системы эти запросы, мы больше не блокируемся на индивидуальные чтения — мы можем блокироваться на все чтения сразу, всю пачку. Или даже можем повесить какой-то обработчик, что, когда только придёт очередной ответ, он вызовется.

[2:16:18] Александр: Ну, то есть нас операционная система позовёт? Сигнал какой-то придёт?

[2:16:21] Андрей: Да, либо сигнал придёт, либо просто когда весь батч прочитается — вот тогда мы получим ответ. Это позволяет нам всего лишь с использованием одного процесса читать из совершенно разных мест на диске то, что нам нужно.

[2:16:37] Александр: То есть мы с помощью одного процесса можем с многих мест прочитать, а до этого мы могли только с помощью многих процессов читать и блокировать?

[2:16:41] Андрей: Либо последовательно читать, либо наспавнить процессы и растянуть тогда это всё удовольствие на долгое время.

[2:16:51] Александр: Ну, это прям очень хороший юзкейс для того, что ты описываешь: тебе действительно иногда с многих мест нужно читать, когда ты этот поиск по графу делаешь, рандом-сики эти, — ты их просто сразу батчом укладываешь: вот отсюда, отсюда, отсюда — и они тебе пачкой приходят. Круто вообще. Да, это прям, мне кажется, boost performance взамен.

[2:17:09] Андрей: Нужно понимать, что это boost на индивидуальный запрос. Если у тебя много параллельных маленьких запросов, то всё равно канал к диску переполняется, это тяжело. Но если у тебя один индивидуальный запрос, и тем самым ты можешь запустить все эти запросы, — то это уже просто. Если тебе нужно минимизировать latency одного запроса — да, это работает.

[2:17:29] Александр: Помимо io_uring, я могу предположить — как будто бы (я не знаю), что при работе с векторами ещё же используются векторные инструкции, я думаю.

[2:17:35] Андрей: Векторные инструкции — это как раз те штуки, о которых мы говорили, про dot product.

[2:17:39] Александр: Ну, то есть вот на ассемблере написано.

[2:17:44] Андрей: Да, да, на ассемблере, из-за того что разные архитектуры поддерживают разные векторные инструкции, мы, по сути, написали десяток разных вариантов того, как векторы нужно перемножать. Это такой абсолютно нечитаемый код, write-only, но который позволяет нам всё ускорить.

[2:18:02] Александр: Ну, то есть самое горячее — вот это рассчитывание дистанции между векторами, насколько они близки, — оно написано с помощью SIMD-инструкций, чтобы все кэшлайны утилизировались круто, и в общем максимально близко к железу. Ближе, наверное, уже и не напишешь.

[2:18:19] Андрей: Ближе уже и не напишешь. Что мы можем сделать дополнительно — это включить автовекторизацию в компиляторе, которая, кстати говоря, не работает.

[2:18:26] Александр: Да, да.

[2:18:26] Андрей: Это не включено по умолчанию. И попытаться остальные места в коде тоже векторизовать. Там есть…

[2:18:32] Александр: Но они не настолько, наверное, горячие, чтобы это было сильно…

[2:18:38] Андрей: Они не столько горячие, и они не настолько подходят под use-case векторных инструкций, чтобы быть очень полезными, но это тоже может дать какой-то процент ускорения.

[2:18:47] Александр: Хорошо, но вы не включаете это по дефолту?

[2:18:52] Андрей: Нет, по дефолту нет. Это связано с тем, что это делает бинарь чувствительным. То есть мы не сможем тогда один и тот же бинарь на Mac’е использовать и на…

[2:19:01] Александр: На Mac’е мы его и так не можем, да, но вот на каких-то старых CPU от Intel’а, где AVX’а, например, нет, мы уже не сможем тогда использовать.

[2:19:10] Андрей: Ну можно же, типа, собрать. Я знаю, что у меня такие машины, я же могу собрать.

[2:19:16] Александр: Только дело в том, что мы open-source, мы не знаем, какие машины у наших пользователей. Мы знаем, что есть ARM и есть x86 — две таких, две разных архитектуры мы поддерживаем. А внутри этих разных архитектур мы не знаем, какой версии ARM, не знаем, какой версии x86 используется. Немножко проблематично, но, как показывает практика, это редкий кейс. Там, когда кто-то на Raspberry Pi пытается собрать, вот это выстреливает в ногу. Но вообще работает, кстати, на Raspberry Pi тоже.

[2:19:47] Андрей: Ну, у меня постоянно тоже чешутся руки чем-нибудь собрать на Raspberry Pi. И если это написано на Rust, ну, по-любому надо.

[2:19:54] Александр: Java собирать под Raspberry Pi — ну, так же. Ну, такое: типа, зачем? А вот Rust — сам Бог велел.

[2:20:04] Андрей: Ну да.

[2:20:04] Александр: Ну, супер просто. Мне кажется, инсайтов накидали за весь выпуск, ребятам, которые интересуются всем этим, будет очень полезно. На самом деле, у меня подошли к концу темы, которые я хотел обсудить, но я тебе могу дать право: если есть что-то, что ты хотел рассказать, вот прям интересно, а я не спросил, мы этого не коснулись, — то вот.

[2:20:26] Андрей: Ну, кажется, что мы много чего коснулись. У нас, естественно, есть ещё больше тем внутри — как работают репликации, например, можем поговорить: как у нас сделано, чтобы, когда одна репликация ломается (машина перезагрузилась, условно), мы могли передавать не все данные вообще, а только последний кусочек. Там мы такую штуку используем — ну, не в каноничном виде, но в каком-то виде мы используем вектор-клок. И вектор-клок ничего общего с эмбеддингами не имеет, кстати говоря, — это просто совпадение. А как у нас работает, например, оценка кардинальности запроса? Вот это интересная тема.

[2:21:12] Александр: Так, это та кардинальность, которая говорит, нужно ли идти в индекс либо делать fullscan?

[2:21:19] Андрей: Да, да, да. Эта кардинальность зависит от… Ну, стратегия выбора зависит от того, насколько много точек нужно перескорить. Если там 100 точек, то можно сделать fullscan. Если миллион точек, то нужно использовать векторный индекс. И весь интерес в том, как понять, а сколько, собственно, точек будет в результате, не выполняя запрос. То есть это должна быть быстрая операция, которая выполняется до запроса.

[2:21:52] Александр: Так, подожди. Вот она решает какую проблему? Ко мне пришёл запрос — вот вектор.

[2:21:57] Андрей: Да, да.

[2:21:57] Александр: Ты называешь это точкой, правильно?

[2:21:59] Андрей: Вектор плюс фильтр. Если просто вектор — то это просто, всегда использовать индекс. Если вектор плюс фильтр — вот тогда возникает сложность. Фильтр может быть разный. Может быть хороший фильтр, который отфильтрует очень мало. Может быть плохой фильтр, который отфильтрует очень много. И нужно понять, что это за фильтр, не выполняя запрос, потому что это должна быть быстрая операция, и мы должны быстро это оценить. Разные индексы позволяют по-разному оценить. Если у нас есть хешмап, то мы можем посмотреть, сколько в хешмапе. Если у нас есть геоиндекс, мы можем посмотреть, сколько в геохэше. А вот, например, если у нас рейндж-фильтр, то мы так просто не сможем сделать. Нам нужна какая-то гистограмма.

[2:22:43] Александр: То есть это называется… Ну да, гистограмма, по сути. Распределение вероятности.

[2:22:47] Андрей: Да, да, да. Если мы хотим получить эту гистограмму и погуглим, как строить гистограммы, то в основном все предложения заключаются в том, что их надо перестраивать батчами. То есть данные наобновлялись — и раз в N операций надо её перестроить. Это то, как многие базы данных делают. ANALYZE, например, в каком-нибудь Postgres.

[2:23:08] Александр: Да, да, да.

[2:23:10] Андрей: У нас такого нет. У нас гистограмма перестраивается real-time, каждый запрос. То есть это тот алгоритм, который пришлось изобрести случайно, с нуля, по сути. Хотя есть академические всякие пейперы, которые говорят, что так вообще нельзя сделать.

[2:23:29] Александр: Подожди, а в чём проблема батчами? Просто некрасиво будет — типа, какие-то батчи, что-то там это самое?

[2:23:35] Андрей: У тебя запрос, запрос, запрос, и вдруг какой-то запрос внезапно выполняется намного дольше, чем предыдущий.

[2:23:45] Александр: Так, а вы, получается, как-то доагрегируете real-time?

[2:23:49] Андрей: Да, у нас есть своя хитрая имплементация этой гистограммы, которая может обновляться real-time. Но гистограмма — это структура данных, по сути. Есть два типа гистограмм. Бывает с фиксированной шириной, когда мы говорим, что каждый столбец диаграммы должен быть, условно, тысячу элементов. И тогда меняется рейндж для каждого столбца: первый столбец от нуля до пяти, второй от пяти до десяти, третий от десяти до ста пятидесяти. А есть диаграммы, у которых, наоборот, фиксированные рейнджи, а сами значения столбцов больше. То есть от нуля до пяти у нас пять значений, от пяти до десяти сто значений, от десяти до пятнадцати там семь значений. Ну, мы можем фиксировать либо одну ось, либо вторую. И вот чтобы хорошо работала наша штука, нам нужны именно гистограммы с фиксированной высотой.

[2:24:45] Александр: А почему?

[2:24:46] Андрей: Потому что мы не знаем, какой запрос придёт. Условно, приходит запрос, а мы хотим быстро понять: если у нас в этом рейндже, скажем, десять тысяч точек, — убираем десять тысяч. Это просто с точки зрения программы проще: проще сделать этот выбор. И вот эти диаграммы, которые имеют фиксированную высоту и динамический рейндж, — если поискать, нет алгоритма, как их строить без перестройки с нуля. Их можно строить только за батч, сразу с нуля и до конца.

[2:25:47] Александр: Ну, потому что, грубо говоря, наивная имплементация просто долго работает, если вставлять и перестраивать?

[2:25:52] Андрей: Я не нашёл имплементации. Я искал пейпер и не нашёл. Пришлось делать самому.

[2:26:01] Александр: А пейпер написал?

[2:26:03] Андрей: Нет, я же не академик. Ну, не знаю. Я не люблю пейперы. Мне кажется, это трата времени. Я лучше блог напишу про это, чем пейпер.

[2:26:16] Александр: Тоже хорошо. А есть блог у тебя личный, или ты в Qdrant запишешь?

[2:26:23] Андрей: У меня был личный блог, но я сейчас полностью в Qdrant переехал. Всё, что я пишу, оно в Qdrant.

[2:26:28] Александр: Хорошо. Ну, я думаю, слово довольно на слух воспринимаемое, пишется как QD

[2:26:38] Андрей: QD

[2:26:41] Александр: QDrant.

[2:26:41] Андрей: Всё верно.

[2:26:50] Александр: В общем, смотри, давай, наверное, завершая, проговорим про две такие вещи. Первое — мне хотелось бы спросить тебя про будущие планы продукта: куда ты его видишь, какие ближайшие направления, и как, например, open-source-комьюнити может тебе помочь. Какое будущее у Qdrant и как, например, я, человек, который интересуется Rust, любит open source и в целом может писать код, могу что-то законтрибьютить?

[2:27:09] Андрей: Говоря про будущее, нужно сказать про vision всего этого векторного поиска. Почему мы считаем, что это должна быть такая отдельная сущность, а не расширение существующего текстового поиска, например? Вот, ну, то есть кажется логично: взял Elasticsearch, добавил векторный индекс — вот у тебя то же самое получилось. На самом деле нифига, потому что, хоть векторный поиск и обычный текстовый поиск пересекаются в одном моменте — что с помощью этого можно делать поиск, — на самом деле у vector similarity, как мы это называем (то есть это же не поиск, это vector similarity), намного больше применений, чем просто поиск. И, соответственно, интерфейсы взаимодействия с этим векторным представлением должны быть совсем другие. Есть вот этот частный случай поиска, но это один частный случай.

[2:28:19] Александр: Ну, мы говорим про full text search, который в Elasticsearch, собственно, есть, да?

[2:28:22] Андрей: Да, это full text search, и с ним вектор очень пересекается, но на самом деле vector search, vector similarity в целом намного более гибкий, он даёт намного больше возможностей, которых в текстовом поиске в принципе нельзя реализовать. Нельзя реализовать, например, поиск между текстом и картинками — вот напрямую. Можно там хитро сделать хаки, что картинка будет описана текстом, но это не то.

[2:28:52] Александр: Ну, грубо говоря, Elasticsearch тебе предоставляет интерфейс такой, что ты туда пишешь текст как запрос, да?

[2:28:58] Андрей: Да, да, да.

[2:28:58] Александр: А когда ты хочешь картинку, тебе нужно, чтобы интерфейс принимал вектор, наверное.

[2:29:03] Андрей: Ты можешь это сделать всё равно, но Elastic эволюционирует, и он настроен на то, чтобы сделать текстовый поиск. Они могут сделать текстовый поиск с поддержкой векторов, что в нашем случае будет не так. В нашем случае это vector first, vector first-order citizen в нашей системе. И поэтому это нам позволяет делать всякие интересные штуки, которые с текстовым поиском в принципе не могут сосуществовать. Например, у нас есть так называемый exploration search. Это такая фишка, когда ты, по сути, вместо одного вектора запроса передаёшь такую маленькую обучающую выборку, которая говорит: вот к этому вектору должно быть ближе, от этого дальше — и вот таких несколько пар. То есть это, по сути, обратный процесс к тому, с которым учится нейросеть. И за счёт этого ты можешь решить, например, такую проблему. Вот когда ты ищешь что-нибудь в гугле — например, мой любимый пример: представь, ты хочешь найти какой-нибудь мем, картинку с мемом, но ты не помнишь текст, не помнишь, как она называется…

[2:30:11] Александр: У меня с тиктоками такая вообще.

[2:30:13] Андрей: Да, да, да. И вот ты начинаешь что-то описывать, в гугле находишь вообще непохожее, начинаешь кликать на похожие картинки, и в какой-то момент понимаешь, что ты зациклился. Гугл тебе даёт те результаты, которые ты уже видел, потому что ты попал в эту локальную область пространства, и из неё выбраться с помощью одного запроса никак нельзя. У тебя единственный вариант — скроллить там на 150-ю страницу, и, может быть, тебе повезёт и ты найдёшь, что искал. Но тут никаких гарантий нет, это очень time-consuming. Вместо этого мы предлагаем сделать так: получив результат, ты говоришь — вот этот результат ближе к тому, что я хочу, а вот этот дальше от того, что я хочу. И таким образом ты разделяешь векторное пространство на две части: те результаты, которые ближе, и те, которые дальше, для каждой пары. Если у тебя таких пар несколько, то ты, получается, разделяешь пространство пополам несколько раз. Если у тебя три пары — ты в углу, отсекаешь вот так вот.

[2:31:09] Александр: Да, да, да.

[2:31:11] Андрей: И таким образом ты можешь прийти к тому, что искал, намного быстрее и эффективнее, чем если бы делал поиск. Мы это называем Discovery Search или Exploration Search. У нас есть для этого отдельная API. Это сложно объяснять, это не то, с чем девелоперы привыкли общаться. Это не стандартные legacy-интерфейсы, это немножко другое. Моя глобальная идея в том, что для такого рода приложений — когда ты работаешь с vector similarity, а не просто с текстовым поиском, каким бы фэнси-умным он ни был, — тебе нужны другие интерфейсы, другая база данных, другой движок. Это то, что мы делаем. Это vision.

[2:31:57] Александр: А если более низко — есть ли какие-то направления, в которые можно окунуться open-source-энтузиастам?

[2:32:04] Андрей: Open-source-энтузиастам можно окунуться в большое разнообразие оптимизаций. Это, наверное, то, что люди с Rust-бэкграундом вообще любят: когда задача уже настолько сформулирована, что она уже работает, нужно просто сделать то же самое быстрее. Такой гольф в каком-то смысле. Таких задач у нас полно, тут всем можно оптимизировать. И мы готовы платить за это баунти, естественно, никаких проблем. Главное — не слать очень большие пул-реквесты. Большие пул-реквесты сложно ревьюить, и это не любят делать.

[2:32:41] Александр: А что такое большой пул-реквест?

[2:32:41] Андрей: Больше 500 строк — это уже большой.

[2:32:43] Александр: Больше 500, окей, хорошо. Ну, отлично. То есть можно прямо на GitHub зайти.

[2:32:47] Андрей: Да, у нас есть куча issue, на которых уже навешаны баунти. Можно предложить что-то своё. То есть это как бы есть баг-баунти, а есть перформанс-баунти. У нас даже фича-баунти есть, если она не очень приоритетна для нас, так что мы можем позволить себе её не самим сделать, а аутсорсить. Мы так тоже делаем.

[2:33:10] Александр: Классно, прикольный подход.

[2:33:12] Андрей: Вообще, open source нам сильно помогает в том плане, что люди охотно оставляют фидбэк. Когда у них что-то не работает, они приходят, говорят: вот у меня не работает. Когда им что-то нужно, какая-то своя фича, некоторые из них могут её просто заимплементировать. Так нам, например, поддержку TLS заимплементировали — просто кому-то нужно было в его компании, вот он пришёл и сделал. И мы смержили, довольны до сих пор.

[2:33:40] Александр: Да, да, я видел в YAML-конфигурации конфигурацию TLS.

[2:33:45] Андрей: Да, там вообще YAML у нас огромный, конфигурация огромная, там много всяких.

[2:33:50] Александр: Ну, там есть, да, но не такой огромный, как в некоторых других базах данных.

[2:33:54] Андрей: Мы не всё экспозим туда.

[2:33:57] Александр: А, тогда окей. Хорошо, про open source супер — вот фидбэк. Я сам из таких, кто любит что-то написать, потому что я понимаю, что это огромная польза обеим сторонам, это вин-вин. И ты себе сделал, и ребята получили фичу — если нормально.

[2:34:15] Андрей: Ну, стоит оговориться, что с первого раза нормально написать сложно, у меня очень придирчивые ревью. Без ревью мы не мержим никогда: как минимум, если это external contributor, как минимум два человека из нашей команды должны проревьюить.

[2:34:29] Александр: Нет ли такого, что косты на это ревью и доведение изначального пул-реквеста до того состояния, когда его можно смержить, выше от команды, чем если бы вы сами это заимплементировали?

[2:34:41] Андрей: Бывает, бывает. Ну, поэтому я и говорю, что маленькие пул-реквесты лучше. Когда прилетает иногда пул-реквест на тысячу строк, то чисто времени его проревьюить — часа 3-4. Просто посидеть и посмотреть внимательно. И в большинстве случаев это означает, что там что-то не так. Такие задачи, которые требуют настолько много кода, мы просто пытаемся не создавать, пытаемся их разбивать. И даже если это нужно, то всегда предпочитаем, чтобы задача была разбита на более маленькие — до того, как делать пул-реквест.

[2:35:18] Александр: Хорошо. Окей, я обязательно посмотрю. Может быть, вам нужна какая-нибудь CLI-тулза для Qdrant?

[2:35:24] Андрей: Может быть. У нас есть, кстати, несколько CLI-тулз. Есть CLI-тулза, которая делает бенчмарки на рандомных данных, которая создаёт очень большой поток запросов, на Rust написанная.

[2:35:37] Александр: А что вы используете как библиотеку для парсинга и так далее?

[2:35:42] Андрей: clap.

[2:35:44] Александр: clap, который вот… аргументы.

[2:35:45] Андрей: Дефолтный.

[2:35:46] Александр: Да-да-да, для clap. Я просто одно время смотрел в Rust — вот недавно хотел что-то более серьёзное, чем с clap, написать, ну типа TUI. Нашёл Ratatui, но что-то там мне не сильно понравилось состояние библиотеки. Нашёл в Гошке Bubble Tea, такая штука есть, и там ну такое замечательное просто — можно писать всё что угодно. Я даже не знал, что такие вещи можно писать в терминале.

[2:36:10] Андрей: Возможно, они и не нужны. Ну, в нашем случае, наверное, не нужны. Но если будет какая-то идея — welcome, конечно.

[2:36:19] Александр: Да, супер, хорошо.

[2:36:26] Александр: И последний вопрос, который я не хотел спойлерить. Нас слушают некоторые разработчики, я уверен, которые свою карьерную траекторию ещё в процессе выстраивания. И часто непонятно, как выстроить обучение, своё отношение к работе, насколько много времени тратить, сколько книжек читать, чтобы в итоге, обычными словами, самореализоваться в профессии. Я думаю, что ты на том пути, который подразумевает какую-то самореализацию, — всё-таки не все разработчики пишут свои базы данных, и потом они становятся отдельными компаниями. Может быть, есть у тебя какие-то житейские или нежитейские — да что угодно — советы? Как вообще позиционировать себя, как ты себя позиционировал, чтобы достичь именно успеха в профессии?

[2:37:17] Андрей: Ну, книжки я не читал, сразу скажу. Книжки, мне кажется, — особенно если мы говорим про какой-нибудь machine learning, — вот эти книжки устаревают раньше, чем их печатают. Это однозначно. Я вообще не видел разработчика моложе 40 лет, который что-то по книжкам учит. Не знаю, когда-то давно я, конечно, читал про Visual Basic, но это связано с тем, что у меня интернета тогда не было, а не с чем-то другим. Ну, с интернетом, с огромным количеством блогов, я вообще не вижу проблемы что-то освоить без книг. Вообще без книг. Главное — как себя мотивировать, как понять, что вообще нужно читать, какой контент. Это — найти себя и поставить задачу. Если можешь придумать проект, то под проект знания накопятся, найдутся. Часто люди, наоборот, хотят найти проблему под решение, и вот в этом возникает сложность. Они прочитали про нейронки, хотят применить нейронку и ищут, где бы её применить. Вот это, мне кажется, такой опасный путь, потому что сделаешь что-то, что никому не надо. Я всегда пробую evangelize — как это по-русски, пропагандировать. Я пропагандирую другой подход: сначала проблема, потом для этой проблемы нужно искать решение. Не всегда правильным решением будет Rust. Даже скорее в большинстве случаев, если вы хотите какие-то user-facing сервисы делать, Rust может вообще не быть хорошим ответом. Это первое. Второе, что я для себя понял в таком карьерном пути: пока не покажешь остальным людям что-то визуальное, всем будет вообще наплевать на то, что ты делаешь. Пока ты будешь теоретически рассуждать, какой у тебя красивый код, это мало кого заинтересует — если, конечно, ты не пишешь блог про код. Все самые интересные карьерные улучшения в моей жизни происходили, когда я делал демку и показывал её другим людям, в том числе с Qdrant. Поэтому делайте демки, которые можно потыкать, вообще ничего не устанавливая, — и тогда люди заинтересуются. Визуализировать не в плане, что UI должен быть, а в плане, что ты должен показать, что оно делает. Даже если это CLI. В случае Qdrant это был сервис, его было сложно объяснить, но мы сделали демку — поиск похожей еды с помощью векторного поиска. Это конкретная демка, которую можно открыть, в которой можно нажать кнопки, которая покажет какой-то результат. Вот если такая демка есть — это может даже не основная технология быть, но это то, что с помощью этой технологии можно показать, что можно построить. И тогда людям станет интересно, тогда люди начнут задумываться, а почему бы не использовать то же самое. А если это будет просто GitHub-репозиторий, ещё и без README, — то никому не нужен. Это горькая правда. Какой бы он умный, интересный, оптимальный внутри ни был, если у него нет хорошего README, куда можно посмотреть и сразу понять, что вообще происходит.

[2:40:39] Александр: Да, это годный совет. Я его тоже себе на вооружение возьму. Вообще спасибо тебе большое. Спасибо тебе, Андрей, что согласился поболтать.

[2:40:46] Андрей: Да, спасибо, что позвали.

[2:40:49] Александр: Ребята, огромный респект тем, кто дослушал. В общем, смотрите на продукт Qdrant и вдохновляйтесь: векторная база данных на Rust. Вот, у меня всё. Пока!

[2:40:56] Андрей: Пока.

[2:41:15] Александр: Пока.

[2:41:17] Андрей: Пока.