Skip to content

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.”

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.

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 DESC

r.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 the k nearest).
  • 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. Prefer minScore: 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 (the vector kind 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.

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.7
RETURN 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_TO means semantically similar, never corresponded with. “Did I email Ada / how much” is the persisted (:AssistantUser)-[:EMAILED]->(:Person) edge; RELEVANT_TO is “threads that read as being about Ada.” The join’s description says 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 tokens
MATCH (c:Concept {value:'Acme'})-[r:RELEVANT_TO {via:'agentic-rag',
intent:'renewal risk'}]->(d:Document) -- judged retrieval

The 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.score is 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.rank is the fused order, and is the ordering to trust. The two retrievals score on scales that do not compare, so ORDER BY r.score DESC re-orders documents by a quantity that means something different in each half. Order by r.rank when 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 vector search 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: 0 is not free — it is a query whose whole cost was retrieval. Read semanticCalls. (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, and EXPLAIN priced 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.

producers/memory.yml
- 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: entityId

Memory 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 system
RETURN m.source, m.text, r.score, r.mode ORDER BY r.rank
MATCH (:Concept {value:'coffee'})-[r:RELEVANT_TO]->(m:DiceMemory) -- the assistant's own
RETURN m.text, m.confidence, r.score
MATCH (p:Person {name:'Ada Lovelace'})-[:REMEMBERED_IN]->(m:Memory) -- by fact, not by name
RETURN m.text

The 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.