6. Vector edges — semantic joins in depth
A vector producer is fundamentally different from the keyed kinds, and the difference is worth
stating precisely because it is the engine’s answer to “relationships that have no foreign key.”
6.1 Similarity is the join
Section titled “6.1 Similarity is the join”A keyed join asks: “which records have email == ada@example.com?” — an exact match. A vector
join asks: “which records are most similar in meaning to this anchor?” — a ranked
approximation. There is no key to match on; the anchor’s text (a person’s name, a meeting’s
subject) is embedded and compared against an index. So a vector join is the only way to express
relationships like “threads about Ada”, “docs like this one”, “chunks relevant to
this question” — where no stored edge or shared id exists.
6.2 The score lives on the edge
Section titled “6.2 The score lives on the edge”The crucial modelling decision: similarity is a property of the relationship, not the node.
MATCH (p:Person {name:'Ada Lovelace'})-[r:RELEVANT_TO]->(t:RelevantEmailThread)RETURN t.subject, r.score ORDER BY r.score DESCr.score is “how relevant is thread t to Ada”. The same thread t reached from a
different anchor (a different person, an organization, a meeting) gets a different r.score on
that edge. Putting the score on the node would be wrong — a convergent match would overwrite it.
The engine routes a producer’s edge data (the score) onto the relationship; node properties (the
thread’s subject, snippet, id) stay on the node.
Always ORDER BY r.score DESC (and optionally a LIMIT) — a vector search returns a ranked
list, and the score is the only signal of how good each match is.
6.3 k, minScore, and why a floor is dangerous
Section titled “6.3 k, minScore, and why a floor is dangerous”k— top-k hits per anchor (default 8). Bounds the fan-out per anchor inherently (a vector join can’t fetch “everything” — it fetches theknearest).minScore— a similarity floor (0..1; default 0 = no floor). Use with care: when the anchor text is short (a name, a subject) it embeds far from topical thread-summary vectors, so a high floor can return nothing. PreferminScore: 0+ top-k +ORDER BY r.score, and let the caller judge relevance from the score, rather than a hard cutoff. A floor that culls is reported, naming how many hits of how many it dropped and the best score it saw — so “nothing relevant” and “everything just missed the cutoff” are distinguishable, and the number to change is in the diagnostic. Where the floor is pushed into the index (thevectorkind sends it as a similarity threshold) the cull happens at the source and only the floor itself is evidence.
6.4 Privacy: the source enforces world and context scope, not the rewriter
Section titled “6.4 Privacy: the source enforces world and context scope, not the rewriter”A vector search runs inside the producer (against an embedding index), bypassing the Cypher
scope rewriter that guards keyed traversals. So the producer/index is responsible for per-world and
per-context scoping: the search is filtered by host-bound worldId, contextId, and access-policy
revision, and a search missing any required scope returns nothing (fail-closed). principalId
authorizes access but never substitutes for either data scope. A vector producer over shared
infrastructure must apply the same filters; a vector join can never surface another context’s or
world’s documents.
6.5 Anchors and composition
Section titled “6.5 Anchors and composition”RelevantEmailThread anchors on Person, Organization, and Meeting — three joins to the
same target, each embedding a different field (name, name, subject). And a vector edge
composes with keyed traversals in one query:
MATCH (me:AssistantUser)-[:EMAILED]->(p:Person)-[r:RELEVANT_TO]->(t:RelevantEmailThread)WHERE r.score > 0.7RETURN p.name, t.subject, r.score ORDER BY r.score DESC— structure (people I email) and meaning (threads relevant to each) in a single MATCH.
Not a factual edge.
RELEVANT_TOmeans semantically similar, never corresponded with. “Did I email Ada / how much” is the persisted(:AssistantUser)-[:EMAILED]->(:Person)edge;RELEVANT_TOis “threads that read as being about Ada.” The join’sdescriptionsays so, so the query generator never confuses the two.
6.6 Relevance modes — the via selector and the intent brief
Section titled “6.6 Relevance modes — the via selector and the intent brief”One anchor→target relationship can be served by several joins, each declaring the edge via
value it answers to. The query selects the mode at the edge; an edge with no via selects the
join declaring none (the default — conventionally the vector join):
MATCH (c:Concept {value:'lotteries'})-[:RELEVANT_TO]->(d:Document) -- semantic (default)MATCH (c:Concept {value:'lotteries'})-[:RELEVANT_TO {via:'keyword'}]->(d:Document) -- lexical, exact tokensMATCH (c:Concept {value:'Acme'})-[r:RELEVANT_TO {via:'agentic-rag', intent:'renewal risk'}]->(d:Document) -- judged retrievalThe three modes answer three different questions — about X (vector), mentions X (keyword), and
bears on this brief (agentic-rag). Vector and keyword are deterministic single passes. The
agentic-rag mode hands retrieval to a bounded LLM loop that may reformulate the query, run
both deterministic modes, and read further into a candidate whose snippet is inconclusive — then
returns only the documents it judges fit the brief.
The vector mode is FUSED, and r.mode says which retrieval actually ran. Asking what a
corpus says about X is not answered by embedding similarity alone: an exact token the corpus
does contain — a clause number, a part number, an invoice id — can sit below the similarity
threshold while a passage that merely reads like the question sits above it. So the semantic lane
runs a semantic and a lexical retrieval over the same scope and interleaves them into one ranking,
and reports r.mode:'fused'. Three guarantees follow, and they are what a query may rely on:
- A document either arm finds is reachable. Adding a distinctive identifier to an otherwise prose question cannot lose the document that contains it.
r.scoreis a SIMILARITY, not a ranking artefact. Comparisons and thresholds (r.score > 0.7) keep meaning what they meant before fusion. What fusion decides is the ORDER and the SET, not the number.r.rankis the fused order, and is the ordering to trust. The two retrievals score on scales that do not compare, soORDER BY r.score DESCre-orders documents by a quantity that means something different in each half. Order byr.rankwhen the ranking matters.
r.mode always reports the retrieval that RAN, never the one requested: fused for the semantic
lane, keyword when a lexical retrieval produced the rows — including when a semantic lane found
nothing and fell back, which is why a vector edge may legitimately return mode:'keyword' rows.
A keyword edge never reports anything else: a lexical miss is an honest empty, because answering
“which documents MENTION X” with “documents ABOUT X” collapses the one distinction the two modes
exist to draw.
The intent directive is the loop’s retrieval brief: a top-level edge property (like via,
not in the ai: namespace), read by the engine and passed to the producer. The producer may
declare a default intent; the query edge’s value overrides it per query. Because a different
brief is a different result set, intent participates in the fetch cache key, and it is stamped
back onto the materialized edge (r.intent) alongside r.score (the judged 0..1 fit),
r.snippet (verbatim evidence the loop actually saw), r.mode, and r.rank.
Two guarantees keep the agentic mode honest:
- Grounded: the loop’s verdict may only reference documents that appeared in its own tool results — a hallucinated id can never materialize a node.
- Fail-open: any loop failure degrades to the deterministic
vectorsearch for that anchor — an LLM hiccup costs precision, never an empty answer.
Cost guidance: an agentic edge spends several LLM calls per anchor. Select it explicitly (never as a default), and pair it with a materialized view (§8.3) when the same brief is queried repeatedly — the loop then runs on the view’s refresh schedule and queries read the cached subgraph.
6.7 What a relevance join costs — semanticCalls
Section titled “6.7 What a relevance join costs — semanticCalls”A result’s cost block reports three kinds of call, and each counts one kind of purchase and no
other:
| counter | what it counts |
|---|---|
apiCalls |
producer fetches — a call to a source outside this world, including a relevance search sent to a store outside it (remote-search, elasticsearch) |
llmCalls |
reasoning: the reductions of §7, a generative producer’s rounds, a concept-mode search’s expansion of each anchor into probe terms, and (on ask) the one call that wrote the Cypher |
semanticCalls |
relevance searches: one per anchor, per materialization |
One semanticCalls is one search of the index, which for the vector mode is one embedding of
the anchor’s text plus one similarity scan, for keyword one fulltext scan, and for agentic-rag
one retrieval — whose judging loop ALSO spends llmCalls, so an agentic edge shows on both. The
three are kept apart because a reader budgets them differently: a fetch is somebody else’s rate
limit, a reduction is reasoning spend, and a search is retrieval. Adding them together would make
every one of the three numbers worth less.
It is one search per distinct anchor key, not per anchor row. Two anchors carrying the same
phrase embed it once in a query; a different phrase is its own search. A producer with a cache:
TTL searches nothing at all inside its window, which is the lever: a repeated question over a
cached relevance producer costs zero searches, and the counter is how you see that it worked.
EXPLAIN prices it before the query runs, as ~n semantic search(es) on the group, the stage
and the total. For a first-stage anchor set the figure is exact — the anchor’s own text is the
key, so the count is the number of anchors and nothing has to be guessed — including for the
pinned shape (c:Concept {value: '…'}), where the anchor exists only because the query named it.
Only a LATER hop, whose anchors are the first fetch’s records, makes the total a floor, and it then
reads at least ~n like the other two.
A semantic query that reports
apiCalls: 0, llmCalls: 0is not free — it is a query whose whole cost was retrieval. ReadsemanticCalls. (This counter is new: before it existed a relevance search was counted by neither of the others, so a Concept-anchored fetch reported two zeroes for seconds of real work, andEXPLAINpriced it at nothing.)
A relevance search sent outside the world is an apiCalls. A remote-search or
elasticsearch relevance producer (§5.16) is relevance-shaped — the anchor’s text is the query —
but each search is a request to a store somebody else runs, which is the purchase apiCalls names.
So it is counted there, one per distinct anchor key, and never on semanticCalls: every relevance
producer lands on exactly one of the two. A request is counted when it is sent, so a search the store
refuses or times out on is still a call. EXPLAIN prices it before the query runs, as
~n API call(s), on the same terms as a semanticCalls figure — exact for a first-stage anchor set,
the pinned (c:Concept {value: '…'}) shape included. The one exception is a remote-search
declared agentic: true (concept mode), which searches once per probe term chosen while the query
runs: its figure is the first search per anchor and reads at least ~n API call(s). Bound these the
way you would a fetch — with maxAnchors, a narrower anchor set, and a cache: TTL.
Concept mode also spends llmCalls. The probe terms a concept-mode search runs are chosen by a
model before the first search: one model call per distinct anchor, counted on llmCalls when it is
asked — so a call that fails is still counted — and listed in the call log by its purpose, never by
the anchor’s text. EXPLAIN prices it as one model call per distinct anchor, an exact figure for a
first-stage anchor set, and the group line says how many of its model calls are expansions. Because
the cost gate reads the model-call total, a concept-mode search over many anchors can be refused
as TOO_EXPENSIVE like any other model spend — narrow the anchor set, or opt in with
{ai: {materialize: true}}.
6.8 Memory — what the assistant remembers, as a relevance source
Section titled “6.8 Memory — what the assistant remembers, as a relevance source”What the assistant remembers about the acting user is a relevance source like any other: the index
memory. A realm reaches it through the same producer kinds, the same via selector and the same
per-match edge vocabulary as documents and email threads, so a query that knows
(:Concept)-[:RELEVANT_TO]->(:Document) already knows memory.
- name: memoryVector kind: vector index: memory # the acting user's memory, in the context they are in- name: memoryKeyword kind: keyword index: memory- name: memoryAbout kind: participant # memories that MENTION a known entity, by its resolved id index: memory identityField: entityIdMemory is a shared parent label; each memory system is a child. The standard model declares
Memory and the assistant’s own DiceMemory with parents: [Memory]. A realm that brings another
memory system declares its own child — ZepMemory, parents: [Memory] — over its own producer
(a remote-search with recordScoreField, or a host-provided index). Ancestors are physically
present (LABELS_AND_COMPOSITION §2), so a query may name the parent and be answered by EVERY
system that has a join, or name one child and read that system alone:
MATCH (:Concept {value:'coffee'})-[r:RELEVANT_TO]->(m:Memory) -- every memory systemRETURN m.source, m.text, r.score, r.mode ORDER BY r.rank
MATCH (:Concept {value:'coffee'})-[r:RELEVANT_TO]->(m:DiceMemory) -- the assistant's ownRETURN m.text, m.confidence, r.score
MATCH (p:Person {name:'Ada Lovelace'})-[:REMEMBERED_IN]->(m:Memory) -- by fact, not by nameRETURN m.textThe data contract every memory label carries. id (identity), text, confidence (0..1),
status, created, revised, source (which system holds it — dice, zep, …), and when the
system knows them validFrom / validTo, mentions (names) and entityIds (resolved ids). A
child may add its own properties; it may not drop these. The per-match data is on the EDGE:
r.score, r.mode, r.rank, r.snippet, r.matchedTerms, and for the fused lane r.probes
(which arms found it).
Three questions, three modes — the same three as a document. The semantic lane (default,
via:'vector') answers “what do you remember ABOUT X”: a vector arm and a term-overlap arm over
the same scoped memories, fused by reciprocal rank, r.mode:'fused'; a fact both arms find
outranks a lone high hit, and r.score stays a similarity where one exists. With no vector arm the
lane falls back to lexical and says so (r.mode:'keyword'). The lexical lane (via:'keyword')
answers “memories that MENTION X”: every assertable term present, or an honest empty. The
participant kind answers “what do you know about THIS entity”: memories whose resolved mentions
include the anchor’s id — a fact, never a name’s neighbourhood. An agentic-rag producer over
memory is accepted and answers with the semantic lane today; r.mode reports what ran.
Scope and curation hold at the source. A memory is read in the acting user’s own context, and
no user — or a user with no context — reads nothing (§6.4). Reads are ACTIVE memories only. A
status constraint on the memory node pushes down and relaxes that to the statuses it names;
a CONTRADICTED memory is known-false and never answers, even when asked for by name — the rule the
scope rewriter already holds persisted Proposition nodes to (§15). A child label from another
system maps its own lifecycle onto the same vocabulary: ACTIVE for a current fact, SUPERSEDED or
STALE for a retired one, CONTRADICTED for one proven false.
The lifecycle is declared, and every memory label is held to it. Memory declares
lifecycle: { active: [ACTIVE], never: [CONTRADICTED] } (DECLARING_TYPES.md §3.3a), and a child
inherits it. So the rule above is not only the source’s: a query naming :Memory, :DiceMemory
or a realm’s :ZepMemory has the same curation predicate written into it that :Proposition
gets, whichever system fetched the node and wherever the node came from — a persisted projection
of a memory system’s facts included. A memory node must therefore carry status; one without it
answers nothing.
Systems a standard child exists for. Beside the assistant’s own DiceMemory: an Omnigraph graph
(OmnigraphMemory, a remote-search over the graph’s stored BM25 query); markdown memory
(MarkdownMemory and its children for Claude Code’s memory directory, OpenClaw’s MEMORY.md and
daily logs, a Cline/Roo memory bank, plain notes — files search producers over the world’s
data/memory/); a self-hosted Graphiti server (GraphitiMemory, remote-search over its /search,
with valid_at / invalid_at as validFrom / validTo and a fact Graphiti has invalidated
read as SUPERSEDED); Zep Cloud (ZepMemory, the same over Zep’s graph search, keyed, with Zep’s
score on the edge); and the MCP memory server (McpMemory, a files producer with unit: line
over its memory.jsonl, one entity per memory, require: {type: entity} leaving relation lines
out). Each is its own realm under embabel-worlds; each maps its system’s own state onto the one
contract and lifecycle above, which is the whole of what a new system has to do.
Cost: one search per anchor, as for every relevance edge; the semantic lane is two probes over one
store. Nothing leaves the deployment for the assistant’s own memory; a child over a remote memory
service pays that service’s call per anchor, which apiCalls counts.

