Two Comments on HN, Two Releases in 24 Hours: LatticeDB Replaces Neo4j + Qdrant + Elastic in One File
On August 25th at 16:52 UTC, a Show HN appeared titled “LatticeDB – Like SQLite but for graph databases”. As of writing, it’s at 90 points and 30 comments: a decent thread, not a viral one.
Two of those comments are the reason this article exists.
itissid asked if there was something like Litestream to back up the database in production cases. The author responded: “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!” — he was in the final stages of adding it based on that comment, and would push the release that same night. Version 0.12.0 came out at 00:05 the next morning, with lattice backup, lattice replicate, and lattice restore.
vladigtr asked how concurrent writers on a single file are handled. The author admitted there was control within a process but no safety between processes, and committed to adding file locking. Version 0.13.0 came out at 13:08 that same day, with mandatory locking on the database file itself.
Two questions from strangers on the internet, two releases, less than twenty-four hours. That pace is the first thing you need to know about this project — and, as we’ll see, it cuts both ways.
What LatticeDB Actually Is
An embedded property-graph database written in Zig, MIT licensed, no dependencies. One file, no server, no configuration. What makes it interesting isn’t the graph part alone: it’s that graph traversal, HNSW vector search, and BM25 full-text search all live in the same engine, under the same query language.
That language is Cypher, with two added operators: <=> for vector distance and @@ for full-text matching. So a single query can do all three things at once:
-- 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
If you’ve ever built a Graph RAG pipeline, you know what that snippet replaces: Neo4j for relationships, Qdrant or Postgres with pgvector for vectors, Elasticsearch or an FTS5 table for the lexical layer, and then your own code stitching three sets of IDs together and hoping all three stores agree on what exists.
It’s worth noting that the project itself resists that framing. The repo description says “for AI/RAG apps”, but the README describes agent memory and RAG as “one example class of workload built on the graph/vector/text substrate, not the definition of the engine” — one class of workload built on the substrate, not the definition of the engine. The engine is being built as an engine. RAG is a client.
Installing and Running It
Everything that follows I ran in a Linux container: dual-core Xeon at 2.8 GHz, 7 GB RAM. Where a number appears, it came from an actual run, not the README.
The shortest path is Python, and there are binary wheels published for manylinux x86_64 and aarch64 plus macOS arm64 and x86_64, so nothing gets compiled on install:
pip install latticedb
Today that gives you 0.13.0, the same version that brought locking, published to PyPI the same day. There’s also a CLI (curl -fsSL https://raw.githubusercontent.com/jeffhajewski/latticedb/main/dist/install.sh | bash), an npm package (@hajewski/latticedb), and Go bindings over cgo.
Now the README example, run as-is. It builds a minimal knowledge graph of authors, documents, and chunks, saves an embedding per chunk, indexes its text, and then queries across all three modes:
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}")
Actual output:
Attention Is All You Need by Alice
Node 4: score=3.0197
It works on the first try, which for a pre-1.0 database written in a pre-1.0 language is no small feat. Watch out for hash_embed: it’s a built-in hash embedding, useful for the example to run without an embeddings service behind it. For something real, LatticeDB brings an HTTP client for Ollama and OpenAI.
One detail the “single file” pitch glosses over: after that block, the directory has knowledge.db and knowledge.db-wal. The same setup as SQLite — one file plus a write-ahead log. It’s not a problem, but if your deployment strategy is “copy the file”, the log is part of the file.
The Lock That Shipped Today, Tested Between Two Processes
This is the most interesting part to verify, because it’s hours old.
Until 0.13.0, LatticeDB didn’t lock the database between processes. The release notes are blunt about what that meant: read-only operations ran during writes and “could return stale or inconsistent results” — could return stale or inconsistent results. Now a read-write handle takes the lock exclusively, a read-only one shares it, and a second writer is rejected. The lock lives on the open file rather than in a separate lock file, so if a process crashes it releases it.
The Python binding exposes it as a lock=True parameter in Database. Here’s what happens with one process holding a handle and another trying to enter:
from latticedb import Database, LatticeDatabaseLockedError
for kwargs, label in [
({}, "second writer"),
({"read_only": True}, "reader during a write"),
({"read_only": True, "lock": False}, "reader with 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}")
```Exit, with a writer holding the file:
second writer: LatticeDatabaseLockedError: Database is open in another process
reader during a write: LatticeDatabaseLockedError: Database is open in another process
reader with lock=False: OK → [{‘n’: 2}]
Read the middle line carefully, because that's where the breaking change is. **A read-only handle is now rejected while a writer has the file** — not queued, rejected. The reason is honest: a reader cannot see the writer's buffered pages or its WAL, so serving it would be lying to them. The CLI equivalent spells it out:
social.lattice is open in another process. Close it first, or pass --no-lock
And `--no-lock` (`lock=False` in Python) does exactly what my third case shows: reads anyway. On filesystems where locking doesn't work, that's your emergency exit. On all the others, it's the behavior that 0.13.0 came to eliminate. If you have a dashboard reading the same file that your ingestion process writes to, this release is the day your architecture changed.
## The Comparison Table, Taken Apart
The README includes a competitive analysis that's going to end up cited, so it's worth being precise about what it establishes and what it doesn't.
For vector search with 1M vectors, LatticeDB clocks in at 0.83 ms average with 100% recall@10, against FAISS single-threaded at 0.5–3 ms, Weaviate at 1.4 ms, Qdrant at ~1–2 ms, pgvector HNSW at ~5 ms, LanceDB at 3–5 ms, Chroma at 4–5 ms, and sqlite-vec at 17 ms.
Three things to keep in mind:
**The hardware isn't the same.** LatticeDB's numbers are first-party, measured on an **Apple M1, single-threaded**. All the other rows are figures published by third parties on hardware the table doesn't name. The project is scrupulous about linking each source — there are no made-up numbers here — but a table where one row is your machine and nine rows are other people's machines measures the work of citation, not performance.
**sqlite-vec isn't doing the same work.** Those 17 ms are **brute force**, an exhaustive scan, not an approximate index. Comparing HNSW against brute force at 1M vectors tells you that indices beat scans, something we already knew. The honest reading of that row is that sqlite-vec chose a different trade-off — exact results, zero index construction cost — not that it's twenty times slower.
**The pgvector row compares an embedded engine against an extension inside a server.** Those ~5 ms for pgvector include Postgres being Postgres: a connection, a planner, MVCC. If you already have Postgres running for everything else, those 5 ms buy you the operational maturity that the README's own "when to use something else" section acknowledges LatticeDB doesn't have.
Where the table is strong is in the graph section, because both halves were measured on the same machine by the same person: LatticeDB against SQLite recursive CTEs, 14x at two hops over 100K nodes, and opening up to three orders of magnitude on deep traversals, where the CTE overhead accumulates at each recursion level. That comparison is reproducible with `zig build graph-benchmark -- --quick`, and it's the one I'd cite.
## The Number Nobody Publishes
Look at all the benchmark tables in that README and notice the column none of them have: **how long it takes to build the index**.
Everything published is on the query side. Lookup latency, search latency, recall, memory. Nothing on ingestion. So I measured it — 128-dimensional vectors, `batch_insert_vectors`, on that 2-core Xeon container:
| Vectors | Ingestion Time | Rate |
|--------:|------------:|-----:|
| 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 |
Building an HNSW index over ten thousand vectors took **just over two minutes**, and the rate degrades as the graph grows — expected behavior in HNSW, not a defect. What stands out is the absolute number on modest hardware, and that you won't find it anywhere in the project's documentation.
For a Graph RAG pipeline, this is the number you actually plan around. Query latency is what your user feels once; ingestion is what you pay every time the corpus changes. If you're going to index a repository, a documentation set, or the accumulated memory of an agent, budget for it.
The usual caveats apply and matter: this is a 2-core cloud container, not an M1, and I used random Gaussian vectors on the unit sphere — the worst-case scenario for HNSW, because there's no cluster structure to exploit. Your absolute numbers will be different. The shape of the curve, no.
## An Anomaly in the Sensitivity Table
While we're on benchmarks, there's something in the README's `ef_search` table at 1M vectors that doesn't add up:
| ef_search | Mean Latency | Recall@10 |
|-----------|-------------|-----------|
| 16 | 506 μs | 57% |
| 32 | **1.9 ms** | 79% |
| 64 | **990 μs** | 100% |
| 128 | 3.2 ms | 100% |
Latency should rise monotonically with `ef_search`: you're asking the search to explore more candidates. Here ef=32 is almost twice as slow as ef=64. In my own run the curve behaves as it should: 1.6 ms, 2.1 ms, 3.0 ms, 4.7 ms for those same four values.
It looks like noise from a single measurement run rather than something structural. It's worth pointing out because it's the same table that supports the headline figure of 0.83 ms at ef=64 — and if one cell in the row has noise, the cell next to it deserves the same skepticism until someone runs `zig build vector-benchmark` again on their own machine. Which, to be fair, you can do.
## When to Use Something Else
The README has a section with that title and it's unusually frank, so I'll point to it instead of paraphrasing: embedded model of a single writer, meaning no multi-client access over the network; if your data is genuinely tabular, it goes in SQLite or Postgres; no sharding or replication across machines; **`OPTIONAL MATCH` and `CALL` procedures aren't implemented**, so Cypher is almost all Cypher, not all; and there's no ecosystem — no visualization tools, no admin dashboards, no drivers in every language.
That last point is the real cost of adopting it today, and it's worth weighing against what the version history shows.
## So, Do You Use It or Not?
The two releases in one day is a genuine signal of an attentive, present maintainer. It's also a signal about maturity. This project went from 0.11.1 to 0.13.0 in about 25 hours. The README still lists release notes down to 0.10.0, three versions behind what `pip` gives you. Inter-process locking — a property most take for granted in any database — arrived today, which means it wasn't there yesterday, which means anyone running a reader alongside a writer before today was getting exactly the inconsistent results the release notes now describe.
None of that is a reason to skip it. It's a reason to stage it:
- **Today:** `pip install latticedb`, run the example above, and see if a single Cypher query over relationships, vectors, and text simplifies something you're gluing together by hand today. It costs you fifteen minutes.
- **This week:** point it at a real corpus you already have embedded somewhere else. Measure your own ingestion before you commit to an update cadence.
- **Before production:** pin the version. In a project that publishes multiple releases a day, "latest" isn't a dependency specification. And test `lattice backup` — it's existed since last night.
What makes LatticeDB worth following isn't that it's faster than Neo4j. It's that what collapses — three services, three query languages, three sets of IDs to reconcile — is a stack of accidental complexity that Graph RAG pipelines simply accepted as the cost of doing business. Someone put it in a file, in Zig, under MIT, and it works.
**And you? How many services are you running today just to answer a question that touches relationships, meaning, and text all at once? Would you collapse them into a single file, or is losing the ecosystem too high a price?**
https://github.com/jeffhajewski/latticedb