TL;DR: Submetemos dois motores de grafo ao mesmo alvo, 200 milhões de nós e 100 milhões de arestas, a partir da mesma fonte federada e da mesma ontologia. O banco híbrido completou os 300 milhões de elementos em 17h11 de ingestão, com 29,7 GiB de RAM fixos, 184 GB de disco e busca pontual entre 1,2 e 3,9 ms, por US$ 353 por mês. O banco em memória serviu 249,3 milhões de elementos, o que coube nos 175 GiB da instância testada, com busca pontual de 0,16 a 0,35 ms e custo de US$ 1.331 por mês. A régua de corte que adotamos ficou em torno de 5 milhões de elementos.
O grafo é a camada que os agentes efetivamente consultam. Antes dele existe a ontologia, um arquivo graph_mapping.yaml versionado por cliente, que declara quais tabelas viram nós, quais colunas viram arestas e quais propriedades cada entidade carrega. O Memory Worker lê essa ontologia e materializa o grafo, servido depois via Cypher e MCP.
As fontes podem chegar de dois jeitos: pela camada clean do lakehouse ou por federação, lendo o sistema de origem em lugar, sem copiar. Nesta campanha usamos federação. O objetivo não era medir ingestão de lake, era descobrir onde cada motor de grafo para, quanto custa até lá e o que entrega de leitura.
A base veio do Databricks, sob Unity Catalog, lida por federação sem materializar nada em raw ou clean. São 100 milhões de usuários e 100 milhões de tickets, um ticket por usuário, com Z-order aplicado por id e por ticket_id, o que organiza fisicamente os arquivos pela coluna de busca. O leitor federado fatia a origem por faixas de chave e pede uma consulta por fatia, com FEDERATED_SLICE_ROWS em 250 mil linhas.
O grafo alvo é raso e largo: 200 milhões de nós, 100 milhões de arestas, uma única relação. O desenho é deliberado, para medir travessia em vez de complexidade de modelo.
A ontologia cabe em uma tela. User vem da tabela federada de usuários, identificado por id. Ticket vem da de tickets, identificado por ticket_id. A aresta OPENED_BY é construída com duas colunas da própria linha do ticket, ticket_id como origem e user_id como destino, sem precisar consultar a tabela de usuários.
Mesma fonte, mesma ontologia, mesmo pipeline, dois motores com filosofias opostas de memória.
| Dimensão | FalkorDB 4.4.1, banco em memória | Neo4j 5.26 CE, banco híbrido |
|---|---|---|
| Elementos servidos | 249,3M, limite da instância testada | 300M, alvo completo |
| Nós e arestas | 174,6M nós · 74,6M arestas | 200M nós · 100M arestas |
| Tempo de carga | 27h08 nas duas fases de ingestão | 17h11 nas duas fases de ingestão |
| Modelo de memória | emergente, cresce com o dado | configurada, teto fixo |
| RAM em regime | 175 GiB | 29,7 GiB |
| Disco do motor | 119 GB, só durabilidade | 184 GB, é o caminho de leitura |
| Busca pontual p50 | 0,16–0,35 ms | 1,2–3,9 ms |
| Vazão de leitura | ~4.100 qps | ~1.000 qps |
| p95 com 40 simultâneas | 11,1 ms | 57,7 ms |
| Instância | r7g.8xlarge, 32 vCPU, 256 GiB | r7g.2xlarge, 8 vCPU, 61 GiB |
| Infra mensal | US$ 1.331 | US$ 353 |
A diferença de volume servido entre os dois vem da linha de memória, e é ela que explica todo o resto.
Memória emergente. No banco em memória, a RAM é o próprio dado. A curva medida em 15,8 mil amostras de 15 segundos mostrou 0,92 GiB por milhão de nós na fase de usuários e 1,13 GiB por milhão de pares nó mais aresta na fase de tickets. A média de 0,70 GiB por milhão de elementos é a régua de capacity planning que este benchmark estabeleceu. Cada crescimento relevante do grafo vira uma decisão de instância.
Memória configurada. No banco híbrido, a RAM é definida antes da carga: 8 GiB de heap e 20 GiB de page cache, com o consumo total parando em 29,7 GiB. De 40 milhões para 200 milhões de nós, cinco vezes mais dado, a memória andou 1 GiB. O que cresce é o disco, cerca de 920 bytes por nó, o que leva os 300 milhões de elementos para 184 GB de EBS.
Os 29,7 GiB medidos se dividem assim: 8 GiB reservados de heap, com uso oscilando entre 1,7 e 6,1 GiB, 20 GiB fixos de page cache guardando a parte quente do grafo, principalmente índices, uma fatia de 130 MiB de pilhas de thread e buffers nativos, e o restante como sobrecarga da própria JVM. A fatia nativa é a única sem teto configurável e incha durante os checkpoints, por isso o contêiner precisa ser dimensionado com folga sobre a soma de heap e page cache.
Os dois motores apoiam-se em um Postgres que guarda o cadastro de entidades e o índice de resolução, e ele não é pequeno. Na campanha do híbrido somou 80 GB; na do banco em memória, 73 GB, divididos entre 29 GB de registro e 43 GB de índice.
Somando motor e registro, a carga do híbrido ocupou cerca de 264 GB. O log de transação ficou em 1,3 GB porque havia política de retenção configurada; sem ela, teria passado de 200 GB durante a carga.
Esses dois números costumam ficar fora do dimensionamento inicial e aparecem como incidente de disco no meio da primeira carga grande.
Bytes por elemento não é constante do motor, é consequência do modelo. Duas medições nossas, com a mesma tecnologia:
| Cenário | Elementos | Bytes por elemento |
|---|---|---|
| Nós enxutos, só id e nome | 21M | ~126 |
| Mistura de nós e arestas com propriedades | 249,3M | ~750 |
A diferença é quanta propriedade cada nó carrega. O efeito na conta de AWS é direto. Pela régua de 0,70 GiB por milhão, 150 milhões de elementos com propriedades pedem cerca de 105 GiB de RAM, que com folga para consulta e para o fork de persistência empurram a escolha para a classe de 256 GiB. Os mesmos 150 milhões com nós enxutos ficam perto de 18 GiB e cabem em uma instância de 32 GB.
A decisão de modelagem que parece inofensiva, colocar mais três atributos em cada nó porque "pode ser útil depois", é uma decisão de infraestrutura. O critério que adotamos é levar para o nó apenas o que a travessia precisa e manter o resto na origem, recuperado sob demanda.
As 17 e 27 horas de carga assustam quando vistas isoladas. Elas são eventos únicos, e o regime permanente é a sincronização incremental, que processa apenas as mudanças desde a última execução.
Dentro da carga, aresta custa mais que nó. No híbrido, a fase de tickets levou 10h48 contra 6h23 da fase de usuários, porque cada aresta faz duas buscas no índice, verifica se o relacionamento já existe, grava o registro e atualiza as listas encadeadas dos dois nós. No banco em memória, a mesma assimetria apareceu: 3.188 linhas por segundo nos usuários contra 1.126 na fase de tickets com arestas.
Duas observações operacionais da campanha. O watermark usado foi created_at, e para tabelas mutáveis a recomendação é updated_at ou configuração direta no conector, sob risco de perder atualização de registro existente. E o lote de escrita no grafo, MEMORY_WORKER_BATCH_SIZE, é o tamanho da transação: ele é independente da fatia de leitura federada e é o parâmetro que mais mexe no tempo total de carga.
Busca pontual é a consulta que entra direto em uma entidade conhecida, pelo identificador indexado, e navega dali para os vizinhos. É a pergunta "quem abriu este ticket" e a pergunta "o que esta conta tem em aberto". Em atendimento e em agentes conversacionais, ela é o padrão dominante, e a latência dela não deveria variar com o tamanho do grafo.
| Cenário | Banco em memória | Banco híbrido |
|---|---|---|
| Lookup por entidade indexada | 0,16 ms | 1,3 ms |
| Ticket e quem o abriu | 0,35 ms | 3,9 ms |
| Histórico do usuário | 0,21 ms | 3,1 ms |
| Contagem total de nós | 6,0 ms | 1,3 ms |
| Lista filtrada com LIMIT 3 | 268 ms | 1,8 ms |
Os dois entregam milissegundos na busca pontual, com uma ordem de grandeza entre eles, e a latência se manteve insensível ao volume nos dois casos.
Sob concorrência, a distância aumenta:
| Simultâneas | Em memória p50 / p95 | Híbrido p50 / p95 |
|---|---|---|
| 1 | 0,35 / 0,52 ms | 2,8 / 6,3 ms |
| 10 | 1,99 / 3,89 ms | 8,7 / 19,5 ms |
| 40 | 10,36 / 11,12 ms | 35,9 / 57,7 ms |
O pico de vazão ficou em cerca de 4.100 consultas por segundo no banco em memória e 1.000 no híbrido. Na prática, 1.000 consultas por segundo equivalem a 2,6 bilhões de consultas por mês, bem acima do que uma operação de atendimento consome.
E há o detalhe que supera qualquer escolha de motor. A mesma busca com índice leva 1,3 ms no híbrido e 9.520 ms sem índice, uma diferença de 7.300 vezes. No banco em memória, 0,16 ms contra 10.777 ms. Busca pontual só é pontual se a entidade estiver indexada; sem índice, o motor varre o grafo inteiro e qualquer vantagem de arquitetura desaparece.
A plataforma alterna entre os dois por configuração, com GRAPH_BACKEND, sem mudança de código.
Até cerca de 5 milhões de elementos: banco em memória, como vem. Cabe na fatia de memória da VM inicial, entrega leitura sub-milissegundo e não exige operação adicional. É a situação da maioria dos clientes na largada.
Acima disso: banco híbrido. A memória do motor em memória cresce 0,70 GiB por milhão de elementos, o que obriga a subir de instância a cada crescimento e chega a US$ 1.331 por mês na casa dos 250 milhões. O híbrido segura de 10 milhões a 300 milhões com a mesma RAM configurada e escala em disco, que é barato e elástico.
A validação do corte são os US$ 353 mensais que serviram 300 milhões de elementos com busca pontual entre 1 e 4 ms, suficiente para atendimento e agentes. Com savings plan de um ano esse valor cai para cerca de US$ 230, e para US$ 165 em três anos.
| Parâmetro | Banco em memória | Banco híbrido |
|---|---|---|
| Contêiner | 200 GiB | 40 GB |
| Teto de memória do motor | 180 GiB, noeviction | heap 8 GB + page cache 20 GB |
| Persistência | AOF e snapshots | checkpoints, log com retenção de 1 GB |
| Worker de ingestão | 12 GiB | 12 GiB |
| Limite do DuckDB | 6 GB | 6 GB |
| Lote de escrita no grafo | 40.000 | 40.000 |
| Fatia de leitura federada | 250.000 | 250.000 |
Dois limites merecem atenção. O contêiner precisa ficar acima de heap mais page cache, porque a JVM usa memória além dos dois. E o limite do DuckDB precisa ser declarado, senão ele se dimensiona pelo host e não pelo contêiner.
created_at não captura atualização de registro existente, e em tabela mutável isso vira dado velho no grafo sem nenhum erro aparente.Os dados são sintéticos e deterministas, o que torna os resultados reprodutíveis, e o código exercitado foi o de produção, com os mesmos conectores, o mesmo leitor federado e o mesmo MemoryWorkerPipeline.
O grafo testado tem uma única relação e um ticket por usuário. Grafos com muitas relações por nó mudam tanto o custo de travessia quanto o peso por elemento, então os números valem como régua de dimensionamento e não como garantia.
O dado é limpo por construção. Uma rodada com dado sujo, incluindo referências quebradas, entidades duplicadas e campos malformados, está planejada, assim como cenários de leitura com mais usuários simultâneos do que os testados aqui.
Porque o mais rápido na leitura é o mais caro na memória. A 249,3 milhões de elementos, o banco em memória exigiu uma instância de US$ 1.331 por mês, contra US$ 353 do híbrido servindo 300 milhões de elementos com latência adequada para agentes.
Não. A ontologia e o pipeline são os mesmos, e a troca é de configuração. O que precisa ser refeito é a carga do grafo, que é reconstruída a partir da fonte.
Sim, e é o desenho padrão. Até cerca de 5 milhões de elementos o grafo roda embarcado na VM inicial, e a mudança de motor acontece por configuração quando o volume passar disso.
Neste caso, para isolar a medição do motor de grafo. Quando a empresa já tem lakehouse ou warehouse, a federação também evita duplicar dado que já está governado na origem.
Comportamento com dado sujo e leitura sob concorrência acima dos cenários testados. Ambos são próximos passos.
Fale com um especialista da Strattum sobre a escolha de motor de grafo, custo de memória e capacidade para o seu caso.