Como construir um chatbot com memória de longo prazo usando vetores

1. Fundamentos da Memória de Longo Prazo em Chatbots

1.1. Diferença entre memória de curto prazo e memória de longo prazo

Chatbots tradicionais operam com memória de curto prazo, limitada ao contexto da sessão atual. Quando a conversa termina, o histórico é perdido. A memória de longo prazo, por outro lado, persiste informações entre sessões, permitindo que o chatbot lembre preferências, fatos mencionados há dias e o progresso do usuário em tarefas complexas.

Memória de curto prazo (sessão):
- Armazenada em variáveis de contexto ou cache
- Expira após término da sessão
- Geralmente limitada a 4k-8k tokens

Memória de longo prazo (persistente):
- Armazenada em banco de dados vetorial
- Persiste indefinidamente (sujeito a políticas de retenção)
- Permite recuperação semântica de informações históricas

1.2. Por que vetores de embeddings?

Embeddings convertem texto em vetores numéricos que capturam significado semântico. Isso permite que o chatbot encontre conversas relevantes mesmo quando as palavras exatas não correspondem. Por exemplo, se o usuário perguntou "Qual a previsão do tempo?" há três dias, e hoje pergunta "Como vai estar o clima?", a busca vetorial identifica a similaridade semântica.

1.3. Visão geral da arquitetura

Usuário → Consulta → Modelo de Embeddings → Busca Vetorial → Contexto Recuperado → Prompt Enriquecido → LLM → Resposta

2. Escolhendo e Configurando o Banco de Dados Vetorial

2.1. Opções populares

Ferramenta Prós Contras
ChromaDB Open-source, fácil setup local Escalabilidade limitada
Pinecone Gerenciado, escalável Custo por uso
Qdrant Performance, filtros avançados Configuração mais complexa
Weaviate Híbrido (vetorial + texto) Maior consumo de recursos

Para este tutorial, usaremos ChromaDB por sua simplicidade de configuração.

2.2. Instalação e configuração inicial

pip install chromadb sentence-transformers openai

2.3. Estrutura de dados para conversas

import chromadb
from chromadb.config import Settings

client = chromadb.Client(Settings(
    chroma_db_impl="duckdb+parquet",
    persist_directory="./chatbot_memory"
))

collection = client.create_collection(
    name="conversas",
    metadata={"hnsw:space": "cosine"}
)

# Estrutura de cada documento:
# id: "user_123_20240101_001"
# embedding: [0.012, -0.045, ...] (vetor de 1536 dimensões)
# metadata: {
#     "usuario": "user_123",
#     "timestamp": "2024-01-01T10:30:00",
#     "sessao": "sessao_456",
#     "tipo": "pergunta"  # ou "resposta"
# }
# document: "Qual a previsão do tempo para amanhã?"

3. Geração de Embeddings para Fragmentos de Conversa

3.1. Seleção do modelo de embeddings

OpenAI text-embedding-3-small: 1536 dimensões, alta qualidade, pago por token.

Sentence Transformers (local): Gratuito, menor latência, qualidade ligeiramente inferior.

from sentence_transformers import SentenceTransformer
modelo_embeddings = SentenceTransformer('all-MiniLM-L6-v2')

def gerar_embedding(texto):
    return modelo_embeddings.encode(texto).tolist()

3.2. Estratégia de chunking

Fragmentar conversas por turno individual é a abordagem mais comum, mas pode ser ajustada:

def fragmentar_conversa(historico):
    """
    Divide histórico em chunks significativos.
    Cada turno (pergunta+resposta) vira um fragmento.
    """
    fragmentos = []
    for i in range(0, len(historico), 2):
        pergunta = historico[i]
        resposta = historico[i+1] if i+1 < len(historico) else ""
        fragmento = f"Usuário: {pergunta}\nAssistente: {resposta}"
        fragmentos.append(fragmento)
    return fragmentos

3.3. Função para vetorizar e armazenar

def armazenar_interacao(usuario, pergunta, resposta, sessao):
    fragmento = f"Usuário: {pergunta}\nAssistente: {resposta}"
    embedding = gerar_embedding(fragmento)

    collection.add(
        embeddings=[embedding],
        documents=[fragmento],
        metadatas=[{
            "usuario": usuario,
            "timestamp": datetime.now().isoformat(),
            "sessao": sessao,
            "tipo": "interacao"
        }],
        ids=[f"{usuario}_{int(time.time())}"]
    )

4. Arquitetura do Pipeline de Memória

4.1. Fluxo de interação completo

def buscar_contexto_relevante(consulta, usuario, top_k=5):
    embedding_consulta = gerar_embedding(consulta)

    resultados = collection.query(
        query_embeddings=[embedding_consulta],
        n_results=top_k,
        where={"usuario": usuario},
        include=["documents", "metadatas", "distances"]
    )

    return resultados

def gerar_resposta_com_memoria(consulta, usuario, sessao_atual):
    # 1. Buscar memória de curto prazo (cache de sessão)
    contexto_sessao = cache_sessao.get(sessao_atual, "")

    # 2. Buscar memória de longo prazo (vetores)
    memorias = buscar_contexto_relevante(consulta, usuario)
    contexto_historico = "\n".join(memorias['documents'][0])

    # 3. Montar prompt enriquecido
    prompt = f"""Contexto da sessão atual:
{contexto_sessao}

Histórico relevante do usuário:
{contexto_historico}

Pergunta atual: {consulta}

Responda considerando todo o contexto acima."""

    # 4. Chamar LLM
    resposta = chamar_llm(prompt)

    # 5. Armazenar nova interação
    armazenar_interacao(usuario, consulta, resposta, sessao_atual)

    return resposta

4.2. Mecanismo de recuperação com filtros

def buscar_por_usuario_e_tempo(consulta, usuario, dias_max=30):
    data_limite = (datetime.now() - timedelta(days=dias_max)).isoformat()

    resultados = collection.query(
        query_embeddings=[gerar_embedding(consulta)],
        n_results=10,
        where={
            "$and": [
                {"usuario": {"$eq": usuario}},
                {"timestamp": {"$gte": data_limite}}
            ]
        }
    )
    return resultados

5. Gerenciamento do Ciclo de Vida da Memória

5.1. Políticas de retenção

def limpar_memorias_antigas(dias_retencao=90):
    data_limite = (datetime.now() - timedelta(days=dias_retencao)).isoformat()

    # Identificar memórias antigas
    antigas = collection.get(
        where={"timestamp": {"$lt": data_limite}}
    )

    # Opção 1: Remover completamente
    if antigas['ids']:
        collection.delete(ids=antigas['ids'])

    # Opção 2: Sumarizar antes de remover
    for usuario in set(m['usuario'] for m in antigas['metadatas']):
        memorias_usuario = [
            doc for doc, meta in zip(antigas['documents'], antigas['metadatas'])
            if meta['usuario'] == usuario
        ]
        resumo = sumarizar_memorias(memorias_usuario)
        armazenar_resumo(usuario, resumo)

5.2. Atualização incremental

def adicionar_interacao_incremental(usuario, pergunta, resposta):
    # Adiciona apenas a nova interação sem reindexar
    armazenar_interacao(usuario, pergunta, resposta, sessao_atual)
    # O ChromaDB gerencia o índice HNSW automaticamente

5.3. Rotina de sumarização automática

def sumarizar_memorias(memorias):
    prompt = f"""Resuma as seguintes interações do usuário em 3-5 pontos principais:
{chr(10).join(memorias)}

Resumo conciso:"""

    resposta = chamar_llm(prompt)
    return resposta

def armazenar_resumo(usuario, resumo):
    embedding = gerar_embedding(resumo)
    collection.add(
        embeddings=[embedding],
        documents=[f"RESUMO: {resumo}"],
        metadatas=[{
            "usuario": usuario,
            "tipo": "resumo",
            "timestamp": datetime.now().isoformat()
        }],
        ids=[f"resumo_{usuario}_{int(time.time())}"]
    )

6. Tratamento de Casos Complexos e Otimizações

6.1. Memória hierárquica

class ChatbotComMemoria:
    def __init__(self):
        self.cache_sessao = {}  # Memória de curto prazo
        self.banco_vetorial = collection  # Memória de longo prazo

    def obter_contexto_completo(self, usuario, sessao, consulta):
        # Cache de sessão (últimos 5 turnos)
        contexto_curto = self.cache_sessao.get(sessao, [])[-5:]

        # Memória de longo prazo (busca semântica)
        contexto_longo = self.banco_vetorial.query(
            query_embeddings=[gerar_embedding(consulta)],
            n_results=3,
            where={"usuario": usuario}
        )

        return {
            "curto_prazo": contexto_curto,
            "longo_prazo": contexto_longo
        }

6.2. Otimizações de escalabilidade

# Batch de embeddings
def processar_lote(interacoes):
    textos = [f"{i['pergunta']} {i['resposta']}" for i in interacoes]
    embeddings = modelo_embeddings.encode(textos, batch_size=32)

    # Adicionar em lote
    collection.add(
        embeddings=embeddings.tolist(),
        documents=textos,
        metadatas=[{
            "usuario": i['usuario'],
            "timestamp": i['timestamp']
        } for i in interacoes],
        ids=[f"batch_{i}" for i in range(len(interacoes))]
    )

6.3. Privacidade e segurança

def anonimizar_dados(texto):
    # Remove emails
    texto = re.sub(r'\S+@\S+', '[EMAIL]', texto)
    # Remove números de telefone
    texto = re.sub(r'\(\d{2}\)\s*\d{4,5}-\d{4}', '[TELEFONE]', texto)
    # Remove nomes próprios (usando NER)
    from spacy import load
    nlp = load('pt_core_news_sm')
    doc = nlp(texto)
    for ent in doc.ents:
        if ent.label_ == "PER":
            texto = texto.replace(ent.text, "[NOME]")
    return texto

7. Testes, Monitoramento e Iteração

7.1. Métricas de qualidade

def avaliar_recuperacao(consultas_teste, relevantes_esperados):
    acertos = 0
    for consulta, esperado in zip(consultas_teste, relevantes_esperados):
        resultados = buscar_contexto_relevante(consulta, "test_user")
        recuperados = resultados['documents'][0]
        if esperado in recuperados:
            acertos += 1
    return acertos / len(consultas_teste)  # recall@k

7.2. Monitoramento

import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("chatbot_memoria")

def buscar_com_log(consulta, usuario):
    inicio = time.time()
    resultados = buscar_contexto_relevante(consulta, usuario)
    latencia = time.time() - inicio

    logger.info(f"Consulta: {consulta[:50]}... | Usuário: {usuario} | "
                f"Resultados: {len(resultados['ids'][0])} | "
                f"Latência: {latencia:.3f}s | "
                f"Tokens embedding: {len(consulta.split())}")

    return resultados

7.3. Estratégias de melhoria contínua

def ajustar_chunk_size(historico_feedback):
    # Analisar feedback do usuário para ajustar tamanho dos chunks
    chunk_sizes = [128, 256, 512]
    melhor_size = 256
    melhor_precisao = 0

    for size in chunk_sizes:
        precisao = simular_com_chunk_size(size, historico_feedback)
        if precisao > melhor_precisao:
            melhor_precisao = precisao
            melhor_size = size

    return melhor_size

Referências

  • ChromaDB Documentation — Documentação oficial do banco vetorial ChromaDB, incluindo guias de instalação, API de consulta e estratégias de indexação para chatbots.

  • OpenAI Embeddings Guide — Guia oficial da OpenAI sobre embeddings, incluindo o modelo text-embedding-3-small, casos de uso para busca semântica e boas práticas de implementação.

  • Sentence Transformers Documentation — Documentação da biblioteca Sentence Transformers para geração de embeddings locais, com modelos pré-treinados e tutoriais de fine-tuning.

  • Pinecone Vector Database Tutorial — Tutorial completo sobre bancos de dados vetoriais, incluindo conceitos de similaridade coseno, indexação HNSW e estratégias de recuperação para chatbots.

  • LangChain Memory Systems — Guia da LangChain sobre implementação de diferentes tipos de memória para LLMs, incluindo buffer de conversação, memória vetorial e sumarização.

  • Qdrant Vector Search Engine — Documentação do Qdrant com exemplos de filtros avançados, busca por payload e otimizações de performance para sistemas de recomendação e chatbots.

  • Weaviate Hybrid Search — Documentação do Weaviate sobre busca híbrida (vetorial + palavra-chave), ideal para chatbots que precisam combinar precisão semântica com recall de termos exatos.