Dos comentarios en HN, dos releases en 24 horas: LatticeDB reemplaza Neo4j + Qdrant + Elastic en un archivo
El 25 de agosto a las 16:52 UTC apareció un Show HN titulado “LatticeDB – Like SQLite but for graph databases”. Al momento de escribir esto va en 90 puntos y 30 comentarios: un thread decente, no uno viral.
Dos de esos comentarios son la razón por la que existe este artículo.
itissid preguntó si había algo tipo Litestream para respaldar la base de datos en casos de producción. El autor respondió: “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á en la etapa final de agregarlo a partir de ese comentario, y publicaría el release esa misma noche. La versión 0.12.0 salió a las 00:05 de la mañana siguiente, con lattice backup, lattice replicate y lattice restore.
vladigtr preguntó cómo se manejan escritores concurrentes sobre un solo archivo. El autor admitió que existía control dentro de un proceso pero no seguridad entre procesos, y se comprometió a agregar file locking. La versión 0.13.0 salió a las 13:08 de ese mismo día, con locking obligatorio sobre el propio archivo de base de datos.
Dos preguntas de desconocidos en internet, dos releases, menos de veinticuatro horas. Ese ritmo es lo primero que necesitas saber de este proyecto — y, como vamos a ver, corta para los dos lados.
Qué es LatticeDB en concreto
Una base de datos property-graph embebida escrita en Zig, licencia MIT, sin dependencias. Un archivo, sin servidor, sin configuración. Lo que la vuelve interesante no es la parte de grafo por sí sola: es que la traversal de grafo, la búsqueda vectorial HNSW y el full-text BM25 viven en el mismo motor, bajo el mismo lenguaje de consulta.
Ese lenguaje es Cypher, con dos operadores agregados: <=> para distancia vectorial y @@ para match de texto completo. O sea que una sola consulta puede hacer las tres cosas a la vez:
-- 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
Si alguna vez armaste un pipeline de Graph RAG, sabes qué reemplaza ese snippet: Neo4j para las relaciones, Qdrant o un Postgres con pgvector para los vectores, Elastic o una tabla FTS5 para la capa léxica, y después tu propio código cosiendo tres conjuntos de IDs y confiando en que los tres stores coincidan sobre qué existe.
Vale la pena notar que el propio proyecto se resiste a ese encuadre. La descripción del repo dice “for AI/RAG apps”, pero el README describe la memoria de agentes y RAG como “one example class of workload built on the graph/vector/text substrate, not the definition of the engine” — una clase de workload construida sobre el sustrato, no la definición del motor. El motor se está construyendo como motor. RAG es un cliente.
Instalarlo y hacerlo correr
Todo lo que sigue lo ejecuté en un contenedor Linux: Xeon de 2 cores a 2,8 GHz, 7 GB de RAM. Donde aparece un número, salió de una corrida real, no del README.
El camino más corto es Python, y hay wheels binarias publicadas para manylinux x86_64 y aarch64 además de macOS arm64 y x86_64, así que no se compila nada al instalar:
pip install latticedb
Eso hoy te da la 0.13.0, la misma versión que trajo el locking, publicada en PyPI el mismo día. También hay CLI (curl -fsSL https://raw.githubusercontent.com/jeffhajewski/latticedb/main/dist/install.sh | bash), un paquete npm (@hajewski/latticedb) y bindings de Go sobre cgo.
Ahora el ejemplo del README, corrido tal cual. Construye un knowledge graph mínimo de autores, documentos y chunks, guarda un embedding por chunk, indexa su texto, y después consulta cruzando los tres 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}")
Salida real:
Attention Is All You Need by Alice
Node 4: score=3.0197
Funciona al primer intento, que para una base de datos pre-1.0 escrita en un lenguaje pre-1.0 no es poco. Ojo con hash_embed: es un hash embedding incorporado, útil para que el ejemplo corra sin un servicio de embeddings detrás. Para algo real, LatticeDB trae un cliente HTTP para Ollama y OpenAI.
Un detalle que el pitch de “un solo archivo” pasa por alto: después de ese bloque, el directorio tiene knowledge.db y knowledge.db-wal. El mismo arreglo que SQLite — un archivo más un write-ahead log. No es un problema, pero si tu estrategia de despliegue es “copio el archivo”, el log es parte del archivo.
El lock que salió hoy, probado entre dos procesos
Esta es la parte más interesante de verificar, porque tiene horas de vida.
Hasta la 0.13.0, LatticeDB no bloqueaba la base de datos entre procesos. Las notas del release son directas sobre lo que eso significaba: las operaciones de solo lectura corrían durante escrituras y “could return stale or inconsistent results” — podían devolver resultados viejos o inconsistentes. Ahora un handle de lectura-escritura toma el lock en exclusiva, uno de solo lectura lo comparte, y un segundo escritor es rechazado. El lock vive sobre el archivo abierto en vez de en un lock file aparte, así que si un proceso se cae lo libera solo.
El binding de Python lo expone como un parámetro lock=True en Database. Esto es lo que pasa con un proceso sosteniendo un handle y otro intentando entrar:
from latticedb import Database, LatticeDatabaseLockedError
for kwargs, label in [
({}, "segundo escritor"),
({"read_only": True}, "lector durante una escritura"),
({"read_only": True, "lock": False}, "lector con lock=False"),
]:
try:
with Database("knowledge.db", **kwargs) as db:
print(f"{label}: OK -> {[r for r in db.query('MATCH (p:Person) RETURN count(p) AS n')]}")
except Exception as e:
print(f"{label}: {type(e).__name__}: {e}")
Salida, con un escritor sosteniendo el archivo:
segundo escritor: LatticeDatabaseLockedError: Database is open in another process
lector durante una escritura: LatticeDatabaseLockedError: Database is open in another process
lector con lock=False: OK -> [{'n': 2}]
Lee con atención la línea del medio, porque ahí está el cambio que rompe compatibilidad. Un handle de solo lectura ahora es rechazado mientras un escritor tiene el archivo — no encolado, rechazado. La razón es honesta: un lector no puede ver las páginas en buffer del escritor ni su WAL, así que atenderlo sería mentirle. El equivalente en la CLI lo dice sin vueltas:
social.lattice is open in another process. Close it first, or pass --no-lock
Y --no-lock (lock=False en Python) hace exactamente lo que muestra mi tercer caso: lee igual. En sistemas de archivos donde el locking no funciona, esa es tu salida de emergencia. En todos los demás, es el comportamiento que la 0.13.0 vino a eliminar. Si tienes un dashboard leyendo el mismo archivo que escribe tu proceso de ingesta, este release es el día en que tu arquitectura cambió.
La tabla comparativa, desarmada
El README trae un análisis competitivo que va a terminar citado, así que conviene ser preciso sobre qué establece y qué no.
Para búsqueda vectorial con 1M de vectores lista a LatticeDB en 0,83 ms de media con 100% de recall@10, contra FAISS single-threaded en 0,5–3 ms, Weaviate en 1,4 ms, Qdrant en ~1–2 ms, pgvector HNSW en ~5 ms, LanceDB en 3–5 ms, Chroma en 4–5 ms y sqlite-vec en 17 ms.
Tres cosas para tener presentes:
El hardware no es el mismo. Los números de LatticeDB son propios, medidos en un Apple M1, single-threaded. Todas las demás filas son cifras publicadas por terceros sobre hardware que la tabla no nombra. El proyecto es escrupuloso en enlazar cada fuente — no hay números inventados acá — pero una tabla donde una fila es tu máquina y nueve filas son las máquinas de otros mide el trabajo de citación, no el rendimiento.
sqlite-vec no está haciendo el mismo trabajo. Esos 17 ms son fuerza bruta, un escaneo exhaustivo, no un índice aproximado. Comparar HNSW contra fuerza bruta a 1M de vectores te dice que los índices le ganan a los escaneos, cosa que ya sabíamos. La lectura honesta de esa fila es que sqlite-vec eligió otro trade-off — resultados exactos, cero costo de construcción de índice — no que sea veinte veces peor.
La fila de pgvector compara un motor embebido contra una extensión dentro de un servidor. Esos ~5 ms de pgvector incluyen a Postgres siendo Postgres: una conexión, un planner, MVCC. Si ya tienes Postgres corriendo para todo lo demás, esos 5 ms te compran la madurez operativa que la propia sección de “cuándo usar otra cosa” del README reconoce que LatticeDB no tiene.
Donde la tabla sí es fuerte es en la parte de grafo, porque las dos mitades fueron medidas en la misma máquina por la misma persona: LatticeDB contra CTEs recursivas de SQLite, 14x a dos hops sobre 100K nodos, y abriéndose a tres órdenes de magnitud en traversals profundos, donde el overhead de la CTE se acumula en cada nivel de recursión. Esa comparación es reproducible con zig build graph-benchmark -- --quick, y es la que yo citaría.
El número que nadie publica
Mira todas las tablas de benchmark de ese README y fíjate en la columna que ninguna tiene: cuánto tarda en construirse el índice.
Todo lo publicado es del lado de la consulta. Latencia de lookup, latencia de búsqueda, recall, memoria. Nada sobre ingesta. Así que lo medí — vectores de 128 dimensiones, batch_insert_vectors, en ese contenedor Xeon de 2 cores:
| Vectores | Tiempo de ingesta | Tasa |
|---|---|---|
| 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 un índice HNSW sobre diez mil vectores tardó poco más de dos minutos, y la tasa se degrada a medida que el grafo crece — comportamiento esperado en HNSW, no un defecto. Lo llamativo es la cifra absoluta en hardware modesto, y que no la encuentres en ninguna parte de la documentación del proyecto.
Para un pipeline de Graph RAG, este es el número que realmente planificas. La latencia de consulta es lo que tu usuario siente una vez; la ingesta es lo que pagas cada vez que el corpus cambia. Si vas a indexar un repositorio, un set de documentación o la memoria acumulada de un agente, presupuéstalo.
Las salvedades de siempre aplican y son importantes: esto es un contenedor cloud de 2 cores, no un M1, y usé vectores gaussianos aleatorios sobre la esfera unitaria — el peor caso posible para HNSW, porque no hay estructura de clusters que aprovechar. Tus números absolutos van a ser distintos. La forma de la curva, no.
Una anomalía en la tabla de sensibilidad
Ya que estamos en los benchmarks, hay algo en la tabla de ef_search del README a 1M de vectores que no cierra:
| ef_search | Latencia media | Recall@10 |
|---|---|---|
| 16 | 506 μs | 57% |
| 32 | 1,9 ms | 79% |
| 64 | 990 μs | 100% |
| 128 | 3,2 ms | 100% |
La latencia debería subir de forma monótona con ef_search: le estás pidiendo a la búsqueda que explore más candidatos. Acá ef=32 es casi el doble de lento que ef=64. En mi propia corrida la curva se comporta como corresponde: 1,6 ms, 2,1 ms, 3,0 ms, 4,7 ms para esos mismos cuatro valores.
Parece ruido de una única corrida de medición más que algo estructural. Vale la pena señalarlo porque es la misma tabla que sostiene la cifra estrella de 0,83 ms en ef=64 — y si una celda de la fila tiene ruido, la celda de al lado merece el mismo escepticismo hasta que alguien vuelva a correr zig build vector-benchmark en su propia máquina. Cosa que, hay que reconocerlo, puedes hacer.
Cuándo usar otra cosa
El README tiene una sección con ese nombre y es inusualmente franca, así que la señalo en vez de parafrasearla: modelo embebido de un solo escritor, o sea nada de acceso multi-cliente por red; si tus datos son genuinamente tabulares, van en SQLite o Postgres; nada de sharding ni replicación entre máquinas; OPTIONAL MATCH y los procedimientos CALL no están implementados, así que el Cypher es casi todo Cypher, no todo; y no hay ecosistema — sin herramientas de visualización, sin dashboards de administración, sin drivers en todos los lenguajes.
Ese último punto es el costo real de adoptarlo hoy, y conviene pesarlo contra lo que muestra el historial de versiones.
Entonces, ¿lo usas o no?
Lo de los dos releases en un día es una señal genuina de un maintainer presente y atento. También es una señal sobre madurez. Este proyecto pasó de 0.11.1 a 0.13.0 en unas 25 horas. El README todavía lista notas de release hasta la 0.10.0, tres versiones por detrás de lo que te da pip. El locking entre procesos — una propiedad que la mayoría da por sentada en cualquier base de datos — llegó hoy, lo que significa que ayer no estaba, lo que significa que cualquiera que corriera un lector junto a un escritor antes de hoy estaba recibiendo exactamente los resultados inconsistentes que ahora describen las notas del release.
Nada de eso es motivo para saltárselo. Es motivo para escalonarlo:
- Hoy:
pip install latticedb, corre el ejemplo de arriba, y fíjate si una sola consulta Cypher sobre relaciones, vectores y texto te simplifica algo que hoy pegas a mano. Te cuesta quince minutos. - Esta semana: apúntalo a un corpus real que ya tengas embebido en otro lado. Mide tu propia ingesta antes de comprometerte con una cadencia de actualización.
- Antes de producción: fija la versión. En un proyecto que publica varios releases por día, “latest” no es una especificación de dependencia. Y prueba
lattice backup— existe desde anoche.
Lo que hace a LatticeDB digno de seguimiento no es que sea más rápido que Neo4j. Es que aquello que colapsa — tres servicios, tres lenguajes de consulta, tres conjuntos de IDs para reconciliar — es una pila de complejidad accidental que los pipelines de Graph RAG simplemente aceptaron como el costo de hacer negocios. Alguien lo metió en un archivo, en Zig, bajo MIT, y funciona.
¿Y tú? ¿Cuántos servicios estás corriendo hoy solo para responder una pregunta que toca relaciones, significado y texto al mismo tiempo? ¿Los colapsarías en un solo archivo, o perder el ecosistema es un precio demasiado alto?