Голографическая память для 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», а четыре независимых канала, результаты которых складываются с весами:
| Стратегия | Вес | Что делает | Технология |
|---|---|---|---|
| FTS5 | 0.3 | Кандидаты по ключевым словам (BM25) | SQLite FTS5 |
| Jaccard | 0.2 | Пересечение токенов | Python sets |
| HRR | 0.2 | Композиционная алгебра (probe/related/reason) | SHA-256 phase vectors, 1024d |
| Semantic | 0.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-v2 | MiniLM-L12-v2 | |
|---|---|---|
| Размерность | 768 | 384 |
| RSS (загружена) | 1440 МБ | 680 МБ |
| RSS (residual после выгрузки) | 693 МБ | 481 МБ |
| Время эмбеддинга | 65 мс | 46 мс |
| Перезагрузка после выгрузки | 20–25 с | 1.33 с |
| sim("компактный формат" ↔ "лаконичные сообщения") | 0.625 | 0.511 |
| sim("компактный формат" ↔ "погода для прогулки") | 0.225 | 0.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.