Я убил все RAG‑системы и понял, как делать AGI. Или просто заменил RAG правилом в шесть строк
Заголовок наглый, знаю, поэтому разоблачу его сам, не дожидаясь комментариев. Никакого общего искусственного интеллекта (AGI) я не изобрёл, хотя это не точно. Но одну маленькую подмену, на которой держится почти вся сегодняшняя «память» ИИ‑агентов, я, кажется, действительно сломал. Сломал не красивым рассуждением, а экспериментом, который вы сможете повторить сами. Дальше я выстрою его по шагам: сначала покажу задачу на живом примере, потом решение и его код, и только когда станет ясно, что именно тут меряется, приведу числа. Никаких цифр в лоб прямо сейчас, они без подготовки ничего не значат. Сначала разберёмся, в чём подмена.
С чего всё началось: три карточки одного человека
Начну не с теории, а с бытовой сцены, в которой подмена видна невооружённым глазом.
Представьте, что у нас есть хранилище фактов про людей, и один человек за карьеру сменил три должности: сначала был инженером, потом стал руководителем группы, а теперь директор. Мы аккуратно записали всё: в хранилище лежат три карточки про этого человека, две устаревшие и одна действующая. Ничего не потеряли, всё честно сохранили.
Теперь агент спрашивает простое: кем этот человек работает? Хранилище ищет подходящую карточку по смыслу, то есть берёт ту, что больше всего похожа на запрос. И вот тут случается беда. Все три карточки про одного и того же человека и про одну и ту же сущность «должность», поэтому для поиска по смыслу они почти неразличимы, одинаково близки.
Похожесть тут считают не по буквам: каждую карточку заранее превращают в длинный набор чисел, вектор, своего рода координаты смысла, и сравнивают уже эти числа, а не сам текст. Мерой служит косинус угла между двумя такими наборами чисел (та самая «косинусная близость», которую вы сейчас увидите на схеме; подробно про векторы я расскажу в следующей главе). А раз все три карточки одинаково близки, поиск с равным правом вернёт любую из них. Причём поиск тут ещё и приближённый: ради скорости база не сверяет запрос со всеми карточками подряд, а находит близкие быстро и почти точно (почему так, я разберу в главе про бенчмарки). Поэтому среди одинаково близких карточек она вправе отдать любую, хоть действующую, хоть позапрошлую, хоть их смесь. Языковая модель возьмёт то, что дали, и уверенно расскажет, что человек до сих пор инженер.
Формально всё безупречно: сосед действительно ближайший, поиск отработал ровно так, как обещал. А по сути перед нами галлюцинация, которую сгенерировала не модель, а само хранилище: оно выдало устаревший факт с той же уверенностью, что и действующий. Различить эти три карточки по похожести нельзя в принципе, потому что различает их не смысл, а время. Запомним это, дальше всё будет крутиться вокруг него.
Почему поиск похожего отвечает не на тот вопрос
Теперь, когда проблема на руках, можно назвать вещи своими именами.
То, чем сегодня чаще всего затыкают память языковых моделей, называется генерация с опорой на поиск (Retrieval‑Augmented Generation, сокращённо RAG). Идея простая и на первый взгляд здравая: прежде чем ответить, модель лезет в хранилище, достаёт оттуда несколько подходящих кусков текста и отвечает уже с их учётом. А «подходящие» тут значит ровно то, что было видно на примере: похожие по смыслу. Под капотом RAG это и есть поиск по близости, тот самый векторный поиск. Каждый текст заранее превращают в вектор, точку в многомерном пространстве смыслов, и близость двух текстов меряют косинусом угла между их векторами: чем меньше угол, тем «похожее». Векторная база данных, то есть хранилище, которое умеет быстро находить ближайшие векторы, и есть механика RAG.
И вот в чём подмена. Векторная база отвечает на вопрос «что похоже на мой запрос». А память агента обязана отвечать на совсем другой вопрос: «что сейчас истинно». Это разные вопросы, и подмена одного другим стоит вам правильных ответов, как в сцене с тремя должностями. Похожесть и истинность‑сейчас это не одно и то же.
В этом и весь смысл дерзкой фразы из заголовка. RAG как поиск живее всех живых, он прекрасно делает своё дело, находит похожее. Умер RAG именно как память: как подход, в котором «похоже» молча выдают за «истинно сейчас». Я просто перестал притворяться, что похожесть это и есть память.
Что на самом деле нужно памяти
Раз карточки различает время, значит, память обязана относиться ко времени всерьёз, а не приклеивать дату сбоку как необязательный ярлычок. Времени нужно дать статус первоклассной сущности, наравне со смыслом.
Из этого сразу вытекают три требования. Во‑первых, версии: старую карточку нельзя затирать новой, обе должны жить рядом, иначе нам нечем будет ответить на вопрос «а кем он был раньше». Во‑вторых, свежесть: среди всех версий надо уметь мгновенно назвать ту, что действует на нужный момент. В‑третьих, забвение: иногда факт надо признать неверным и убрать из ответов, но так, чтобы это тоже осталось в истории, а не исчезло бесследно.
Свести всё это хочется не к вороху отдельных механизмов, а к одному простому закону. И такой закон есть, на словах он звучит почти оскорбительно банально: побеждает самая свежая незабытая версия. Дальше я покажу, что вся система вырастает именно из этой фразы.
Ядро: точка в пространстве плюс журнал версий
Здесь я начну ссылаться на «код где‑то в репозитории» и покажу устройство прямо в статье. Ядро сшито из двух простых идей, и полезно держать перед глазами карту.
Первая идея: знание это точка в пространстве смыслов, а близость точек это похожесть, и за неё отвечают модули geometry и hnsw.
Вторая идея: над точками лежит неизменяемый журнал, из которого выводятся версии и свежесть, это модуль journal. Сшивает обе половины фасад memory, добавляя к ним честное воздержание. Вот и всё ядро целиком, дальше только детали этих трёх частей.
Начну с геометрии, потому что на ней стоит всё остальное. Точка это вектор, нормированный к единичной длине, а близость двух точек это их скалярное произведение: чем оно больше, тем точки ближе. В коде это буквально две функции из geometry.rs.
// точка это нормированный вектор, близость это скалярное произведение pub fn dot(a: &[f32], b: &[f32]) -> f32 { /* складывает произведения пачками по 8 чисел, инструкциями f32x8 */ } pub fn normalize(v: &[f32]) -> Vec<f32> { /* делит вектор на его длину */ }
Скалярное произведение я считаю не по одному числу, а пачками по восемь за раз (векторные инструкции процессора, тип f32x8), и раскидываю работу по всем ядрам. Сами векторы хранятся хитро: основная масса это файл на диске, отображённый прямо в оперативную память по требованию, а в куче живёт только то, что дописано в текущей сессии. Забегая вперёд, именно поэтому память у меня выходит меньше, чем у соперников: второй копии векторов в куче попросту нет.
Теперь вторая половина, журнал. Факт это карточка, и карточки можно только дозаписывать, переписать нельзя. Вот настоящий тип из journal.rs, я убрал пару служебных полей ради краткости.
pub struct Card { pub id: usize, pub text: String, // текст факта pub value: String, // значение факта pub ts: u64, // время записи, растёт с каждой операцией pub fact_key: Option<String>, // ключ факта: у всех версий одного факта он общий pub scope: String, // арендатор, чтобы данные разных клиентов не смешивались }
Главное поле здесь fact_key. У трёх карточек про должность одного человека он один и тот же, поэтому система понимает, что перед ней три версии одного факта, а не три разных факта. А забвение это не удаление, а отдельная запись‑надгробие, которая говорит «начиная с такого‑то момента эту карточку не показывать».
Раз карточки не переписываются, «текущей версии» как хранимого поля вообще нет, её вычисляют на лету. Если ужать суть до шести строк, она такая: берём все версии факта, выкидываем те, что появились позже интересующего момента, выкидываем забытые, из оставшихся берём самую позднюю по времени.
// суть в шести строках: какая версия факта действует на момент as_of fn effective_one(versions: &[Card], as_of: u64) -> Option<&Card> { versions.iter() .filter(|c| c.ts <= as_of) // не из будущего .filter(|c| !forgotten(c, as_of)) // не забыта надгробием .max_by_key(|c| c.ts) // свежая побеждает }
Вот те самые шесть строк из заголовка. В настоящем ядре та же логика применяется сразу ко всем фактам за один проход журнала, и выглядит она так (функция effective из journal.rs, я оставил только сердцевину).
// действующие карточки на момент as_of: по одной свежей на каждый ключ факта pub fn effective(&self, as_of: Option<u64>) -> Vec<&Card> { let limit = as_of.unwrap_or(self.tx); // «сейчас» либо любой прошлый момент let dead = self.forgotten(as_of); // что забыто надгробием к этому моменту let mut latest: HashMap<(String, String), &Card> = HashMap::new(); for c in &self.cards { if c.ts > limit || dead.contains(&c.id) { continue; } // из будущего или забыта if let Some(fk) = &c.fact_key { let key = (c.scope.clone(), fk.clone()); // (арендатор, ключ факта) if latest.get(&key).map_or(true, |p| c.ts > p.ts) { latest.insert(key, c); // держим самую свежую } } } latest.into_values().collect() }
Из этого одного прохода бесплатно выпадают сразу четыре свойства, за каждое из которых в обычной базе пришлось бы платить отдельным кодом. Версии есть, потому что карточки копятся, а не затирают друг друга. Свежесть есть, потому что по каждому ключу остаётся максимум по времени. Забвение есть, потому что надгробие исключает карточку из отбора. А взгляд в прошлое есть, потому что параметр as_of одинаково подрезает и карточки, и надгробия: подставьте вместо «сейчас» любую прошлую дату, и функция вернёт базу такой, какой она была тогда. Ни одно из четырёх свойств я не программировал по отдельности, все они следствия одного сравнения по времени.
Сюда же цепляется пятое свойство, и оно мне дороже прочих: честное воздержание. Обратите внимание, оно другого сорта, чем первые четыре, и держится уже не на времени, а на расстоянии, том самом расстоянии в пространстве смыслов с первого рисунка. Если ближайшее к запросу знание лежит дальше некоторого порога, система не выдаёт ближайшего соседа лишь бы что‑то ответить, а честно говорит «не знаю».
Порог это граница расстояния, за которой сосед считается уже слишком далёким, чтобы быть ответом. Он не взят с потолка, а откалиброван, то есть подобран по данным так, чтобы отсекать именно мусор, и задаётся переменной окружения GEOLLM_THRESHOLD (переменная окружения это настройка, которую задают снаружи при запуске программы, не трогая код). Это ровно та способность, ради которой всё затевалось против нейросети: модель почти всегда что‑нибудь да сгенерирует, а правило умеет молчать, когда знать нечего.
Дуэль в упор: шесть строк против нейросети, которой я разжевал ответ
Вот теперь, когда правило показано и понятно, вернёмся к задаче из вступления, но уже с полной методикой, чтобы каждое число стояло на своём месте.
Задача называется разрешением противоречий фактов: перед системой кладут две версии одного факта с датами, старую и новую, и просят отдать действующую, не соблазнившись устаревшей. Полигон это набор данных MQUAKE‑CF. MQUAKE это известный тест на многошаговые вопросы поверх отредактированных знаний, а приписка CF (counterfactual, контрфактический) означает, что факты в нём намеренно перевёрнуты во времени: у одной и той же сущности значение менялось, и старую версию специально держат рядом как приманку. Всего я прогнал четыреста таких состязательных случаев (состязательных значит намеренно запутанных, собранных так, чтобы сбить отвечающего с толку).
Теперь про соперника, и тут важно, что я поставил его в тепличные условия, чтобы меня потом не обвинили в подсуживании. Соперник это связка из двух частей. Сначала кодировщик, то есть модель, которая превращает тексты в векторы для поиска похожего, я взял сильный, bge‑m3. Он находит близкие карточки. А дальше вступает арбитр: это отдельная нейросеть (у меня локальная gemma3), которой отдают найденные карточки и просят выбрать правильный ответ, то есть нейросеть в роли судьи. Схема, стало быть, двухступенчатая: сначала кодировщик bge‑m3 достаёт похожие версии факта, потом арбитр gemma3 выбирает среди них ту, что считает действующей. И, чтобы совсем убрать поводы для отговорок, обеим версиям факта я явно, открытым текстом, положил в подсказку их даты. Арбитру не надо угадывать, что свежее, ему это прямо сказано.
Обе стороны, и моё правило, и связка «кодировщик плюс арбитр», получают одинаковый вход. Меряем ровно качество разрешения противоречия, а не качество поиска. Вот результат.
|
Разрешение противоречий, 400 случаев MQUAKE‑CF |
Действующая версия |
Устаревший факт |
Выдуманный третий |
|---|---|---|---|
|
Правило свежести (6 строк, без ИИ) |
почти 100% |
0,0% |
0,0% |
|
Векторный поиск + арбитр gemma3 (даты переданы) |
3,8% |
55,3% |
41,0% |
Как проверить. Обе строки этой таблицы, и правило свежести, и связку bge‑m3 плюс арбитр gemma3, считает один и тот же скриптbench_factcons_strong.py: он подаёт обеим сторонам одинаковый вход и меряет их ответы бок о бок, чтобы сравнивать именно качество разрешения противоречия, а не качество поиска. Данные MQUAKE‑CF, случаи с одной правкой факта.
Прочитаю таблицу словами. Правило без единого грамма понимания мира ни разу за четыреста случаев не сочинило несуществующий факт и ни разу не отдало устаревший: почти сто процентов ответов действующие. А нейросеть, которой я разжевал условие и вложил обе даты прямо в подсказку, отвечает правильно лишь в 3,8% случаев. В 41% она с невозмутимым лицом выдумывает третью версию, которой не было ни в одной карточке, а в большей части остального цепляется за устаревший факт. Разница не только в проценте попаданий, она в самом характере ошибки: правило молчит, когда не знает, а модель фантазирует, когда сомневается.
Дальше честная оговорка, без неё таблица превращается в рекламу. MQUAKE набор состязательный, он специально сбивает модель с толку, а арбитр у меня локальный, не топовый. Я не утверждаю, что любая нейросеть на любых данных проиграет. Я утверждаю ровно измеренное: на задаче, где ответ однозначно определяется временем, правило свежести бьёт нейросетевого арбитра в десятки раз (почти сто процентов против 3,8, это примерно двадцать шесть раз).
Вывод практический: семантику времени стоит решать логикой, а не надеждой на здравый смысл модели.
Как я обогнал четыре промышленные базы
Правильные ответы это половина дела. До сих пор я проверял правило как чистую логику, скорость тут была ни при чём. Но векторная память обязана быть ещё и быстрой, иначе её не поставят в прод. Поэтому дальше я за двое суток довёл ту же идею уже до полноценного быстрого поискового движка на Rust и вышел с ним против трех промышленных векторных баз данных: Qdrant, pgvector и Manticore. Это готовые продукты, каждый из которых умеет искать ближайшие векторы, их я и взял в соперники. Поднял всех на одном стенде и опросил одинаково.
Сначала про стенд и метрики, чтобы таблицы не свалились как снег на голову. Стенд для всех замеров один: ноутбук Apple M1, восемь процессорных ядер, без видеокарты. Данные это открытый набор ann‑benchmarks mnist-784, шестьдесят тысяч векторов размерности 784 и тысяча запросов к ним (размерность это длина вектора, то есть каждая картинка описана 784 числами). Эталоном полноты служит точный перебор, то есть честно найденные ближайшие соседи без всяких приближений.
Индекс у меня приближённый, он называется HNSW (иерархический навигируемый граф малого мира, hierarchical navigable small world). Звучит страшно, а идея простая. Представьте, что все векторы заранее связали в сеть по принципу «кто на кого похож»: у каждого вектора есть несколько ближайших соседей, и к ним протянуты рёбра. Поиск не сравнивает запрос со всеми шестьюдесятью тысячами векторов подряд, а стартует с одной точки входа и перебегает по рёбрам от соседа к соседу, каждый раз шагая к тому, кто ближе к запросу, пока не упрётся в самых близких. Это и есть «приближённый»: путь по сети находит почти тех же соседей, что честный перебор, но во много раз быстрее. Точный перебор, которым я потом меряю полноту, наоборот, тупо сверяет запрос со всеми векторами и потому даёт эталонный, идеально верный ответ, только медленно.

Параметры графа для всех соперников одинаковы, чтобы сравнение было честным: m равно 16, ef_construction равно 200, ef_search равно 96. Это внутренние ручки HNSW: сколько рёбер у каждого узла и насколько широко разбегается обход при поиске. Версии соперников: Qdrant 1.18.3, pgvector 0.8.6 на PostgreSQL 16, Manticore 28.6.6.
Метрик будет три, и вот что каждая значит. Полнота recall@10 это доля запросов, в которых искомые ближайшие соседи реально попали в первую десятку выдачи; единица означает идеальное совпадение с точным перебором. Задержка p50 это медиана: половина запросов обслужена быстрее этого времени. Задержка p99 это хвост: девяносто девять процентов запросов уложились в это время, и она показывает, как ведёт себя не средний, а самый невезучий запрос. Ещё в таблицах будет резидентная память, это сколько оперативной памяти реально занял процесс во время работы.
Начнём с одного клиента, без всякой конкуренции. Здесь видно чистую полноту и чистую задержку.
|
Один клиент |
geollm |
Qdrant |
pgvector |
Manticore |
|---|---|---|---|---|
|
Полнота recall@10 |
0,9998 |
0,9997 |
0,9990 |
0,9973 |
|
Запросов в секунду |
953 |
347 |
327 |
121 |
|
Задержка p50, мс |
0,98 |
2,79 |
3,03 |
8,21 |
|
Задержка p99, мс |
1,36 |
6,22 |
4,55 |
10,46 |
Полнота у меня на уровне лучших, а по скорости отрыв почти втрое от ближайшего соперника (953 против 347 у Qdrant). Теперь включим конкуренцию, тридцать два клиента разом.
|
32 клиента |
geollm |
Qdrant |
pgvector |
Manticore |
|---|---|---|---|---|
|
Запросов в секунду, на 32 клиентах |
5600 |
1225 |
421 |
835 |
|
Задержка p99, мс |
8,0 |
64,2 |
551 |
72,0 |
|
Резидентная память, МБ |
318 |
528 |
149¹ |
479 |
|
Данные на диске, МБ |
187 |
244 |
927 |
450 |
Как проверить. Таблицу одного клиента снимает скрипт bench_three.py. Конкурентные замеры (и на восьми, и на тридцати двух клиентах) гоняет отдельный генератор нагрузки на сыром сокете loadgen командой cargo run —release —bin loadgen, число клиентов задаётся параметром запуска. Данные для обоих готовит load_mnist.py.
Сноска ¹ к таблице тридцати двух клиентов, честно и в одном месте. У pgvector память помечена единицей, потому что это память контейнера по docker stats, а не резидент самого процесса, и с остальными строками она строго не сравнима. Больше того, и пропускную способность, и p99 этих двух баз под конкуренцией (421 и 551 у pgvector) я снимал другим клиентом, поэтому их стоит сравнивать с остальными лишь по порядку величины, а не число в число. Врать так врать до конца я не хочу, поэтому оговариваю сразу под таблицей.
Свой пик, около 5800 запросов в секунду, geollm набирает уже на восьми клиентах, а к тридцати двум держит 5600. Для сравнения, ближайший соперник Qdrant выдаёт на пике 1225 запросов в секунду (это уже на тридцати двух клиентах) и всего 347 на одном клиенте. То есть под конкуренцией мой поток больше, чем у Qdrant, в несколько раз, а против одноклиентного Qdrant и вовсе примерно в шестнадцать раз.
Почему так выходит. Граф HNSW у меня не держит вторую копию векторов в куче (куча это оперативная память, которую программа выделяет под свои данные), а читает их из отображённого в память файла по номеру узла. Отображённый в память файл (memory‑mapped file) это приём, когда программа обращается к файлу на диске как к обычной области памяти и подтягивает нужный вектор прямо с диска по требованию, не делая его лишней копии в оперативной памяти. Поэтому резидентная память у меня меньше, чем у всех. Плюс квантование: векторы можно сжать из чисел с плавающей точкой в восьмибитные целые (int8), и тогда база занимает 47 мегабайт вместо 188, вчетверо меньше, при полноте recall@10 = 0,973, причём граф ищет прямо по сжатому представлению, не разворачивая его обратно (замер повторяется примером quantbench.rs).
И сразу то, где я проигрываю, чтобы таблицы не выглядели глянцем. На массовой загрузке с построением индекса я даю 1103 вектора в секунду против 1767 у Qdrant. Под высокой долей записи Qdrant тоже опережает за счёт посегментной параллельной записи. Так что «обогнал четыре базы» это про чтение и про ресурсы, а не про запись.
Как проверить. Массовую загрузку гоняет тот же loadgen в режиме только вставки, откуда и берутся 1103 вектора в секунду. Цифру чистого ядра, около 7950 векторов в секунду, даёт микрозамер, который вставляет уже разобранные векторы прямо в ядро, минуя разбор JSON на приёме; разница между 1103 и 7950 и показывает, что узкое место сидит в разборе JSON, а не в самом индексе.
Три бенчмарка, которые оказались враньём
Это самая полезная глава, потому что она не про мой код, а про измерения, на которых горит каждый второй. Сразу оговорюсь, чтобы не было путаницы: числа во всех предыдущих таблицах это уже очищенный, честный результат. А в этой главе я показываю грабли, на которых те же самые числа сперва оказывались завышенными, и как я эти грабли убирал.
Грабля первая. Тут придётся ввести два новых понятия сразу. Первое: смешанная нагрузка это когда часть запросов не читают базу, а дописывают её, «пять процентов записи» значит, что каждый двадцатый запрос это вставка нового вектора, а не поиск (до сих пор все замеры шли на чистом чтении). Второе: пора сказать пару слов про одновременный доступ. Когда несколько запросов лезут в общие данные разом, их приходится упорядочивать блокировкой. Блокировка это как ключ от общей комнаты: пока один взял ключ, остальные ждут под дверью. Кусок кода, огороженный таким ключом, называют критической секцией. А гонка это когда двое всё‑таки протиснулись в дверь одновременно и затёрли данные друг друга.
Так вот, под смешанной нагрузкой всего пять процентов записи роняли пропускную способность почти в десять раз. Я часами перечитывал эти самые критические секции, переставлял блокировки и был уверен, что это моя гонка в ядре. Виноват оказался не движок, а мой же измеритель. Имя тупика ловушка Нейгла: алгоритм Нейгла придерживает мелкий сетевой запрос, копя пакет покрупнее, а отложенное подтверждение протокола TCP (transmission control protocol, базовый протокол надёжной доставки) придерживает ответ, и на петле «запрос, ответ» две вежливые задержки встречаются лбами и дают десятки миллисекунд простоя на ровном месте. Лечится одной опцией сокета TCP_NODELAY плюс своим генератором нагрузки на сыром сокете. Урок дорогой: прежде чем винить своё ядро, проверь транспорт.
Грабля вторая. И только сняв транспортный тупик, я увидел настоящий изъян ядра. Каждая запись помечала граф грязным, и первый же следующий поиск пересчитывал так называемый слабый хвост (узлы, до которых не дойти обходом от точки входа, их приходится досматривать перебором) полным обходом по всем шестидесяти тысячам узлов, под блокировкой чтения. Один писатель, а платит каждый читатель после него. Я перевёл слабый хвост на инкрементальное поддержание и разнёс пути так, чтобы чтение вообще не ждало запись, то есть чтобы читателей не заставляли стоять в очереди за писателем: дорогое планирование вставки идёт под общей блокировкой (её берут разом много читателей, она никого не выгоняет), эксклюзив (единоличный захват, когда писатель на миг выставляет всех за дверь) берётся лишь на миг, а дисковая дозапись вынесена под отдельный замок, ещё один такой же ключ. Числа до и после на тридцати двух клиентах говорят сами за себя.
|
Смешанная нагрузка, 32 клиента |
Было |
Стало |
|---|---|---|
|
5% записи, запросов/с |
577 |
3739 |
|
20% записи, запросов/с |
163 |
2023 |
Как проверить. Смешанную нагрузку гоняет тот же loadgen с ненулевой долей записи, подробности расследования вынесены в HIGHLOAD.md.

И побочный, но важный урок оттуда же. Когда я распараллелил построение графа потоковой вставкой, полнота тихо просела с 0,9998 до 0,84, потому что потоковая вставка откладывала починку недостижимых узлов. Скорость при этом была прекрасной. Это самый коварный тип дефекта: графики радуют, а правильные ответы молча выпадают из выдачи, и ни один секундомер этого не покажет. Воспроизводится это переключением построения графа в режим потоковой вставки (тот самый флаг построения) вместо единого параллельного прохода. Лечится только тем, что полноту меришь рядом со скоростью, всегда, единый параллельный проход построения вместо потоковой вставки вернул полноту к 0,9998.
Что не взлетело
Раз уж я обещал честность, вот и провалы, которые остались провалами.
Пошардовость (переменная окружения GEOLLM_SHARDS) должна была ускорять запись параллелизмом: разрезаешь данные на несколько независимых кусков, шардов, и пишешь в них одновременно. На одной машине это не оправдалось. При хеш‑шардировании каждый запрос обязан веерно опросить все шарды, и выигрыш параллельной записи съедается веером чтения, а чистое чтение на четырёх шардах вообще проседает в разы. Сейчас шарды это задел под будущий разнос по узлам, где веер по сети уже оправдан. По умолчанию оставил один шард. Проверяется примеромshards.rs.
Сегментный индекс с неблокирующим чтением я написал, замерил и выбросил из работы. Он устроен так: свежие вставки копятся в маленьком сегменте, а когда сегмент наполняется, его запечатывают, то есть замораживают в неизменяемый готовый кусок индекса и открывают следующий. Единственный поток запечатывания захлёбывался, а необходимость при каждом поиске веерно опрашивать все сегменты резала пропускную способность. Модуль лежит в репозитории неподключённым, как памятник красивой идее, проигравшей замеру (изолированный замер этоsegbench.rs).
А вот таблица слабых мест, которую я не смягчаю.
|
Слабое место |
Как есть |
|---|---|
|
Масштаб |
— всё измерено на 60 000 векторов на одной машине; |
|
Массовая загрузка |
— 1103 вектора в секунду против 1767 у Qdrant; |
|
Набор соперников |
— подобран мной; |
|
Распределённость |
— репликация на Raft (алгоритм согласования реплик через выборного лидера) проверена сценарием по мотивам Jepsen (методика проверки распределённых систем на устойчивость к разрыву сети), строгих гарантий при разделении сети нет |
|
Зрелость |
— в проде не стоял; |
Итог
Скорость и малый расход памяти это приятно, но не главное, их рано или поздно догонят. Главное в том, что слой памяти собран из очень немногого. Четыре свойства, версии, свежесть, забвение и взгляд в прошлое, выпадают из одного‑единственного сравнения по времени, а не из вороха эвристик поверх векторной базы. Пятое, честное «не знаю», добавляется к ним из другого сравнения, по расстоянию в пространстве смыслов. И именно связка этих двух сравнений, времени и расстояния, даёт то, что заслуживает называться памятью. Именно поэтому шесть строк обыгрывают нейросеть‑арбитр на разрешении противоречий, а не наоборот.
Код, данные и скрипты замеров открыты под лицензией BSL в репозитории на GitHub. Каждое число этой статьи привязано к своему скрипту прямо по тексту, так что проверять меня на слово не надо: клонируйте, запустите, сверьте. Систему я учил отвечать «не знаю», когда знания нет за порогом, и того же трезвого отношения к собственным числам желаю всякому, кто меряет.
И всё‑таки про AGI: что я накручу на эту геометрию дальше
В начале я обещал, что AGI не изобрёл, и не соврал. Но заголовок был не только ради перехода, у меня есть под него гипотеза.
Сначала соберём то, что уже построено и измерено, на одном языке. То, что выше я называл косинусной близостью векторов, дальше удобно представлять так: знание это точка в пространстве смыслов, и рядом лежащие точки похожи по смыслу. Это геометрия, и она у меня работает. Поверх геометрии навёрнуто время: у точки есть версии, есть действующая на сейчас, есть забвение и есть взгляд в прошлое. И есть примитив, который я считаю недооценённым, честное воздержание: система умеет сказать «не знаю», когда ближайшее знание слишком далеко. Это уже маленькая модель того, как психика обходится со знанием: не только «что похоже», но и «что верно сейчас», и «а знаю ли я это вообще».
Чего здесь не хватает до чего‑то по‑настоящему похожего на ум? Оценки. Человек помнит не только что и когда, он помнит, было это хорошо или плохо. Память у нас окрашена: обжёгся, значит к этой области смысла в следующий раз подходишь осторожно; обрадовался, значит тянешься к ней снова. Без этой окраски нет ни предпочтений, ни целей, ни выбора, одна холодная справочная выдача.
Отсюда следующий слой, и ложится он на ту же геометрию бесплатно, по той же близости. Я размечаю в пространстве области со знаком, отрицательные и положительные. Не бирку к отдельной точке приклеиваю, а крашу целые области смысла, и тогда новая точка наследует окраску оттуда, куда она попала, ровно тем же соседством, которое уже работает на поиск. Попал в «плохую» окрестность, унаследовал минус, попал в «хорошую», унаследовал плюс. Валентность, то есть знак переживания, становится таким же следствием положения в пространстве, каким свежесть и забвение уже стали следствием положения во времени.
Дальше включается обучение, и вот тут появляется поведение. Исход действия толкает окраску областей: то, что привело к хорошему, поднимает плюс вокруг соответствующих точек, то, что привело к плохому, топит их в минус. Система начинает не просто отвечать «что похоже и что верно сейчас», а предпочитать, тянуться к положительному и обходить отрицательное, то есть вести себя целенаправленно. Это ровно тот контур, на котором учится человек: аффективная разметка опыта и есть та награда, вокруг которой строится всё научение, и без неё, как показывает нейробиология принятия решений, человек не решает вообще ничего, даже имея все факты.
Сложите три оси вместе, и станет видно, почему я вообще позволил себе слово AGI из заголовка. Геометрия отвечает на вопрос «где в смысле», время отвечает на «что истинно сейчас», валентность отвечает на «хорошо это или плохо», а обучение превращает эти ответы в действие. Память, которая знает, что сейчас правда, чувствует, хорошо это или плохо, и учится на исходах, это уже не справочник, это заготовка субъекта. Я не утверждаю, что из этого гарантированно вырастет общий интеллект. Я утверждаю, что это честная, проверяемая дорога к чему‑то, устроенному как человек.
Спасибо, что дочитал до конца! Надеюсь тебе было интересно. Если есть вопросы, всегда можно задать в комментах в ТГ‑канале.
Источник: habr.com
Похожие записи
Оцените материал:
Присоединяйтесь и подпишитесь на рассылку самых свежих новостей по Email
Получайте свежие новости и идеи на почту. Без спама — только самое интересное.
Нажимая «Подписаться», вы соглашаетесь с политикой конфиденциальности.
