Cookbook / Search and retrieval · Advanced
Hybrid recall: vector + full-text + RRF
An engineering-support agent keeps 40 notes from three working sessions on a project. Asked "what did we decide about oauth tokens", it runs one Cypher statement: a vector search and a full-text search over the notes, fused by reciprocal rank, then a hop from each recalled note to its topic and that topic's latest decision. The best match is an early note that says Auth0. The hop shows that the team later decided to move to Okta.
The graph is (:Note {id, text, session, ts, embedding})-[:ABOUT]->(:Topic {name}).
Nine notes also record a decision as (:Note)-[:DECIDED {text, ts}]->(:Topic).
ts is an integer, epoch milliseconds.
The recall statement the recipe builds up to, in one picture:
Embeddings, computed ahead of time
Every note and every question in this recipe was embedded offline, with the
companion example's deterministic hash-384 embedder. It hashes lowercased
words and character trigrams into 384 buckets and normalizes the result. It
captures lexical overlap, not meaning, which is enough to show the mechanics.
In an application, call your embedding model here instead.
import hashlib, math, re
import relate
DIMS = 384
def embed(text: str) -> list[float]:
"""hash-384: feature hashing over words and character trigrams, L2-normalized."""
v = [0.0] * DIMS
words = re.findall(r"[a-z0-9]+", text.lower())
feats = words + [w[i:i + 3] for w in words for i in range(max(1, len(w) - 2))]
for f in feats:
h = int.from_bytes(hashlib.blake2b(f.encode(), digest_size=8).digest(), "little")
v[h % DIMS] += 1.0 if h >> 63 else -1.0
norm = math.sqrt(sum(x * x for x in v)) or 1.0
return [x / norm for x in v]
# At recall time, embed the question and pass it with the raw text.
# RECALL is the statement in "Hop to the topic and its latest decision".
question = "what did we decide about oauth tokens"
params = {"q": question, "v": embed(question), "k": 5, "since": "2026-03-01T00:00:00Z"}
with relate.connect("notes.rl") as db:
rows = db.execute(RECALL, params).fetchall()The notes' vectors are in the notes data block. Each question's data block
holds the question text q, its precomputed vector v, and two knobs: k
(results per list) and since (the oldest note in scope). Editing k or
since on this page works as you would expect. Editing q does not re-embed
it: the full-text search sees the new text, but the vector search still uses
the vector of the original question.
The fixtures round each component to six significant digits, which leaves
every score below unchanged at four decimals. They write every component as a
float (0.0, not 0), because parameters arrive exactly as the JSON spells
them.
Create the constraints and search indexes
Constraints keep note ids and topic names unique, and keep DECIDED.ts an
integer. Then three search indexes:
note_e, a vector index over the note embeddings (384 dimensions, cosine).WITH [n.session, n.ts]declares the properties a vector search may filter on inside the search.note_t, a full-text index over the note text.dec_t, a full-text index over the text of theDECIDEDrelationships.
Show the setup step
CREATE CONSTRAINT note_id IF NOT EXISTS FOR (n:Note) REQUIRE n.id IS UNIQUE;
CREATE CONSTRAINT topic_name IF NOT EXISTS FOR (t:Topic) REQUIRE t.name IS UNIQUE;
CREATE CONSTRAINT decided_ts IF NOT EXISTS
FOR ()-[d:DECIDED]-() REQUIRE d.ts IS :: INTEGER;
CREATE VECTOR INDEX note_e IF NOT EXISTS
FOR (n:Note) ON (n.embedding) WITH [n.session, n.ts]
OPTIONS {indexConfig: {`vector.dimensions`: 384,
`vector.similarity_function`: 'cosine'}};
CREATE FULLTEXT INDEX note_t IF NOT EXISTS FOR (n:Note) ON EACH [n.text];
CREATE FULLTEXT INDEX dec_t IF NOT EXISTS FOR ()-[r:DECIDED]-() ON EACH [r.text](no rows) · indexes added: 3, constraints added: 3
Load the notes
vector(r.e, 384, FLOAT32) stores a typed vector, and doubles as a write
guard: an embedding of the wrong length fails the whole statement. Without
the guard, a wrong-length list is stored anyway, and every vector search
skips it without an error.
Show the setup step
UNWIND $notes AS r
CREATE (n:Note {id: r.id, text: r.text, session: r.session, ts: r.ts,
embedding: vector(r.e, 384, FLOAT32)})
WITH n, r
UNWIND r.topics AS name
MERGE (t:Topic {name: name})
CREATE (n)-[:ABOUT]->(t);
UNWIND $decisions AS r
MATCH (n:Note {id: r.note}), (t:Topic {name: r.topic})
CREATE (n)-[:DECIDED {text: r.text, ts: r.ts}]->(t);
MATCH (n:Note)
RETURN count(n) AS notes, count(DISTINCT n.session) AS sessions,
COUNT { MATCH (:Topic) } AS topics,
COUNT { MATCH ()-[:ABOUT]->() } AS about,
COUNT { MATCH ()-[:DECIDED]->() } AS decided| notes | sessions | topics | about | decided |
|---|---|---|---|---|
| 40 | 3 | 6 | 47 | 9 |
1 row · nodes created: 46, relationships created: 56, properties set: 224, labels added: 46
| notes | sessions | topics | about | decided | |-------|----------|--------|-------|---------| | 40 | 3 | 6 | 47 | 9 | 1 row · nodes created: 46, relationships created: 56, properties set: 224, labels added: 46
The vector list
SEARCH binds n to the k nearest notes and names the similarity with
SCORE AS. The time filter sits inside the search, so it runs before the
LIMIT: whatever since is, the search returns k notes from the time
range, as long as the range holds that many.
MATCH (n:Note)
SEARCH n IN (VECTOR INDEX note_e FOR $v
WHERE n.ts >= datetime($since).epochMillis LIMIT $k) SCORE AS vs
RETURN n.id AS id, n.session AS session, round(vs, 4) AS vector, n.text AS text
ORDER BY vs DESC, idParameters
| id | session | vector | text |
|---|---|---|---|
| "n02" | "session-1" | 0.6252 | "Customer login goes through Auth0; refresh tokens live in HttpOnly cookies." |
| "n25" | "session-2" | 0.5692 | "Refresh tokens rotate on every use once Okta is live." |
| "n03" | "session-1" | 0.5585 | "OAuth callbacks are published to Kafka so the audit service sees every login." |
| "n15" | "session-2" | 0.5536 | "Migrate customer login from Auth0 to Okta before the April renewal." |
| "n04" | "session-1" | 0.5435 | "Each tenant gets its own Kafka topic for ticket events." |
5 rows
| id | session | vector | text | |-------|-------------|--------|---------------------------------------------------------------------------------| | "n02" | "session-1" | 0.6252 | "Customer login goes through Auth0; refresh tokens live in HttpOnly cookies." | | "n25" | "session-2" | 0.5692 | "Refresh tokens rotate on every use once Okta is live." | | "n03" | "session-1" | 0.5585 | "OAuth callbacks are published to Kafka so the audit service sees every login." | | "n15" | "session-2" | 0.5536 | "Migrate customer login from Auth0 to Okta before the April renewal." | | "n04" | "session-1" | 0.5435 | "Each tenant gets its own Kafka topic for ticket events." | 5 rows
Change since to 2026-03-09T00:00:00Z and run it again: the three
session-1 notes drop out, and the search still returns five notes, all from
sessions 2 and 3. The procedure form,
CALL db.index.vector.queryNodes('note_e', $k, $v), returns the same five
notes here, but it has no filter.
The full-text list
Full-text SEARCH splits the question into lowercase terms and ORs them.
The score is BM25: a term that few notes contain counts for more, and a
match in a short note counts for more than one in a long note. There is no
stemming, and short words such as do or the count as terms too. Three
notes match one term each: n03 holds oauth, which no other note
contains, and n25 and n02 hold tokens, the shorter note scoring
higher. n.id is the second sort key, for notes whose scores tie.
A full-text search takes no WHERE. So this list fetches three times as
many notes as it needs, filters them by time, and then keeps k.
MATCH (n:Note)
SEARCH n IN (FULLTEXT INDEX note_t FOR $q LIMIT $k * 3) SCORE AS fs
WHERE n.ts >= datetime($since).epochMillis
RETURN n.id AS id, fs AS fulltext, n.text AS text
ORDER BY fs DESC, id LIMIT $kParameters
| id | fulltext | text |
|---|---|---|
| "n03" | 1.4178942683735956 | "OAuth callbacks are published to Kafka so the audit service sees every login." |
| "n25" | 1.335407782356475 | "Refresh tokens rotate on every use once Okta is live." |
| "n02" | 1.2865960449569087 | "Customer login goes through Auth0; refresh tokens live in HttpOnly cookies." |
3 rows
| id | fulltext | text | |-------|--------------------|---------------------------------------------------------------------------------| | "n03" | 1.4178942683735956 | "OAuth callbacks are published to Kafka so the audit service sees every login." | | "n25" | 1.335407782356475 | "Refresh tokens rotate on every use once Okta is live." | | "n02" | 1.2865960449569087 | "Customer login goes through Auth0; refresh tokens live in HttpOnly cookies." | 3 rows
The procedure form, CALL db.index.fulltext.queryNodes('note_t', $q),
returns the same notes with the same scores.
Filter inside the search, not after it
SEARCH takes its LIMIT window from the whole index first. A WHERE after
the clause only filters that window, and so does a property in the MATCH
pattern (MATCH (n:Note {session: $session})). Both silently return fewer
than k rows.
Here the scope is session 2 from 10:00 on, which holds nine notes, and the
question is kafka with k = 3:
MATCH (n:Note)
SEARCH n IN (VECTOR INDEX note_e FOR $v
WHERE n.session = $session AND n.ts >= datetime($since).epochMillis
LIMIT $k) SCORE AS s
WITH n ORDER BY s DESC, n.id
WITH collect(n.id) AS ids
RETURN 'vector: WHERE inside SEARCH' AS filter, ids
UNION ALL
MATCH (n:Note)
SEARCH n IN (VECTOR INDEX note_e FOR $v LIMIT $k) SCORE AS s
WHERE n.session = $session AND n.ts >= datetime($since).epochMillis
WITH n ORDER BY s DESC, n.id
WITH collect(n.id) AS ids
RETURN 'vector: WHERE after SEARCH' AS filter, ids
UNION ALL
MATCH (n:Note)
SEARCH n IN (FULLTEXT INDEX note_t FOR $q LIMIT $k) SCORE AS s
WHERE n.session = $session AND n.ts >= datetime($since).epochMillis
WITH n ORDER BY s DESC, n.id
WITH collect(n.id) AS ids
RETURN 'full-text: LIMIT $k, then WHERE' AS filter, ids
UNION ALL
MATCH (n:Note)
SEARCH n IN (FULLTEXT INDEX note_t FOR $q LIMIT $k * 3) SCORE AS s
WHERE n.session = $session AND n.ts >= datetime($since).epochMillis
WITH n ORDER BY s DESC, n.id LIMIT $k
WITH collect(n.id) AS ids
RETURN 'full-text: LIMIT $k * 3, WHERE, LIMIT $k' AS filter, idsParameters
| filter | ids |
|---|---|
| "vector: WHERE inside SEARCH" | ["n18", "n19", "n23"] |
| "vector: WHERE after SEARCH" | ["n18"] |
| "full-text: LIMIT $k, then WHERE" | [] |
| "full-text: LIMIT $k * 3, WHERE, LIMIT $k" | ["n17", "n18", "n19"] |
4 rows
| filter | ids | |--------------------------------------------|-----------------------| | "vector: WHERE inside SEARCH" | ["n18", "n19", "n23"] | | "vector: WHERE after SEARCH" | ["n18"] | | "full-text: LIMIT $k, then WHERE" | [] | | "full-text: LIMIT $k * 3, WHERE, LIMIT $k" | ["n17", "n18", "n19"] | 4 rows
Only the first and last forms return k notes. The three best full-text
matches for kafka are notes from sessions 1 and 3, so filtering them
afterwards leaves nothing.
Fuse the two lists by reciprocal rank
Reciprocal rank fusion (RRF) ignores the raw scores, which are not comparable
across the two lists. Each list ranks its notes 1, 2, 3, …; a note earns
1 / (60 + rank) from every list it appears in, and the shares add up. The
constant 60 is the usual choice: it keeps one list's first place from
swamping the other list.
The explorer fuses the two lists above, the vector and fulltext steps
for "what did we decide about oauth tokens", by that arithmetic. Its k
slider runs both steps again with the chosen k, and the fused list is
recomputed from the ids the engine returns. At k = 5 the fused scores are
the rrf column of the recall step further down. The full-text list never holds
more than three notes for this question: only three notes contain oauth or
tokens.
The 2 ranked lists for "what did we decide about oauth tokens", as recorded with k = 5:
| Rank | vector |
fulltext |
|---|---|---|
| 1 | n02 |
n03 |
| 2 | n25 |
n25 |
| 3 | n03 |
n02 |
| 4 | n15 |
|
| 5 | n04 |
Fused by reciprocal rank: each list gives an id 1 / (60 + its rank there), and the shares add up. 5 ids, best first:
| id | Rank in vector |
Rank in fulltext |
Arithmetic | Fused score |
|---|---|---|---|---|
n02 |
1 | 3 | 1/(60+1) + 1/(60+3) | 0.0323 |
n03 |
3 | 1 | 1/(60+3) + 1/(60+1) | 0.0323 |
n25 |
2 | 2 | 1/(60+2) + 1/(60+2) | 0.0323 |
n15 |
4 | 1/(60+4) | 0.0156 | |
n04 |
5 | 1/(60+5) | 0.0154 |
Live: a k slider runs the page's search statements on its database in your browser with that k, and the view marks the ids the engine returns.
CALL () { … UNION ALL … } runs both searches in one statement. Each branch
collects its notes in score order and unwinds them with their position. This
question needs the vector list: the note that answers it says Token revocation and propagates, and full-text search has no stemming, so it
never matches tokens, revoking, or propagate.
CALL () {
MATCH (n:Note)
SEARCH n IN (VECTOR INDEX note_e FOR $v
WHERE n.ts >= datetime($since).epochMillis LIMIT $k) SCORE AS vs
WITH n ORDER BY vs DESC, n.id
WITH collect(n) AS ns
UNWIND range(0, size(ns) - 1) AS i
RETURN ns[i] AS n, 'vector' AS list, i + 1 AS rank
UNION ALL
MATCH (n:Note)
SEARCH n IN (FULLTEXT INDEX note_t FOR $q LIMIT $k * 3) SCORE AS fs
WHERE n.ts >= datetime($since).epochMillis
WITH n ORDER BY fs DESC, n.id LIMIT $k
WITH collect(n) AS ns
UNWIND range(0, size(ns) - 1) AS i
RETURN ns[i] AS n, 'fulltext' AS list, i + 1 AS rank
}
WITH n, sum(1.0 / (60 + rank)) AS rrf,
min(CASE list WHEN 'vector' THEN rank END) AS vector_rank,
min(CASE list WHEN 'fulltext' THEN rank END) AS fulltext_rank
ORDER BY rrf DESC, n.id LIMIT $k
RETURN n.id AS id, vector_rank, fulltext_rank, round(rrf, 4) AS rrf, n.text AS textParameters
| id | vector_rank | fulltext_rank | rrf | text |
|---|---|---|---|---|
| "n25" | 3 | 1 | 0.0323 | "Refresh tokens rotate on every use once Okta is live." |
| "n02" | 2 | 2 | 0.0323 | "Customer login goes through Auth0; refresh tokens live in HttpOnly cookies." |
| "n16" | 1 | null | 0.0164 | "Token revocation now propagates in under a second via the Redis session store." |
| "n01" | 4 | null | 0.0156 | "Relay kickoff: ticket intake, the agent console and billing all live in one monorepo." |
| "n32" | 5 | null | 0.0154 | "Kafka status: consumer lag is back under one second after the rebalance fix." |
5 rows
| id | vector_rank | fulltext_rank | rrf | text | |-------|-------------|---------------|--------|-----------------------------------------------------------------------------------------| | "n25" | 3 | 1 | 0.0323 | "Refresh tokens rotate on every use once Okta is live." | | "n02" | 2 | 2 | 0.0323 | "Customer login goes through Auth0; refresh tokens live in HttpOnly cookies." | | "n16" | 1 | null | 0.0164 | "Token revocation now propagates in under a second via the Redis session store." | | "n01" | 4 | null | 0.0156 | "Relay kickoff: ticket intake, the agent console and billing all live in one monorepo." | | "n32" | 5 | null | 0.0154 | "Kafka status: consumer lag is back under one second after the rebalance fix." | 5 rows
n16 is first in the vector list and absent from the full-text list, and it
survives the fusion in third place. The next question needs the full-text
list. The long incident note holds three of its terms (kafka, 142, and
alert), more than any other note, so full-text ranks it first, but it is not among the five nearest
vectors.
CALL () {
MATCH (n:Note)
SEARCH n IN (VECTOR INDEX note_e FOR $v
WHERE n.ts >= datetime($since).epochMillis LIMIT $k) SCORE AS vs
WITH n ORDER BY vs DESC, n.id
WITH collect(n) AS ns
UNWIND range(0, size(ns) - 1) AS i
RETURN ns[i] AS n, 'vector' AS list, i + 1 AS rank
UNION ALL
MATCH (n:Note)
SEARCH n IN (FULLTEXT INDEX note_t FOR $q LIMIT $k * 3) SCORE AS fs
WHERE n.ts >= datetime($since).epochMillis
WITH n ORDER BY fs DESC, n.id LIMIT $k
WITH collect(n) AS ns
UNWIND range(0, size(ns) - 1) AS i
RETURN ns[i] AS n, 'fulltext' AS list, i + 1 AS rank
}
WITH n, sum(1.0 / (60 + rank)) AS rrf,
min(CASE list WHEN 'vector' THEN rank END) AS vector_rank,
min(CASE list WHEN 'fulltext' THEN rank END) AS fulltext_rank
ORDER BY rrf DESC, n.id LIMIT $k
RETURN n.id AS id, vector_rank, fulltext_rank, round(rrf, 4) AS rrf, n.text AS textParameters
| id | vector_rank | fulltext_rank | rrf | text |
|---|---|---|---|---|
| "n23" | 1 | 2 | 0.0325 | "Kafka lag dashboard added to Grafana with a five minute alert." |
| "n04" | 2 | 4 | 0.0318 | "Each tenant gets its own Kafka topic for ticket events." |
| "n07" | 3 | 3 | 0.0317 | "Kafka consumer lag on the ticket-events group is the first alert to build." |
| "n26" | null | 1 | 0.0164 | "Incident KAFKA-142: a broker disk filled overnight because retention on the audit stream was unbounded; retention is now capped and a disk alert pages the on-call engineer." |
| "n18" | 4 | null | 0.0156 | "Consolidate ticket events into shared Kafka topics keyed by tenant id." |
5 rows
| id | vector_rank | fulltext_rank | rrf | text | |-------|-------------|---------------|--------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | "n23" | 1 | 2 | 0.0325 | "Kafka lag dashboard added to Grafana with a five minute alert." | | "n04" | 2 | 4 | 0.0318 | "Each tenant gets its own Kafka topic for ticket events." | | "n07" | 3 | 3 | 0.0317 | "Kafka consumer lag on the ticket-events group is the first alert to build." | | "n26" | null | 1 | 0.0164 | "Incident KAFKA-142: a broker disk filled overnight because retention on the audit stream was unbounded; retention is now capped and a disk alert pages the on-call engineer." | | "n18" | 4 | null | 0.0156 | "Consolidate ticket events into shared Kafka topics keyed by tenant id." | 5 rows
Hop to the topic and its latest decision
This is the whole recall tool: the fusion from the last step, then the graph.
Each fused note leads to its topics, and each topic to every DECIDED edge on
it. Sorting the edges newest first and taking head(collect(d.text)) keeps
the latest decision per note and topic. The agent can log this one string,
replay it, and evaluate it.
CALL () {
MATCH (n:Note)
SEARCH n IN (VECTOR INDEX note_e FOR $v
WHERE n.ts >= datetime($since).epochMillis LIMIT $k) SCORE AS vs
WITH n ORDER BY vs DESC, n.id
WITH collect(n) AS ns
UNWIND range(0, size(ns) - 1) AS i
RETURN ns[i] AS n, i + 1 AS rank
UNION ALL
MATCH (n:Note)
SEARCH n IN (FULLTEXT INDEX note_t FOR $q LIMIT $k * 3) SCORE AS fs
WHERE n.ts >= datetime($since).epochMillis
WITH n ORDER BY fs DESC, n.id LIMIT $k
WITH collect(n) AS ns
UNWIND range(0, size(ns) - 1) AS i
RETURN ns[i] AS n, i + 1 AS rank
}
WITH n, sum(1.0 / (60 + rank)) AS rrf
ORDER BY rrf DESC, n.id LIMIT $k
MATCH (n)-[:ABOUT]->(t:Topic)
OPTIONAL MATCH (t)<-[d:DECIDED]-()
WITH n, rrf, t, d ORDER BY d.ts DESC
WITH n, rrf, t, head(collect(d.text)) AS decided
RETURN n.id AS id, n.text AS text, t.name AS topic, decided, round(rrf, 4) AS rrf
ORDER BY rrf DESC, id, topicParameters
| id | text | topic | decided | rrf |
|---|---|---|---|---|
| "n02" | "Customer login goes through Auth0; refresh tokens live in HttpOnly cookies." | "auth" | "migrate customer login to Okta" | 0.0323 |
| "n03" | "OAuth callbacks are published to Kafka so the audit service sees every login." | "auth" | "migrate customer login to Okta" | 0.0323 |
| "n03" | "OAuth callbacks are published to Kafka so the audit service sees every login." | "kafka" | "three-day retention on shared Kafka topics" | 0.0323 |
| "n25" | "Refresh tokens rotate on every use once Okta is live." | "auth" | "migrate customer login to Okta" | 0.0323 |
| "n15" | "Migrate customer login from Auth0 to Okta before the April renewal." | "auth" | "migrate customer login to Okta" | 0.0156 |
| "n04" | "Each tenant gets its own Kafka topic for ticket events." | "kafka" | "three-day retention on shared Kafka topics" | 0.0154 |
6 rows
| id | text | topic | decided | rrf | |-------|---------------------------------------------------------------------------------|---------|----------------------------------------------|--------| | "n02" | "Customer login goes through Auth0; refresh tokens live in HttpOnly cookies." | "auth" | "migrate customer login to Okta" | 0.0323 | | "n03" | "OAuth callbacks are published to Kafka so the audit service sees every login." | "auth" | "migrate customer login to Okta" | 0.0323 | | "n03" | "OAuth callbacks are published to Kafka so the audit service sees every login." | "kafka" | "three-day retention on shared Kafka topics" | 0.0323 | | "n25" | "Refresh tokens rotate on every use once Okta is live." | "auth" | "migrate customer login to Okta" | 0.0323 | | "n15" | "Migrate customer login from Auth0 to Okta before the April renewal." | "auth" | "migrate customer login to Okta" | 0.0156 | | "n04" | "Each tenant gets its own Kafka topic for ticket events." | "kafka" | "three-day retention on shared Kafka topics" | 0.0154 | 6 rows
The top note says Auth0 and was written in session 1. Its topic, auth, has
two decisions, and the newer one is migrate customer login to Okta. Neither
search could have told the agent that on its own.
Note Take the latest edge per group with
ORDER BYandhead(collect(...)), not withORDER BY d.ts DESC LIMIT 1. Relate currently mis-orders a relationship property underORDER BY … LIMITwhen the pattern starts from more than one node: over all nine edges,MATCH (:Note)-[d:DECIDED]->(:Topic) RETURN d.text ORDER BY d.ts DESC LIMIT 1returns the oldest decision,use Auth0 for customer login. A second sort key (ORDER BY d.ts DESC, d.text) avoids it.
To see the shape, return paths from each recalled note to its topic and the note that made the topic's latest decision. Open the graph view:
CALL () {
MATCH (n:Note)
SEARCH n IN (VECTOR INDEX note_e FOR $v
WHERE n.ts >= datetime($since).epochMillis LIMIT $k) SCORE AS vs
WITH n ORDER BY vs DESC, n.id
WITH collect(n) AS ns
UNWIND range(0, size(ns) - 1) AS i
RETURN ns[i] AS n, i + 1 AS rank
UNION ALL
MATCH (n:Note)
SEARCH n IN (FULLTEXT INDEX note_t FOR $q LIMIT $k * 3) SCORE AS fs
WHERE n.ts >= datetime($since).epochMillis
WITH n ORDER BY fs DESC, n.id LIMIT $k
WITH collect(n) AS ns
UNWIND range(0, size(ns) - 1) AS i
RETURN ns[i] AS n, i + 1 AS rank
}
WITH n, sum(1.0 / (60 + rank)) AS rrf
ORDER BY rrf DESC, n.id LIMIT $k
MATCH (n)-[:ABOUT]->(t:Topic)<-[d:DECIDED]-(:Note)
WITH n, rrf, t, d ORDER BY d.ts DESC
WITH n, rrf, t, head(collect(d)) AS d
MATCH p = (n)-[:ABOUT]->(t)<-[d]-(m:Note)
RETURN n.id AS note, t.name AS topic, m.id AS decided_by, p AS path
ORDER BY rrf DESC, note, topicParameters
// 6 rows
[{note: "n02", topic: "auth", decided_by: "n15"} | (n1:Note {embedding: cypher`vector([0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.1348399966955185, -0.1348399966955185, 0.0, 0.0, 0.0, -0.1348399966955185, 0.0, 0.1348399966955185, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.1348399966955185, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.1348399966955185, 0.1348399966955185, -0.1348399966955185, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.1348399966955185, 0.0, 0.0, 0.0, -0.269679993391037, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.1348399966955185, -0.1348399966955185, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.1348399966955185, 0.0, 0.1348399966955185, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.1348399966955185, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.1348399966955185, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.1348399966955185, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.1348399966955185, 0.0, 0.0, 0.0, 0.0, -0.1348399966955185, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.1348399966955185, 0.0, 0.0, 0.0, -0.1348399966955185, 0.0, 0.0, 0.0, 0.0, -0.1348399966955185, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.1348399966955185, 0.0, 0.0, 0.0, 0.1348399966955185, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.1348399966955185, 0.0, 0.0, -0.1348399966955185, 0.1348399966955185, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.1348399966955185, 0.0, 0.0, 0.0, 0.0, 0.1348399966955185, 0.0, 0.0, 0.0, 0.0, 0.1348399966955185, 0.0, 0.1348399966955185, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.1348399966955185, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.1348399966955185, 0.0, 0.0, 0.0, -0.1348399966955185, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.1348399966955185, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.1348399966955185, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.1348399966955185, -0.1348399966955185, 0.0, 0.0, 0.0, 0.0, 0.1348399966955185, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.1348399966955185, 0.0, 0.0, -0.1348399966955185, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.1348399966955185, -0.269679993391037, 0.1348399966955185, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.1348399966955185, 0.0, 0.0, 0.1348399966955185, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.1348399966955185, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.1348399966955185, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.1348399966955185, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], 384, FLOAT32)`, id: "n02", session: "session-1", text: "Customer login goes through Auth0; refresh tokens live in HttpOnly cookies.", ts: 1772442900000})-[r1:ABOUT]->(n41:Topic {name: "auth"})<-[r50:DECIDED {text: "migrate customer login to Okta", ts: 1773048000000}]-(n14:Note {embedding: cypher`vector([0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.144337996840477, -0.144337996840477, 0.0, 0.0, 0.0, 0.0, 0.0, 0.144337996840477, 0.144337996840477, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.2886750102043152, -0.144337996840477, 0.0, 0.0, 0.0, -0.144337996840477, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.144337996840477, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.144337996840477, 0.0, 0.0, -0.144337996840477, 0.0, -0.144337996840477, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.144337996840477, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.144337996840477, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.144337996840477, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.144337996840477, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.144337996840477, 0.144337996840477, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.144337996840477, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.144337996840477, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.144337996840477, 0.144337996840477, 0.0, 0.0, 0.0, 0.0, -0.144337996840477, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.144337996840477, 0.0, 0.0, 0.0, 0.0, 0.144337996840477, 0.0, 0.0, 0.0, 0.0, 0.144337996840477, -0.2886750102043152, 0.144337996840477, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.144337996840477, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.144337996840477, 0.0, 0.0, 0.0, -0.144337996840477, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.144337996840477, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.144337996840477, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.144337996840477, 0.0, 0.0, -0.144337996840477, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.144337996840477, 0.0, 0.0, 0.0, 0.0, 0.144337996840477, 0.0, 0.144337996840477, 0.0, 0.0, 0.0, 0.0, 0.0, -0.144337996840477, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.144337996840477, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.144337996840477, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.144337996840477, -0.144337996840477, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], 384, FLOAT32)`, id: "n15", session: "session-2", text: "Migrate customer login from Auth0 to Okta before the April renewal.", ts: 1773048000000})]
[{note: "n03", topic: "auth", decided_by: "n15"} | (n2:Note {embedding: cypher`vector([0.0, 0.0, 0.0, 0.25197601318359375, 0.0, 0.0, 0.0, 0.0, -0.12598800659179688, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.12598800659179688, -0.12598800659179688, -0.12598800659179688, -0.12598800659179688, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.12598800659179688, 0.0, -0.12598800659179688, 0.0, 0.0, 0.0, 0.0, 0.12598800659179688, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.12598800659179688, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.25197601318359375, 0.0, 0.0, 0.0, -0.12598800659179688, 0.0, -0.12598800659179688, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.12598800659179688, 0.0, 0.0, 0.0, 0.12598800659179688, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.12598800659179688, 0.0, 0.0, 0.0, 0.0, -0.12598800659179688, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.12598800659179688, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.12598800659179688, 0.0, 0.12598800659179688, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.12598800659179688, 0.0, 0.0, 0.0, -0.12598800659179688, 0.12598800659179688, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.12598800659179688, 0.0, 0.12598800659179688, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.25197601318359375, 0.12598800659179688, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.12598800659179688, 0.0, 0.0, 0.0, 0.12598800659179688, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.12598800659179688, 0.0, 0.0, 0.0, 0.0, 0.0, -0.25197601318359375, 0.12598800659179688, 0.0, 0.0, -0.12598800659179688, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.12598800659179688, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.12598800659179688, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.12598800659179688, 0.0, 0.0, 0.0, -0.12598800659179688, 0.0, -0.12598800659179688, 0.0, -0.12598800659179688, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.12598800659179688, 0.0, 0.0, -0.12598800659179688, 0.25197601318359375, 0.0, 0.0, 0.12598800659179688, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.12598800659179688, 0.0, 0.0, 0.0, 0.0, 0.12598800659179688, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.12598800659179688, -0.12598800659179688, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.12598800659179688, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], 384, FLOAT32)`, id: "n03", session: "session-1", text: "OAuth callbacks are published to Kafka so the audit service sees every login.", ts: 1772443800000})-[r2:ABOUT]->(n41)<-[r50]-(n14)]
[{note: "n03", topic: "kafka", decided_by: "n27"} | (n2)-[r3:ABOUT]->(n42:Topic {name: "kafka"})<-[r53:DECIDED {text: "three-day retention on shared Kafka topics", ts: 1773652800000}]-(n26:Note {embedding: cypher`vector([0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.16903099417686462, 0.0, 0.0, -0.16903099417686462, 0.16903099417686462, 0.0, 0.0, 0.0, 0.33806198835372925, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.16903099417686462, 0.0, 0.0, 0.16903099417686462, 0.0, 0.0, 0.0, 0.0, 0.16903099417686462, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.16903099417686462, 0.0, 0.0, 0.0, 0.16903099417686462, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.33806198835372925, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.33806198835372925, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.16903099417686462, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.16903099417686462, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.16903099417686462, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.16903099417686462, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.16903099417686462, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.16903099417686462, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.16903099417686462, -0.16903099417686462, 0.0, 0.0, 0.0, 0.0, 0.0, 0.16903099417686462, 0.0, 0.16903099417686462, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.16903099417686462, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.16903099417686462, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.16903099417686462, 0.0, 0.0, -0.16903099417686462, -0.16903099417686462, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], 384, FLOAT32)`, id: "n27", session: "session-3", text: "Retention on shared Kafka topics is three days.", ts: 1773652800000})]
[{note: "n25", topic: "auth", decided_by: "n15"} | (n24:Note {embedding: cypher`vector([0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.14907099306583405, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.2981419861316681, 0.0, 0.0, 0.0, 0.0, 0.0, -0.14907099306583405, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.2981419861316681, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.14907099306583405, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.14907099306583405, 0.0, 0.0, 0.0, 0.14907099306583405, 0.0, -0.14907099306583405, 0.2981419861316681, 0.0, 0.0, 0.0, 0.0, -0.14907099306583405, 0.0, 0.14907099306583405, 0.2981419861316681, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.14907099306583405, 0.0, 0.0, 0.0, 0.0, -0.14907099306583405, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.14907099306583405, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.2981419861316681, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.14907099306583405, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.14907099306583405, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.14907099306583405, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.14907099306583405, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.14907099306583405, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.14907099306583405, 0.0, 0.14907099306583405, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.14907099306583405, -0.14907099306583405, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.14907099306583405, 0.0, 0.0, -0.14907099306583405, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.14907099306583405, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.14907099306583405, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.14907099306583405, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], 384, FLOAT32)`, id: "n25", session: "session-2", text: "Refresh tokens rotate on every use once Okta is live.", ts: 1773064800000})-[r28:ABOUT]->(n41)<-[r50]-(n14)]
[{note: "n15", topic: "auth", decided_by: "n15"} | (n14)-[r17:ABOUT]->(n41)<-[r50]-(n14)]
[{note: "n04", topic: "kafka", decided_by: "n27"} | (n3:Note {embedding: cypher`vector([0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.15617400407791138, 0.0, 0.0, -0.15617400407791138, 0.0, 0.0, 0.0, -0.15617400407791138, 0.0, 0.0, 0.0, -0.15617400407791138, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.15617400407791138, 0.0, 0.0, 0.0, 0.0, 0.0, -0.15617400407791138, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.15617400407791138, -0.15617400407791138, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.15617400407791138, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.15617400407791138, 0.0, 0.0, 0.0, 0.15617400407791138, 0.0, 0.0, 0.0, 0.0, 0.0, 0.15617400407791138, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.15617400407791138, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.15617400407791138, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.15617400407791138, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.15617400407791138, 0.0, 0.0, 0.15617400407791138, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.15617400407791138, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.15617400407791138, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.15617400407791138, 0.0, 0.0, 0.31234800815582275, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.15617400407791138, -0.15617400407791138, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.15617400407791138, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.15617400407791138, -0.15617400407791138, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.15617400407791138, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.31234800815582275, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.15617400407791138, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.15617400407791138, -0.15617400407791138, 0.0, 0.0, 0.0, 0.0, 0.0, -0.31234800815582275, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], 384, FLOAT32)`, id: "n04", session: "session-1", text: "Each tenant gets its own Kafka topic for ticket events.", ts: 1772444700000})-[r4:ABOUT]->(n42)<-[r53]-(n26)]n15 appears as both a recalled note and the decider: it is the note that
made the Okta decision.
Search the decisions themselves
The decision text lives on the DECIDED edge, and dec_t indexes it, so the
agent can also ask which decisions mention a term. A relationship search binds
r in MATCH ()-[r:DECIDED]->(). Write the arrow: an undirected pattern
matches each edge once per direction, so every hit comes back twice.
MATCH ()-[r:DECIDED]->()
SEARCH r IN (FULLTEXT INDEX dec_t FOR $q LIMIT $k) SCORE AS s
RETURN startNode(r).id AS note, endNode(r).name AS topic, r.text AS decision,
date(datetime.fromEpochMillis(r.ts)) AS decided_on, s AS score
ORDER BY score DESC, decided_on, noteParameters
| note | topic | decision | decided_on | score |
|---|---|---|---|---|
| "n04" | "kafka" | "one Kafka topic per tenant" | "2026-03-02" | 1.0354512120083705 |
| "n18" | "kafka" | "shared Kafka topics keyed by tenant id" | "2026-03-09" | 0.8976179364575441 |
| "n30" | "billing" | "nightly invoices from Kafka usage events" | "2026-03-16" | 0.3514558522085262 |
| "n27" | "kafka" | "three-day retention on shared Kafka topics" | "2026-03-16" | 0.3280639672217632 |
4 rows
| note | topic | decision | decided_on | score | |-------|-----------|----------------------------------------------|--------------|--------------------| | "n04" | "kafka" | "one Kafka topic per tenant" | "2026-03-02" | 1.0354512120083705 | | "n18" | "kafka" | "shared Kafka topics keyed by tenant id" | "2026-03-09" | 0.8976179364575441 | | "n30" | "billing" | "nightly invoices from Kafka usage events" | "2026-03-16" | 0.3514558522085262 | | "n27" | "kafka" | "three-day retention on shared Kafka topics" | "2026-03-16" | 0.3280639672217632 | 4 rows
The kafka topic changed course: one topic per tenant on March 2, shared
topics keyed by tenant a week later.
Note Convert epoch milliseconds with
datetime.fromEpochMillis(ms). Relate currently returns a date in year 0000 fordatetime({epochMillis: ms}).
SEARCH rules worth remembering
Note A few rules cover most
SEARCHmistakes, whether a person or a text-to-Cypher prompt writes the query:
LIMITis required inside the search.SKIP(orOFFSET) works for full-text only, and goes beforeLIMIT.- The
MATCHbeforeSEARCHholds only the searched variable:(n:Note), or()-[r:DECIDED]->()with bare endpoints. Traverse after aWITH.- There is no implicit score variable. Name it with
SCORE AS.WHEREinside the search works for vector indexes only. It may use only the properties the index declares inWITH [...], compared with=,<,<=,>, or>=, and joined byAND.
An IN over a declared property works too — it says the same thing as the
range form below:
MATCH (n:Note)
SEARCH n IN (VECTOR INDEX note_e FOR $v
WHERE n.session IN ['session-2', 'session-3'] LIMIT $k) SCORE AS s
RETURN n.id AS idParameters
| id |
|---|
| "n25" |
| "n15" |
| "n31" |
| "n35" |
| "n23" |
5 rows
| id | |-------| | "n25" | | "n15" | | "n31" | | "n35" | | "n23" | 5 rows
A range over the same property says the same thing:
MATCH (n:Note)
SEARCH n IN (VECTOR INDEX note_e FOR $v
WHERE n.session >= 'session-2' AND n.session <= 'session-3'
LIMIT $k) SCORE AS s
RETURN n.id AS id, n.session AS session, round(s, 4) AS vector
ORDER BY s DESC, idParameters
| id | session | vector |
|---|---|---|
| "n25" | "session-2" | 0.5692 |
| "n15" | "session-2" | 0.5536 |
| "n31" | "session-3" | 0.5386 |
| "n35" | "session-3" | 0.5383 |
| "n23" | "session-2" | 0.529 |
5 rows
| id | session | vector | |-------|-------------|--------| | "n25" | "session-2" | 0.5692 | | "n15" | "session-2" | 0.5536 | | "n31" | "session-3" | 0.5386 | | "n35" | "session-3" | 0.5383 | | "n23" | "session-2" | 0.529 | 5 rows
About the data
- Dataset
- 40 notes from three working sessions, 6 topics and 9 decisions, with 384-dimension embeddings for every note and question (85 KB)
- Source
- Synthetic, written for the examples project's 12-hybrid-recall; embedded offline with its deterministic hash-384 embedder (lib/embed.py)
- 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
- Context graph for a codebaseAnswer multi-hop questions about a codebase: IDF-weighted full-text search finds where to start, and bounded graph hops follow supersessions, dependencies, and citations.
- Query the relate docsFind entry points in the relate docs graph with vector SEARCH, then let the graph expand them into context, scope the search by a traversal, and reach the capability and its worked example.