Verificando acesso...

MÓDULO 2.4

🧠 Memória: SQLite FTS5 vs vector vs Markdown

A pergunta que define se seu agente é ferramenta de chat ou colega de trabalho. Três tecnologias, três volumes, três casos de uso — e uma tabela de decisão que evita você queimar US$ 400/mês em Pinecone sem precisar.

6
Tópicos
85
Minutos
Técnico
Nível
Prática
Tipo
1

🎯 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

D1

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.

D7

Dia 7 — Decisão registrada

"Decidimos AWS sobre GCP por causa do Bedrock."

Category decision + metadata {date, rationale}. Auditável em 6 meses.

D30

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.

D90

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.
Custo do esquecimento

Repetir info = quebra de confiança.

Custo do excesso

Tudo no prompt = US$ por turn ↑↑.

Sweet spot

recent(10) + search(top-5) por turn.

LGPD

Right to forget é lei, não favor.

2

🗄️ 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_id no 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.
Setup

1 arquivo .db. Zero infra.

Latência

<5ms em 100k entries.

Custo

R$ 0. Roda em qualquer VPS.

Backup

cp memory.db. Pronto.

3

🧮 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".

Embedding cost

~US$ 0,02 / 1M tokens (OpenAI ada).

Storage cost

Pinecone ~US$ 70/mês mínimo.

Local embed

sentence-transformers grátis, 200ms/query.

4

📝 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-load diz 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.mdteam/MEMORY.mduser/MEMORY.md.

Versionado

Git. PR. Code review.

Sem migração

Estrutura muda livre.

Frontmatter

YAML → metadata estruturada.

Sempre carregado

Vai no system prompt — não consulta.

5

📊 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 entriesRegras, identidadeN/A — sempre carregadoMD blocks no system prompt
100 — 10kFacts, decisões, interaçõesKeyword + recentSQLite FTS5
10k — 100kDocs internos, transcriçõesSemântica + filtrosChroma local + FTS5 híbrido
100k — 1MKnowledge base multi-tenantSemântica + alta concorrênciapgvector (já tem Postgres)
> 1MCatálogo público, RAG escalaSemântica + SLAPinecone ou Qdrant Cloud
QualquerPII sensível, LGPD strictQualquerSQLite 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.

Default 90% dos casos

MD + SQLite FTS5.

RAG real

Chroma local primeiro.

Multi-tenant SaaS

pgvector.

Hiperescala

Pinecone/Qdrant.

6

🗑️ 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=true e 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
Base legal

LGPD art.18 inciso VI.

SLA típico

15 dias úteis. Melhor: 48h.

Audit retenção

5+ anos. Imutável.

Granularidade

Por category, não só total.

📝 Resumo do módulo

Memória é o que separa demo de colega — se user repete info entre sessões, sua memória está errada.
SQLite FTS5 resolve 90% dos casos — zero infra, <5ms em 100k entries, BM25 + snippet de graça.
Vector só quando precisa de paráfrase — Chroma local primeiro, Pinecone só em >1M vetores ou SLA exigente.
MD blocks com frontmatter — identidade, regras e contexto duradouro versionados em git, editáveis por humano.
Tabela de decisão — volume × tipo × tipo de busca decide tudo. Pinecone em <5k entries é desperdício.
Forget é operação técnica + audit imutável — roda em todos os stores (SQLite, vector, MD, logs, backups). LGPD não aceita promessa.

Próximo módulo:

2.5 — Tools que dão dinheiro (shell, web, calendar, JSON Schema bem feito)