🎯 Por que persistir (chat vs agente)
Um chat esquece de propósito — começa do zero a cada sessão e isso é feature, não bug. Um agente que esquece é um estagiário com amnésia: simpático no primeiro dia, frustrante no segundo, demitido no terceiro. Memória persistente é o que transforma "demo bonita" em "colega que aprende com você".
📌 A regra prática
Se o usuário precisa repetir a mesma informação 2 vezes em conversas diferentes, sua memória está errada (ou inexistente). Esse é o teste de cheiro mais barato que existe.
A linha do tempo de uma conversa real
Dia 1 — Onboarding
"Trabalho com SaaS B2B, time de 12, meu nome é Marco, prefiro respostas curtas."
Agente salva 4 facts categorizadas. Sem isso, é só log de chat.
Dia 7 — Decisão registrada
"Decidimos AWS sobre GCP por causa do Bedrock."
Category decision + metadata {date, rationale}. Auditável em 6 meses.
Dia 30 — Recall sob pressão
"Lembra por que escolhemos AWS?"
memory.search("AWS GCP escolha") → 1 hit. Sem isso, agente inventa razão plausível e errada.
Dia 90 — Forget por LGPD
"Apaga tudo que sabe sobre o cliente X."
memory.forget() + audit row. Sem operação explícita, você não tem compliance — tem promessa.
✓ Memória bem feita
- ✓Usuário sente continuidade entre sessões.
- ✓Toda entry tem
user_id,category,created_at. - ✓Recall é busca, não scan de tudo no contexto.
- ✓Forget existe, é testado, gera audit.
✗ Memória de demo
- ✗"História toda no system prompt" — input tokens explodem.
- ✗Dict global em RAM — perde no restart.
- ✗Sem categoria → não consegue filtrar nem deletar seletivamente.
- ✗Pinecone no dia 1 com 200 entries — overkill caro.
Repetir info = quebra de confiança.
Tudo no prompt = US$ por turn ↑↑.
recent(10) + search(top-5) por turn.
Right to forget é lei, não favor.
🗄️ SQLite FTS5 — implementação completa com aiosqlite
FTS5 é a extensão de full-text search do SQLite. Zero setup, zero servidor, zero custo de hospedagem. Suporta MATCH com operadores booleanos, snippet highlighting, ranking BM25. Para até ~1M entries por user, é a resposta certa em 90% dos casos.
import aiosqlite, uuid, time, json
from dataclasses import dataclass, field
SCHEMA = """
CREATE TABLE IF NOT EXISTS memories(
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
category TEXT NOT NULL,
content TEXT NOT NULL,
metadata TEXT NOT NULL DEFAULT '{}',
created_at REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_user_cat ON memories(user_id, category, created_at);
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
content,
content='memories', content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2'
);
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
INSERT INTO memories_fts(rowid, content) VALUES (new.rowid, new.content);
END;
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, content) VALUES('delete', old.rowid, old.content);
END;
"""
class SQLiteMemory:
def __init__(self, path: str): self.path = path
async def init(self):
async with aiosqlite.connect(self.path) as db:
await db.executescript(SCHEMA); await db.commit()
async def save(self, content, category, user_id, metadata=None) -> str:
eid = str(uuid.uuid4())
async with aiosqlite.connect(self.path) as db:
await db.execute(
"INSERT INTO memories(id,user_id,category,content,metadata,created_at) VALUES(?,?,?,?,?,?)",
(eid, user_id, category, content, json.dumps(metadata or {}), time.time())
)
await db.commit()
return eid
async def search(self, query, user_id, limit=10):
# FTS5 MATCH + snippet + bm25 ranking, escopado por user_id
async with aiosqlite.connect(self.path) as db:
cur = await db.execute("""
SELECT m.id, m.content, m.category, m.created_at, m.metadata,
snippet(memories_fts, 0, '<b>', '</b>', '...', 16) as snip
FROM memories_fts
JOIN memories m ON m.rowid = memories_fts.rowid
WHERE memories_fts MATCH ? AND m.user_id = ?
ORDER BY bm25(memories_fts) LIMIT ?
""", (query, user_id, limit))
return [dict(zip([c[0] for c in cur.description], r)) async for r in cur]
async def forget(self, entry_id, user_id) -> bool:
async with aiosqlite.connect(self.path) as db:
cur = await db.execute(
"DELETE FROM memories WHERE id=? AND user_id=?", (entry_id, user_id))
await db.commit()
return cur.rowcount > 0
📊 O que ganhou de graça
- BM25 ranking: resultados ordenados por relevância real, não por data.
- Snippet com highlight:
<b>...</b>direto na resposta, ótimo pra debug. - Operadores:
"aws AND bedrock","prefere*"(prefix),NEAR(x y, 5). - unicode61 + remove_diacritics: "São" casa com "Sao". Importante em pt-BR.
- Triggers automáticos: insert/delete propaga pro índice FTS sem código extra.
💡 Tip de produção
Ative PRAGMA journal_mode=WAL e PRAGMA synchronous=NORMAL na inicialização. Throughput sobe ~5x e você ganha leituras concorrentes sem bloquear writers. Para multi-process, considere busy_timeout=5000.
⚠️ 3 pegadinhas
- 1. FTS5 não entende sinônimos. "carro" não casa com "automóvel". Se isso importa, vector é melhor.
- 2. Não esquecer
user_idno WHERE. Sem ele, busca vaza memória entre users. Bug catastrófico. - 3. MATCH é sensível a sintaxe. Input do user precisa de sanitização — aspas duplas viram operador.
1 arquivo .db. Zero infra.
<5ms em 100k entries.
R$ 0. Roda em qualquer VPS.
cp memory.db. Pronto.
🧮 Vector stores (Pinecone, Chroma) — quando faz sentido
Vector busca por similaridade semântica, não por palavras. "Como cancelo a assinatura?" casa com "quero dar baixa no plano". FTS5 não faz isso. Mas embedding custa, vector DB custa, e você só precisa quando o conteúdo é parafraseável em larga escala — knowledge base de suporte, RAG sobre documentos, busca em transcrições.
import chromadb
from chromadb.utils import embedding_functions
# Chroma local — embarcado, sem servidor. Bom pra dev e produção pequena.
client = chromadb.PersistentClient(path="./chroma_db")
embed = embedding_functions.SentenceTransformerEmbeddingFunction(
model_name="paraphrase-multilingual-MiniLM-L12-v2" # bom pra pt-BR
)
collection = client.get_or_create_collection(
name="memories", embedding_function=embed,
metadata={"hnsw:space": "cosine"}
)
# Save: id, document, metadata
collection.add(
ids=["mem-001"],
documents=["Marco prefere AWS por causa do Bedrock e equipe já treinada"],
metadatas=[{"user_id": "u_42", "category": "decision", "ts": 1715900000}]
)
# Search: top-k semantic + filter
hits = collection.query(
query_texts=["por que escolhemos cloud provider"],
n_results=5,
where={"user_id": "u_42"} # SEMPRE filtrar por user
)
for doc, dist, meta in zip(hits["documents"][0], hits["distances"][0], hits["metadatas"][0]):
print(f"[{dist:.3f}] {meta['category']}: {doc}")
✓ Quando usar vector
- ✓RAG sobre >10k documentos heterogêneos.
- ✓Suporte/FAQ onde mesma intenção tem 20 formulações.
- ✓Conteúdo multilíngue (pt + en + es no mesmo índice).
- ✓Recall > precisão (você quer "achar algo parecido").
✗ Quando NÃO usar
- ✗<5k entries no total. Custo > benefício.
- ✗Busca por nome próprio, código, ID. FTS5 ganha disparado.
- ✗"Quero saber TUDO sobre user X" — isso é SQL, não vector.
- ✗Latência crítica (<50ms) com modelo de embedding remoto.
📊 Pinecone vs Chroma vs pgvector
- Chroma (embarcado): dev, projetos <1M vetores, zero ops. Default.
- Pinecone (SaaS): >10M vetores, SLA, multi-region. ~US$ 70/mês mínimo + por uso.
- pgvector (Postgres): já tem Postgres? Adicione extension, evite uma stack nova.
- Qdrant, Weaviate, Milvus: nichos. Considere só depois de saber o que precisa.
💡 Híbrido é a melhor resposta na prática
Use FTS5 para facts curtas (preferências, decisões, identidades) — busca por keyword é o que você quer. Use vector para chunks longos de documento. O mesmo agente pode chamar os dois e fundir resultados. Não é "ou" — é "e".
~US$ 0,02 / 1M tokens (OpenAI ada).
Pinecone ~US$ 70/mês mínimo.
sentence-transformers grátis, 200ms/query.
📝 Markdown blocks (padrão Claude Code, frontmatter, MEMORY.md)
Nem toda memória é entry de banco. Algumas coisas precisam ser editáveis por humanos: identidade do agente, regras de negócio, lista de stakeholders, glossário. O padrão estabelecido pelo Claude Code é arquivo Markdown versionado com frontmatter YAML — o que Anthropic chama de memory blocks.
---
agent: marco-os
version: 2.4
updated: 2026-05-18
owner: marco@empresa.com
tags: [identidade, regras, escopo]
priority: always-load
---
# MEMORY.md — Marco OS
## Identidade
Você é o agente pessoal do Marco. Responde em pt-BR formal, frases curtas.
Nunca usa emoji exceto em listas de check (✓/✗).
## Stakeholders
- **Marco** (CEO) — decisão final, prefere ver número antes de prosa
- **Ana** (CTO) — discutir antes de mudar arquitetura
- **Time eng** (12 pessoas) — pode ler tudo exceto folha de pagamento
## Regras inegociáveis
1. Nunca envia email sem aprovação explícita
2. Audit log obrigatório em qualquer ação financeira
3. PII só fica em SQLite local, nunca em prompt de LLM externo
## Decisões arquiteturais ativas (2026)
- Cloud: AWS (Bedrock, RDS, S3)
- LLM default: Claude Sonnet 4.6, fallback GPT-4o
- Memory: SQLite FTS5 + Chroma local pra docs
📊 Por que MD em vez de DB pra isso
- Git diff legível: mudança de regra aparece no PR como mudança de regra, não como UPDATE SQL.
- Frontmatter = metadata:
priority: always-loaddiz pro orquestrador injetar no system prompt. - Editável por não-dev: CEO consegue abrir o arquivo no editor e mudar uma regra.
- Reuso entre agentes: mesmo MEMORY.md serve N agentes da mesma empresa.
- Sem migração: mudou estrutura? Salva o arquivo. Acabou.
✓ MD blocks para
- • Identidade e tom do agente
- • Regras de negócio estáveis
- • Glossário, stakeholders, contexto duradouro
- • Templates de resposta padronizados
- • Documentos que humanos releem mensalmente
✗ MD blocks NÃO para
- • Histórico de conversas (vai pra DB)
- • Facts efêmeras ("hoje o user disse que está cansado")
- • Dados que mudam toda hora (vira ruído no git)
- • PII de cliente (vaza em backup de repo)
- • Mais de ~5k tokens (system prompt incha)
💡 Hierarquia que funciona
Anthropic recomenda 3 níveis: ~/.claude/CLAUDE.md (global do usuário), repo/CLAUDE.md (projeto), repo/sub/CLAUDE.md (módulo). Cada nível adiciona contexto. Adote a mesma hierarquia para seus agentes: org/MEMORY.md → team/MEMORY.md → user/MEMORY.md.
Git. PR. Code review.
Estrutura muda livre.
YAML → metadata estruturada.
Vai no system prompt — não consulta.
📊 Tabela de decisão (volume × precisão × custo)
A pergunta que aparece em toda reunião técnica: "então o que a gente usa?". A resposta honesta é "depende de 3 variáveis", e essa tabela resolve a maioria dos casos sem precisar de discussão de 1h.
🧩 Volume × Tipo × Recomendação
| Volume | Tipo de conteúdo | Tipo de busca | Recomendação |
|---|---|---|---|
| < 100 entries | Regras, identidade | N/A — sempre carregado | MD blocks no system prompt |
| 100 — 10k | Facts, decisões, interações | Keyword + recent | SQLite FTS5 |
| 10k — 100k | Docs internos, transcrições | Semântica + filtros | Chroma local + FTS5 híbrido |
| 100k — 1M | Knowledge base multi-tenant | Semântica + alta concorrência | pgvector (já tem Postgres) |
| > 1M | Catálogo público, RAG escala | Semântica + SLA | Pinecone ou Qdrant Cloud |
| Qualquer | PII sensível, LGPD strict | Qualquer | SQLite local + embedding local |
✓ Padrão de evolução
- 1. Comece com MEMORY.md + SQLite FTS5.
- 2. Quando RAG aparece como necessidade real, adicione Chroma local.
- 3. Quando ops pesa, migre Chroma → pgvector ou Pinecone.
- 4. Nunca pule etapa por "futurismo" — paga em complexidade hoje.
✗ Anti-padrões clássicos
- • Pinecone com 500 entries — US$ 70/mês de overhead inútil.
- • Tudo no MD — system prompt vira novela russa, custo/turn explode.
- • Vector pra buscar email específico — FTS encontra em <1ms, vector erra.
- • Reescrever de SQLite pra Postgres na semana 2 — premature scaling.
💡 A pergunta que destrava decisão
"O usuário vai buscar usando as mesmas palavras que estão guardadas, ou usando uma paráfrase?" Se a primeira: FTS5 ganha (mais barato, mais rápido, mais previsível). Se a segunda: vector ganha. Se ambas acontecem: híbrido.
MD + SQLite FTS5.
Chroma local primeiro.
pgvector.
Pinecone/Qdrant.
🗑️ Forget e LGPD (operação delete + audit)
LGPD artigo 18 dá ao titular o direito ao apagamento dos dados. Para agente com memória, isso é uma operação técnica concreta — não promessa. E ela precisa rodar em todos os stores: SQLite, vector DB, MD, logs estruturados, backups. Esquecer um lugar = não cumprir a lei.
import time, uuid, json
class ForgetService:
"""Apaga em todos os stores + grava audit imutável.
Audit fica em tabela separada que NÃO é deletada — só ela
prova depois que o apagamento aconteceu."""
def __init__(self, sqlite_mem, vector_mem, audit_db):
self.sqlite = sqlite_mem
self.vector = vector_mem
self.audit = audit_db
async def forget_user(self, user_id: str, requested_by: str, reason: str) -> dict:
req_id = str(uuid.uuid4())
started = time.time()
# 1. SQLite — DELETE escopado por user_id
sql_deleted = await self.sqlite.forget_all_for_user(user_id)
# 2. Vector store — Chroma where filter
vec_deleted = await self.vector.delete(where={"user_id": user_id})
# 3. MD blocks — busca arquivos com owner=user e remove
md_files = await self._purge_md_blocks(user_id)
# 4. Logs — anonimiza request_ids associados
logs_anon = await self._anonymize_logs(user_id)
# 5. Audit imutável — append-only, NÃO deletar nem editar
await self.audit.append({
"request_id": req_id,
"operation": "forget_user",
"subject_user_id": user_id,
"requested_by": requested_by,
"reason": reason,
"started_at": started,
"finished_at": time.time(),
"results": {
"sqlite_rows_deleted": sql_deleted,
"vector_rows_deleted": vec_deleted,
"md_files_purged": md_files,
"log_entries_anonymized": logs_anon,
}
})
return {"request_id": req_id, "status": "completed"}
🚨 5 lugares que todo mundo esquece
- 1. Backups antigos — política de retenção precisa estar escrita. "Apagado em prod, vivo no S3" não cola.
- 2. Cache de embeddings — Redis, disco local, CDN. Tudo precisa de purge.
- 3. Logs estruturados — Datadog, CloudWatch. Anonimize ou apague.
- 4. Conversas exportadas pra fine-tuning — dataset precisa ser regenerado.
- 5. Memória de LLM externo (OpenAI Memory, Anthropic Files API) — chame a API de delete.
📊 O que o audit precisa registrar
- request_id: UUID rastreável end-to-end.
- requested_by: quem pediu (o titular ou DPO em nome dele).
- reason: "LGPD art.18 inciso VI" ou "encerramento de contrato".
- timestamps: início e fim — prova de SLA.
- contagens por store: evidência de execução em cada lugar.
- imutabilidade: audit log nunca deleta. Append-only com hash da linha anterior se for paranoico.
💡 Forget granular vs total
LGPD permite o titular pedir apagamento parcial: "esqueça só meu endereço, mantenha histórico de compras anônimo". Modele isso: forget(user_id, category="address"). Sem granularidade, todo pedido vira "apaga tudo" — perda de dado útil agregado (analytics, ML training).
✓ Boa operação forget
- • 1 endpoint único, idempotente, async
- • Roda em todos os stores
- • Gera audit imutável
- • SLA escrito (ex: 48h)
- • Testado em CI com fixture de user falso
✗ Forget de mentirinha
- • Marca flag
deleted=truee segue usando - • Esquece backup / cache / logs
- • Sem audit — como prova que apagou?
- • Operação manual via SQL no console
- • Sem teste — descobre que quebrou no incidente
LGPD art.18 inciso VI.
15 dias úteis. Melhor: 48h.
5+ anos. Imutável.
Por category, não só total.
📝 Resumo do módulo
Próximo módulo:
2.5 — Tools que dão dinheiro (shell, web, calendar, JSON Schema bem feito)