Most pages about embeddings show you a picture of some dots and ask you to trust the maths.
This one is different: the Terraphim side of every demo below runs the real algorithm —
the actual terraphim_rolegraph Rust crate, compiled to WebAssembly and executing in your
browser — on a real thesaurus and corpus. Nothing is staged or ported: the counters you watch
increment are the same code paths the Rust tests exercise.
Two retrieval worlds, one question
The same eight documents, two retrieval systems. On the left, the classic picture: every document is a point in a high-dimensional space; a query lands as another point; similarity is distance. On the right, the Terraphim graph: every concept is a node; documents feed co-occurrence counters; a query lights up a path.
Run a query — including byzantine fault tolerance, which is deliberately absent from the
thesaurus — and watch what each side does with it.
Dense vectors illustrative
Every dot is a document. The ring is a cosine-similarity radius. You get a score — 0.98, 0.87, 0.76 — and nothing else. There is no answer to why this document matched.
Terraphim RoleGraph real algorithm
Nodes are concepts; numbers inside are node.rank —
co-occurrence counts. Lit nodes and edges are the ones the query actually traversed.
Scene one: how the text is read
Before anything is a graph, there is a dictionary. The role's thesaurus maps synonyms to
normalised concepts — leader election and election are the same node, id 3. At startup the
thesaurus compiles into an Aho-Corasick automaton: a finite-state machine that scans text in a
single pass, regardless of how many patterns it holds.
Watch it read. The matcher runs leftmost-longest, case-insensitive: at each position it takes the longest pattern that fits, then jumps past it. Type anything — the highlights and the concept sequence update live.
Aho-Corasick matching real algorithm
Try a consensus algorithm vs
a consensus: consensus algorithm wins as one match,
not two. That is LeftmostLongest. Overlapping terms collapse into a single concept id —
and that ordered sequence of ids is all the indexer sees.
Scene two: indexing — where the "embedding" is built
Here is the whole trick, and it is smaller than you expect. When a document is indexed:
- The automaton scans it once, producing the matched concept sequence you saw above.
(The crate matches
title + body—Document::to_string()— which is why "Raft notes" adds araft → raftself-loop: the title itself matches.) - Each consecutive pair
(a, b)becomes a directed edgemagic_pair(a, b)— an integer key computed by a Cantor-style pairing function, so(raft → consensus)and(consensus → raft)are different edges. node.rank += 1for both endpoints;edge.doc_hash[doc] += 1counts how often that pair appeared in that document.
That is the embedding. What a concept "is" here: a node plus its edges in conceptual space — the synonyms that map to it and the concepts that appear next to it — not a point in a vector space. Press "Index next" a few times and watch the graph accrete; then index your own sentence (only thesaurus terms count).
Build the graph real algorithm
Edge thickness ∝ total document count on that pair. Loops are real:
partition tolerance → partition tolerance and leader → leader appear
when the same concept is matched twice in a row. Re-indexing removes a document's
contributions before re-counting — replace semantics. Honest caveat: removal decrements
node rank once per edge, while insertion counts once per pair occurrence,
so a document that repeats the same directed pair leaves a small rank residual on re-index.
That is the Rust implementation's real behaviour, running live — not smoothed over.
Scene three: asking a question
Querying is the mirror image. The automaton matches the query terms to node ids; for each matched node, the engine walks its edges; for every document on each edge it adds:
score += node.rank + edge.rank + doc_hash[doc]
Documents are sorted by the sum. And because every point of the sum comes from a named node, a named edge and a count you can inspect, the ranking is auditable — click a query and read the ledger. This is the part a cosine score cannot give you.
Query the graph real algorithm
Scene four: when does the work happen?
The deepest difference is not the data structure — it is where computation lives.
Traditional graph embeddings — DeepWalk, node2vec, GraphSAGE, TransE, Cleora — front-load everything: random walks or message passing produce dense vectors once, then queries are cheap ANN lookups. Adding a term means re-walking and re-training. Terraphim inverts this: all the work is tiny counters at index time; a query is a lookup and a sum.
Add a synonym right now. The Terraphim column measures the real rebuild time in your browser.
Cost of change
Terraphim RoleGraph
Learned graph embeddings node2vec / GCN / TransE / Cleora
Dense text embeddings API + vector DB
Timings on the Terraphim column are measured live in this page; the other columns are indicative ranges consistent with published measurements, shown for comparison.
The honest comparison
| Aspect | Learned graph embeddings (node2vec, GCN, TransE, Cleora) | Dense text embeddings (API + vector DB) | Terraphim graph embeddings |
|---|---|---|---|
| What a concept "is" | A point in ℝᵈ learned from walks/adjacency | A point in ℝᵈ learned from text | A node + its edges + integer counters |
| Training | Walks + SGD / iterative propagation | Pre-trained model; per-doc embed call | None — counters at index time |
| Query mechanism | Vector → ANN cosine | Vector → ANN cosine | AC match → traverse → integer sum |
| Multi-hop semantics | Yes — walks/message passing | Indirect | Neighbourhood only (1 hop) |
| Unseen vocabulary | Degrades silently | Generalises (that's the point) | Returns nothing — honestly |
| Update cost | Re-embed affected subgraph | Re-embed document | Edit thesaurus, ~ms rebuild |
| Explainability | Vector distance only | Vector distance only | Every contribution named and countable |
| Memory / hardware | Float32 × nodes × dims | ANN index + API | ints; 15–20 MB typical; runs on a Pi |
| Determinism | Seed-dependent | Model/version dependent | Same input → same output, always |
Where the graph loses — honestly
- Out-of-vocabulary blindness. If it is not in the thesaurus, it does not exist. The
byzantine fault tolerancequery above returns an empty set while the vector panel cheerfully returns neighbours. Domain coverage is an authoring problem, not a learning problem. - Frequency bias.
node.rankcounts co-occurrences, not importance. Popular concepts dominate. Hybrid search already exists to soften this:query_graph_with_trigger_fallbackfalls back to aTriggerIndex(TF-IDF over trigger descriptions) inside the rolegraph itself — exposed in the editor bridge as thefallbackquery mode — andsearch_sessions_hybridinterraphim_sessionsfuses BM25 scores with knowledge-graph boosts. - One hop. The graph captures adjacency, not transitive similarity. Two concepts that never sit next to each other in a document stay unlinked, however related they are.
- Coarse ties. Integer sums collide; ties break on document id. It is explainable, not subtle.
From page to code
Every label above maps to a real symbol:
RoleGraph::new_sync→ builds the automaton (MatchKind::LeftmostLongest, case-insensitive)insert_document→find_matching_node_ids→tuple_windows→add_or_update_documentmagic_pair/magic_unpair→ directed edge idsquery_graph→ thenode.rank + edge.rank + doc_hashsum and sort you watchedremove_document_contributions→ the replace semantics that made re-indexing idempotent
One honest detail the prose docs gloss over: edge.rank is currently always 1 — the real
per-document count lives in edge.doc_hash, which is why the ledger above shows the
document count rather than an edge rank. This page shows the code, not the approximation.
The reference text lives at Graph Embeddings; the short version of why it matters is in Why Graph Embeddings Matter.