Dois comentários em HN, dois releases em 24 horas: LatticeDB substitui Neo4j + Qdrant + Elastic em um arquivo
Em 25 de agosto às 16:52 UTC apareceu um Show HN intitulado “LatticeDB – Like SQLite but for graph databases”. No momento em que escrevo isto, está em 90 pontos e 30 comentários: uma thread decente, não uma viral.
Dois desses comentários são a razão pela qual este artigo existe.
itissid perguntou se havia algo tipo Litestream para fazer backup da base de dados em casos de produção. O autor respondeu: “I am in the final stages of adding this based on your comment. Just wrapping up doc updates and will push a new release with hot copy functionality tonight!” — está na etapa final de adicionar isso a partir desse comentário, e publicaria o release naquela mesma noite. A versão 0.12.0 saiu às 00:05 da manhã seguinte, com lattice backup, lattice replicate e lattice restore.
vladigtr perguntou como são tratados escritores concorrentes em um único arquivo. O autor admitiu que havia controle dentro de um processo mas não segurança entre processos, e se comprometeu a adicionar file locking. A versão 0.13.0 saiu às 13:08 daquele mesmo dia, com locking obrigatório no próprio arquivo de base de dados.
Duas perguntas de desconhecidos na internet, dois releases, menos de vinte e quatro horas. Esse ritmo é o primeiro que você precisa saber sobre este projeto — e, como vamos ver, corta para os dois lados.
O que é LatticeDB na prática
Uma base de dados property-graph embarcada escrita em Zig, licença MIT, sem dependências. Um arquivo, sem servidor, sem configuração. O que a torna interessante não é a parte de grafo por si só: é que a traversal de grafo, a busca vetorial HNSW e o full-text BM25 vivem no mesmo motor, sob a mesma linguagem de consulta.
Essa linguagem é Cypher, com dois operadores adicionados: <=> para distância vetorial e @@ para match de texto completo. Ou seja, uma única consulta pode fazer as três coisas ao mesmo tempo:
-- Find chunks similar to a query, traverse to their document, then to the author
MATCH (chunk:Chunk)-[:PART_OF]->(doc:Document)-[:AUTHORED_BY]->(author:Person)
WHERE chunk.embedding <=> $query_vector < 0.3
AND doc.content @@ "neural networks"
RETURN doc.title, chunk.text, author.name
ORDER BY chunk.embedding <=> $query_vector
LIMIT 10
Se você já montou um pipeline de Graph RAG, sabe o que esse snippet substitui: Neo4j para as relações, Qdrant ou um Postgres com pgvector para os vetores, Elastic ou uma tabela FTS5 para a camada léxica, e depois seu próprio código unindo três conjuntos de IDs e confiando que os três stores concordem sobre o que existe.
Vale notar que o próprio projeto resiste a esse enquadramento. A descrição do repo diz “for AI/RAG apps”, mas o README descreve a memória de agentes e RAG como “one example class of workload built on the graph/vector/text substrate, not the definition of the engine” — uma classe de workload construída sobre o substrato, não a definição do motor. O motor está sendo construído como motor. RAG é um cliente.
Instalá-lo e rodá-lo
Tudo o que se segue executei em um container Linux: Xeon de 2 cores a 2,8 GHz, 7 GB de RAM. Onde aparece um número, saiu de uma corrida real, não do README.
O caminho mais curto é Python, e há wheels binários publicados para manylinux x86_64 e aarch64 além de macOS arm64 e x86_64, então nada é compilado ao instalar:
pip install latticedb
Isso hoje te dá a 0.13.0, a mesma versão que trouxe o locking, publicada no PyPI no mesmo dia. Também há CLI (curl -fsSL https://raw.githubusercontent.com/jeffhajewski/latticedb/main/dist/install.sh | bash), um pacote npm (@hajewski/latticedb) e bindings de Go sobre cgo.
Agora o exemplo do README, executado tal qual. Constrói um knowledge graph mínimo de autores, documentos e chunks, salva um embedding por chunk, indexa seu texto, e depois consulta cruzando os três modos:
from latticedb import Database
from latticedb.embedding import hash_embed
with Database("knowledge.db", create=True, enable_vectors=True, vector_dimensions=128) as db:
# --- Build the graph ---
with db.write() as txn:
alice = txn.create_node(labels=["Person"], properties={"name": "Alice", "field": "ML"})
bob = txn.create_node(labels=["Person"], properties={"name": "Bob", "field": "Systems"})
txn.create_edge(alice.id, bob.id, "COLLABORATES_WITH")
for title, text, author in [
("Attention Is All You Need", "The transformer architecture uses self-attention...", alice),
("Scaling Laws for LLMs", "We find that model performance scales predictably...", alice),
("Log-Structured Merge Trees", "LSM trees optimize write-heavy workloads...", bob),
]:
doc = txn.create_node(labels=["Document"], properties={"title": title})
chunk = txn.create_node(labels=["Chunk"], properties={"text": text})
txn.set_vector(chunk.id, "embedding", hash_embed(text, dimensions=128))
txn.fts_index(chunk.id, text)
txn.create_edge(chunk.id, doc.id, "PART_OF")
txn.create_edge(doc.id, author.id, "AUTHORED_BY")
txn.commit()
# --- Vector search + graph traversal in one query ---
results = db.query("""
MATCH (chunk:Chunk)-[:PART_OF]->(doc:Document)-[:AUTHORED_BY]->(author:Person)
WHERE chunk.embedding <=> $query < 0.5
RETURN doc.title, chunk.text, author.name
ORDER BY chunk.embedding <=> $query
LIMIT 5
""", parameters={"query": hash_embed("transformer attention mechanism", dimensions=128)})
for row in results:
print(f"{row['doc.title']} by {row['author.name']}")
# --- Full-text search ---
for r in db.fts_search("self-attention transformer"):
print(f"Node {r.node_id}: score={r.score:.4f}")
Saída real:
Attention Is All You Need by Alice
Node 4: score=3.0197
Funciona na primeira tentativa, que para uma base de dados pré-1.0 escrita em uma linguagem pré-1.0 não é pouca coisa. Cuidado com hash_embed: é um hash embedding incorporado, útil para que o exemplo rode sem um serviço de embeddings por trás. Para algo real, LatticeDB traz um cliente HTTP para Ollama e OpenAI.
Um detalhe que o pitch de „um único arquivoSaída, com um escritor segurando o arquivo:
segundo escritor: LatticeDatabaseLockedError: Database is open in another process
leitor durante uma escrita: LatticeDatabaseLockedError: Database is open in another process
leitor com lock=False: OK -> [{'n': 2}]
Leia com atenção a linha do meio, porque aí está a mudança que quebra compatibilidade. Um handle de somente leitura agora é rejeitado enquanto um escritor tem o arquivo — não enfileirado, rejeitado. A razão é honesta: um leitor não consegue ver as páginas em buffer do escritor nem seu WAL, então atendê-lo seria mentir para ele. O equivalente na CLI diz sem voltas:
social.lattice is open in another process. Close it first, or pass --no-lock
E --no-lock (lock=False em Python) faz exatamente o que meu terceiro caso mostra: lê normal. Em sistemas de arquivos onde o locking não funciona, essa é sua saída de emergência. Em todos os demais, é o comportamento que a 0.13.0 veio eliminar. Se você tem um dashboard lendo o mesmo arquivo que seu processo de ingestão escreve, este release é o dia em que sua arquitetura mudou.
A tabela comparativa, desmontada
O README traz uma análise competitiva que vai ser citada, então convém ser preciso sobre o que estabelece e o que não.
Para busca vetorial com 1M de vetores, o LatticeDB está em 0,83 ms de média com 100% de recall@10, contra FAISS single-threaded em 0,5–3 ms, Weaviate em 1,4 ms, Qdrant em ~1–2 ms, pgvector HNSW em ~5 ms, LanceDB em 3–5 ms, Chroma em 4–5 ms e sqlite-vec em 17 ms.
Três coisas para ter em mente:
O hardware não é o mesmo. Os números do LatticeDB são próprios, medidos em um Apple M1, single-threaded. Todas as demais linhas são cifras publicadas por terceiros em hardware que a tabela não nomeia. O projeto é escrupuloso em linkar cada fonte — não há números inventados aqui — mas uma tabela onde uma linha é sua máquina e nove linhas são as máquinas de outros mede o trabalho de citação, não o desempenho.
sqlite-vec não está fazendo o mesmo trabalho. Esses 17 ms são força bruta, um escaneamento exaustivo, não um índice aproximado. Comparar HNSW contra força bruta em 1M de vetores te diz que índices vencem buscas, coisa que já sabíamos. A leitura honesta daquela linha é que sqlite-vec escolheu outro trade-off — resultados exatos, custo zero de construção de índice — não que seja vinte vezes pior.
A linha do pgvector compara um motor embarcado contra uma extensão dentro de um servidor. Esses ~5 ms do pgvector incluem Postgres sendo Postgres: uma conexão, um planner, MVCC. Se você já tem Postgres rodando para tudo mais, esses 5 ms te compram a maturidade operacional que a própria seção de “quando usar outra coisa” do README reconhece que o LatticeDB não tem.
Onde a tabela é forte é na parte de grafo, porque as duas metades foram medidas na mesma máquina pela mesma pessoa: LatticeDB contra CTEs recursivas do SQLite, 14x em dois hops sobre 100K nós, e abrindo-se em três ordens de magnitude em traversals profundos, onde o overhead da CTE se acumula a cada nível de recursão. Essa comparação é reproduzível com zig build graph-benchmark -- --quick, e é a que eu citaria.
O número que ninguém publica
Olhe todas as tabelas de benchmark daquele README e repare na coluna que nenhuma tem: quanto tempo leva para construir o índice.
Tudo publicado é do lado da consulta. Latência de lookup, latência de busca, recall, memória. Nada sobre ingestão. Então eu medi — vetores de 128 dimensões, batch_insert_vectors, naquele container Xeon de 2 cores:
| Vetores | Tempo de ingestão | Taxa |
|---|---|---|
| 1.000 | 6,6 s | 151 vec/s |
| 2.000 | 15,4 s | 130 vec/s |
| 4.000 | 38,0 s | 105 vec/s |
| 10.000 | 124,6 s | 80 vec/s |
Construir um índice HNSW sobre dez mil vetores levou pouco mais de dois minutos, e a taxa se degrada conforme o grafo cresce — comportamento esperado em HNSW, não um defeito. O chamativo é a cifra absoluta em hardware modesto, e que você não encontre em lugar nenhum da documentação do projeto.
Para um pipeline de Graph RAG, este é o número que você realmente planeja. A latência de consulta é o que seu usuário sente uma vez; a ingestão é o que você paga cada vez que o corpus muda. Se você vai indexar um repositório, um conjunto de documentação ou a memória acumulada de um agente, orçamente isso.
As ressalvas de sempre se aplicam e são importantes: isto é um container cloud de 2 cores, não um M1, e usei vetores gaussianos aleatórios na esfera unitária — o pior caso possível para HNSW, porque não há estrutura de clusters para aproveitar. Seus números absolutos vão ser diferentes. A forma da curva, não.
Uma anomalia na tabela de sensibilidade
Já que estamos nos benchmarks, há algo na tabela de ef_search do README em 1M de vetores que não fecha:
| ef_search | Latência média | Recall@10 |
|---|---|---|
| 16 | 506 μs | 57% |
| 32 | 1,9 ms | 79% |
| 64 | 990 μs | 100% |
| 128 | 3,2 ms | 100% |
A latência deveria subir de forma monotônica com ef_search: você está pedindo à busca que explore mais candidatos. Aqui ef=32 é quase o dobro de lento que ef=64. Na minha própria corrida a curva se comporta como corresponde: 1,6 ms, 2,1 ms, 3,0 ms, 4,7 ms para esses mesmos quatro valores.
Parece ruído de uma única corrida de medição mais do que algo estrutural. Vale a pena apontar porque é a mesma tabela que sustenta a cifra estrela de 0,83 ms em ef=64 — e se uma célula da linha tem ruído, a célula ao lado merece o mesmo ceticismo até que alguém rode novamente zig build vector-benchmark em sua própria máquina. Coisa que, tem que se reconhecer, você consegue fazer.
Quando usar outra coisa
O README tem uma seção com esse nome e é unusualmente franca, então a aponto em vez de parafrasear: modelo embarcado de um único escritor, ou seja nada de acesso multi-cliente pela rede; se seus dados são genuinamente tabulares, vão no SQLite ou Postgres; nada de sharding nem replicação entre máquinas; OPTIONAL MATCH e os procedimentos CALL não estão implementados, então o Cypher é quase todo Cypher, não todo; e não há ecosistema — sem ferramentas de visualização, sem dashboards de administração, sem drivers em todas as linguagens.
Esse último ponto é o custo real de adotá-lo hoje, e convém pesar contra o que mostra o histórico de versões.
Então, você usa ou não?
Isso dos dois releases em um dia é um sinal genuíno de um maintainer presente e atento. É também um sinal sobre maturidade. Este projeto passou de 0.11.1 a 0.13.0 em umas 25 horas. O README ainda lista notas de release até a 0.10.0, três versões atrás do que pip te dá. O locking entre processos — uma propriedade que a maioria dá por garantida em qualquer banco de dados — chegou hoje, o que significa que ontem não estava, o que significa que qualquer um que rodasse um leitor junto a um escritor antes de hoje estava recebendo exatamente os resultados inconsistentes que agora descrevem as notas do release.
Nada disso é motivo para pular. É motivo para escalonar:
- Hoje:
pip install latticedb, rode o exemplo de cima, e veja se uma única consulta Cypher sobre relações, vetores e texto simplifica algo que você hoje cola na mão. Custa quinze minutos. - Esta semana: aponte para um corpus real que você já tem embarcado em outro lugar. Meça sua própria ingestão antes de se comprometer com uma cadência de atualização.
- Antes de produção: fixe a versão. Em um projeto que publica vários releases por dia, “latest” não é uma especificação de dependência. E teste
lattice backup— existe desde ontem à noite.
O que torna LatticeDB digno de acompanhamento não é que seja mais rápido que Neo4j. É que aquilo que desaba — três serviços, três linguagens de consulta, três conjuntos de IDs para reconciliar — é uma pilha de complexidade acidental que os pipelines de Graph RAG simplesmente aceitaram como o custo de fazer negócios. Alguém meteu isso em um arquivo, em Zig, sob MIT, e funciona.
E você? Quantos serviços você está rodando hoje só para responder uma pergunta que toca relacionamentos, significado e texto ao mesmo tempo? Você os colapsaria em um único arquivo, ou perder o ecossistema é um preço muito alto?