Cookbook
All recipes
Hybrid recall: vector + full-text + RRFSearch and retrieval

Agent memory

Search and retrieval

Data quality

Graph analytics

Geospatial

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.

Data model: notes about topics, some of which record a decisionA Note has an id, text, a session, a timestamp and a 384-float embedding, and is ABOUT a Topic. A Note that records a decision also has a DECIDED relationship to the Topic, carrying the decision's text and timestamp.Noteid · text · session · tsembedding (384 floats)TopicnameABOUTDECIDEDtext · ts
Data model: notes about topics, some of which record a decisionA Note has an id, text, a session, a timestamp and a 384-float embedding, and is ABOUT a Topic. A Note that records a decision also has a DECIDED relationship to the Topic, carrying the decision's text and timestamp.Noteid · text · session · tsembedding (384 floats)TopicnameABOUTDECIDEDtext · ts
Data model: notes about topics, some of which record a decision

The recall statement the recipe builds up to, in one picture:

One statement: two searches fused by reciprocal rank, then a hop to each topic's latest decisionA question runs a vector SEARCH over the embeddings and a full-text SEARCH over the text, each returning its top k notes. Reciprocal rank fusion merges the two lists, scoring each note by the sum of 1 / (60 + rank). The top notes then hop ABOUT to their topics, and each topic's latest DECIDED gives the current decision.

ABOUT

latest DECIDED

Question

Vector SEARCH
embedding · top k

Full-text SEARCH
text · top k

Reciprocal rank fusion
sum of 1 / (60 + rank)

Top notes

Their topics

Current decision

One statement: two searches fused by reciprocal rank, then a hop to each topic's latest decisionA question runs a vector SEARCH over the embeddings and a full-text SEARCH over the text, each returning its top k notes. Reciprocal rank fusion merges the two lists, scoring each note by the sum of 1 / (60 + rank). The top notes then hop ABOUT to their topics, and each topic's latest DECIDED gives the current decision.

ABOUT

latest DECIDED

Question

Vector SEARCH
embedding · top k

Full-text SEARCH
text · top k

Reciprocal rank fusion
sum of 1 / (60 + rank)

Top notes

Their topics

Current decision

One statement: two searches fused by reciprocal rank, then a hop to each topic's latest decision

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 the DECIDED relationships.
Step 1
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]
Recorded result
(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.

Step 2
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
Loads notes.json (76 KB)
Recorded result
notessessionstopicsaboutdecided
4036479

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.

Step 3
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, id
Parameters
Recorded result
idsessionvectortext
"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.

Step 4
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 $k
Parameters
Recorded result
idfulltexttext
"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:

Step 5
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, ids
Parameters
Recorded result
filterids
"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.

Step 6
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 text
Parameters
Recorded result
idvector_rankfulltext_rankrrftext
"n25"310.0323"Refresh tokens rotate on every use once Okta is live."
"n02"220.0323"Customer login goes through Auth0; refresh tokens live in HttpOnly cookies."
"n16"1null0.0164"Token revocation now propagates in under a second via the Redis session store."
"n01"4null0.0156"Relay kickoff: ticket intake, the agent console and billing all live in one monorepo."
"n32"5null0.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.

Step 7
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 text
Parameters
Recorded result
idvector_rankfulltext_rankrrftext
"n23"120.0325"Kafka lag dashboard added to Grafana with a five minute alert."
"n04"240.0318"Each tenant gets its own Kafka topic for ticket events."
"n07"330.0317"Kafka consumer lag on the ticket-events group is the first alert to build."
"n26"null10.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"4null0.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.

Step 8
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, topic
Parameters
Recorded result
idtexttopicdecidedrrf
"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 BY and head(collect(...)), not with ORDER BY d.ts DESC LIMIT 1. Relate currently mis-orders a relationship property under ORDER BY … LIMIT when 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 1 returns 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:

Step 9
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, topic
Parameters
Recorded result
// 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.

Step 10
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, note
Parameters
Recorded result
notetopicdecisiondecided_onscore
"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 for datetime({epochMillis: ms}).

SEARCH rules worth remembering

Note A few rules cover most SEARCH mistakes, whether a person or a text-to-Cypher prompt writes the query:

  • LIMIT is required inside the search. SKIP (or OFFSET) works for full-text only, and goes before LIMIT.
  • The MATCH before SEARCH holds only the searched variable: (n:Note), or ()-[r:DECIDED]->() with bare endpoints. Traverse after a WITH.
  • There is no implicit score variable. Name it with SCORE AS.
  • WHERE inside the search works for vector indexes only. It may use only the properties the index declares in WITH [...], compared with =, <, <=, >, or >=, and joined by AND.

An IN over a declared property works too — it says the same thing as the range form below:

Step 11
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 id
Parameters
Recorded result
id
"n25"
"n15"
"n31"
"n35"
"n23"

5 rows

A range over the same property says the same thing:

Step 12
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, id
Parameters
Recorded result
idsessionvector
"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