Cookbook
All recipes
Query the relate docsSearch and retrieval

Agent memory

Search and retrieval

Data quality

Graph analytics

Geospatial

Cookbook / Search and retrieval · Intermediate

Query the relate docs

The relate documentation is published as a relate graph: pages and their sections, the links and prerequisites between pages, the glossary terms each page uses, the surfaces it applies to, and a catalog of capabilities that cite the pages. This recipe loads that structure, with every section of five pages and their vectors, and asks two questions. Full-text search misses the first one; vector search finds the sections that answer it, and the graph turns them into context: the pages to read first and the terms to know. For the second, the nearest section is about a different kind of "Cursor"; a traversal chooses the pages to search, which sets it aside, and two more hops reach the capability the answer belongs to and the page or recipe that proves it.

The graph is the docs graph's own shape, cut down. A (:Page {id, title, url}) has HAS_SECTION relationships to its (:Section {id, page, heading, position, url, text, embedding}) nodes, LINKS_TO and PREREQUISITE_OF relationships to other pages, USES_TERM to a (:Term {id, term, short}) and APPLIES_TO to a (:Surface {id}). A (:Capability {id, title, url}) CITES pages and SOLVES a (:Problem {id, kind, text}); a (:CookbookRecipe {id, title, url}) DEMONSTRATES capabilities, and a capability is VERIFIED_BY a recipe or a page. Each question is a (:Question {text, embedding}) node, so a step can search with a stored vector.

Data model: pages, their sections, terms and surfaces, and the capabilities that cite themA Page has Sections, links to and is a prerequisite of other Pages, uses Terms and applies to Surfaces. A Capability cites Pages, solves Problems and is verified by a recipe or a page; a CookbookRecipe demonstrates Capabilities. Sections and Questions carry 384-float embeddings.Sectionheading · textpageembeddingPageid · titleurlTermterm · shortSurfaceidCapabilityid · titleProblemkind · textCookbookRecipetitle · urlQuestiontextembeddingHAS_SECTIONLINKS_TOPREREQUISITE_OFUSES_TERMAPPLIES_TOCITESSOLVESDEMONSTRATESVERIFIED_BYcell
Data model: pages, their sections, terms and surfaces, and the capabilities that cite themA Page has Sections, links to and is a prerequisite of other Pages, uses Terms and applies to Surfaces. A Capability cites Pages, solves Problems and is verified by a recipe or a page; a CookbookRecipe demonstrates Capabilities. Sections and Questions carry 384-float embeddings.Sectionheading · textpageembeddingPageid · titleurlTermterm · shortSurfaceidCapabilityid · titleProblemkind · textCookbookRecipetitle · urlQuestiontextembeddingHAS_SECTIONLINKS_TOPREREQUISITE_OFUSES_TERMAPPLIES_TOCITESSOLVESDEMONSTRATESVERIFIED_BYcell
Data model: pages, their sections, terms and surfaces, and the capabilities that cite them

Where the data comes from

The docs build writes the whole graph as a Cypher script, https://relatedb.ai/docs/graph.cypher. Every section there carries a vector from bge-small-en-v1.5, a small open embedding model: 384 floats computed from the page title, the heading and the section text, with CLS pooling, normalized. This recipe keeps the graph's pages, terms, surfaces, capabilities, problems and recipes, and the relationships among them that the steps read. It keeps the sections of five pages only: two agent guides, the vector search guide, the search reference, and the Python API reference, whose Cursor class is a database cursor. Each section's text is cut to 480 characters and each vector rounded to four decimals, so a score here can differ from the same search on the whole graph in the fourth decimal. The two questions were embedded the same way.

The vectors were computed ahead of time with transformers.js (@huggingface/transformers 4.3.1), which runs the model's ONNX weights in Node.js:

import { pipeline } from "@huggingface/transformers";

const embed = await pipeline("feature-extraction", "Xenova/bge-small-en-v1.5",
  { dtype: "fp32", revision: "ea104dacec62c0de699686887e3f920caeb4f3e3" });
const question = "stop the model from deleting nodes";
const out = await embed([question], { pooling: "cls", normalize: true });
const v = out.tolist()[0]; // 384 floats: pass it as the vector to search with

Create the constraints and search indexes

The index names are the docs graph's: docs, a full-text index over each section's heading and text, and docs_vector, a vector index over the section embeddings (384 dimensions, cosine). WITH [s.page] declares the one property a vector search may filter on before it ranks. A statement written here runs unchanged on the whole graph.

Step 1
Show the setup step
CREATE CONSTRAINT page_id IF NOT EXISTS FOR (p:Page) REQUIRE p.id IS UNIQUE;
CREATE CONSTRAINT section_id IF NOT EXISTS FOR (s:Section) REQUIRE s.id IS UNIQUE;
CREATE CONSTRAINT term_id IF NOT EXISTS FOR (t:Term) REQUIRE t.id IS UNIQUE;
CREATE CONSTRAINT surface_id IF NOT EXISTS FOR (s:Surface) REQUIRE s.id IS UNIQUE;
CREATE CONSTRAINT capability_id IF NOT EXISTS
  FOR (c:Capability) REQUIRE c.id IS UNIQUE;
CREATE CONSTRAINT cookbookrecipe_id IF NOT EXISTS
  FOR (r:CookbookRecipe) REQUIRE r.id IS UNIQUE;
CREATE CONSTRAINT question_text IF NOT EXISTS
  FOR (q:Question) REQUIRE q.text IS UNIQUE;
CREATE FULLTEXT INDEX docs IF NOT EXISTS FOR (s:Section) ON EACH [s.heading, s.text];
CREATE VECTOR INDEX docs_vector IF NOT EXISTS
  FOR (s:Section) ON (s.embedding) WITH [s.page]
  OPTIONS {indexConfig: {`vector.dimensions`: 384,
                         `vector.similarity_function`: 'cosine'}}
Recorded result
(no rows) · indexes added: 2, constraints added: 7

Load the graph

Each page row lists the ids its relationships point at, and each statement of the load turns one list into relationships. vector(r.e, 384, FLOAT32) stores a typed vector, and fails the statement if an embedding has the wrong length.

Step 2
Show the setup step
UNWIND $pages AS r
CREATE (:Page {id: r.id, title: r.title, url: r.url});

UNWIND $terms AS r
CREATE (:Term {id: r.id, term: r.term, short: r.short});

UNWIND $pages AS r
MATCH (a:Page {id: r.id})
UNWIND r.links_to AS id
MATCH (b:Page {id: id})
CREATE (a)-[:LINKS_TO]->(b);

UNWIND $pages AS r
MATCH (a:Page {id: r.id})
UNWIND r.prerequisite_of AS id
MATCH (b:Page {id: id})
CREATE (a)-[:PREREQUISITE_OF]->(b);

UNWIND $pages AS r
MATCH (p:Page {id: r.id})
UNWIND r.uses_term AS id
MATCH (t:Term {id: id})
CREATE (p)-[:USES_TERM]->(t);

UNWIND $pages AS r
MATCH (p:Page {id: r.id})
UNWIND r.applies_to AS id
MERGE (s:Surface {id: id})
CREATE (p)-[:APPLIES_TO]->(s);

UNWIND $capabilities AS r
CREATE (c:Capability {id: r.id, title: r.title, url: r.url})
WITH c, r
UNWIND r.solves AS x
CREATE (c)-[:SOLVES]->(:Problem {id: x.id, kind: x.kind, text: x.text});

UNWIND $capabilities AS r
MATCH (c:Capability {id: r.id})
UNWIND r.cites AS id
MATCH (p:Page {id: id})
CREATE (c)-[:CITES]->(p);

UNWIND $recipes AS r
CREATE (k:CookbookRecipe {id: r.id, title: r.title, url: r.url})
WITH k, r
UNWIND r.demonstrates AS id
MATCH (c:Capability {id: id})
CREATE (k)-[:DEMONSTRATES]->(c);

UNWIND $capabilities AS r
MATCH (c:Capability {id: r.id})
UNWIND r.verified_by AS v
MATCH (x:CookbookRecipe|Page {id: v.id})
WHERE v.label IN labels(x)
CREATE (c)-[:VERIFIED_BY {cell: v.cell}]->(x);

UNWIND $sections AS r
MATCH (p:Page {id: r.page})
CREATE (p)-[:HAS_SECTION]->(:Section {id: r.id, page: r.page, heading: r.heading,
  position: r.position, url: r.url, text: r.text,
  embedding: vector(r.e, 384, FLOAT32)});

UNWIND $questions AS r
CREATE (:Question {text: r.q, embedding: vector(r.e, 384, FLOAT32)});

RETURN COUNT { MATCH (:Page) } AS pages, COUNT { MATCH (:Section) } AS sections,
       COUNT { MATCH (:Term) } AS terms, COUNT { MATCH (:Capability) } AS capabilities,
       COUNT { MATCH (:CookbookRecipe) } AS recipes,
       COUNT { MATCH ()-[]->() } AS relationships
Loads docs.json (369 KB)
Recorded result
pagessectionstermscapabilitiesrecipesrelationships
142756520112547

1 row · nodes created: 453, relationships created: 2547, properties set: 1645, labels added: 453

Full-text search matches words

Full-text SEARCH splits the question into lowercase terms and ORs them. The score is BM25: a term that few sections hold counts for more, and a match in a short section counts for more than one in a long section. There is no stemming, and common words such as the and from count too. The page that answers this question, "Keep an agent safe", says it in other words: read-only stores, safe mode, permissions.

Step 3
MATCH (s:Section)
SEARCH s IN (FULLTEXT INDEX docs FOR $q LIMIT $k) SCORE AS score
RETURN s.page AS page, s.heading AS heading, score
ORDER BY score DESC, s.id
Parameters
Recorded result
pageheadingscore
"guides/cypher/vector-search""Prerequisites"2.9741323045141
"reference/api/python""ResultGraph"2.2898271927135236
"guides/cypher/vector-search""Find the nearest nodes"2.1949869909764943
"guides/cypher/vector-search""Search by vector similarity"1.764290491759474
"guides/agents/agent-safety""Back up before a write"1.7070002894276977

5 rows

One of the five, the backup section, comes from "Keep an agent safe"; the other four share the question's words, not its meaning.

Vector search finds the entry points

The vector search starts from the question's stored embedding. SEARCH binds s to the k nearest sections; the score is the cosine similarity rescaled to 0 to 1, so higher is closer.

Step 4
MATCH (question:Question {text: $q})
MATCH (s:Section)
SEARCH s IN (VECTOR INDEX docs_vector FOR question.embedding LIMIT $k)
  SCORE AS score
RETURN s.page AS page, s.heading AS heading, round(score, 4) AS score
ORDER BY score DESC, s.id
Parameters
Recorded result
pageheadingscore
"guides/agents/agent-safety""Verify"0.8147
"guides/agents/agent-safety""Turn on safe mode"0.8067
"guides/agents/agent-safety""Permission matrix"0.8035
"reference/api/python""Open options"0.8028
"guides/agents/agent-safety""Control network egress"0.7987

5 rows

Four of the five sections come from "Keep an agent safe". The fifth is the Python API's "Open options", whose read_only option refuses writes. These are the entry points; the next step starts from them.

Expand the entry points into context

A section is a fragment. Before a model can answer from it, it needs the page the section belongs to, what a reader should know first, and the words the page assumes. This step groups the nearest sections by page, then walks back along PREREQUISITE_OF up to three hops, a quantified path pattern, for the pages to read first, farthest first, and follows USES_TERM to the glossary terms with their short definitions. The rows are the context an agent would pass to a model with the sections' text.

Step 5
MATCH (question:Question {text: $q})
MATCH (s:Section)
SEARCH s IN (VECTOR INDEX docs_vector FOR question.embedding LIMIT $k)
  SCORE AS score
MATCH (p:Page)-[:HAS_SECTION]->(s)
WITH p, s, score ORDER BY score DESC, s.id
WITH p, collect(s.heading) AS sections, max(score) AS best
RETURN p.title AS page, sections, round(best, 4) AS score,
  COLLECT { MATCH path = (pre:Page)-[:PREREQUISITE_OF]->{1,3}(p)
            WITH pre, max(length(path)) AS steps
            RETURN pre.title ORDER BY steps DESC, pre.title } AS read_first,
  COLLECT { MATCH (p)-[:USES_TERM]->(t:Term)
            RETURN t.term + ': ' + t.short ORDER BY t.id } AS terms
ORDER BY score DESC, page
Parameters
Recorded result
pagesectionsscoreread_firstterms
"Keep an agent safe"["Verify", "Turn on safe mode", "Permission matrix", "Control network egress"]0.8147["Install relate"]["checkpoint: The step that writes a fresh snapshot of committed state and recycles old WAL segments: every 300 seconds by default, on clean exit, and on `.checkpoint`.", "MCP: The Model Context Protocol, which agent hosts use to call tools; `relate serve --mcp` exposes a store as `read_cypher`, `get_schema` and `write_cypher`.", "read-only: An open mode (`--read-only`) that never writes, checkpoints or migrates the store; writes fail, and a directory store can be read beside its writer.", "safe mode: An opt-in policy (`--safe`) that refuses a new named node without a label, an unbounded `MATCH` read and a remote `--load`, before anything runs.", "store: A relate database: a directory, a single `.rl` file, or `:memory:`, opened with `--database`."]
"Python API"["Open options"]0.8028[]["client: A language package that runs the relate engine inside a program's process: the Python, Node.js, Rust, Java, Go and Swift clients.", "node: An entity in the graph: it carries zero or more labels and a map of properties, and relationships connect it to other nodes.", "path: An alternating sequence of nodes and relationships that a pattern matched, bound with `p = …` and read with `nodes(p)`, `relationships(p)` and `length(p)`.", "relationship: A directed, typed connection between two nodes, with properties of its own; every relationship has exactly one type and a start and an end node.", "store: A relate database: a directory, a single `.rl` file, or `:memory:`, opened with `--database`."]

2 rows

The four sections of "Keep an agent safe" become one row: read "Install relate" first, and know five terms, from checkpoint to store. The Python API has no prerequisite in the graph, so its read_first is empty: a COLLECT { … } subquery gives each row a list, empty or not, and never drops the row.

Ask your own question

Change q to a question of your own and the vector search returns no rows: only the two questions in the data have a stored vector. A question's vector is only comparable with vectors from the same model, so a new question needs bge-small-en-v1.5 at the revision and pooling the sections were embedded with. In the browser, this element downloads that model, embeds the question in the tab, and runs the context step with the new vector in place of the stored one, so the answer comes back with its pages, prerequisites and terms.

The element embeds a typed question with the model that made these vectors: bge-small-en-v1.5 at revision ea104da, with CLS pooling, normalized, as its half-precision ONNX weights. The button downloads 82 MB: the weights (66.7 MB) and the tokenizer (0.7 MB) from huggingface.co, and the ONNX runtime (14.2 MB) from this site. Nothing downloads before the press, and each file is checked against its SHA-256 before it is used. The question, at most 200 characters, is embedded in the browser tab and never sent anywhere.

Step context then runs with its first line, MATCH (question:Question {text: $q}), replaced by WITH {embedding: vector($embedding, 384, FLOAT32)} AS question, so question.embedding is the typed question's vector and every other line runs as written.

Step context's recorded result, for the stored question "stop the model from deleting nodes":

page sections score read_first terms
Keep an agent safe ["Verify","Turn on safe mode","Permission matrix","Control network egress"] 0.8147 ["Install relate"] ["checkpoint: The step that writes a fresh snapshot of committed state and recycles old WAL segments: every 300 seconds by default, on clean exit, and on .checkpoint.","MCP: The Model Context Protocol, which agent hosts use to call tools; relate serve --mcp exposes a store as read_cypher, get_schema and write_cypher.","read-only: An open mode (--read-only) that never writes, checkpoints or migrates the store; writes fail, and a directory store can be read beside its writer.","safe mode: An opt-in policy (--safe) that refuses a new named node without a label, an unbounded MATCH read and a remote --load, before anything runs.","store: A relate database: a directory, a single .rl file, or :memory:, opened with --database."]
Python API ["Open options"] 0.8028 [] ["client: A language package that runs the relate engine inside a program's process: the Python, Node.js, Rust, Java, Go and Swift clients.","node: An entity in the graph: it carries zero or more labels and a map of properties, and relationships connect it to other nodes.","path: An alternating sequence of nodes and relationships that a pattern matched, bound with p = … and read with nodes(p), relationships(p) and length(p).","relationship: A directed, typed connection between two nodes, with properties of its own; every relationship has exactly one type and a start and an end node.","store: A relate database: a directory, a single .rl file, or :memory:, opened with --database."]

Live: a button that states the download size fetches the model's weights and tokenizer from huggingface.co and the ONNX runtime from this site, checks each file's SHA-256, embeds the question you type in your browser (the question is never sent anywhere), and runs the step's vector search with that vector in place of the stored one on the page's database in your browser; nothing downloads before the press.

Elsewhere, embed the question with the code in "Where the data comes from" and pass the vector as a parameter (FOR $v). The full-text search works on any text.

A word with two meanings

The second question names an editor, Cursor. The Python API reference has a class of the same name, a database cursor, and the question's vector lands nearest to it. This step runs the plain vector search and adds one hop per hit: the surfaces its page applies to.

Step 6
MATCH (question:Question {text: $q})
MATCH (s:Section)
SEARCH s IN (VECTOR INDEX docs_vector FOR question.embedding LIMIT $k)
  SCORE AS score
MATCH (p:Page)-[:HAS_SECTION]->(s)
RETURN p.title AS page, s.heading AS heading, round(score, 4) AS score,
       COLLECT { MATCH (p)-[:APPLIES_TO]->(x:Surface)
                 RETURN x.id ORDER BY x.id } AS surfaces
ORDER BY score DESC, s.id
Parameters
Recorded result
pageheadingscoresurfaces
"Python API""Cursor"0.8454["python"]
"Connect over MCP""Cursor"0.8432["agents", "cli", "mcp"]
"Python API""ResultGraph"0.8089["python"]
"Python API""Open options"0.8055["python"]
"Keep an agent safe""Check destructive statements before they run"0.8036["agents", "cli", "mcp"]

5 rows

The Python API's Cursor ranks first, and three of the five sections come from that page, the only one among them that applies to python and not to mcp.

Search only where the graph points

When the graph knows where to look, the search can start there. A reader connecting Cursor works over MCP, so this step walks from the mcp surface to the pages that apply to it, then runs the vector search over those pages' sections only. WHERE s.page IN scope inside the SEARCH filters before the top k are chosen, so the search still returns k sections. A vector search may filter only on a property its index declares, which is what WITH [s.page] on docs_vector is for.

Step 7
MATCH (question:Question {text: $q})
MATCH (:Surface {id: $surface})<-[:APPLIES_TO]-(p:Page)
WITH question, collect(p.id) AS scope
MATCH (s:Section)
SEARCH s IN (VECTOR INDEX docs_vector FOR question.embedding
  WHERE s.page IN scope LIMIT $k) SCORE AS score
RETURN s.page AS page, s.heading AS heading, round(score, 4) AS score
ORDER BY score DESC, s.id
Parameters
Recorded result
pageheadingscore
"guides/agents/connect-over-mcp""Cursor"0.8432
"guides/agents/agent-safety""Check destructive statements before they run"0.8036
"guides/agents/connect-over-mcp""Windsurf"0.8007
"guides/agents/agent-safety""Open the store read-only"0.8006
"guides/agents/agent-safety""Keep an agent safe"0.7974

5 rows

Thirty pages apply to mcp, and two of them, the agent guides, have sections in this data. The Python sections are gone; the five left are the Cursor setup, the check on destructive statements, Windsurf, the read-only open, and the safety page's introduction.

Note A WHERE after the SEARCH clause filters only the rows the search returned, so it can return fewer than k. Without WITH [s.page] on the index, the filter inside the search fails with WrongPropertyForVectorSearchFilter.

Walk from the answer to its worked example

The capability catalog says what relate is for: each capability states a problem, cites the pages that explain it, and names the recipe or page step that proves it. This step follows CITES back from the scoped hits' pages, counts the hits under each capability, and returns the top three with the problem each solves, the recipes that demonstrate it, and what verifies it.

Step 8
MATCH (question:Question {text: $q})
MATCH (:Surface {id: $surface})<-[:APPLIES_TO]-(p:Page)
WITH question, collect(p.id) AS scope
MATCH (s:Section)
SEARCH s IN (VECTOR INDEX docs_vector FOR question.embedding
  WHERE s.page IN scope LIMIT $k) SCORE AS score
MATCH (c:Capability)-[:CITES]->(:Page)-[:HAS_SECTION]->(s)
WITH c, count(s) AS hits
MATCH (c)-[:SOLVES]->(problem:Problem {kind: 'problem'})
RETURN c.title AS capability, hits, problem.text AS problem,
  COLLECT { MATCH (r:CookbookRecipe)-[:DEMONSTRATES]->(c)
            RETURN r.title ORDER BY r.title } AS demonstrated_by,
  COLLECT { MATCH (c)-[v:VERIFIED_BY]->(x)
            RETURN x.title + ' (' + v.cell + ')' ORDER BY x.title } AS verified_by
ORDER BY hits DESC, capability LIMIT 3
Parameters
Recorded result
capabilityhitsproblemdemonstrated_byverified_by
"Graph queries over MCP"5"Give a coding agent or an agent framework a graph it can query through MCP, without letting it change the data."[]["Use relate with the Claude Agent SDK (options)"]
"Migration rehearsal"3"Check that a data migration changed only what it was meant to, before it runs on the real graph."["Rehearse a migration against a golden graph"]["Rehearse a migration against a golden graph (verdict)"]
"Offline and sandboxed use"3"Run a graph database where an agent works isolated, with no network after setup and nothing sent anywhere."[]["Run relate in a sandbox (step-1)"]

3 rows

"Graph queries over MCP" cites the pages of all five hits, and the problem it solves restates the question: a graph a coding agent can query through MCP without changing the data. No recipe demonstrates it; the catalog verifies it with the step named options on the Claude Agent SDK guide. The next two cite "Keep an agent safe" for other reasons, rehearsing a migration and running offline, and the first of them has a recipe.

The whole graph

The same statements run on the whole docs graph, every section of every page, once it is loaded; with every section in the index, the nearest ones, and so the rows, can differ from the ones recorded here. Both indexes cover the same sections, so the two lists can be fused by reciprocal rank, as Hybrid recall does. relate --load opens the docs graph's store from its URL:

relate --load https://relatedb.ai/docs/relate-docs.rl --read-only --mode envelope \
  "MATCH (s:Section) SEARCH s IN (FULLTEXT INDEX docs FOR 'safe mode' LIMIT 5)
   RETURN s.page, s.heading, s.url"

About the data

Dataset
The relate docs graph's pages, glossary terms, surfaces, capabilities, problems and recipes with the relationships the steps read, every section of five pages with its 384-dimension bge-small-en-v1.5 embedding, and 2 embedded questions (about 373 KB)
Source
The relate docs graph (https://relatedb.ai/docs/graph.cypher); section text from the pages, cut to 480 characters; vectors from the committed docs embeddings
License
Apache-2.0

Every data block is published beside this page as data/<name>.json, and the whole recipe as markdown.

More in Search and retrieval