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:
- Guard - Mensagens vazias retornam imediatamente. Sem evento, sem chamada LLM, sem tokens consumidos.
- Registrar o evento - A mensagem bruta e salva como trilha de auditoria imutavel (nunca modificada ou deletada). O timestamp do evento usa
occurred_atse fornecido, caso contrario usa o momento atual. - Detectar emoção - Classifica a emoção, intensidade e nível de energia da mensagem.
- Alias lookup + Pré-retrieval - Escaneia a mensagem por entidades conhecidas e carrega contexto existente (perfis de entidade ou fatos via pgvector).
- 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.
- Resolver entidades - Deduplica menções ("Ana", "minha esposa Ana", "Aninha") em uma única entidade canônica.
- 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:
-
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.
-
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)
- Quando a entidade tem um perfil (
-
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) eimportance_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:
- Entity scan - Identifica todas as entidades mencionadas na mensagem
- 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:
- Timestamp do evento - O
MemoryEvent.occurred_atrecebe esse valor (em vez denow) - 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."
}
}
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:
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."
- Extração: Dois nomes encontrados: "Guili" e "Guilherme"
- Fase 1 (exata): "Guilherme" bate com a entidade existente
person:guilherme_maturana - Fase 2 (fuzzy): "Guili" tem 0.87 de similaridade com "Guilherme" → resolve automaticamente
- 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, comspeaker_name="Rafael", "I live in São Paulo" resolve "I" paraperson: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çatype="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:
- Intra-mensagem - Agrupamento de aliases na extração previne duplicatas dentro de uma mesma mensagem
- 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:
- Busca fatos existentes para a mesma entidade
- Calcula similaridade entre o novo fato e cada fato existente (via embeddings)
- 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:
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_toquantoinvalidated_atdefinidos, e um novo fato é criado comsupersedes_fact_idapontando para o antigo - Fatos deletados recebem tanto
valid_toquantoinvalidated_atdefinidos
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) |
Fact-Entity Links¶
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 UPDATEpara 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:
- 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 cujofact_textmenciona ambos os nomes de entidade. - Se múltiplos fatos fizerem match, o de maior confidence é selecionado.
- O ID do fato é armazenado como
evidence_fact_idno 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
MemoryFactno 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"]