Pular para conteúdo

Write Pipeline

Quando você chama memory.write(), o SDK lê uma mensagem em linguagem natural e automaticamente extrai quem e o que foi mencionado, descobre se é informação nova ou atualizada, e armazena tudo como fatos estruturados e versionados - numa única chamada.

Você não precisa entender os internals pra usar. Basta chamar write() e verificar result.facts_added. Esta página explica o que acontece por baixo dos panos, pra quando você quiser ajustar o comportamento ou debugar resultados.

flowchart LR
    A["Message"] --> B["Alias Lookup +\nPré-retrieval"]
    B --> C["Extração Informada\n(1 chamada LLM)"]
    C --> D["Resolve Entities"]
    D --> E["Upsert"]
    E --> F["WriteResult"]

Visão Geral

Toda chamada a memory.write(agent_id, message, speaker_name) (opcionalmente com occurred_at) executa estes passos:

  1. Guard - Mensagens vazias retornam imediatamente. Sem evento, sem chamada LLM, sem tokens consumidos.
  2. Registrar o evento - A mensagem bruta e salva como trilha de auditoria imutavel (nunca modificada ou deletada). O timestamp do evento usa occurred_at se fornecido, caso contrario usa o momento atual.
  3. Detectar emoção - Classifica a emoção, intensidade e nível de energia da mensagem.
  4. Alias lookup + Pré-retrieval - Escaneia a mensagem por entidades conhecidas e carrega contexto existente (perfis de entidade ou fatos via pgvector).
  5. Extração informada - Uma única chamada LLM recebe a mensagem + contexto existente e retorna entidades, fatos (com action NEW/UPDATE e importance_category) e relações.
  6. Resolver entidades - Deduplica menções ("Ana", "minha esposa Ana", "Aninha") em uma única entidade canônica.
  7. Upsert - Salva os resultados no banco de dados.

Fallback para extração cega

Se a extração informada falhar (timeout, erro do LLM, etc.), o pipeline automaticamente cai para o fluxo antigo de extração cega (entity scan + fact extraction + relation extraction) seguido de reconciliação. O resultado final é equivalente - apenas o caminho é diferente.

Cada estágio é independentemente fail-safe: se a extração falhar, o evento ainda é registrado. Se a reconciliação falhar para um fato, os outros prosseguem normalmente.


Estágio 1: Extração Informada por Memória

Em português claro: Em vez de mandar a mensagem pro LLM "às cegas", o SDK primeiro descobre o que já sabe sobre as entidades mencionadas. Depois manda a mensagem junto com esse contexto existente pro LLM, que consegue decidir de forma mais inteligente o que e novo e o que ja e conhecido. Resultado: menos duplicatas, menos ruido, e fatos ja conhecidos nao sao re-extraidos.

A partir da v0.9.0, o write pipeline usa extração informada como caminho padrão. Em vez de 3-4 chamadas LLM no fluxo antigo (entity scan + fact extraction + relation extraction + reconciliation), o novo fluxo faz tudo em 3 fases, sendo apenas 1 delas uma chamada LLM.

Como Funciona

A extração informada opera em 3 fases:

  1. Alias lookup (sem LLM) - Escaneia a mensagem procurando nomes de entidades conhecidas via word-boundary matching no cache de aliases. Identifica quais entidades mencionadas na mensagem ja existem no sistema.

  2. Pre-retrieval (sem LLM) - Para cada entidade conhecida encontrada no passo anterior, carrega contexto existente:

    • Quando a entidade tem um perfil (profile_text), usa o perfil diretamente (mais compacto e informativo)
    • Quando nao tem perfil, busca os top-K fatos existentes por entidade via pgvector
    • O orcamento de contexto e controlado por informed_extraction_context_budget_tokens (padrao 800 tokens)
  3. Extracao informada (1 chamada LLM) - Recebe a mensagem + speaker context + contexto existente (perfis ou fatos) e retorna um unico JSON com:

    • Entidades com aliases
    • Fatos com action (NEW ou UPDATE) e importance_category
    • Relacoes entre entidades
    • Perfis atualizados (updated_profiles) para as entidades mencionadas

O prompt de extracao instrui o LLM a extrair TODA informacao factual da mensagem, incluindo detalhes especificos. A deduplicacao fica por conta do pipeline (reconciliacao ADD/UPDATE/NOOP/DELETE e dedup semantico pos-extracao). Essa abordagem maximiza o recall -- detalhes especificos que antes eram filtrados pelo LLM agora sao capturados e reconciliados pelo sistema.

Fallback: Se a extracao informada falhar (timeout, JSON invalido, etc.), o pipeline cai automaticamente pro fluxo antigo de extracao cega + reconciliacao. O resultado final e equivalente.

Importancia Semantica (importance_category)

Cada fato extraido recebe uma categoria de importancia semantica atribuida pelo LLM durante a extracao. A categoria e mapeada para um valor numerico que alimenta o score de importancia do fato:

Categoria Valor Descricao
biographical_milestone 0.95 Marcos de vida (nascimento, casamento, formatura)
relationship_change 0.90 Mudancas em relacionamentos (novo emprego, mudanca de cidade)
stable_preference 0.75 Preferencias estaveis (comida favorita, hobby)
specific_event 0.65 Eventos especificos (viagem, reuniao)
routine_activity 0.50 Atividades rotineiras (almocou no restaurante X)
conversational 0.30 Informacao conversacional sem valor duradouro

Isso substitui a importancia base fixa (0.5) do fluxo antigo por uma avaliacao semantica que reflete melhor o valor da informacao.

Extracao Cega (Fallback)

O fluxo antigo de extracao cega ainda existe como fallback e roda 3 chamadas LLM, com as duas ultimas concorrentes:

  1. Entity scan - Identifica todas as entidades mencionadas na mensagem
  2. Fact extraction + Relation extraction - Rodam concorrentemente via asyncio.gather(): fact extraction recebe todas as entidades numa unica chamada, enquanto relation extraction identifica relacionamentos entre entidades

A extracao de relations inclui um retry automatico: se o LLM retornar 0 relations mas 2+ entities foram encontradas, o SDK repete a chamada uma vez. Quando verbose=True, o trace inclui relation_retry_triggered.

Quando o fluxo cego e usado, o pipeline tambem executa o estagio de reconciliacao (comparando fatos novos contra existentes) que nao e necessario na extracao informada, ja que o LLM ja decide NEW/UPDATE na fonte.

Resolucao Temporal (occurred_at)

O parametro occurred_at permite especificar a data/hora em que a mensagem foi originalmente enviada. Por padrao e datetime.now(UTC).

from datetime import datetime, UTC

# Importar conversa historica de junho de 2025
result = await memory.write(
    agent_id="user_123",
    message="Yesterday I moved to a new apartment in Brooklyn",
    speaker_name="Rafael",
    occurred_at=datetime(2025, 6, 15, tzinfo=UTC),
)
# O LLM resolve "yesterday" para 2025-06-14 (baseado no occurred_at),
# nao para a data de hoje

O occurred_at e usado em dois lugares:

  1. Timestamp do evento - O MemoryEvent.occurred_at recebe esse valor (em vez de now)
  2. Resolucao temporal na extracao - O prompt de extracao recebe a data da mensagem como contexto ("This message was sent on June 15, 2025"). Quando a mensagem contem referencias temporais relativas ("yesterday", "last week", "three years ago"), o LLM as resolve para datas absolutas baseadas nessa data, nao na data atual

Isso e essencial para imports historicos: sem occurred_at, importar uma mensagem de 2024 que diz "yesterday" resolveria "yesterday" para ontem (data atual), produzindo fatos incorretos.

Extracao subject-centric: Facts sao extraidos do ponto de vista do subject principal apenas. "Carlos mora em Curitiba" e um fact sobre Carlos - o sistema NAO cria "Curitiba e onde Carlos mora". O relacionamento Carlos -> lives_in -> Curitiba + entity links cuidam do retrieval cross-entity.

Dedup semantico: Apos a extracao (informada ou cega), facts sao comparados par a par por cosine similarity de embeddings. Near-duplicates (> 0.85 de similaridade) sao removidos, mantendo a primeira ocorrencia. Isso elimina reformulacoes cross-entity que o LLM as vezes produz apesar das instrucoes do prompt.

Configuração

Parâmetro Default Descrição
extraction_timeout_sec 30.0 Timeout por chamada LLM
enable_informed_extraction True Habilita extração informada por memória. Quando False, usa o fluxo antigo de extração cega + reconciliação
informed_extraction_topk 10 Número máximo de fatos existentes a carregar por entidade no pré-retrieval (quando perfil não está disponível)
informed_extraction_context_budget_tokens 800 Orçamento máximo de tokens para o contexto existente enviado ao LLM na extração informada

O Que é Extraído

Para cada mensagem, o estágio de extraction produz:

  • Entidades - Coisas nomeadas: pessoas, organizações, lugares, conceitos, etc.
  • Fatos - Afirmações auto-contidas sobre entidades em linguagem natural (ex: "Fernanda Lima é engenheira de software", "Marcos Tavares mora em Porto Alegre"). Cada texto de fato sempre inclui o nome da entidade - nunca apenas "é engenheira de software" sem um sujeito. Toda relação também gera um fato correspondente - então "Sarah é minha esposa" produz tanto uma relação (user → spouse_of → sarah) quanto um fato ("Sarah é esposa do user"). Fatos duplicados (mesmo subject + mesmo texto, ignorando pontuação) são removidos automaticamente pós-extração.
  • Relações - Conexões entre entidades (ex: "Rafael" → works_at → "Acme Corp"). Relações servem como arestas de grafo pra traversal; o fato pareado torna a informação pesquisável via texto/embedding.

Cada fato inclui um nível de confiança:

Nível Score Exemplo
Declaração explícita 0.95 "Eu moro em São Paulo"
Inferência forte 0.80 "Fomos ao escritório de São Paulo" (implica localização)
Inferência fraca 0.60 Implicação contextual
Especulação 0.40 Informação incerta

Como a confiança funciona na prática

A confiança é atribuída pelo LLM durante a extração com base em como a informação foi declarada. Afirmações diretas ("Eu moro em SP") recebem alta confiança; afirmações incertas ("Acho que talvez...") recebem confiança mais baixa. Você não pode definir a confiança diretamente - ela é inferida. Você pode filtrar fatos de baixa confiança no retrieval usando min_confidence no MemoryConfig (padrão 0.55).

Passo a passo: como a extração informada funciona

Mensagem: "Clara Rezende saiu da Vertix e foi pra Orion Tech como head de engenharia. O Thiago Nogueira a contratou pessoalmente."

Contexto existente: Clara Rezende e Vertix já existem no sistema. Clara tem perfil: "Software engineer at Vertix since 2023."

Fase 1 - Alias lookup (sem LLM):

Entidades conhecidas encontradas: [Clara Rezende, Vertix]
Entidades novas: [Orion Tech, Thiago Nogueira]

Fase 2 - Pré-retrieval (sem LLM):

Clara Rezende → perfil carregado: "Software engineer at Vertix since 2023."
Vertix → sem perfil, carrega top-10 fatos via pgvector

Fase 3 - Extração informada (1 chamada LLM):

{
  "entities": [
    {"name": "Clara Rezende", "type": "person"},
    {"name": "Vertix", "type": "organization"},
    {"name": "Orion Tech", "type": "organization"},
    {"name": "Thiago Nogueira", "type": "person"}
  ],
  "facts": [
    {"subject": "Clara Rezende", "text": "Clara Rezende left Vertix", "action": "NEW", "importance_category": "relationship_change"},
    {"subject": "Clara Rezende", "text": "Clara Rezende joined Orion Tech as head of engineering", "action": "NEW", "importance_category": "relationship_change"},
    {"subject": "Thiago Nogueira", "text": "Thiago Nogueira personally hired Clara Rezende", "action": "NEW", "importance_category": "specific_event"}
  ],
  "relations": [
    {"source": "Clara Rezende", "target": "Vertix", "type": "former_employee_of"},
    {"source": "Clara Rezende", "target": "Orion Tech", "type": "works_at"},
    {"source": "Thiago Nogueira", "target": "Clara Rezende", "type": "hired"}
  ],
  "updated_profiles": {
    "Clara Rezende": "Former software engineer at Vertix. Now head of engineering at Orion Tech, hired personally by Thiago Nogueira."
  }
}
Note que o LLM marcou todos como action: "NEW" porque, apesar de já conhecer Clara, a informação sobre a saída e o novo cargo são novas. O fato existente "works at Vertix" não foi re-extraído.

Passo 4 - Dedup semântico: Nenhum near-duplicate encontrado (todos os fatos são distintos). 3 fatos passam.

Resultado: 4 entidades, 3 fatos, 3 relações, 1 perfil atualizado. Total: 1 chamada LLM.

Passo a passo: como a extração cega funciona (fallback)

Mensagem: "Clara Rezende saiu da Vertix e foi pra Orion Tech como head de engenharia. O Thiago Nogueira a contratou pessoalmente."

Passo 1 - Entity scan (1 chamada LLM):

Entities: [Clara Rezende (person), Vertix (organization), Orion Tech (organization), Thiago Nogueira (person)]

Passo 2a - Fact extraction (1 chamada LLM, todas as entidades):

Facts:
  [Clara Rezende] "Clara Rezende left Vertix" (0.95)
  [Clara Rezende] "Clara Rezende joined Orion Tech as head of engineering" (0.95)
  [Thiago Nogueira] "Thiago Nogueira personally hired Clara Rezende" (0.95)

Passo 2b - Relation extraction (1 chamada LLM, concorrente com 2a):

Relations:
  Clara Rezende → former_employee_of → Vertix
  Clara Rezende → works_at → Orion Tech
  Thiago Nogueira → hired → Clara Rezende

Passo 3 - Dedup semântico: Nenhum near-duplicate encontrado (todos os fatos são distintos). 3 fatos passam.

Resultado: 4 entidades, 3 fatos, 3 relações. Total: 3 chamadas LLM (+ reconciliação depois).

Agrupamento de Aliases e Normalização de Subjects

Quando a mesma entidade é mencionada por múltiplos nomes em uma única mensagem (ex: "meu amigo Guili (Guilherme Maturana)"), a extração agrupa tudo em uma única entidade com aliases ao invés de criar duplicatas.

O LLM é instruído a escolher um nome canônico (geralmente o mais completo) e listar os outros como aliases:

{
  "entities": [
    {"name": "Guilherme Maturana", "type": "person", "aliases": ["Guili"]}
  ]
}

Após a extração, um passo de normalização de subjects reescreve qualquer fato ou relação que referencia um alias para usar o nome canônico. Relações de identidade (ex: same_as entre um alias e seu nome canônico) são removidas automaticamente, já que se tornam auto-referenciantes após a normalização.

Isso elimina a duplicação de entidades intra-mensagem na origem - antes mesmo do entity resolution rodar.

Entity Types

Entity types são strings livres - o LLM escolhe o tipo mais apropriado pra cada entidade. Tipos comuns incluem person, organization, place, product, event, concept, pet, mas qualquer tipo descritivo é aceito. Types são normalizados pra lowercase durante a entity resolution (ex: "Person""person", "PRODUCT""product").

O prompt de extração instrui o LLM a classificar types com cuidado - por exemplo, cidades são place, empresas são organization, produtos de software são product.

Comportamento Fail-safe

Se uma chamada LLM falhar (timeout, JSON inválido, rate limit), a extraction retorna um resultado vazio em vez de lançar uma exceção. O evento ainda é registrado - nenhum dado é perdido. A próxima mensagem pode capturar a mesma informação.

Detectando timeouts

Quando a extração sofre timeout, o resultado é indistinguível de "mensagem sem conteúdo extraível" - 0 entidades, 0 fatos, sem exceção. Para detectar timeouts, compare o duration_ms da extração no trace com o extraction_timeout_sec configurado, ou verifique 0 entidades apesar de uma mensagem com conteúdo.

Paralelo com neurociência

A extraction espelha a codificação na memória humana - o processo de converter input sensorial (uma conversa) em um traço de memória. Assim como a codificação humana é seletiva (não lembramos cada palavra), o LLM extrai apenas fatos e entidades salientes. A extração informada vai além: assim como o cérebro humano codifica informação nova em relação ao que já sabe (resposta de orientação), o LLM recebe contexto existente e decide na fonte o que é genuinamente novo. Isso espelha a codificação orientada por esquema - novos estímulos são processados no contexto de esquemas mentais existentes, não no vácuo.

Perfis de Entidade no Write Pipeline

A extração informada produz e consome perfis de entidade (profile_text) - resumos concisos (~100-300 tokens) que capturam o essencial de cada entidade.

Como input: Quando a extração informada roda, perfis existentes são carregados durante o pré-retrieval e enviados ao LLM como contexto. Perfis são mais compactos que fatos individuais e fornecem uma visão consolidada da entidade, permitindo ao LLM tomar decisões melhores sobre o que é novo vs. conhecido.

Como output: O LLM retorna updated_profiles como parte do JSON de saída. Quando a mensagem contém informação que muda o perfil de uma entidade (novo emprego, mudança de cidade, etc.), o LLM retorna o perfil atualizado. Perfis são persistidos na mesma transação do upsert.

Cold start: Na primeira mensagem sobre uma entidade (quando ainda nao existe perfil), o perfil inicial e criado a partir dos fatos extraidos.

Semeadura vs consolidacao: O write pipeline so semeia perfis para entidades que ainda nao possuem um (profile_text IS NULL). Perfis existentes nao sao sobrescritos pelo write pipeline. A fonte autoritativa para perfis abrangentes e o background job consolidate_entity_profiles(), que le todos os fatos por entidade e gera perfis completos.

profile_text vs summary_text

profile_text é gerado inline no write pipeline durante a extração informada. summary_text é gerado por background jobs (sleep-time compute). São mecanismos separados que coexistem: o perfil é atualizado em tempo real a cada write, enquanto o summary é recalculado periodicamente em background.

Lingua do conteudo gerado

Todo conteudo gerado por LLM -- perfis de entidade, summaries, resumos de cluster, meta-observations e diretivas -- e sempre produzido em ingles, independente da lingua da conversa. Isso garante consistencia no retrieval e na comparacao semantica. Os fatos (fact_text), por outro lado, permanecem na lingua original da conversa (ex: se o usuario fala em portugues, os fatos sao extraidos em portugues).


Estágio 2: Entity Resolution

Em português claro: Quando alguém fala "Ana", "minha esposa Ana" e "Aninha" em mensagens diferentes, estão falando da mesma pessoa. Este estágio descobre isso e linka tudo a uma única entidade canônica - pra você não acabar com três registros separados de "Ana" no banco.

Resolução em Três Fases

flowchart LR
    A["Entity name"] --> B{"Exact match?"}
    B -->|Yes| F["Resolved"]
    B -->|No| C{"Fuzzy match?"}
    C -->|"≥ 0.85"| F
    C -->|"0.50–0.85"| D{"LLM decides"}
    C -->|"< 0.50"| E["Create new entity"]
    D -->|Match| F
    D -->|No match| E
    E --> F

Fase 1: Exact match

Verifica o cache de aliases, slugs de entidades e display names. Instantâneo, sem chamada LLM.

Inclui prefix/diminutive matching para entidades do tipo pessoa: "Carol" corresponde a "Carolina" (mínimo 3 caracteres). Nota: "Jo" NÃO corresponde a "João" (< 3 chars). "Bob" corresponde a "Roberto" apenas se registrado como alias, não via prefix matching.

Fase 2: Fuzzy match

Usa similaridade de cosseno via embedding (in-memory) para encontrar candidatos:

  • fuzzy_threshold (default 0.85) - Match de alta confiança, resolve diretamente
  • 0.50 - fuzzy_threshold - Ambíguo, encaminha top-3 candidatos para a Fase 3 (LLM)
  • < 0.50 - Sem match, cria uma nova entidade

Reduzir fuzzy_threshold expande a faixa de fuzzy-resolve e reduz chamadas LLM. Por exemplo, definir fuzzy_threshold=0.50 elimina a faixa ambígua inteiramente - tudo acima de 0.50 resolve diretamente.

Faz fallback para difflib.SequenceMatcher quando embeddings não estão disponíveis.

Fase 3: LLM fallback

Envia candidatos ambíguos para o LLMProvider injetado para desambiguação. O LLM vê o nome da entidade, os candidatos, e decide qual (se algum) é um match.

Passo a passo: como a entity resolution funciona

Mensagem: "Falei com o Guili sobre o projeto. O Guilherme disse que tá no prazo."

  1. Extração: Dois nomes encontrados: "Guili" e "Guilherme"
  2. Fase 1 (exata): "Guilherme" bate com a entidade existente person:guilherme_maturana
  3. Fase 2 (fuzzy): "Guili" tem 0.87 de similaridade com "Guilherme" → resolve automaticamente
  4. Resultado: Ambos os nomes resolvem para a mesma entidade. Alias "Guili" registrado.

Da próxima vez que "Guili" aparecer, a Fase 1 resolve instantaneamente via cache de aliases - sem precisar de fuzzy ou LLM.

Casos Especiais

  • Pronomes do speaker - "I", "me", "eu", "myself" resolvem automaticamente para a entidade do speaker (person:{speaker_slug}). Por exemplo, com speaker_name="Rafael", "I live in São Paulo" resolve "I" para person:rafael.
  • Termos de relacionamento - "girlfriend", "brother", "amigo" resolvem para a entidade do speaker quando a palavra bare é o nome da entidade (não "my girlfriend Ana" - nesse caso "Ana" é a entidade). O match é ativado quando o nome da entidade em si é um termo de relacionamento, não a frase completa.
  • Dicas relacionais - "Carol (namorada do Rafael)" remove a dica e força type="person"

Registro de Aliases

Quando um novo alias é descoberto (ex: "Aninha" resolve para person:ana), ele é registrado em MemoryEntityAlias com semântica first-write-wins - escritas concorrentes não criam aliases conflitantes. Aliases são scoped por agent_id: o mesmo alias pode mapear para entidades diferentes para agentes diferentes.

Aliases da extração também são registrados automaticamente: quando o entity resolution cria uma nova entidade que tem aliases da extração (ex: "Guili" para "Guilherme Maturana"), todos os aliases são registrados em MemoryEntityAlias e adicionados ao cache de aliases in-memory. Isso significa que entidades subsequentes no mesmo batch podem resolver imediatamente via Fase 1 exact match - sem chamadas fuzzy ou LLM.

Isso cria uma defesa em duas linhas contra duplicatas:

  1. Intra-mensagem - Agrupamento de aliases na extração previne duplicatas dentro de uma mesma mensagem
  2. Cross-mensagem - Aliases registrados habilitam exact match em mensagens futuras (ex: se mensagem 1 cria "Guilherme Maturana" com alias "Guili", mensagem 2 mencionando "Guili" resolve instantaneamente via Fase 1)

Persistência de Entidades

Após o entity resolution completar, o pipeline garante que toda entidade resolvida tenha um row na tabela memory_entities - não apenas as recém-criadas. Entidades resolvidas via exact match, fuzzy match ou LLM também recebem upsert via ON CONFLICT DO UPDATE (idempotente).

Isso é crítico porque background jobs (importance scoring, summary refresh, spreading activation) leem de memory_entities. Sem um row, esses jobs ficam cegos à entidade e não operam sobre ela.

O upsert de entidades é fail-safe: se uma entidade falhar ao persistir (ex: constraint violation), as outras prosseguem normalmente e o pipeline continua.

Configuração

Parâmetro Default Descrição
fuzzy_threshold 0.85 Threshold de similaridade de cosseno para fuzzy match direto
enable_llm_resolution True Se deve usar LLM para casos ambíguos. Quando False, candidatos ambíguos criam uma nova entidade ao invés de chamar LLM.

Seleção de modelo

O modelo LLM usado para entity resolution é determinado pelo LLMProvider injetado no MemoryClient. Para usar um modelo diferente para resolução vs. extração, injete providers diferentes.

Paralelo com neurociência

A entity resolution espelha a memória associativa - a capacidade do cérebro de vincular novos estímulos a representações existentes. Ouvir "Carol" ativa o padrão neural de "Carolina" através de pattern completion, assim como o fuzzy matching ativa entidades candidatas através de similaridade de embedding.


Estágio 3: Reconciliação

Reconciliação sempre roda

A reconciliação roda incondicionalmente, independente do modo de extração (informada ou cega). A extração informada fornece contexto que melhora a qualidade dos fatos extraídos, mas a decisão do que fazer com cada fato (ADD, UPDATE, NOOP, DELETE) é sempre feita por reconcile_facts(). Isso garante que fatos contraditórios sejam detectados e invalidados.

Em português claro: Se o usuário disse "Eu moro em São Paulo" na semana passada e agora diz "Me mudei pro Rio", o sistema precisa entender que isso é uma atualização, não uma segunda casa. Este estágio compara cada fato novo contra o que já tá armazenado e decide: é informação nova? Atualização de algo existente? Já conhecido? Ou uma retratação?

Lógica de Decisão

Para cada fato extraído, o reconciler:

  1. Busca fatos existentes para a mesma entidade
  2. Calcula similaridade entre o novo fato e cada fato existente (via embeddings)
  3. Decide a ação:
Ação Quando Exemplo
ADD Informação nova, sem fato existente similar (similaridade < 0.50) "fala francês" quando não existe fato de idioma
UPDATE Substitui um fato existente (similaridade 0.50-0.85+) "mora no Rio" substitui "mora em São Paulo"
NOOP Já é conhecido (alta similaridade) "trabalha na Acme" quando esse fato já existe
DELETE Retrata explicitamente um fato "Não trabalho mais na Acme"
Passo a passo: como a reconciliação decide

Cenário: O usuário disse anteriormente "Ricardo mora em São Paulo". Agora diz "Ricardo se mudou pra Austin, Texas."

Passo 1 - Buscar fatos existentes para Ricardo:

Existente: "Ricardo Gomes lives in São Paulo" (confidence: 0.95, ativo)

Passo 2 - Calcular similaridade:

Novo fato: "Ricardo Gomes moved to Austin, Texas"
vs existente: "Ricardo Gomes lives in São Paulo"
Similaridade de cosseno: 0.72 (ambos sobre localização do Ricardo)

Passo 3 - Similaridade >= 0.50 → slow path (chamada LLM): O LLM vê ambos os fatos e decide: é um UPDATE. O usuário se mudou.

Resultado:

Fato antigo: "Ricardo Gomes lives in São Paulo" → valid_to = now, invalidated_at = now
Fato novo: "Ricardo Gomes moved to Austin, Texas" → supersedes_fact_id = old_fact.id
Relacionamento: ricardo → lives_in → sao_paulo → INVALIDADO (cascata)
Novo relacionamento: ricardo → lives_in → austin

Se a similaridade fosse < 0.50 (ex: "Ricardo gosta de jazz"), seria auto-ADD sem chamada LLM - fast path.

Performance da Reconciliação

  • Fast path (similaridade < 0.50): Auto-ADD sem chamada LLM (~300ms). Caminho comum para informação nova.
  • Slow path (similaridade ≥ 0.50): LLM avalia se deve ADD, UPDATE, DELETE ou NOOP (~2-3s). Requer chamada LLM com contexto completo.

Planeje adequadamente: importações em massa de dados novos são rápidas; atualizações de conhecimento existente requerem decisão do LLM.

Chains de UPDATE podem ramificar

O LLM de reconciliação pode escolher ADD ao invés de UPDATE quando interpreta a nova informação como distinta, não como substituição. Por exemplo, "me mudei pra BH" pode criar fatos separados para "mora em BH" e "morou no RJ" ao invés de um simples update chain. Isso preserva mais informação mas pode quebrar a chain de supersedes_fact_id. Este é o comportamento esperado - o LLM prioriza preservação de informação.

Comportamento Fail-safe

Se a chamada LLM de reconciliação falhar, o sistema faz default para ADD - é melhor ter uma quase-duplicata do que perder informação. Os jobs de consolidation em background (clustering, deduplicação) limpam duplicatas depois.

Versionamento de Fatos

Fatos são versionados usando janelas de validade temporal (valid_from, valid_to):

  • Fatos ativos têm valid_to = NULL
  • Fatos atualizados recebem tanto valid_to quanto invalidated_at definidos, e um novo fato é criado com supersedes_fact_id apontando para o antigo
  • Fatos deletados recebem tanto valid_to quanto invalidated_at definidos

Isso permite queries de time-travel: você pode perguntar o que o sistema sabia em qualquer momento no tempo.

Paralelo com neurociência

A reconciliação espelha a reconsolidação - o processo pelo qual memórias recuperadas se tornam lábeis e podem ser modificadas. Quando você recupera uma memória ("mora em São Paulo") e encontra nova informação ("acabou de se mudar pro Rio"), a memória original é atualizada. O cérebro não simplesmente sobrescreve - ele cria um novo traço ligado ao original, assim como UPDATE cria um novo fato com supersedes_fact_id.


Estágio 4: Upsert

Em português claro: Aqui as decisões do estágio anterior são de fato salvas no banco. Fatos novos são inseridos, fatos desatualizados são marcados como substituídos, e relacionamentos entre entidades são criados ou reforçados. Tudo roda dentro de uma transação - se um fato falhar ao salvar, os outros ainda passam.

O estágio de upsert executa as decisões de reconciliação no banco de dados:

Decisão Ação no banco
ADD Cria novo MemoryFact com embedding
UPDATE Fecha fato antigo (valid_to = now), cria novo com supersedes_fact_id
NOOP Atualiza last_confirmed_at no fato existente
DELETE Fecha fato (valid_to = now, invalidated_at = now)

Após cada fato ser persistido (ADD ou UPDATE), o pipeline cria entity links conectando o fato a todas as entidades que ele menciona - não só ao subject primário. Isso permite retrieval cross-entity sem duplicar fatos.

Por exemplo, "Clara Rezende saiu da Vertix" é armazenado uma vez com entity_key = person:clara_rezende (subject primário). Mas entity links são criados tanto pra person:clara_rezende (primário) quanto pra organization:vertix (secundário). Quando se busca sobre Vertix, o sistema encontra esse fato via link - sem necessidade de fato duplicado.

Links são criados por match de display names contra o texto do fato (case-insensitive substring match). Nomes muito curtos (< 3 caracteres) são pulados pra evitar false positives. A criação de links é fail-safe: se falhar, o fato persiste normalmente.

Passo a passo: como upsert + entity links funcionam

Fato a persistir: "Clara Rezende joined Orion Tech as head of engineering" (decisão: ADD)

Passo 1 - Criar MemoryFact:

id: fact_abc123
entity_key: person:clara_rezende (subject primário)
fact_text: "Clara Rezende joined Orion Tech as head of engineering"
confidence: 0.95
valid_from: now
valid_to: NULL

Passo 2 - Criar entity links:

Entity map contém: {Clara Rezende → person:clara_rezende, Orion Tech → organization:orion_tech, ...}

Escanear fact_text por display names de entidades:
  "Clara Rezende" encontrado → link (fact_abc123, person:clara_rezende, is_primary=true)
  "Orion Tech" encontrado → link (fact_abc123, organization:orion_tech, is_primary=false)

Passo 3 - Persistir relacionamento:

clara_rezende → works_at → orion_tech (strength: 0.8)
Evidência: fact_abc123 (match porque fact_text menciona tanto "Clara Rezende" quanto "Orion Tech")

Resultado: 1 fato, 2 entity links, 1 relacionamento. Quando alguém perguntar "Quem trabalha na Orion Tech?", o sistema encontra esse fato via o link organization:orion_tech - sem precisar de um fato separado sobre a Orion Tech.

Rastreamento de Relacionamentos

Durante o upsert, relacionamentos extraídos também são persistidos:

  • Cria/atualiza registros MemoryEntityRelationship
  • Resolve entidades de origem e destino via entity map
  • Filtro de self-referencing: relacionamentos onde source e target resolvem para a mesma entidade sao descartados silenciosamente (ex: "caroline child_of caroline"). Isso pode ocorrer quando a normalizacao de aliases faz dois nomes diferentes resolverem para a mesma entity key
  • Reforço de strength: relacionamentos repetidos aumentam strength (inicial: 0.8, reforçado até 1.0 ao longo de múltiplas mensagens)
  • Usa ON CONFLICT DO UPDATE para upserts idempotentes

Relacionamentos são unidirecionais

Escrever "Ana trabalha na Acme" cria ana → works_at → acme_corp, mas não acme_corp → employs → ana. Isso significa que o graph retrieval começando por "Acme Corp" não vai encontrar Ana via relacionamentos (mas pode encontrá-la via similaridade semântica). Para criar ambas as direções, mencione-as explicitamente: "Ana trabalha na Acme. A Acme tem a Ana como data scientist."

Vinculação de Evidência e Cascata de Invalidação

O problema: Sem vinculação entre fatos e relacionamentos, arestas contraditórias se acumulam. Se um usuário diz "moro em Curitiba" e depois "me mudei pra São Paulo", o relacionamento antigo user --[lives_in]--> curitiba continuaria ativo junto com o novo - poluindo o retrieval com contexto obsoleto.

A solução: Cada relacionamento é vinculado ao fato que o sustenta via evidence_fact_id. Quando esse fato é substituído (UPDATE) ou retratado (DELETE), o relacionamento é automaticamente invalidado - sem necessidade de limpeza manual.

Como a vinculação de evidência funciona:

  1. Após os fatos serem persistidos no estágio de upsert, um match heurístico associa cada relacionamento a um fato correspondente. Para um relacionamento (source, target), o matcher busca fatos cujo fact_text menciona ambos os nomes de entidade.
  2. Se múltiplos fatos fizerem match, o de maior confidence é selecionado.
  3. O ID do fato é armazenado como evidence_fact_id no relacionamento.

Quando um fato é invalidado (via UPDATE ou DELETE), a cascata de invalidação automaticamente seta invalidated_at e valid_to em todos os relacionamentos que o referenciam. O BFS do graph retrieval já filtra relacionamentos invalidados, então arestas obsoletas são imediatamente excluídas do contexto.

Usuário: "Moro em Curitiba"
  → fato: "User mora em Curitiba" (fact_1)
  → rel:  user --[lives_in]--> curitiba (evidence_fact_id = fact_1)

Usuário: "Me mudei pra São Paulo"
  → reconciliação: UPDATE fact_1 → fact_2 "User mora em São Paulo"
  → cascata: rel lives_in→curitiba INVALIDADA (evidence_fact_id = fact_1)
  → nova rel: user --[lives_in]--> sao_paulo (evidence_fact_id = fact_2)

Tipos de relacionamento são dinâmicos

O campo rel_type aceita qualquer string descritiva em snake_case - não apenas um conjunto fixo. Tipos comuns incluem works_at, lives_in, family_of, mas o LLM também pode produzir tipos como mentored_by ou inspired_by. Veja Tipos de Relacionamento Dinâmicos para detalhes sobre normalização e aliases.

Fatos-espelho

Às vezes o LLM infere um relacionamento a partir do contexto sem extrair um fato correspondente. Por exemplo, "vou pra Curitiba ver minha mãe" implica mãe --[lives_in]--> curitiba, mas o LLM pode extrair apenas um fato sobre a viagem do usuário - não sobre onde a mãe mora. Sem um fato, o relacionamento não participa da cascata de invalidação e não é encontrável via busca semântica.

Para resolver isso, um fato-espelho é criado automaticamente como fallback quando nenhum match heurístico é encontrado. O fato-espelho é uma frase simples em linguagem natural gerada a partir do relacionamento: "{source_name} {rel_type} {target_name}" (ex: "Mom lives in Curitiba").

Fatos-espelho passam pelo pipeline canônico. A partir da v0.11.7, fatos-espelho não são persistidos diretamente — eles são construídos como ExtractedFact sintéticos e roteados pelo mesmo caminho reconcile_facts → execute_upsert que os fatos extraídos pelo LLM. Isso garante:

  • Dedup semântica: o reconciliador detecta quase-duplicatas como "Pedro lives in Brazil" vs "Pedro lives in Brasil" via similaridade de embedding + raciocínio do LLM — não apenas match de string.
  • Propagação de speaker: o campo speaker é preenchido nos fatos-espelho pelo mesmo mecanismo dos fatos normais, então a proveniência é preservada.
  • Ponto de entrada único: a única forma de criar um MemoryFact no write pipeline é via _add_fact (para ADD) ou _update_fact (para UPDATE versionado). Sem caminhos paralelos.

Fatos-espelho são marcados com:

  • confidence = 0.60 (inferência fraca - prioridade menor no ranking de retrieval)
  • source_context = "inferred_from_relation" (permite filtrar ou reduzir prioridade se necessário)

Fatos-espelho podem ficar stale

Fatos-espelho não são automaticamente invalidados quando o relacionamento de origem é removido. Eles podem persistir como dados obsoletos. Aplicações devem considerar filtrar por source_context quando a acurácia é crítica.

O ID do fato-espelho é usado como evidence_fact_id do relacionamento, então a cascata de invalidação funciona também para relacionamentos inferidos. Quando o reconciliador decide NOOP (um fato equivalente já existe), o ID do fato existente é usado como link de evidência — fortalecendo a cadeia de provenance.

Reduzindo fatos-espelho

Os prompts de extração instruem o LLM a extrair fatos implícitos junto com relacionamentos (ex: "minha mãe mora em Curitiba" como fato, não apenas como relação). Conforme a extração LLM melhora, menos fatos-espelho são necessários - eles são a rede de segurança, não o mecanismo principal.

Segurança Transacional

O write pipeline inteiro roda dentro de uma transação do banco de dados. Upserts individuais de fatos usam savepoints (session.begin_nested()) para que uma falha em um fato não aborte o batch inteiro:

# Se este fato falhar, apenas este savepoint faz rollback
async with session.begin_nested():
    session.add(new_fact)
    await session.flush()

O registro do evento é criado e flushed primeiro, então ele sobrevive mesmo se todos os estágios subsequentes falharem.


WriteResult

Após o pipeline completar, você recebe um WriteResult com observabilidade total:

result = await memory.write(
    agent_id="user_123",
    message="...",
    speaker_name="Rafael",
    recent_messages=["mensagem anterior para resolução de pronomes"],  # opcional
    occurred_at=datetime(2025, 6, 15, tzinfo=UTC),  # opcional, default: now
)

# O que aconteceu
print(result.facts_added)       # Lista de fatos criados
print(result.facts_updated)     # Lista de fatos substituídos
print(result.facts_unchanged)   # Lista de fatos confirmados (decisões NOOP)
print(result.facts_deleted)     # Lista de fatos retratados (decisões DELETE)
print(result.entities_resolved) # Lista de entidades resolvidas
print(result.duration_ms)       # Tempo total do pipeline
print(result.event_id)          # ID único do evento para esta escrita
print(result.tokens_used)       # TokenUsage(input_tokens=..., output_tokens=..., total_tokens=...)
print(result.pipeline)          # PipelineTrace (quando verbose=True)
print(result.success)           # True se o pipeline completou sem erros (sempre verifique)
print(result.error)             # Mensagem de erro se o pipeline falhou (None em sucesso)

Enriquecimento do Trace (verbose=True)

Quando verbose=True, o step de extraction no PipelineTrace inclui metadados adicionais:

Campo Tipo Descrição
relation_retry_triggered bool Se o retry automático de relations foi utilizado
result = await memory.write(agent_id, message, speaker_name="Rafael", verbose=True)
extraction_step = result.pipeline.steps[0]  # "extraction"
print(extraction_step.data["relation_retry_triggered"])  # True/False

Token Usage

tokens_used reporta o total de tokens LLM consumidos em todas as chamadas do pipeline (extraction, entity resolution, reconciliation). Útil pra benchmarking e estimativa de custo.

result = await memory.write(agent_id, message, speaker_name="Rafael")
print(result.tokens_used.input_tokens)   # ex: 1200
print(result.tokens_used.output_tokens)  # ex: 350
print(result.tokens_used.total_tokens)   # ex: 1550

Token tracking requer suporte do provider

tokens_used é populado a partir de LLMResult.usage retornado pelo seu LLMProvider. O OpenAIProvider built-in reporta usage automaticamente. Providers customizados que retornam LLMResult(text=..., usage=None) mostrarão zero tokens.

Config Overrides

Sobrescreva qualquer campo do MemoryConfig pra uma única chamada write() sem criar novo client:

result = await memory.write(
    agent_id="user_123",
    message="...",
    speaker_name="Rafael",
    config_overrides={"extraction_timeout_sec": 60.0},
)

Só as chaves fornecidas são alteradas; todas as outras herdam do config do client. Chaves inválidas emitem warning e são ignoradas. Incompatibilidade de tipo levanta ValueError.

Dry Run

Rode extraction sem persistir nada no banco:

result = await memory.write(
    agent_id="user_123",
    message="Eu moro em São Paulo com minha esposa Ana",
    speaker_name="Rafael",
    dry_run=True,
)
# result.facts_added contém o que SERIA extraído
# result.tokens_used mostra o custo dessa extraction
# Nenhum evento, fato ou entidade é persistido

Útil pra benchmarking: rode a mesma mensagem com dry_run=True e compare tokens_used entre configurações diferentes.


Diagrama do Pipeline (Completo)

Fluxo Padrão (Extração Informada)

flowchart TD
    MSG["User message"] --> EVT["Create MemoryEvent\n(immutable log + embedding)"]
    EVT --> ALIAS["Alias Lookup\n(word-boundary match no cache)"]
    ALIAS --> PRE["Pré-retrieval\n(carregar perfis ou fatos via pgvector)"]
    PRE --> INF["Extração Informada\n(1 chamada LLM: entities + facts + relations + profiles)"]
    INF --> DEDUP["Semantic Dedup\n(remove near-duplicates)"]
    DEDUP --> RES["Entity Resolution\n(exact → fuzzy → LLM)"]
    RES --> UPS["Upsert + Entity Links + Perfis\n(with savepoints)"]
    UPS --> REL["Relationship Tracking\n(strength reinforcement)"]
    REL --> WR["WriteResult"]

Fluxo Fallback (Extração Cega)

flowchart TD
    MSG["User message"] --> EVT["Create MemoryEvent\n(immutable log + embedding)"]
    EVT --> EXT["Extraction\n(Entity Scan → Facts + Relations concurrent)"]
    EXT --> DEDUP["Semantic Dedup\n(remove near-duplicates)"]
    DEDUP --> RES["Entity Resolution\n(exact → fuzzy → LLM)"]
    RES --> REC["Reconciliation\n(ADD / UPDATE / NOOP / DELETE)"]
    REC --> UPS["Upsert + Entity Links\n(with savepoints)"]
    UPS --> REL["Relationship Tracking\n(strength reinforcement)"]
    REL --> WR["WriteResult"]