Голографическая память для AI-агента на Potato VPS

Как запустить гибридный поиск (FTS5 + Jaccard + HRR + fastembed MiniLM-L12-v2) на дешёвом VPS. Lazy-loading модели, ~680 MB RSS, выгрузка при shutdown. Почему не ChromaDB и не Pinecone — а свой плагин на SQLite.

Голографическая память для AI-агента на Potato VPS

У меня есть так называемый Potato VPS — дешёвый сервер с минимумом оперативки. На нём крутится AI-агент — Hermes — который должен помнить контекст между сессиями: факты о пользователях, конфигурации проектов, предпочтения, решения. Не просто «записать в файл и grep'нуть», а смысловой поиск с пониманием перефразировок и мультиязычности.

Проблема: ChromaDB жрёт 400 МБ просто на старте. Pinecone — это SaaS, а я хочу локально. FAISS — без индексации по ключевым словам. Мне нужен гибрид: полнотекстовый поиск + векторная семантика + композиционная алгебра. И всё это на скромном сервере, включая саму модель эмбеддингов.

Решение — плагин holographic-memory, четыре стратегии поиска в одном SQLite-файле. Вот как это устроено и почему.

Архитектура: четыре стратегии, один запрос

Гибридный скоринг — не «векторный поиск с fallback на FTS», а четыре независимых канала, результаты которых складываются с весами:

СтратегияВесЧто делаетТехнология
FTS50.3Кандидаты по ключевым словам (BM25)SQLite FTS5
Jaccard0.2Пересечение токеновPython sets
HRR0.2Композиционная алгебра (probe/related/reason)SHA-256 phase vectors, 1024d
Semantic0.3Смысловое сходствоfastembed MiniLM-L12-v2, 384d

Финальный скор: relevance × trust_score × temporal_decay, где relevance = fts×0.3 + jaccard×0.2 + hrr×0.2 + semantic×0.3.

Зачем четыре, если можно одним векторным поиском обойтись? Потому что векторный поиск плохо работает на коротких точных запросах («порядок деплоя nginx»), а FTS5 не понимает перефразировок («как выкатить nginx на прод»). HRR даёт алгебраические операции — probe по сущности, связь между фактами, мульти-сущностные JOIN'ы. Jaccard — дешёвый фильтр мусора.

Запуск поиска: пайплайн

1. FTS5 MATCH → limit×3 кандидатов (AND-семантика: все термы обязательны)
2. Если FTS5 пустой + semantic доступен → _semantic_candidates() (полный cosine scan)
3. Предвычисление эмбеддинга запроса (~46 мс)
4. Реранжирование: relevance = fts×0.3 + jaccard×0.2 + hrr×0.2 + semantic×0.3
5. Финал: score = relevance × trust × temporal_decay

Ключевой момент: если FTS5 возвращает 0 (запрос на русском, а факты записаны по-английски, или перефразировка), пайплайн автоматически переключается на чистый семантический поиск. Semantic — не роскошь, а load-bearing компонент для мультиязычности.

Почему не готовые решения

ChromaDB

Два процесса (Chroma + Hermes), ~800 МБ до того, как агент хоть что-то запомнил. На Potato VPS — это половина RAM. Плюс Chroma тянет за собой HNSW, который строит индекс в памяти. Для фактов (сотни, может тысячи записей) — это стрельба из пушки по воробьям.

Pinecone

SaaS. Требует API-ключ, интернет, и доверие к третьей стороне с данными агента. Для хобби-проекта на дешёвом VPS — overkill и vendor lock-in.

FAISS

Отличная библиотека для векторного поиска, но без полнотекстовой индексации. Придётся поверх городить FTS5 отдельно. А если уж использовать SQLite и для текста, и для векторов — зачем FAISS?

Своё решение

SQLite + FTS5 + WAL — это уже есть в Python stdlib (sqlite3). Добавляем fastembed для эмбеддингов и numpy для HRR-алгебры. Один файл базы, один процесс, zero infrastructure.

Выбор модели: mpnet vs MiniLM

Я тестировал две модели multilingual fastembed:

mpnet-base-v2MiniLM-L12-v2
Размерность768384
RSS (загружена)1440 МБ680 МБ
RSS (residual после выгрузки)693 МБ481 МБ
Время эмбеддинга65 мс46 мс
Перезагрузка после выгрузки20–25 с1.33 с
sim("компактный формат" ↔ "лаконичные сообщения")0.6250.511
sim("компактный формат" ↔ "погода для прогулки")0.2250.008

MiniLM: 680 МБ RSS, 481 МБ residual. mpnet: 1440 МБ RSS, 693 МБ residual. На Potato VPS mpnet не влезает — после загрузки модели + Hermes + системы остаётся ~300 МБ, и OOM-killer стучится.

MiniLM даёт 0.511 для семантически схожих фраз и 0.008 для несвязанных — separation достаточная. Не идеальная (mpnet лучше на ~20%), но работоспособная.

Residual — это ONNX Runtime, который не освобождает пуллы памяти даже после del model + gc.collect(). 481 МБ — плата за один вызов fastembed за жизнь процесса. Поэтому стратегия: lazy-load при первом запросе, держать в памяти до shutdown.

Lazy-loading и lifecycle модели

_model = None
_available: Optional[bool] = None

def is_available() -> bool:
    """Проверка без загрузки модели."""
    if _available is not None:
        return _available
    try:
        import fastembed
        _available = True
    except ImportError:
        _available = False
    return _available

def _get_model():
    """Lazy-load при первом вызове embed_text()."""
    global _model
    if _model is None:
        from fastembed import TextEmbedding
        _model = TextEmbedding("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
    return _model

def embed_text(text: str) -> np.ndarray:
    model = _get_model()
    vec = list(model.embed([text]))[0]
    return vec / np.linalg.norm(vec)  # normalize → unit vector

Первый вызов embed_text() — ~1.3 с (загрузка ONNX-модели). Все последующие — ~46 мс. Модель живёт в памяти до shutdown.

Выгрузка при shutdown

# В __init__.py плагина, на shutdown hook:
import embedder, gc

def on_shutdown():
    embedder._model = None
    gc.collect()
    # Освобождает ~200 МБ (веса модели), но ONNX residual (481 МБ) остаётся

На практике: после shutdown RSS падает с ~1.1 ГБ до ~620 МБ. ONNX Runtime не отдаёт память — это известная особенность. 620 МБ — рабочий baseline для Potato VPS.

Хранение: два вектора на факт

Каждый факт в SQLite хранит два вектора:

CREATE TABLE facts (
    fact_id INTEGER PRIMARY KEY,
    content TEXT NOT NULL,
    category TEXT DEFAULT 'general',
    tags TEXT DEFAULT '',
    trust_score REAL DEFAULT 0.5,
    retrieval_count INTEGER DEFAULT 0,
    helpful_count INTEGER DEFAULT 0,
    hrr_vector BLOB,        -- 1024 × float64 = 8192 bytes
    semantic_vector BLOB,   -- 384 × float32 = 1536 bytes
    created_at TEXT,
    updated_at TEXT
);

HRR-вектор (8 КБ на факт) — SHA-256 phase encoding. Токены контента → бандл атомов → bind с ROLE_CONTENT и ROLE_ENTITY. Используется для алгебраических операций: probe («все факты про X»), related («что связано с X»), reason («что общего у X, Y, Z»). Работает на numpy, без модели.

Semantic-вектор (1.5 КБ на факт) — выход fastembed. Нормализованный unit vector для cosine similarity. Используется в гибридном скоринге.

Итого: ~9.7 КБ на факт. Для 1000 фактов — ~10 МБ. Пустяк.

HRR: композиционная алгебра без нейросетей

HRR (Holographic Reduced Representations) — это способ кодировать структуру в фиксированный вектор. Вместо обучения — SHA-256 хеши от токенов, преобразованные в phase vectors.

def encode_atom(token: str, dim: int = 1024) -> np.ndarray:
    """Токен → единичный вектор в phase space."""
    h = hashlib.sha256(token.encode()).digest()
    rng = np.frombuffer(h, dtype=np.uint8).astype(np.float64)
    # Фазовый код: cos + j*sin → unit vector в комплексном пространстве
    phases = rng[:dim] / 255.0 * 2 * np.pi
    return np.cos(phases) + 1j * np.sin(phases)

def bind(a, b):
    """Связывание: circular convolution в frequency domain."""
    return np.fft.ifft(np.fft.fft(a) * np.fft.fft(b))

def bundle(vectors):
    """Бандлинг: поэлементная сумма + нормализация."""
    result = np.sum(vectors, axis=0)
    return result / np.linalg.norm(result)

Зачем это нужно, если есть fastembed? Потому что HRR поддерживает алгебраические операции, которые векторные модели не умеют:

  • probe(entity) — «дай все факты, привязанные к сущности X». Bind/unbind с entity-атомом.
  • related(entity) — «что структурно соседствует с X». Через HRR similarity.
  • reason(e1, e2, e3) — «что общего у нескольких сущностей». Multi-entity JOIN, min(scores).

Semantic-модель даёт «похожесть по смыслу», HRR — «связанность по структуре». Это разные оси.

Jaccard: дешёвый фильтр

Jaccard — пересечение токенов запроса и факта, поделённое на объединение. Стоимость: O(n) по токенам, ноль выделений памяти. Работает как грубый фильтр: если запрос и факт не пересекаются ни одним токеном — штраф.

def jaccard(query_tokens: set, fact_tokens: set) -> float:
    if not query_tokens or not fact_tokens:
        return 0.0
    return len(query_tokens & fact_tokens) / len(query_tokens | fact_tokens)

Вес 0.2 — не главный канал, но отсекает шум. На практике: запрос «деплой nginx» и факт «настройка DNS» получают jaccard=0, и это правильно.

FTS5: полнотекстовая индексация

SQLite FTS5 — встроенная полнотекстовая индексация. AND-семантика: все слова запроса должны присутствовать в факте. Это жёстко, но предсказуемо.

CREATE VIRTUAL TABLE facts_fts USING fts5(content, content=facts, content_rowid=fact_id);

-- Поиск:
SELECT fact_id, rank FROM facts_fts WHERE facts_fts MATCH 'деплой nginx'
ORDER BY rank LIMIT 30;

Проблема FTS5: не понимает перефразировок. «Как выкатить nginx на прод» не матчит «деплой nginx через CI/CD». Поэтому fallback на semantic search при пустом FTS5 — критичен.

Стоимость на Potato VPS

Типичный memory footprint при работающем агенте:

КомпонентRSS
Hermes core (Python)~380 МБ
fastembed (загружена)+300 МБ
ONNX Runtime residual(включено выше)
SQLite + FTS5 index~5 МБ
Итого~680 МБ

Остаётся достаточно для системы, swap, и других процессов. Комфортно.

При shutdown модель выгружается, RSS падает до ~620 МБ. ONNX residual не освобождается — это цена одного import fastembed за жизнь процесса.

Weight auto-redistribution

Если fastembed недоступна (не установлена, или numpy отсутствует), веса перераспределяются автоматически:

def _redistribute_weights(self):
    if not embedder.is_available():
        # Semantic недоступен: 0.3 → FTS +0.15, Jaccard +0.1, HRR +0.05
        self.fts_weight = 0.45
        self.jaccard_weight = 0.30
        self.hrr_weight = 0.25
        self.semantic_weight = 0.0
    elif not _HAS_NUMPY:
        # HRR недоступен: 0.2 → FTS +0.1, Semantic +0.1
        self.fts_weight = 0.40
        self.jaccard_weight = 0.20
        self.hrr_weight = 0.0
        self.semantic_weight = 0.40

Graceful degradation: система работает и без эмбеддингов (FTS + Jaccard), и без HRR (FTS + Jaccard + Semantic). Но полная четвёрка — оптимальный режим.

Практические грабли

1. FTS5 AND — слишком строго

Запрос «компактный формат сообщений» требует наличия всех трёх слов. Если факт записан как «лаконичные ответы» — FTS5 молчит. Semantic спасает, но только если модель загружена.

Решение: не полагаться на FTS5 как единственный канал. Всегда держать semantic включённым.

2. Missing semantic vectors после обновления

Агент обновился, но работает старый процесс. Новые факты записываются без semantic_vector. Симптом: русские запросы возвращают пустой результат.

SELECT COUNT(*) FROM facts WHERE semantic_vector IS NULL;

Если > 0 — запустить backfill:

import embedder, sqlite3

conn = sqlite3.connect(db_path)  # путь к вашей базе
rows = conn.execute('SELECT fact_id, content FROM facts WHERE semantic_vector IS NULL').fetchall()

for fid, content in rows:
    vec = embedder.embed_text(content)
    conn.execute('UPDATE facts SET semantic_vector = ? WHERE fact_id = ?',
                 (embedder.vector_to_bytes(vec), fid))

conn.commit()

3. fastembed pooling change

Версия 0.8.0+ меняет pooling с CLS на mean. Старые векторы несовместимы с новыми. Решение: полный re-embed всех фактов после обновления fastembed.

4. ONNX memory leak (это не leak)

del model + gc.collect() освобождает веса, но не ONNX Runtime pools. Это не утечка — так устроен ONNX. 481 МБ residual — норма. Не пытайтесь «починить».

5. RSS ≠ used memory

ru_maxrss показывает пик, а не текущее потребление. Для точного измерения:

def current_rss_mb():
    with open('/proc/self/statm') as f:
        pages = int(f.read().split()[1])
    return pages * 4096 / 1024 / 1024

Web-интерфейс для просмотра фактов

Для отладки я сделал standalone htmx-приложение на stdlib http.server. Тёмная тема, моноширинный шрифт, FTS5-поиск, inline-редактирование, кнопки feedback, цветовые бейджи по категориям. Zero dependencies — только Python stdlib. Запускается одной командой, слушает на локальном порту.

Итого

Четыре стратегии поиска в одном SQLite-файле. 680 МБ RSS при работающей модели. Lazy-load, graceful degradation, выгрузка при shutdown. Никаких внешних сервисов, никаких docker-compose, никаких Pinecone API-ключей.

Для хобби-проекта на Potato VPS — это единственный адекватный вариант. Не потому что «лучше ChromaDB», а потому что ChromaDB не влезает в скромный объём RAM, а Pinecone — это чужой компьютер.

Свой плагин на SQLite — это контроль. Контроль над памятью, над индексацией, над lifecycle модели. И когда в 3 часа ночи OOM-killer стучится — ты точно знаешь, кто виноват и что делать.


Плагин holographic-memory — часть проекта Hermes Agent.