# AiAkiv graph queries: the ak graph request, the query subset, and reading the result > `query_memory_graph(query, params)` runs a read-only Cypher subset over the > memory the signed-in user can read. It answers questions about scope and > structure that a flat search cannot: meaning search inside one entity or tag, > memories where two names both appear, neighbours through shared names, what came > next in a thread. It writes nothing. > > Human version: https://www.aiakiv.com/docs/graph-query ## When the phrase appears, and when it does not The request phrase is `ak graph` (Korean `ak ๊ทธ๋ž˜ํ”„`), usually followed by a question in the user's own words. With the phrase, the user has chosen the graph query, and the work is a faithful translation of that question into one query. Without the phrase, a question stays an ordinary `search_memory` call, even when it is about connections. The graph query is not picked on the model's own initiative. Other tools fit other shapes: | Shape of the question | Tool | |---|---| | A sentence to find by meaning | `search_memory` | | A scope first, then meaning; a walk over relations; a full list of a scope | `query_memory_graph` (with the phrase) | | Exact wording in summaries, exact counts, table facts | `query_memories_with_sql` | The phrase on its own gets three example requests back, adapted to names from the user's memory, for instance a name scope plus a sentence, a tag scope plus a sentence, and "memories where both A and B appear". ## The subset Shape: `START a = events(...) [MATCH ... [WHERE ...]] RETURN ... [ORDER BY ...] [LIMIT n]`. | Part | What is allowed | |---|---| | `START` | `text: $q` (k 5, max 20); `entity: $name` or `tag: $t` alone, newest first (k 5, max 200); `ids: $ids` (up to 20, k ignored); `entity: $name, text: $q` and `tag: $t, text: $q`, closest to `$q` inside the scope (k 5, max 20). A larger k is clamped with a note | | Names | Exact match on the stored spelling (`find_memories_by_entity` shows it). One name resolves to at most 20 entities or tags | | `MATCH` | Optional. Left out, the start itself is the answer | | Relations | `PARTICIPATED_IN` (entity-event), `SHARES {min, min_w}` (event-event; `count`, `weight`, `via`), `SIMILAR {k, min}` (`cos`), `FAR {max}` (`cos`), `NEXT {source}` (event->event, directed, `*1..3`, `hops`), `MEMBER_OF {kind}` (event->tag, directed), `RESOLVED_BY {relation}` (event->event, directed; `asserted_at`), `CONNECTED` (entity-entity; `event_count`), `SAME_AS` (entity-entity; `confidence`) | | Variable length | `NEXT` and `SHARES` only, 1..3 | | Direction | A missing arrow is filled in, with a note, when the end labels allow one way (`(a)-[:MEMBER_OF]-(t)` reads `->`). `NEXT` and `RESOLVED_BY` between two events need the arrow written. A wrong arrow is rejected, not flipped | | `WHERE` | Only after a `MATCH`. `=`, `<>`, `<`, `<=`, `>`, `>=`, `IN`, `AND`, `OR`, `NOT`, `cos(a, b)`, and `CONTAINS` on `Entity.name` or `Tag.name` only (case and full-width forms ignored; an all-ASCII piece needs 2 characters, one Hangul or Han character is enough) | | `RETURN` | `DISTINCT`, `AS`, aggregates `count`, `collect`, `min`, `max`, `sum`, `avg`. With aggregates, `ORDER BY` uses a returned column | | `a.score` | The start event's cosine to the start text, not clipped. Null without start text and on events reached by a hop; nulls sort last. Stable order: `ORDER BY a.score DESC, a.id` | | `LIMIT` | Default 20, max 200 | | Parameters | `$name`, filled from `params`. The user's sentence goes through `params`, not inline | | Absent | `WITH` (conditions go in relation params such as `{min: 3}`), writes (`CREATE`, `SET`), `STARTS WITH`, text matching on `Event.summary` | Rows are projected small: an event to `{id, summary, timestamp, order_index}`, an entity to `{id, name, type}`, a tag to `{id, name}`. Full content opens with `get_memory_content`. ## Translation recipes Request shape, then the query and its params. 1. "Among memories about , the ones about ": `START a = events(entity: $name, text: $q, k: 20) RETURN a, a.score ORDER BY a.score DESC, a.id` params `{"name": "save format", "q": "fixed a load bug"}` 2. "In the tag, the ones about ": `START a = events(tag: $tag, text: $q, k: 20) RETURN a, a.score ORDER BY a.score DESC, a.id` params `{"tag": "balance", "q": "changed enemy health"}` 3. "Names appearing with that contain ": `START a = events(entity: $name, k: 200) MATCH (a)-[:PARTICIPATED_IN]-(n) WHERE n.name CONTAINS $part RETURN DISTINCT n.name LIMIT 200` params `{"name": "save format", "part": "bug"}` 4. "All", "every", "the full list" is a listing, not a closeness ranking: `START a = events(tag: $tag, k: 200) RETURN a LIMIT 200` (or `entity: $name`) params `{"tag": "balance"}` 5. "Memories where both A and B appear" (the default k 5 would see only A's five newest): `START a = events(entity: $a, k: 200) MATCH (a)-[:PARTICIPATED_IN]-(n {name: $b}) RETURN DISTINCT a LIMIT 200` params `{"a": "inventory", "b": "save format"}` 6. "Memories that share the most names with these": `START a = events(ids: $ids) MATCH (a)-[:PARTICIPATED_IN]-(e)-[:PARTICIPATED_IN]-(b) WHERE NOT b.id IN $ids RETURN b, count(DISTINCT e) AS shared ORDER BY shared DESC LIMIT 20` params `{"ids": ["evt_a", "evt_b"]}` 7. "Similar to this one" / "what came next": `START a = events(ids: $ids) MATCH (a)-[f:SIMILAR {k: 5}]-(b) RETURN b, f.cos ORDER BY f.cos DESC LIMIT 10` `START a = events(ids: $ids) MATCH (a)-[n:NEXT*1..3]->(b) RETURN b, n.hops ORDER BY n.hops LIMIT 30` 8. "Not the ones already seen": `START a = events(ids: $ids) MATCH (a)-[s:SHARES {min: 2}]-(b) WHERE NOT b.id IN $seen RETURN b, s.count, s.via ORDER BY s.weight DESC LIMIT 20` params `{"ids": ["evt_a"], "seen": ["evt_c", "evt_d"]}` 9. "Why this order, with scores": `START a = events(tag: $tag, text: $q, k: 10) RETURN a.id, a.summary, a.score ORDER BY a.score DESC, a.id` params `{"tag": "balance", "q": "changed enemy health"}` ## Reading the response The response is `{columns, rows, row_count, start, partial, truncated, plan, budget, notes}`. Three row caps are not budget cuts and set neither `partial` nor `truncated`: - `k`. The combined starts and `tag:` alone set `start.has_more: true` when the scope holds more candidates than came back. `start.candidates` is `{count, exact}`; `exact: true` means every candidate was measured, not that every candidate was returned. `exact` with count 224 and k 20 is "the closest 20 of 224". - `start.hub: true`. `entity:` or `tag:` alone found more events than the k newest it returned. For `entity:` alone this is the only signal. - The default `LIMIT` 20 when the query has none. Budget cuts set `partial: true` and add an entry to `truncated`: `filled: false` (a large scope searched through the vector index, `scoped_mode: "filtered_ann"`, found fewer than k), a names cap (`start.entities_truncated` or `tags_truncated`), a statement timeout, or the deadline. A cut means the walk was too wide, not that the tool is broken; the next query is narrower (smaller k or `LIMIT`, higher `SHARES {min}`), not the same one again. Lines that can appear in `notes`, verbatim: start: top-20 of 224 candidates; 204 more exist in scope (not a budget cut) rows cut to the default LIMIT 20; add LIMIT to return more (max 200) scoped START did not cover its whole scope (filled false, names truncated, or cut by a statement timeout or the deadline) - rows may be missing; 0 rows means 'not confirmed here', not 'none exist' CONTAINS filtered after the walk and the walk was truncated - 0 rows means 'not confirmed here', not 'none exist' A `+` after a number in the first line marks a lower bound (`201+`). The third line replaces the first when the scope was not fully covered, and the first line is left off a 0-row result that a budget cut emptied. Clamps (`start.k clamped 50 -> 20`, `limit clamped 500 -> 200`) and inferred arrows (`direction inferred: ...`) also appear here. 0 rows is "not confirmed", not "none", whenever anything was cut, and also when only the top k was seen (`has_more`, `hub`). "None" is supported by a 0-row result over a fully covered scope, or by another route that sees everything, such as `find_memories_by_entity` for a name. A rejection is `{error, blocked_by: syntax|grammar|params, hint?, allowed_*}`. One corrected retry, based on `hint` and the `allowed_*` lists, is the expected path; blind repeated retries are not. ## What the reply to the user contains - The query that ran, and its meaning in one line, next to the answer. Example: the query from recipe 1, then "the 20 save format memories closest in meaning to 'fixed a load bug'". - The limits in plain words: "the closest 20 of 64", "only the newest 200", "the scope was not fully covered". - Absence only with evidence, as above. - For a listing that still has `start.hub: true`, a statement that there are more than 200 and not all of them were seen. ## Servers without the feature When `query_memory_graph` is not in the tool list, the server does not offer graph queries; the reply says so and plain search takes over. A server can also have the new syntax off (the combined starts, `tag:`, a query without `MATCH`, `a.score`, `CONTAINS`, arrow filling). Those come back as a rejection with `blocked_by` and an error ending in `is not available on this server (scoped match is off)`. The fallback is `text:` or `entity:` alone, and the user hears that the scoped search is off on this server. ## Language The reply is in the user's language. The `notes` lines and error texts arrive in English and are put into the user's words, not pasted untranslated.