Научил ИИ-агента помнить важное и забывать лишнее в SQLite. ai.. ai. ai agent.. ai. ai agent. embedding.. ai. ai agent. embedding. python.. ai. ai agent. embedding. python. SQLite.. ai. ai agent. embedding. python. SQLite. vector.. ai. ai agent. embedding. python. SQLite. vector. базы данных.. ai. ai agent. embedding. python. SQLite. vector. базы данных. ИИ.. ai. ai agent. embedding. python. SQLite. vector. базы данных. ИИ. ии-агенты.. ai. ai agent. embedding. python. SQLite. vector. базы данных. ИИ. ии-агенты. искусственный интеллект.. ai. ai agent. embedding. python. SQLite. vector. базы данных. ИИ. ии-агенты. искусственный интеллект. память.

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
Научил ИИ-агента помнить важное и забывать лишнее в SQLite - 1

Это исследовательский проект, в которое проверяются различные архитектурные подходы. Кроме памяти в нем интересное:

  • 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 SQLiteSession — передаётся в Runner.run()

Долгосрочная память

Факты, эпизоды, процедуры, мнения — всё, что переживает рестарт

Расширение memory — графовая БД на SQLite

Сессионная память — это рабочий буфер: она хранит историю текущего диалога и очищается при смене сессии (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 '{}'
);
Научил ИИ-агента помнить важное и забывать лишнее в SQLite - 2

Четыре типа узлов отражают когнитивные категории с разным жизненным циклом:

Тип

Кто создаёт

Описание

Decay

episodic

Hot path (каждое сообщение)

Сырой диалог. Иммутабельная аудит-линия

Никогда не затухает

semantic

Консолидатор / orchestrator

Факты: «Виталий предпочитает тёмную тему»

Подвержен Эббингаузу

procedural

Консолидатор

Паттерны действий: «Для деплоя запустить X, потом Y»

Подвержен Эббингаузу

opinion

Консолидатор

Субъективные оценки: «Инструмент 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
);
Научил ИИ-агента помнить важное и забывать лишнее в SQLite - 3

Пять типов рёбер — каждый несёт информацию, которую нельзя получить из эмбеддингов:

Тип

Связывает

Назначение

Кто создаёт

temporal

Эпизод → Эпизод

Хронологическая цепочка

Hot path (автоматически)

causal

Эпизод → Эпизод

Причинно-следственная связь

LLM-инференс (ночной)

entity

Узел → Сущность

Привязка к именованной сущности

Write-path агент

derived_from

Факт → Эпизод

Провенанс: «факт извлечён из этих диалогов»

Консолидатор

supersedes

Новый факт → Старый

Эволюция знаний: «заменяет устаревший факт»

correct_fact / консолидатор

Таблица 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 '{}'
);
Научил ИИ-агента помнить важное и забывать лишнее в SQLite - 4

Без сущностей «Витя», «Виталий» и «мой руководитель» — три разных человека в памяти. 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]
);
Научил ИИ-агента помнить важное и забывать лишнее в SQLite - 5

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)
Научил ИИ-агента помнить важное и забывать лишнее в SQLite - 6
В коде это выглядит так
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))
Научил ИИ-агента помнить важное и забывать лишнее в SQLite - 7

Обратите внимание: 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      │
                    └───────────────────────┘
Научил ИИ-агента помнить важное и забывать лишнее в SQLite - 8

Каждая 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.

Когда запускается консолидация

Три триггера:

  1. Смена сессии — когда пользователь начинает новый разговор (или прошло 30 минут неактивности), MessageRouter ротирует session_id и публикует session.completed в EventBus. Memory подписан на это событие.

  2. Детекция в hot path — если в user_message пришёл новый session_id, Memory запускает консолидацию старой сессии как asyncio.create_task.

  3. Ночной cron (ежедневно в 03:00) — проходит по всем неконсолидированным сессиям.

Write-path агент

Консолидатор — это приватный LLM-агент (дешёвая модель вроде gpt-5-mini или или локальная) внутри memory-расширения. Он не виден Оркестратору и не добавляется в его список инструментов. У него свой набор:

Инструмент

Описание

is_session_consolidated

Проверка идемпотентности

get_session_episodes

Загрузка эпизодов сессии (пагинация)

save_nodes_batch

Сохранение извлечённых фактов + derived_from рёбра + batch embedding

extract_and_link_entities

NER + резолвинг сущностей

detect_conflicts

Поиск противоречий через гибридный поиск

resolve_conflict

Soft-delete старого факта + supersedes ребро

mark_session_consolidated

Отметить сессию как обработанную

Вот как выглядит 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
Научил ИИ-агента помнить важное и забывать лишнее в SQLite - 9

Идемпотентность: Если консолидация прервалась между шагами 2-8, сессия остаётся неконсолидированной. Следующий запуск (ночной cron или retry) перезапустит с шага 1, увидит consolidated = false и переработает. Частичные результаты (сохранённые узлы) безвредны — дедупликация ловит дубли.

Стоимость: На дешёвой модели (gpt-5-mini: $0.25/1M input, $2/1M output на момент написания) одна консолидация ~30 эпизодов обходится меньше цента (~3K input + ~500 output токенов). С локальной моделью — бесплатно.


8. Разрешение конфликтов: эволюция знаний

Вот где начинается самое интересное. Пользователь полгода назад сказал «Я работаю в компании А». Сегодня: «Я перешёл в компанию Б». Классический RAG выдаст оба факта — и агент запутается.

В моём решении:

  1. Консолидатор извлекает новый факт: «Пользователь работает в компании Б».

  2. detect_conflicts находит старый факт через гибридный поиск (по тексту и вектору).

  3. Консолидатор (LLM) решает, что факты противоречат друг другу.

  4. 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",
        ...
    })
Научил ИИ-агента помнить важное и забывать лишнее в SQLite - 10

Результат: старый факт помечен как неактуальный (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
    ...
Научил ИИ-агента помнить важное и забывать лишнее в SQLite - 11

9. Кривая забывания Эббингауза

Ещё одна фича, которую мало кто реализует. Человек забывает — и агент тоже должен. Без забывания контекст переполняется неактуальной информацией.

Формула

confidence(t) = confidence₀ × exp(−λ × (t − t_last_access)^0.8)
Научил ИИ-агента помнить важное и забывать лишнее в SQLite - 12

Где:

  • λ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)}
Научил ИИ-агента помнить важное и забывать лишнее в SQLite - 13

get_decayable_nodes() выбирает только неэпизодические узлы с decay_rate > 0 и valid_until IS NULL. Эпизоды никогда не затухают — это иммутабельная аудит-линия.

Access reinforcement

Забывание — только половина картины. Когда факт востребован (возвращён поиском), его confidence получает бонус:

Δ=0.05 × log(1 + accesscount / 20)

Чем чаще факт используется — тем медленнее он забывается. Это реализовано атомарным UPDATE:

UPDATE nodes
SET access_count = access_count + 1,
    last_accessed = ?,
    confidence = MIN(1.0, confidence + ?)
WHERE id = ? AND valid_until IS NULL
Научил ИИ-агента помнить важное и забывать лишнее в SQLite - 15

Защищённые факты

decay_rate = 0.0 означает, что confidence никогда не меняется. Устанавливается через инструмент confirm_fact: пользователь подтвердил факт — он навсегда в памяти. Например, имена сущностей – они не меняются.


10. Гибридный поиск с Reciprocal Rank Fusion

Теперь самое вкусное: как достаём знания. Четыре стратегии поиска, объединённые через RRF.

Почему не только вектора

Чисто векторный поиск плох для коротких фактов: «Виталий живёт в Вильнюсе» — 4 слова. Косинусное расстояние между этим и «Кто живёт в Литве?» может быть неожиданно большим. FTS5 с BM25 здесь точнее. Но FTS5 не понимает семантику. Графовый обход по рёбрам temporal или causal даёт хронологический и причинно-следственный контекст. Ни один метод не самодостаточен.

Четыре стратегии

Стратегия

Когда

Как

FTS5

Всегда

nodes_fts MATCH ?, ранжировано BM25

Vector (KNN)

Когда embedding доступен

vec_nodes KNN через sqlite-vec

Graph traversal

По intent-маршруту

Temporal/causal chain через рекурсивные CTE

Entity

Для «кто/что» запросов

node_entities JOIN

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",
        "что ты знаешь о", "расскажи всё о",
    ],
}
Научил ИИ-агента помнить важное и забывать лишнее в SQLite - 16

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'
Научил ИИ-агента помнить важное и забывать лишнее в SQLite - 17

Маршрутизация по intent

Intent

Графовая стратегия

Fallback

why

Causal BFS (рекурсивный CTE по causal рёбрам)

+ FTS5 + vector

when

Temporal chain (вперёд + назад по temporal рёбрам)

+ FTS5 + vector

who/what

Entity lookup → node_entities JOIN

+ FTS5 + vector

general

Нет графового обхода

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;
Научил ИИ-агента помнить важное и забывать лишнее в SQLite - 18

Глубина ограничена (2 для simple-запросов, 4 для complex). Индексы на relation_type, source_id, target_id делают обход быстрым.

RRF: слияние результатов

Три списка (FTS5, vector, graph) сливаются через Reciprocal Rank Fusion:

Score(node)=Σ wᵢ / (k + rankᵢ)

Где:

  • 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]
Научил ИИ-агента помнить важное и забывать лишнее в SQLite - 20

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
Научил ИИ-агента помнить важное и забывать лишнее в SQLite - 21

Контекст инжектится в 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
Научил ИИ-агента помнить важное и забывать лишнее в SQLite - 22

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)
Научил ИИ-агента помнить важное и забывать лишнее в SQLite - 23

Causal inference — самая рискованная часть (LLM может галлюцинировать причинно-следственные связи). Поэтому:

  • Каузальные рёбра создаются с confidence = 0.7 (ниже пользовательских фактов с 1.0).

  • Промпт требует явного языка причинности («because of that», «which led to»), а не просто временной последовательности.

  • Глубина каузального BFS ограничена 3 уровнями.


14. Инструменты оркестратора: что видит агент

Memory предоставляет 10 инструментов для основного агента:

Инструмент

Описание

search_memory

Intent-aware гибридный поиск с фильтрами по типу, сущности, времени

remember_fact

Явно сохранить факт с dedupliation (vector similarity > 0.92 → skip)

correct_fact

Заменить устаревший факт: soft-delete + supersedes edge

confirm_fact

Защитить факт от decay: confidence=1.0, decay_rate=0.0

forget_fact

Soft-delete факта по запросу пользователя

get_entity_info

Профиль сущности: summary + связанные факты + timeline

get_timeline

Хронологические события с фильтрами

memory_stats

Метрики графа: узлы/рёбра по типам, сущности, orphans, размер БД

explain_fact

Провенанс: source episodes, supersedes chain, linked entities

weak_facts

Факты с низким confidence, которым скоро грозит decay

explain_fact — особенно интересный: когда пользователь спрашивает «Откуда ты это знаешь?», агент проходит по derived_from рёбрам до исходных эпизодов (диалогов), из которых был извлечён факт. Полная прозрачность.


15. Graceful degradation: работает даже без LLM

Каждый слой деградирует независимо:

Компонент

Если недоступен

Фоллбэк

embedding extension

Нет vector search

FTS5 keyword search + entity lookup

sqlite-vec

Нет ANN-индекса

FTS5 + entity lookup

LLM для write-path

Нет консолидации

Hot path записывает эпизоды; regex entities; decay работает

session.completed event

Нет event-driven консолидации

Детекция по session_id diff + ночной cron

С минимальной конфигурацией (только 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 от text-embedding-3-large

Storage per node

1 KB (embedding)

float32[256]; тело узла — дополнительно

DB file

Один memory.db

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

Какие итоги эксперимента

  1. SQLite — достаточно для памяти single-user агента, если правильно спроектировать схему. FTS5, sqlite-vec, рекурсивные CTE — полнотекстовый, векторный и графовый поиск в одном файле.

  2. LLM on Write, Algorithms on Read — ключевой принцип. LLM работает только при консолидации (фоново, дёшево). Read path — чистые алгоритмы: детерминированный, быстрый, отлаживаемый.

  3. Граф лучше плоской таблицы для долгоживущего агента. supersedes, derived_from, temporal, causal — каждый тип рёбер несёт информацию, которую нельзя получить из эмбеддингов.

  4. Забывание — это фича, а не баг. Кривая Эббингауза с access reinforcement — простой и элегантный механизм: важные факты «защищаются» через частое использование, неважные плавно вымываются.

  5. RRF элегантнее нормализации. Три метода поиска с несопоставимыми скорами? RRF работает только с рангами — не нужно ничего нормализовать.


Ссылки


Код модуля памяти лежит в sandbox/extensions/memory/ репозитория Yodoca . Буду рад любой обратной связи как по памяти, так и по проекту.
Материал создан руками, отредактирован и поправлен с помощью агента.

Автор: VitalyOborin

Источник

Rambler's Top100