TL;DR
Я делаю локально работающего ИИ-агента и столкнулся с тем, что стандартный подход «закинуть текст в векторную базу, достать по косинусу» для долгоживущего агента не работает: контекст замусоривается, факты конфликтуют, ничего не забывается. Вместо этого реализовал графовую когнитивную память поверх одного файла SQLite: эпизодические и семантические узлы, типизированные рёбра, именованные сущности, гибридный поиск (FTS5 + vector + graph) с Reciprocal Rank Fusion, кривую забывания Эббингауза и фоновую LLM-консолидацию. В статье — полная архитектура с кодом, SQL-схемой и формулами. Код и минимальный пример — в репозитории.
1. Введение: почему классический RAG не работает для агентов
Все, кто делал бота с «памятью», знают стандартный рецепт: берём Qdrant/pgvector/Chroma/Pinecone/Milvus, нарезаем диалог на чанки, генерируем эмбеддинги, при каждом запросе достаём Top-K по косинусному расстоянию. Для одноразового Q&A по документам это работает. Для долгоживущего агента — нет.
Конкретный пример из моего первого прототипа: пользователь сказал «Я предпочитаю Python». Через неделю: «Пишу сейчас на Rust». Ещё через неделю спросил: «На чём мне написать CLI-утилиту?» Агент достал из pgvector оба факта с почти одинаковым скором и выдал ответ, в котором мешал click и clap, предлагал argparse рядом с structopt и вообще выглядел шизофренически. Факты не были помечены временем, не было механизма «новый заменяет старый», и агент не мог решить, какому верить.
Другие системные проблемы, с которыми я сталкивался:
Контекст замусоривается. Через месяц в базе тысячи фрагментов диалогов. Вектора вчерашнего разговора о Python и сегодняшнего о Rust одинаково «близки» к запросу о разработке приложения. Агент получает противоречивый контекст и выдает мусор.
Нет разрешения конфликтов. Пользователь вчера работал в компании А, сегодня перешёл в компанию Б. Векторная база выдаёт оба факта с одинаковым скором. Какой из фактов актуален не понятно.
Нет provenance. Откуда факт? Когда записан? Можно ли ему доверять? Плоский вектор-store об этом ничего не знает.
Нет забывания. Неважная или "неуверенная" информация хранится вечно и конкурирует за место в контексте с важной.
Моя цель: агент должен помнить актуальные факты, забывать неважное, разрешать противоречия, показывать хронологию и объяснять, откуда он что-то знает. И всё это — локально, автономно, в одном файле SQLite без лишних зависимостей.
2. Контекст: что за агент
Для понимания архитектуры памяти нужен минимальный контекст. Агент работает локально, все данные хранятся в SQLite (события, память, сессии — ноль внешних зависимостей). Архитектура — nano-kernel + extensions: каждая фича (каналы, память, расписание) — отдельное расширение с manifest.yaml. Память — одно из таких расширений. О нём и поговорим.
Вот как данные текут через систему — от пользовательского сообщения до ответа агента с релевантным контекстом из памяти:
Пользователь │ ▼ Channel (CLI / Telegram) │ emit("user.message") ▼ EventBus → MessageRouter │ ├─► Memory: HOT PATH (<50 мс, без LLM) │ │ episodic node → FTS5 trigger │ │ temporal edge → write queue │ └─► SLOW PATH (async, ~200 мс) │ embedding → vec_nodes │ ├─► Memory: CONTEXT INJECTION (перед вызовом агента) │ │ intent classify → FTS5 + vector + graph │ │ RRF fusion → budget assembly │ └─► inject в system prompt │ ▼ Orchestrator (LLM) → ответ пользователю │ └─► Memory: HOT PATH (agent_response → episodic node) ◇ ◇ ◇ ФОНОВЫЕ ПРОЦЕССЫ (не на горячем пути): Session switch / 03:00 cron └─► CONSOLIDATION (write-path LLM agent) episodes → facts + entities + edges detect_conflicts → resolve_conflict mark_session_consolidated 03:00 cron └─► NIGHTLY MAINTENANCE Ebbinghaus decay → prune entity enrichment → re-embed causal inference → causal edges
Это исследовательский проект, в которое проверяются различные архитектурные подходы. Кроме памяти в нем интересное:
nano-kernel + extensions
Может писать свои extensions по запросу: "Давай общаться в slack" и он пойдет допишет интеграцию со Slack сам.
Event Bus для общения между компонентами
Нет heartbeat, зато есть события от компонентов в agent loop
Есть многошаговая реализация фоновых задач
Защищенное хранение секретов через keyring
Что еще запланировано:
skills - доказанная эффективность для усиления слабых моделей
Интеграция MCP - сегодня must-have
Computer Use (управление браузером) - тяжело, но попробую
Голосовое общение с агентом +в будущем может быть в режиме реального времени - лично мне тяжело общаться с агентами голосом, просто попробуем
GraphRAG База знаний - почему бы все документы на компьютере + в confluence + еще где-то не объединить в одну базу знаний?
3. Два слоя памяти: сессия vs долгосрочная
Прежде чем нырять в детали, важно разделить два слоя:
Слой |
Ответственность |
Реализация |
|---|---|---|
Сессионная память |
Контекст текущего разговора |
OpenAI Agents SDK |
Долгосрочная память |
Факты, эпизоды, процедуры, мнения — всё, что переживает рестарт |
Расширение |
Сессионная память — это рабочий буфер: она хранит историю текущего диалога и очищается при смене сессии (30 минут неактивности). Долгосрочная память — это хранилище знаний, которое живёт месяцами. Дальше речь только о ней.
4. Графовая схема: узлы, рёбра, сущности
Почему граф, а не плоская таблица
Первая версия использовала плоскую таблицу memories с полем kind. Отношения хранились в JSON-массивах (source_ids, entity_ids). Это быстро показало свои ограничения:
WHERE entity_ids LIKE '%"uuid"%'— full table scan. На 10K записей — ощутимо.Нет типизированных связей: нельзя выразить «факт X заменяет факт Y» или «эпизод A вызвал эпизод B».
Доказательства (provenance, откуда факт) — в JSON-массиве, не индексируемый.
Вторая версия — полноценный граф: nodes + edges + entities + junction-таблица node_entities.
Таблица nodes — атомы памяти
Код CREATE TABLE
CREATE TABLE nodes ( id TEXT PRIMARY KEY, type TEXT NOT NULL CHECK(type IN ('episodic','semantic','procedural','opinion')), content TEXT NOT NULL, embedding BLOB, event_time INTEGER NOT NULL, created_at INTEGER NOT NULL, valid_from INTEGER NOT NULL, valid_until INTEGER, -- NULL = актуален confidence REAL NOT NULL DEFAULT 1.0, access_count INTEGER NOT NULL DEFAULT 0, last_accessed INTEGER, decay_rate REAL NOT NULL DEFAULT 0.1, source_type TEXT, -- conversation | consolidation | tool_result source_role TEXT, -- user | orchestrator | memory_agent session_id TEXT, attributes TEXT DEFAULT '{}' );
Четыре типа узлов отражают когнитивные категории с разным жизненным циклом:
Тип |
Кто создаёт |
Описание |
Decay |
|---|---|---|---|
|
Hot path (каждое сообщение) |
Сырой диалог. Иммутабельная аудит-линия |
Никогда не затухает |
|
Консолидатор / orchestrator |
Факты: «Виталий предпочитает тёмную тему» |
Подвержен Эббингаузу |
|
Консолидатор |
Паттерны действий: «Для деплоя запустить X, потом Y» |
Подвержен Эббингаузу |
|
Консолидатор |
Субъективные оценки: «Инструмент Z неудобен» |
Подвержен Эббингаузу |
Soft-delete: Записи никогда не удаляются физически. valid_until = now означает «неактуален». Все запросы включают WHERE valid_until IS NULL.
Таблица edges — типизированные связи
Скрытый текст
CREATE TABLE edges ( id TEXT PRIMARY KEY, source_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, target_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, relation_type TEXT NOT NULL CHECK(relation_type IN ('temporal','causal','entity','derived_from','supersedes')), predicate TEXT, weight REAL NOT NULL DEFAULT 1.0, confidence REAL NOT NULL DEFAULT 1.0, valid_from INTEGER NOT NULL, valid_until INTEGER, evidence TEXT DEFAULT '[]', created_at INTEGER NOT NULL );
Пять типов рёбер — каждый несёт информацию, которую нельзя получить из эмбеддингов:
Тип |
Связывает |
Назначение |
Кто создаёт |
|---|---|---|---|
|
Эпизод → Эпизод |
Хронологическая цепочка |
Hot path (автоматически) |
|
Эпизод → Эпизод |
Причинно-следственная связь |
LLM-инференс (ночной) |
|
Узел → Сущность |
Привязка к именованной сущности |
Write-path агент |
|
Факт → Эпизод |
Провенанс: «факт извлечён из этих диалогов» |
Консолидатор |
|
Новый факт → Старый |
Эволюция знаний: «заменяет устаревший факт» |
|
Таблица entities — реестр сущностей
Код CREATE TABLE
CREATE TABLE entities ( id TEXT PRIMARY KEY, canonical_name TEXT NOT NULL, type TEXT NOT NULL CHECK(type IN ('person','project','organization','place','concept','tool')), aliases TEXT DEFAULT '[]', -- JSON: ["Саша", "мой босс", "Alex"] summary TEXT, -- LLM-описание embedding BLOB, first_seen INTEGER NOT NULL, last_updated INTEGER NOT NULL, mention_count INTEGER NOT NULL DEFAULT 1, attributes TEXT DEFAULT '{}' );
Без сущностей «Витя», «Виталий» и «мой руководитель» — три разных человека в памяти. Entity-якоря решают проблему через canonical_name + aliases. При каждом упоминании write-path агент резолвит упоминание по имени и алиасам: нашёл — инкрементирует mention_count, мержит новые алиасы; не нашёл — создаёт новую запись.
Junction-таблица node_entities (с индексом по entity_id) позволяет за O(log n) находить все узлы, связанные с сущностью — вместо full scan по JSON.
Виртуальные таблицы
Код
-- Полнотекстовый поиск CREATE VIRTUAL TABLE nodes_fts USING fts5( content, content=nodes, content_rowid=rowid, tokenize='unicode61' ); -- Векторный поиск (sqlite-vec) CREATE VIRTUAL TABLE vec_nodes USING vec0( node_id TEXT PRIMARY KEY, embedding float[256] ); CREATE VIRTUAL TABLE vec_entities USING vec0( entity_id TEXT PRIMARY KEY, embedding float[256] );
FTS5 сконфигурирован как external content table (content=nodes): он не дублирует данные, а ссылается на nodes. Триггеры AFTER INSERT/UPDATE/DELETE — стандартный паттерн для синхронизации external content FTS5. Без них индекс рассинхронизировался бы после UPDATE/DELETE. Векторный индекс vec_nodes использует расширение sqlite-vec для KNN-поиска.
5. Hot Path: запись за 50 мс без LLM
Ключевой архитектурный принцип: LLM на запись, алгоритмы на чтение. На горячем пути — ни одного вызова к LLM.
Когда пользователь отправляет сообщение:
user_message событие → Создать episodic-узел (type='episodic', content, session_id) → Отправить в write-queue (fire-and-forget) → FTS5-триггер срабатывает автоматически на INSERT → Найти предыдущий эпизод в сессии → создать temporal-ребро → Если embedding доступен: asyncio.create_task(slow_path)
В коде это выглядит так
async def _on_user_message(self, data: dict) -> None: text = (data.get("text") or "").strip() session_id = data.get("session_id") # Детекция смены сессии → запуск консолидации старой if session_id and session_id != self._current_session_id: if self._current_session_id: asyncio.create_task( self._consolidate_session(self._current_session_id) ) self._current_session_id = session_id # Создаём эпизодический узел prev_id = await self._storage.get_last_episode_id(session_id) node_id = str(uuid.uuid4()) self._storage.insert_node({ "id": node_id, "type": "episodic", "content": text, "event_time": now, "created_at": now, "valid_from": now, "source_type": "conversation", "source_role": "user", "session_id": session_id, }) # Temporal-ребро к предыдущему эпизоду if prev_id: self._storage.insert_edge({ "source_id": prev_id, "target_id": node_id, "relation_type": "temporal", ... }) # Embedding — в фоне, не блокируя UI if self._embed_fn: asyncio.create_task(self._slow_path(node_id, text))
Обратите внимание: insert_node и insert_edge — это fire-and-forget вызовы. Они кладут операцию в asyncio.Queue, где единственный writer-таск последовательно применяет записи к SQLite (WAL-режим). Вызывающий код не ждёт подтверждения. Это критически важно: UI не блокируется.
Trade-off: latency vs durability. Fire-and-forget означает, что при аварийном завершении процесса последние несколько сообщений из очереди могут не доехать до диска. Для эпизодических узлов (сырые диалоги) это допустимая потеря — ночной cron всё равно консолидирует только завершённые сессии, а диалог до падения вряд ли содержит полную сессию. Критичные записи (извлечённые факты, сущности) идут через await-able путь с future — там потерь нет. При graceful shutdown writer-таск дренит очередь перед закрытием соединения.
Slow path (~200 мс) запускается как asyncio.create_task: генерирует embedding через extension embedding и сохраняет его в vec_nodes. С retry-логикой и exponential backoff — если API упал, повторяем до 3 раз.
6. Writer Queue: как не сломать SQLite конкурентными записями
SQLite — single-writer. В нашем async-приложении горячий путь, медленный путь, консолидатор и decay — все хотят писать одновременно. Подход "в лоб" ведёт к database is locked.
Решение — паттерн single-writer queue:
┌─────────────┐ ┌──────────────┐ ┌───────────┐ │ Hot path │──┐ │ Slow path │──┐ │ Agent │──┐ │ (writes) │ │ │ (writes) │ │ │ (writes) │ │ └─────────────┘ │ └──────────────┘ │ └───────────┘ │ ▼ ▼ ▼ ┌──────────────────────────────────────────────┐ │ asyncio.Queue (write ops) │ └────────────────────┬─────────────────────────┘ ▼ ┌───────────────────────┐ │ Writer task (single) │ ← sequential apply │ conn с WAL mode │ └───────────────────────┘
Каждая write-операция — это WriteOp(sql, params, future). Hot path отправляет без future (fire-and-forget). Slow path и агент отправляют с future и await-ят результат. Все записи проходят через один writer-таск, который последовательно исполняет их на write-соединении.
Чтение идёт через отдельное read-соединение с PRAGMA query_only=ON. WAL-режим позволяет читать параллельно с записью.
7. Консолидация: как агент превращает диалоги в знания
Эпизоды — это сырой диалог. Полезная информация в них закопана: «Привет, я тут переехал в Берлин» → факт «Пользователь живёт в Берлине». Извлечение фактов из эпизодов — задача для LLM.
Когда запускается консолидация
Три триггера:
Смена сессии — когда пользователь начинает новый разговор (или прошло 30 минут неактивности),
MessageRouterротируетsession_idи публикуетsession.completedв EventBus. Memory подписан на это событие.Детекция в hot path — если в
user_messageпришёл новыйsession_id, Memory запускает консолидацию старой сессии какasyncio.create_task.Ночной cron (ежедневно в 03:00) — проходит по всем неконсолидированным сессиям.
Write-path агент
Консолидатор — это приватный LLM-агент (дешёвая модель вроде gpt-5-mini или или локальная) внутри memory-расширения. Он не виден Оркестратору и не добавляется в его список инструментов. У него свой набор:
Инструмент |
Описание |
|---|---|
|
Проверка идемпотентности |
|
Загрузка эпизодов сессии (пагинация) |
|
Сохранение извлечённых фактов + |
|
NER + резолвинг сущностей |
|
Поиск противоречий через гибридный поиск |
|
Soft-delete старого факта + |
|
Отметить сессию как обработанную |
Вот как выглядит prompt консолидатора:
You are a memory consolidation agent. Your task is to extract durable knowledge from conversation episodes and store it in a structured graph. Workflow: 1. Check idempotency: is_session_consolidated(session_id)? 2. Fetch episodes: get_session_episodes(session_id) 3. Extract: semantic facts, procedural patterns, opinions 4. Conservative extraction: only clearly stated information 5. Deduplication: detect_conflicts before creating new facts 6. Save: save_nodes_batch with derived_from edges 7. Entity linking: extract_and_link_entities 8. Conflict resolution: resolve_conflict if contradictions 9. Mark complete: mark_session_consolidated
Идемпотентность: Если консолидация прервалась между шагами 2-8, сессия остаётся неконсолидированной. Следующий запуск (ночной cron или retry) перезапустит с шага 1, увидит consolidated = false и переработает. Частичные результаты (сохранённые узлы) безвредны — дедупликация ловит дубли.
Стоимость: На дешёвой модели (gpt-5-mini: $0.25/1M input, $2/1M output на момент написания) одна консолидация ~30 эпизодов обходится меньше цента (~3K input + ~500 output токенов). С локальной моделью — бесплатно.
8. Разрешение конфликтов: эволюция знаний
Вот где начинается самое интересное. Пользователь полгода назад сказал «Я работаю в компании А». Сегодня: «Я перешёл в компанию Б». Классический RAG выдаст оба факта — и агент запутается.
В моём решении:
Консолидатор извлекает новый факт: «Пользователь работает в компании Б».
detect_conflictsнаходит старый факт через гибридный поиск (по тексту и вектору).Консолидатор (LLM) решает, что факты противоречат друг другу.
resolve_conflictвыполняет:
async def resolve_conflict(old_node_id, new_node_id): # Понижаем confidence и ускоряем decay старого факта await storage.update_node_fields( old_node_id, {"confidence": 0.3, "decay_rate": 0.5}, ) # Soft-delete: не физическое удаление, а маркировка await storage.soft_delete_node(old_node_id) # Ребро эволюции знаний await storage.insert_edge_awaitable({ "source_id": new_node_id, "target_id": old_node_id, "relation_type": "supersedes", ... })
Результат: старый факт помечен как неактуальный (valid_until = now), его confidence упал до 0.3, а decay_rate повышен до 0.5 — он быстро «забудется». Новый факт имеет confidence = 0.8 (LLM-извлечение) и supersedes-ребро к старому. Полная история сохранена, но агент видит только актуальное.
Оркестратор тоже может исправлять факты через инструмент correct_fact:
@function_tool async def correct_fact(old_fact: str, new_fact: str): # Находим старый факт через гибридный поиск candidates = await retrieval.search(old_fact, ...) old_node = candidates[0] # Soft-delete + supersedes edge ...
9. Кривая забывания Эббингауза
Ещё одна фича, которую мало кто реализует. Человек забывает — и агент тоже должен. Без забывания контекст переполняется неактуальной информацией.
Формула
confidence(t) = confidence₀ × exp(−λ × (t − t_last_access)^0.8)
Где:
λ—decay_rate(по умолчанию 0.1; 0.0 для защищённых фактов)0.8— суб-экспоненциальный показатель (медленнее чистой экспоненты, моделирует человеческое забывание)t_last_access— время последнего обращения к факту
Реализация
class DecayService: async def apply(self, storage) -> dict: nodes = await storage.get_decayable_nodes() updates, to_prune = [], [] for node in nodes: days_since = (now - last_accessed) / 86400.0 confidence_new = confidence * math.exp( -decay_rate * (max(0, days_since) ** 0.8) ) if confidence_new < self._threshold: # default: 0.05 to_prune.append(node["id"]) else: updates.append((node["id"], confidence_new)) await storage.batch_update_confidence(updates) await storage.soft_delete_nodes(to_prune) return {"decayed": len(updates), "pruned": len(to_prune)}
get_decayable_nodes() выбирает только неэпизодические узлы с decay_rate > 0 и valid_until IS NULL. Эпизоды никогда не затухают — это иммутабельная аудит-линия.
Access reinforcement
Забывание — только половина картины. Когда факт востребован (возвращён поиском), его confidence получает бонус:
Чем чаще факт используется — тем медленнее он забывается. Это реализовано атомарным UPDATE:
UPDATE nodes SET access_count = access_count + 1, last_accessed = ?, confidence = MIN(1.0, confidence + ?) WHERE id = ? AND valid_until IS NULL
Защищённые факты
decay_rate = 0.0 означает, что confidence никогда не меняется. Устанавливается через инструмент confirm_fact: пользователь подтвердил факт — он навсегда в памяти. Например, имена сущностей - они не меняются.
10. Гибридный поиск с Reciprocal Rank Fusion
Теперь самое вкусное: как достаём знания. Четыре стратегии поиска, объединённые через RRF.
Почему не только вектора
Чисто векторный поиск плох для коротких фактов: «Виталий живёт в Вильнюсе» — 4 слова. Косинусное расстояние между этим и «Кто живёт в Литве?» может быть неожиданно большим. FTS5 с BM25 здесь точнее. Но FTS5 не понимает семантику. Графовый обход по рёбрам temporal или causal даёт хронологический и причинно-следственный контекст. Ни один метод не самодостаточен.
Четыре стратегии
Стратегия |
Когда |
Как |
|---|---|---|
FTS5 |
Всегда |
|
Vector (KNN) |
Когда embedding доступен |
|
Graph traversal |
По intent-маршруту |
Temporal/causal chain через рекурсивные CTE |
Entity |
Для «кто/что» запросов |
|
Intent-aware маршрутизация
Перед поиском запрос классифицируется на intent: why, when, who, what, general (+русскоязычные варианты). Да, не сильно универсально, но 40% случаев покрывает. Два классификатора за стратегическим интерфейсом:
EmbeddingIntentClassifier — cosine similarity против pre-embedded экземпляры на двух языках:
EXEMPLARS = { "why": [ "why did this happen", "what caused the failure", "почему это произошло", "в чем причина", ], "when": [ "when did we discuss", "timeline of events", "когда мы обсуждали", "хронология событий", ], "who": [ "who is responsible", "whose idea", "кто отвечает за", "чья это идея", ], "what": [ "what do you know about", "tell me everything about", "что ты знаешь о", "расскажи всё о", ], }
Exemplars embedded один раз при старте (с кэшированием в JSON-файл). Классификация — 28 cosine similarity за <2 мс. Порог: 0.45. Эмбеддинг запроса уже вычислен для векторного поиска — переиспользуем, ноль лишних вызовов API.
KeywordIntentClassifier — regex-фоллбэк, когда embedding недоступен:
def classify(self, query: str) -> str: q = query.strip().lower() if re.search(r'\b(why|cause|reason|because)\b', q): return 'why' if re.search(r'\b(when|after|before|timeline)\b', q): return 'when' if re.search(r'\b(who|whom|whose)\b', q): return 'who' if re.search(r'\b(what|which|everything about)\b', q): return 'what' return 'general'
Маршрутизация по intent
Intent |
Графовая стратегия |
Fallback |
|---|---|---|
|
Causal BFS (рекурсивный CTE по |
+ FTS5 + vector |
|
Temporal chain (вперёд + назад по |
+ FTS5 + vector |
|
Entity lookup → |
+ FTS5 + vector |
|
Нет графового обхода |
FTS5 + vector |
Пример рекурсивного CTE для каузального обхода
WITH RECURSIVE causal_chain(node_id, depth) AS ( SELECT source_id, 1 FROM edges WHERE target_id = ? AND relation_type = 'causal' AND valid_until IS NULL UNION ALL SELECT e.source_id, cc.depth + 1 FROM edges e JOIN causal_chain cc ON e.target_id = cc.node_id WHERE e.relation_type = 'causal' AND e.valid_until IS NULL AND cc.depth < 3 ) SELECT DISTINCT n.* FROM nodes n JOIN causal_chain cc ON n.id = cc.node_id WHERE n.valid_until IS NULL ORDER BY n.event_time DESC;
Глубина ограничена (2 для simple-запросов, 4 для complex). Индексы на relation_type, source_id, target_id делают обход быстрым.
RRF: слияние результатов
Три списка (FTS5, vector, graph) сливаются через Reciprocal Rank Fusion:
Где:
k = 60(стандартная константа RRF)wᵢ— вес метода (по умолчанию 1.0 для каждого)rankᵢ— позиция узла в i-м списке
Реализация
def _rrf_merge(self, fts_results, vec_results, limit, graph_results=None): scores: dict[str, float] = {} all_items: dict[str, dict] = {} for rank, item in enumerate(fts_results, 1): nid = item["id"] scores[nid] = scores.get(nid, 0) + self._w_fts / (self._k + rank) all_items.setdefault(nid, item) for rank, item in enumerate(vec_results, 1): nid = item["id"] scores[nid] = scores.get(nid, 0) + self._w_vec / (self._k + rank) all_items.setdefault(nid, item) if graph_results: for rank, item in enumerate(graph_results, 1): nid = item["id"] scores[nid] = scores.get(nid, 0) + self._w_graph / (self._k + rank) all_items.setdefault(nid, item) ranked = sorted(scores, key=scores.get, reverse=True)[:limit] return [all_items[nid] for nid in ranked]
RRF элегантен: не требует нормализации скоров между методами (BM25-скор несопоставим с L2-расстоянием). Он работает только с рангами, что делает слияние устойчивым и предсказуемым.
Адаптивная сложность
Запрос классифицируется на simple (< 10 слов, нет агрегирующих ключевых слов) и complex:
Сложность |
Token budget |
Лимит |
Глубина графа |
|---|---|---|---|
simple |
1000 |
5 |
2 |
complex |
3000 |
20 |
4 |
11. Контекстная инъекция: как память попадает в промпт
Расширение memory реализует протокол ContextProvider. Перед каждым вызовом агента ядро (Loader/MessageRouter) вызывает get_context(prompt):
ContextProvider.get_context(prompt) → classify_query_complexity(prompt) → embed_fn(prompt) [если embedding доступен] → intent_classifier.classify(prompt) → hybrid search: FTS5 + vector + graph → RRF fusion → assemble_context(results, token_budget) → return markdown или None
Контекст инжектится в system role через agent.clone(instructions=instructions + context), а не в user message. Это означает, что агент получает релевантные знания «из коробки», без явного вызова search_memory. Получаем сокращение вызова инструментов работы с памятью на порядок, а значит экономия на токенах и быстрые ответы.
Budget-based assembly
Результаты поиска распределяются по секциям с бюджетом:
Секция |
Доля бюджета |
Приоритет |
|---|---|---|
Facts |
40% |
Высший |
Entity profiles |
25% |
Высокий |
Temporal context |
25% |
Средний |
Evidence (provenance) |
10% |
Низкий |
Каждая секция обрезается по своему бюджету. Overflow отбрасывается начиная с нижнего приоритета. Дедупликация по нормализованному content предотвращает появление одного и того же факта дважды.
12. Matryoshka-эмбеддинги: компактность без катастрофической потери качества
Для векторного поиска я использую text-embedding-3-large от OpenAI с нативной поддержкой параметра dimensions для сокращения до 256 измерений (вместо штатных 3072). Локальные модели параметр dimensions не всегда поддерживают через LM Studio, приходится обрезать.
Почему это работает:
text-embedding-3-largeподдерживает Matryoshka Representation Learning: первые N измерений содержат наибольшую информативность. По данным OpenAI, даже сокращённая до 256 измерений версияtext-embedding-3-largeпревосходит несокращённыйtext-embedding-ada-002на бенчмарке MTEB. Это не гарантирует «95% от full» на произвольном датасете, но для коротких фактов и диалоговых фрагментов компромисс приемлемый.Хранение:
float32[256]= 1 КБ на узел (vs 12 КБ дляfloat32[3072]). В 12 раз компактнее.sqlite-vec работает с in-process данными — чем компактнее вектора, тем быстрее KNN.
Конфигурация — одна строка в manifest.yaml:
config: embedding_dimensions: 256
Batch embedding (embed_batch) сокращает I/O с ~200мс×N до ~300мс за один API-call для всего батча при консолидации.
13. Ночной pipeline: всё вместе
Каждую ночь в 03:00 (или чаще при желании) запускается полный maintenance:
execute_task("run_nightly_maintenance") │ ├─ 1. Консолидировать неконсолидированные сессии │ → Для каждой: write-path agent → факты, рёбра, сущности │ ├─ 2. Ebbinghaus decay + pruning │ → confidence × exp(−λ × days^0.8) │ → soft-delete если confidence < 0.05 │ ├─ 3. Entity enrichment │ → Сущности с ≥3 упоминаниями и без summary │ → LLM генерирует описание, re-embed │ └─ 4. Causal inference → Пары последовательных эпизодов (лимит: 50) → LLM: «A вызвало B?» → causal edge (confidence 0.7)
Causal inference — самая рискованная часть (LLM может галлюцинировать причинно-следственные связи). Поэтому:
Каузальные рёбра создаются с
confidence = 0.7(ниже пользовательских фактов с 1.0).Промпт требует явного языка причинности («because of that», «which led to»), а не просто временной последовательности.
Глубина каузального BFS ограничена 3 уровнями.
14. Инструменты оркестратора: что видит агент
Memory предоставляет 10 инструментов для основного агента:
Инструмент |
Описание |
|---|---|
|
Intent-aware гибридный поиск с фильтрами по типу, сущности, времени |
|
Явно сохранить факт с dedupliation (vector similarity > 0.92 → skip) |
|
Заменить устаревший факт: soft-delete + |
|
Защитить факт от decay: |
|
Soft-delete факта по запросу пользователя |
|
Профиль сущности: summary + связанные факты + timeline |
|
Хронологические события с фильтрами |
|
Метрики графа: узлы/рёбра по типам, сущности, orphans, размер БД |
|
Провенанс: source episodes, supersedes chain, linked entities |
|
Факты с низким confidence, которым скоро грозит decay |
explain_fact — особенно интересный: когда пользователь спрашивает «Откуда ты это знаешь?», агент проходит по derived_from рёбрам до исходных эпизодов (диалогов), из которых был извлечён факт. Полная прозрачность.
15. Graceful degradation: работает даже без LLM
Каждый слой деградирует независимо:
Компонент |
Если недоступен |
Фоллбэк |
|---|---|---|
|
Нет vector search |
FTS5 keyword search + entity lookup |
|
Нет ANN-индекса |
FTS5 + entity lookup |
LLM для write-path |
Нет консолидации |
Hot path записывает эпизоды; regex entities; decay работает |
|
Нет event-driven консолидации |
Детекция по |
С минимальной конфигурацией (только SQLite + FTS5, без LLM, без embeddings) агент всё равно:
записывает все диалоги как эпизоды,
индексирует их через FTS5,
строит temporal-цепочки,
отвечает на
search_memoryчерез keyword search.
Каждый дополнительный слой (embeddings → vector search → write-path agent → causal inference) добавляет качество, но не является обязательным.
16. Ограничения и когда так делать не надо
Статья была бы нечестной без этого раздела. Про слабые места:
Текущее решение ориентировано в первую очередь на использование OpenAI Agents SDK и моделей OpenAI (нужен рабочий API ключ). Я пробовал работать с локальными моделями, но мой RTX 4070Ti 12 Гб тянет далеко не всё, а то, что тянет - ну очень слабое. В решении заложено использование других провайдеров, но их надо дорабатывать и проверять.
Решение создается с использованием Cursor, много кода пишет агент. Но цикл разработки гораздо сложнее "напиши мне память": поиск идей, анализ научных материалов, компиляция в ADR, множество итераций по планированию реализации, поэтапная реализация, множественные проверки результата, рефакторинг. Я отдельно писал коммент про свой цикл разработки с ИИ агентами.
sqlite-vec — pre-v1. Использую sqlite-vec для KNN-поиска. Проект развивается, но на момент написания находится в статусе pre-v1: возможны breaking changes в API и формате хранения. Для production с жёсткими требованиями к стабильности это риск. Наш mitigation: если sqlite-vec недоступен, система деградирует до FTS5-only без потери функциональности. Рассматриваю Vectorlite или полная замена SQLite на Turso.
WAL и сетевые FS. SQLite WAL использует shared memory (-shm файл) и не предназначен для сетевых файловых систем (NFS, SMB). Для моего случая (локальный агент, один процесс) это не проблема, но в Docker с монтированием сетевого тома могут быть проблемы.
Качество каузального инференса. Causal edges — самая «галлюционогенная» часть системы. LLM анализирует пары эпизодов и решает, есть ли причинно-следственная связь. Даже с жёстким промптом («только явный язык причинности») ложноположительные рёбра неизбежны. Решение: confidence 0.7 (ниже пользовательских фактов), ограниченная глубина BFS, промпт запрещает спекуляции. Но это не решает проблему полностью.
Точность intent-классификации. Embedding-классификатор работает хорошо для чётких запросов («Почему проект провалился?»), но на размытых вопросах («Расскажи про ситуацию с проектом») может ошибиться с маршрутизацией. Fallback на general (FTS5 + vector без графа) спасает от полного промаха, но граф-специфические стратегии в таких случаях не применяются.
Рост эпизодической базы. Эпизоды не затухают — это иммутабельная аудит-линия. При активном использовании (50K-100K эпизодов за год) FTS5 всё ещё работает за доли секунды, но размер базы растёт линейно. Пока не реализовал архивацию старых эпизодов — это в планах.
Matryoshka 256 dims — компромисс. Сознательный выбор 256 вместо 3072 ради компактности и скорости. На коротких фактах («User lives in Berlin») разница с full-size эмбеддингами минимальна. На длинных или нюансных текстах может быть заметнее. Если вашему решению критична семантическая точность — стоит протестировать с 512 или 1024. Альтернатива - модели с меньшим dimension (обратите внимание на text-embedding-jina-embeddings-v5-text-small-retrieval - 1024 dim).
17. Цифры и итоги
Характеристики системы (конкретные latency зависят от железа, размера базы и модели):
Метрика |
Значение |
Примечание |
|---|---|---|
Hot path latency |
десятки мс |
Fire-and-forget write + FTS5 trigger, без LLM |
Slow path (embedding) |
~200 мс |
Async, не блокирует UI; зависит от API latency |
Context injection |
< 200 мс |
FTS5 + vector + RRF; zero LLM на read path |
Vector dimensions |
256 |
Matryoshka reduction от |
Storage per node |
1 KB (embedding) |
|
DB file |
Один |
SQLite, WAL mode |
External dependencies |
Ноль |
Нет Redis, Postgres, Pinecone |
Intent classification |
~2 мс |
28 cosine similarities; pre-embedded exemplars |
Graph depth |
2—4 |
Adaptive: simple queries → 2, complex → 4 |
Какие итоги эксперимента
SQLite — достаточно для памяти single-user агента, если правильно спроектировать схему. FTS5, sqlite-vec, рекурсивные CTE — полнотекстовый, векторный и графовый поиск в одном файле.
LLM on Write, Algorithms on Read — ключевой принцип. LLM работает только при консолидации (фоново, дёшево). Read path — чистые алгоритмы: детерминированный, быстрый, отлаживаемый.
Граф лучше плоской таблицы для долгоживущего агента.
supersedes,derived_from,temporal,causal— каждый тип рёбер несёт информацию, которую нельзя получить из эмбеддингов.Забывание — это фича, а не баг. Кривая Эббингауза с access reinforcement — простой и элегантный механизм: важные факты «защищаются» через частое использование, неважные плавно вымываются.
RRF элегантнее нормализации. Три метода поиска с несопоставимыми скорами? RRF работает только с рангами — не нужно ничего нормализовать.
Ссылки
Zep/Graphiti — temporal knowledge graph, bi-temporal model, −90% latency vs MemGPT
Mem0 — vector + graph, LLM-driven write-path, +26% over OpenAI Memory
MAGMA — 4 orthogonal graphs, adaptive traversal, dual-stream evolution
sqlite-vec — vector search extension for SQLite
OpenAI Matryoshka embeddings — нативная поддержка
dimensionsвtext-embedding-3-*SQLite FTS5 External Content Tables — паттерн с триггерами
Код модуля памяти лежит в sandbox/extensions/memory/ репозитория Yodoca . Буду рад любой обратной связи как по памяти, так и по проекту.
Материал создан руками, отредактирован и поправлен с помощью агента.
Комментарии (14)

funca
04.03.2026 19:21Спасибо, очень крутая работа. Расскажите, как вы тестируете. Какие критерии, что и с чем сравниваете?

VitalyOborin Автор
04.03.2026 19:21Кроме обычных юнит-тестов есть ручные end-to-end сценарии. Память приходится часто сбрасывать в ноль и заполнять пошагово фактами, конфликтными фактами, проверять на edge cases. Пока вручную, хочу автоматизировать с готовыми эмбеддингами. Кроме понятных простых сценариев есть те, которые показывают преимущества такой архитектуры памяти:
1. Дается несколько последовательных факта в разных сообщениях, затем агент должен восстановить их хронологию, а не просто выдать все три факта пачкой. Это проверка temporal ребер.
2. Дается большим набором сразу куча фактов - агент должен сохранить их как отдельные. Затем дается конфликтный факт - в памяти должен поменяться только 1 факт. Это проверка supersedes.
3. Агенту дается цепочка последовательных фактов вида: "прод упал", потом "причина в в памяти - повысили лимит", потом в новой сессии "почему мы повысили лимит памяти?" - агент должен назвать корневую причину "потому что прод упал". Это графовый обход по causal ребру, он может быть многошаговым.
4. Проверка сущности и ее связей - это может быть человек, проект, что угодно. В разных сессиях дается разная информация об одной сущности, при этом с разными видами упоминаний вида "Алексей, Леша, Леха, Мой руководитель". Например, "Я вчера с Лехой играл в шахматы", "Алексей мой начальник", "Босс поручил мне проект B2B". В памяти собираются связи с сущностью и при запросе сущности должна выводиться вся связанная информация. "Кто такой Алеша?". Ответ должен быть вида "Это твой руководитель, ты с ним играл в шахматы и он тебе поручил проект B2B".
Ключевой критерий производительности - на сколько механизм памяти предугадывает контекст. Агенту по сути обращаться к поиску в памяти надо только в крайнем случае, у него в системном промпте с помощью механизма Context Injection уже есть что-то полезное и в 80-90% случаев этого хватает для ответов.
rPman
04.03.2026 19:21К сожалению проблема не в фактах и не в их пониманием моделью, когда они в контекстном окне, а в поиске нужных среди большого их объема и составлении индексов.
Максимально верный способ поиска нужного факта, это когда к текущему контексту беседы (можно использовать саморизацию), добавляется следующий факт из базы по очереди, с вопросом - нужен ли он на текущий момент, с ответом да/нет, затем все нужные факты подсовываются в основной контекст беседы.
Дорого, медленно..
Есть RAG, но, к сожалению, делает это хоть и быстрее на пару порядков, но хуже и главное, лишний мусор... т.е. в вашем примере вместе с фактами о том что леша руководитель, вылезут анекдоты про Алешу, факты про руководителя соседнего отдела, а факт про порученный проект B2B затеряется в списке проектов, которые босс поручал леше, его коллегам, или просто чем занимается компания, где работает Леша... потому что не существует красивого способа остановиться и переставить валить в общую кучу факты из индекса RAG.

coregabe
04.03.2026 19:21Очень похожую схему реализовывал на Neo4j. Намного проще получилось

VitalyOborin Автор
04.03.2026 19:21да, с графовой БД всё сильно проще, но одна из целей была минимум зависимостей для локального однопользовательского агента. Мне зашел Memgraph - неплохая альтернатива Neo4j.

Joolg
04.03.2026 19:21Neo4j имеет смысл брать, если у тебя количество связей переваливает за миллионы и глубина поиска уходит дальше 5-6 прыжков

Xexa
04.03.2026 19:21Могу раз в сутки, потому в одном комментарии.
Статья интересная. Многое не понял(не мой профиль), но заставило местами пояндексить и почитать глубже. Интересно.
Про neo4j мнение, не критики ради, а мнение моё субъективное.
Может на текущий момент всё иначе(сомневаюсь). Кто-нибудь сравнивал скорость работы в одинаковых условиях neo4j(или иной графовой) с обычной реляционкой?
В году 16 в контору пришёл новый тимлид с пачкой сертификатов и сразу начал продвигать Neo4j как таблетку от всех наших бед. Мы исторически сидели на msmsql как основной СУБД
Я скептически отнёсся к этой теме и мы с коллегой(ну чтобы мой субъективизм кто-то придержал и быстрее тестовую среду создать) для интереса провели эксперимент.
1) Две БД, neo4j и mssql, развёрнутые из коробки методом далее, далее, далее
2) В mssql две таблицы [сущность] и [связь сущности с другой сущностью] + расширенная процедура с алгоритмом Дейкстры на C#(максимально т.е не оптимально по вызовам)
3) Данные одни и те же залиты в обе БД.
Запросы одинаковые c разной глубиной между объектами "Показать связь между объектом A и X".
neo4j стабильно проигрывала по скорости в разы. На тот момент это было ожидаемо, т.к любой граф, ложится на 2 таблицы, а уж математическая скорость работы в реляционных БД рассчитана, доказана и за десятилетия вылизана по скорости. Что могли предложить этому "молодые" СУБД тех лет - не понятно.
ЗЫ: "но в neo4j можно навешать свойства на связи". Так и в том решении на 2-х таблицах навешать свойства на связи и объекты можно. Вопрос это уже архитектурный от задачи.

SunRiseX64
04.03.2026 19:21Я правильно понимаю, что "долгоживущий" агент, как отправная точка, выбран потому что нужна высокая скорость ответа и ожидание загрузки модели при запуске нового агента нужно минимизировать?
Просто если нет, тогда непонятно, что мешает перезапустить агента и скормить ему conventions.md ну или данные из rag-базы?

SabMakc
04.03.2026 19:21Да, в SQLite однопоточная запись. Но если установить busyTimeout - то попытка записи будет ждать указанное время своей очереди (хорошо работает с WAL-режимом).
Так что отправлять все записи в одну очередь нет особого смысла.
Единственное - транзакцию на запись рекомендую начинать какBEGIN IMMEDIATE- иначе запись начнется в момент реальной записи и есть риск получить ошибкуSQLITE_BUSY.
SabMakc
04.03.2026 19:21P.S. впрочем, писать в БД через какой-нибудь примитив синхронизации потоков надежнее будет, чем busyTimeout.

Joolg
04.03.2026 19:21Спасибо! Особенно ценен кусок про единую очередь записи, потому что уже на ступал на грабли с конкурентной записью в WAL-режиме SQLite при асинхронной работе с агентами)

rocoss
04.03.2026 19:21Мне очень понравилась ваша статья, хотел бы уточнить один пару моментов, а именно увидел, что в Вашей архитектуре разрешение конфликтов реализовано через LLM-консолидацию на запись (supersedes-рёбра + soft-delete), а не через pre-load детектирование противоречий на уровне векторного сходства, как в некоторых GraphRAG-подходах. Как вы обеспечиваете семантическую точность при слиянии фактов из разнородных источников (например, клинические рекомендации с разным уровнем доказательности), если: (1) векторный поиск использует сокращённые Matryoshka-эмбеддинги (256 dim), которые могут терять нюансы для коротких фактов, (2) гибридный RRF-фьюжн объединяет результаты с несопоставимыми скорами (BM25 vs. cosine vs. graph rank), и (3) механизм Эббингауза ускоряет «забывание» старых фактов, что может преждевременно удалить важный, но редко используемый контекст? Есть ли в вашей схеме нативный способ взвешивать рёбра по уровню доверия к источнику (аналог GRADE), или это требует кастомной логики поверх write-path агента - и как это влияет на latency при масштабировании до сотен тысяч узлов в одном SQLite-файле?

VitalyOborin Автор
04.03.2026 19:21Векторный поиск, матрёшка и RRF скорее как механизм подбора кандидатов, а не как финальный выбор истины. Они полезны для поиска, но не для того, чтобы автоматически понять, какой из конфликтующих фактов правильнее. В таких случаях я бы скорее опирался на источник, актуальность и отдельные правила приоритета. Ну и “забывание” может оказаться вредным, если применять его ко всему одинаково. Важные факты, особенно из сильных источников, лучше держать отдельно и не давать им пропадать просто потому, что к ним давно не обращались. Но это всё уже как развитие. При работе агента я уже сталкиваюсь с разной "ценностью" обсуждаемой информации. Есть беседа пользователя с агентом, а есть новости или научные материалы, найденные в интернете. Ценность источника везде разная, а устаревание и модификация проходят одинаково. Это уже как раз тонкая настройка алгоритмов, это уже следующий слой системы - не столько retrieval, сколько политика работы со знанием и памятью.
normal
да, очень круто. Хабр все же иногда еще торт!