Terraphim
Clients v1.21.22
Terraphim

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.

How to read this page. Each scene has controls. Press buttons, type queries, index your own sentence. If a number is on screen, it was computed in your browser a moment ago. Where the comparison side is simulated rather than real, it says so on the panel. Esc clears highlights and ledgers; your indexed data stays.

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:

  1. 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 a raft → raft self-loop: the title itself matches.)
  2. Each consecutive pair (a, b) becomes a directed edge magic_pair(a, b) — an integer key computed by a Cantor-style pairing function, so (raft → consensus) and (consensus → raft) are different edges.
  3. node.rank += 1 for both endpoints; edge.doc_hash[doc] += 1 counts 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
Edit thesaurus → rebuild automaton~ms
Index doc: one AC scan + counter incrementsµs
Query: match + traverse + integer sumsub-ms
Answer to "why?": full provenancefree
Learned graph embeddings node2vec / GCN / TransE / Cleora
Random walks / message passingcorpus pass
Skip-gram / gradient trainingminutes–hours
Query: cosine vs ANN indexms
New term → re-embed affected subgraphretrain
Dense text embeddings API + vector DB
Embed every document via API50–500 ms/doc
Build ANN index (HNSW)build pass
Query: embed query + ANN scan5–50 ms + API
New vocabulary → re-embed corpusretrain/index

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

AspectLearned 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/adjacencyA point in ℝᵈ learned from textA node + its edges + integer counters
TrainingWalks + SGD / iterative propagationPre-trained model; per-doc embed callNone — counters at index time
Query mechanismVector → ANN cosineVector → ANN cosineAC match → traverse → integer sum
Multi-hop semanticsYes — walks/message passingIndirectNeighbourhood only (1 hop)
Unseen vocabularyDegrades silentlyGeneralises (that's the point)Returns nothing — honestly
Update costRe-embed affected subgraphRe-embed documentEdit thesaurus, ~ms rebuild
ExplainabilityVector distance onlyVector distance onlyEvery contribution named and countable
Memory / hardwareFloat32 × nodes × dimsANN index + APIints; 15–20 MB typical; runs on a Pi
DeterminismSeed-dependentModel/version dependentSame 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 tolerance query 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.rank counts co-occurrences, not importance. Popular concepts dominate. Hybrid search already exists to soften this: query_graph_with_trigger_fallback falls back to a TriggerIndex (TF-IDF over trigger descriptions) inside the rolegraph itself — exposed in the editor bridge as the fallback query mode — and search_sessions_hybrid in terraphim_sessions fuses 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_document
  • magic_pair / magic_unpair → directed edge ids
  • query_graph → the node.rank + edge.rank + doc_hash sum and sort you watched
  • remove_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.