Разбираю tiered memory system с L1/L2/L3 слоями, тематическими коллекциями в ChromaDB, age decay и интеграцией с evaluator-LLM. Сравниваю с MemGPT/Letta — совпадения есть, но архитектурные решения специфичны для production-случая.
Где мой ассистент в AI-экосистеме: МНЕМОС — это не Memory SDK уровня Mem0/Letta/Zep. Это конкретная реализация tiered memory внутри приложения, дополненная замкнутым циклом самообучения.
Это вторая статья из серии «Как я собрал self-improving AI-ассистента». Первая — общий обзор архитектуры.
Зачем нужна долгосрочная память
Представим, мы каждый раз обращаемся к другому человеку (другу, супругу, коллеге) и он каждый раз не помнит о чем говорили вчера, каждый новый диалог начинается с чистого листа, с нового знакомства. Удобно? Не думаю)) Память человека так не работает. Значит и мой AI-ассистент не должен работать так, подумал я: (в первой статье я описывал, чем меня там бесили дипсики и прочие GPT-чаты). Мне захотелось чтоб мой ассистент работал так:
- Вчера обсуждали прайс — сегодня ожидаем, что ассистент помнит
- На прошлой неделе ассистент ошибся в расчёте НДС — на этой неделе не должен повторить
- Три месяца назад пользователь сказал «обращайся на ты» — это должно работать всегда
Короткая история диалога (последние 20 сообщений) решает только первое. Для остального нужна отдельная система, которая:
- Сохраняет успешные ответы как примеры «как надо»
- Сохраняет провалы как примеры «как НЕ надо»
- Подтягивает релевантные примеры в prompt в зависимости от темы запроса
- Обновляется автоматически, без ручной модерации
Я назвал эту систему МНЕМОС (по аналогии с греческой богиней памяти).
Три уровня памяти
Я разделил долгосрочную память на три логических уровня:
flowchart TB
subgraph L1["L1: Глобальный банк опыта"]
L1a["Seed-примеры<br/>">15 синтетических<br/>(холодный старт)"]
L1b["Real Q&A<br/>score ≥ ">4<br/>со всех диалогов"]
L1c["Gapfill<br/>авто-синтез<br/>по темам"]
end
subgraph L2["L2: Тематические коллекции"] L2finance["expL2_finance"] L2business["expL2_business"] L2tech["expL2_tech"] L2law["expL2_law"] L2health["expL2_health"] L2science["expL2_science"] L2history["expL2_history"] L2weather["expL2_weather"] end
subgraph L3["L3: Антипаттерны"] L3a["score ≤ ">2<br/>с reasoning<br/>для инъекции"] end
Query[Запрос пользователя] --> Classify{Классификатор темы} Classify -->|finance| L2_finance Classify -->|business| L2_business Classify -->|...| L2_other[...] Classify -->|unknown| L1
L1 — глобальный банк опыта (experience_bank)
Что: Все реальные Q&A с оценкой ≥ 4, плюс seed-примеры для холодного старта.
Где: ChromaDB коллекция experiencebank + SQLite responselog.
Метаданные каждого вектора:
{
"user_query": "hl-kw">class="hl-kw">$"hl-num">1,
"response": "hl-kw">class="hl-kw">$"hl-num">1,
"score": "hl-kw">class="hl-kw">$"hl-num">1, # "hl-num">1"hl-num">-5
"theme": "hl-kw">class="hl-kw">$"hl-num">1, # finance/business/tech/...
"user_id": "hl-kw">class="hl-kw">$"hl-num">1,
"is_seed": "hl-kw">class="hl-kw">$"hl-num">1,
"created_at": datetime,
"tokens_used": "hl-kw">class="hl-kw">$"hl-num">1,
"latency_ms": "hl-kw">class="hl-kw">$"hl-num">1,
}
Размерность: 1024 (GigaChat embeddings).
Seed-данные для холодного старта — 14 синтетических примеров:
| Категория | Кол-во |
|---|---|
| Приветствия и шутки | 2 |
| Погода | 1 |
| Напоминания | 1 |
| Календарь | 1 |
| Финансы (EBITDA, NPV) | 2 |
| Анализ договоров | 1 |
| Курсы валют | 1 |
| Email и планы | 2 |
| Сообщения | 1 |
| Возможности ассистента | 1 |
L2 — тематические коллекции (expL2{theme})
Что: Дубликаты L1-векторов, разложенные по 8 тематическим коллекциям.
Зачем: Приоритетный поиск по теме запроса. Если пользователь спрашивает про кредит — сначала ищем в expL2finance, потом (если мало) — в общем experience_bank.
THEMES = ["finance", "business", "tech", "law",
"health", "science", "history", "weather"]
"hl-kw">def saveexperience(userquery, response, score, user_id, ...):
theme = classifyquerytheme(user_query)
savetosqlite(user_query, response, score, theme)
savetochroma("experience_bank", ...)
"hl-kw">if theme "hl-kw">and score >= "hl-num">4:
savetochroma(f"expL2{theme}", ...)Тематический классификатор — простой, keyword-based, но взвешенный:
TOPIC_RULES = {
"finance": ["бюджет", "налог", "прибыль", "кредит",
"инвестиция", "выручка", ...],
"weather": ["погода", "температура", "дождь", "снег"],
"health": ["врач", "больница", "лекарство", "диагноз"],
"tech": ["код", "сервер", "база", "API", "Python", "SQL"],
"history": ["история", "век", "война", "царь"],
"science": ["физика", "химия", "биология", "формула"],
"law": ["закон", "суд", "иск", "договор", "юрист"],
"business":["компания", "производство", "продажа",
"маркетинг", "персонал"],
}
"hl-kw">def classifyquerytheme(query):
"""Возвращает тему с наибольшим score."""
scores = {theme: "hl-num">0 "hl-kw">for theme "hl-kw">in TOPIC_RULES}
words = query.lower().split()
"hl-kw">for word "hl-kw">in words:
"hl-kw">for theme, keywords "hl-kw">in TOPIC_RULES.items():
"hl-kw">if word "hl-kw">in keywords:
# Длинные слова весят больше
scores[theme] += "hl-num">2 "hl-kw">if "hl-kw">class="hl-kw">$"hl-num">1(word) >= "hl-num">6 "hl-kw">else "hl-num">1
best = max(scores, key=scores.get)
"hl-kw">if scores[best] >= "hl-num">4:
"hl-kw">return best
"hl-kw">return "general"Длинные слова (≥ 6 букв) имеют вес × 2. Порог срабатывания — score ≥ 4 (из 8+ тем).
L3 — антипаттерны (experience_fails)
Что: Ответы с оценкой ≤ 2 + reasoning от Sufler.
Где: Отдельная коллекция experience_fails.
Зачем: Few-shot работает не только на «как надо», но и на «как НЕ надо». Если пользователь спросит «как посчитать НДС?», LLM увидит:
⚠️ АНТИПРИМЕРЫ (не делай так):
Вопрос: «Когда применяется НДС?»
Ошибка: «НДС всегда 20% на всё, даже на услуги»
Правильно: НДС не применяется на ряд операций (ст. 149 НК РФ)flowchart LR Response[Ответ LLM] --> Sufler Sufler|score ≥ "hl-num">4| L1 Sufler|score "hl-num">3| L1low[save в L1<br/>без приоритета] Sufler|score ≤ "hl-num">2| L3 Sufler|score ≤ "hl-num">2| FailsLog[(researchsuflerfails)] FailsLog -.->|каждые "hl-num">3ч| Likbez[ЛИКБЕЗ]
Как работает поиск: 3 уровня fallback
При каждом запросе ассистент ищет релевантные примеры в три стадии:
Возрастной decay — старые примеры менее ценны:
"hl-kw">def applyagedecay(results):
"""Понижаем score старых примеров."""
now = datetime.now()
"hl-kw">for r "hl-kw">in results:
agedays = (now - r["createdat"]).days
r["adjustedscore"] = r["score"] ("hl-num">1 - 0.01 agedays)
"hl-kw">return "hl-kw">class="hl-kw">$"hl-num">1(results, key="hl-kw">lambda r: r["adjusted_score"], reverse="hl-kw">class="hl-kw">$"hl-num">1)Через 100 дней пример автоматически «устаревает» (× 0). Это не удаление, а переранжирование — если ничего свежего нет, старый пример всё равно всплывёт.
ChromaDB: как это устроено под капотом
У меня пять типов коллекций:
flowchart TB
subgraph "ChromaDB"
EB["experience_bank<br/>1024d<br/>~">1000 векторов"]
L2["expL2{theme} × ">8<br/>1024d<br/>~">50">-200 каждый"]
EF["experience_fails<br/>1024d<br/>~">20">-50"]
Docs["Документы<br/>user-scoped<br/>384d"]
Likbez["Статьи ЛИКБЕЗ<br/>doc_id ≥ ">100000<br/>1024d"]
end
Search[Поиск] --> EB
Search --> L2
Search --> EF
Docs -.->|RAG| Assistant
Likbez -.->|RAG| AssistantПочему 1024d у МНЕМОС и 384d у документов?
GigaChat embeddings API — 1024d для основного чата. Документы — старый формат с 384d (GigaChat embeddings v1). Когда-нибудь переиндексирую, но пока работает.
Dimension freeze — важная мелочь:
"hl-kw">class GigaChatEmbeddingFunction:
embeddingdim = "hl-kw">class="hl-kw">$"hl-num">1 # замораживается при первом вызове
"hl-kw">def call("hl-kw">class="hl-kw">$"hl-num">1, texts):
vectors = "hl-kw">class="hl-kw">$"hl-num">1.callapi(texts)
"hl-kw">if vectors "hl-kw">and "hl-kw">class="hl-kw">$"hl-num">1.embeddingdim "hl-kw">is "hl-kw">class="hl-kw">$"hl-num">1:
"hl-kw">class="hl-kw">$"hl-num">1.embeddingdim = "hl-kw">class="hl-kw">$"hl-num">1(vectors["hl-num">0])
# Если GigaChat сменил размерность — pad/truncate
"hl-kw">return ["hl-kw">class="hl-kw">$"hl-num">1.padtruncate(v, "hl-kw">class="hl-kw">$"hl-num">1.embeddingdim "hl-kw">or "hl-num">1024)
"hl-kw">for v "hl-kw">in vectors]Без freeze ChromaDB начнёт ругаться при первом же изменении размерности API.
Как few-shot попадает в промпт
Перед каждым вызовом LLM ассистент инжектит топ-3 примера:
"hl-kw">def injectfewshot(prompt, message, userid, mgtopic="hl-kw">class="hl-kw">$"hl-num">1):
"""Добавляет в промпт топ-">3 примера из МНЕМОС."""
examples = searchexperiences(message, userid, top_k="hl-num">5)
examples = examples[:"hl-num">3] # берём "hl-num">3 лучших
fails = searchfailexamples(user_id, limit="hl-num">2)
echoes = searchpersonalecho(message, userid, topk="hl-num">2)
block = "\n\n📚 ПРИМЕРЫ (схожий вопрос):\n"
"hl-kw">for ex "hl-kw">in examples:
block += f"Вопрос: {ex['user_query']}\n"
block += f"Ответ: {ex['response'][:">500]}\n\n"
"hl-kw">if fails:
block += "⚠️ АНТИПРИМЕРЫ (не делай так):\n"
"hl-kw">for f "hl-kw">in fails:
block += f"Вопрос: {f['user_query']}\n"
block += f"Ошибка: {f['response'][:">200]}\n"
"hl-kw">if f.get('reasoning'):
block += f"Почему: {f['reasoning'][:">300]}\n"
block += "\n"
"hl-kw">if echoes:
block += "📝 ЭХО-ПАМЯТЬ (ваш прошлый похожий вопрос):\n"
"hl-kw">for e "hl-kw">in echoes:
block += f"Вопрос: {e['user_query']}\n"
block += f"Ответ: {e['response'][:">300]}\n\n"
"hl-kw">return prompt + blockЧто попадает в промпт пользователю:
📚 ПРИМЕРЫ (схожий вопрос):Вопрос: «Посчитай рентабельность проекта»
Ответ: «Рентабельность = прибыль / выручка × 100%...»⚠️ АНТИПРИМЕРЫ (не делай так):
Вопрос: «Сколько стоит открыть ООО?»
Ошибка: «5000₽ — это госпошлина за регистрацию»
📝 ЭХО-ПАМЯТЬ (ваш прошлый похожий вопрос):
Вопрос: «Расскажи про НДС»
Ответ: «НДС — это косвенный налог...»
Meta-Sufler: фильтрация нерелевантных примеров
Проблема: иногда топ-3 примера по косинусу — формально близки, но семантически мимо. Например, запрос про «базу данных сотрудников» находит пример про «SQL-запрос к базе». Векторно близко, по смыслу — нет.
Решение: Meta-Sufler — отдельный LLM-вызов, который оценивает каждый пример от 1 до 5 по релевантности, и отсекает score < 4.
"hl-kw">def metafilterexperiences(experiences, userquery, providercfg):
"""Фильтрует примеры через LLM-оценку релевантности."""
items = "\n".join(
f"ID={i}: {ex['user_query'][:">200]}"
"hl-kw">for i, ex "hl-kw">in "hl-kw">class="hl-kw">$"hl-num">1(experiences)
)
prompt = (
"Ты помогаешь отфильтровать релевантные примеры для few-shot.\n"
f"Запрос пользователя: {user_query}\n\n"
f"Примеры:\n{items}\n\n"
"Оцени релевантность каждого от ">1 до ">5.\n"
"Если пример НЕ релевантен — напиши 'НЕ ИСПОЛЬЗОВАТЬ'.\n"
"Формат: ID=X ОЦЕНКА=Y или ID=X НЕ ИСПОЛЬЗОВАТЬ"
)
reply = calljudgellm(prompt, provider_cfg)
keep = []
"hl-kw">for line "hl-kw">in reply.split("\n"):
"hl-kw">if "ОЦЕНКА" "hl-kw">in line "hl-kw">and "hl-kw">class="hl-kw">$"hl-num">1(line.split("ОЦЕНКА:")["hl-num">1].strip()["hl-num">0]) >= "hl-num">4:
idx = "hl-kw">class="hl-kw">$"hl-num">1(line.split("ID=")["hl-num">1].split()["hl-num">0])
keep.append(experiences[idx])
"hl-kw">return keep
Стоимость: +"hl-num">1 LLM-вызов перед каждым сообщением. Это ощутимо, поэтому используется только в assistantaction (не-streaming путь), а не в chatstream.
flowchart LR
Stream["chatstream<br/>(онлайн)"] --> InjectSync["injectfew_shot<br/>только косинус"]
"hl-kw">Async["assistantaction<br/>(оффлайн)"] --> InjectAS["injectfewshotasync<br/>косинус + Meta-Sufler"]Что в МНЕМОС лежит сейчас
| Коллекция | Объём | Скорость поиска |
|---|---|---|
experiencebank | ~1000 векторов | <50ms |
expL2finance | ~150 | <20ms |
expL2business | ~80 | <20ms |
expL2tech | ~200 | <20ms |
expL2law | ~30 | <15ms |
expL2health | ~15 | <15ms |
expL2science | ~25 | <15ms |
expL2weather | ~20 | <15ms |
experiencefails | ~30 | <20ms |
| Итого | ~1 550 векторов | ~50ms max |
Сравнение с MemGPT / Letta
Архитектурно совпадение сильное:
flowchart LR
subgraph "MemGPT"
MG_Core[Core Memory]
MG_Arch[Archival Memory]
MG_Recall[Recall Tool]
end
subgraph "Мой МНЕМОС"
MyL1[L1 experiencebank]
My_L2[L2 тематические]
My_L3[L3 антипаттерны]
end
MGCore ---|не соответствует| MyL1
MGCore ---|частично| MyL2
MGArch --- MyL1
MGRecall ---|нет аналога| MyL3| Фича | MemGPT/Letta | Мой МНЕМОС |
|---|---|---|
| Tiered memory | core / archival / recall | L1 / L2 / L3 |
| Vector DB | любой (PGVector, Chroma, Qdrant) | ChromaDB |
| Self-editing | да | нет (только через Sufler) |
| External evaluator | нет | Sufler |
| Тематический приоритет | нет | да (8 тем) |
| Антипаттерны в few-shot | нет | да |
| Personal echo (по user_id) | частично | да |
| Age decay | нет | да (1% в день) |
| Автосинтез пробелов (Gapfill) | нет | да |
| Weekly self-calibration | нет | да (Selfcal) |
- Антипаттерны как часть few-shot — я не видел в академических работах. Reflexion хранит рефлексии, но не инжектит их как «негативные примеры».
- Тематические коллекции — MemGPT ищет по всей archival memory. Я сначала ищу в тематической. На малом объёме это быстрее, на большом — не факт.
- Age decay — простая, но эффективная эвристика.
- Полный цикл: MНЕМОС сам по себе бесполезен без Sufler (наполнение) и ЛИКБЕЗ (расширение). Это часть larger system — об этом в следующей статье.
Что дальше
В следующих статьях:
- STM + МНЕМОС вместе — как оперативная и долгосрочная память работают в паре
- Пару слов о интуитивном контуре: Contour - как фундаментальная надстройка над памятью
- LTM: долгосрочная память о пользователе внутри МНЕМОС
- Замкнутый цикл обучения — Sufler → fail → ЛИКБЕЗ → знание
- Matematicus — 3-level matching для точных расчётов
Вопросы к читателям:
- Делали tiered memory в проде? Какие пороги для min_score работают?
- Кто использовал Meta-Sufler / LLM-based фильтрацию few-shot? Ощутимая разница?
- ChromaDB vs PGVector vs Qdrant — кто что использует на self-hosted?
Если есть вопросы по конкретному компоненту — пишите в комментариях или в личку.
Комментарии
Для комментирования авторизуйтесь на сайте