7.7 Aggregations — reduce a whole GROUP to one cell
The primitives above judge rows one at a time. An aggregation goes the other way: it reduces the
group a traversal produced to a single cell, as the model sibling of count() / collect(). Neo4j’s
implicit GROUP-BY supplies the grouping for free — RETURN topic.name, summarize(n.description)
yields one digest per topic.
MATCH (t:ResearchTopic {name:'retrieval augmented generation'})-[:HAS_NEWS]->(n:NewsItem)RETURN summarize(n.description, 'what is newest and most important') AS digest| Function | Returns | Reduces a group to… |
|---|---|---|
summarize(text [, instruction]) |
prose | a neutral overview |
render(text [, instruction]) |
prose | the same items as prose — all of them, in order, hedges kept |
synthesize(text, goal) |
prose | a goal-directed answer (argues toward goal) |
classify(item, labels [, rubric]) |
one label | exactly one label from the closed set 'a,b,c' |
extract(text, what) |
list | the distinct things asked for (deduped) |
themes(text [, focus] [, count]) |
list | the recurring cross-item topics (labels only) |
cluster(text [, k]) |
list of maps | semantic groups, each with a COUNTED size and examples |
score(item, rubric) |
number 0–1 | one ordinal judgment of the whole group |
holds(item, question) |
boolean or null | a three-valued verdict on a claim |
relevant(item, criterion) |
list | only the items matching a subjective criterion — over nodes, the group’s own nodes |
argmax(key, text, criterion) |
winner payload | the best candidate under a comparative rubric |
correlate(x, y) |
map | the Pearson correlation of two numeric expressions: {r, n, dropped, ci, detectable} |
regress([x1, …], y, label) |
map | a least-squares fit of y on the predictors: fit quality, per-predictor strengths, and the rows most above and most below their prediction, one list per direction |
render keeps what summarize compresses. Both write prose from a group; they differ in what
they promise. summarize gives a neutral overview and will drop, merge and reword items to get
there — right for a group of items nobody has read. render promises the opposite: every item
appears, in the order given, with its figures as written and its qualifying clauses intact. It is
for rows a realm has already composed for a reader — a sentence per row, a status, a caveat — where
only the joins between them are missing.
The distinction matters most where it is least visible. A row that ends “stated, not verified as
current”, “a name match to check”, or “no record was found” is carrying the reader’s warrant to
doubt it; compressed out by a digest, the same row reads as established fact, with its figures
still correct. Ask for render whenever losing such a clause would change what the prose means.
Neither function may ADD. Both are held to what the items literally state — no description of what
a subject is or does from outside the rows — and both report an honest miss rather than write from
nothing. render additionally never sums or combines figures across items: a total it calculated
would appear in none of them, and could not be checked against any row.
render COSTS A MODEL CALL AND VARIES BETWEEN RUNS, like every prose reduction. Where the same
words are required every time, build the sentence in Cypher from the row’s own columns and return
it; render buys prose that reads well, never reproducibility.
correlate and regress are arithmetic: no model call, and the same rows always yield the same
result. They reduce a group’s NUMERIC columns, not its text, and both report ASSOCIATION across
the group — never causation, and never a claim about any individual row.
MATCH (d:District)RETURN regress([d.medianPay, d.avgPrice, d.density], d.crimeRatePer1000, d.name) AS model-
correlate(x, y)returns{r, n, dropped, ci, detectable}— Pearson’s r over the rows where BOTH operands were numeric,ncounting those rows anddroppedthe rows that were not.ciis the 95% confidence interval for r (null when the group is too small to bound one), anddetectablesays whether that interval excludes zero — when it is false, the sample cannot distinguish the relationship from none, and the honest report is “no detectable relationship”, never the bare r. It returns null rather than a map when fewer than three usable pairs remain or when either operand never varies. -
regress([x1, x2, …], y, label)fitsyagainst the predictor LIST by ordinary least squares. The first argument is a Cypher list of numeric expressions (any number of them), the second the numeric outcome, the third an expression naming each row. It returns{n, dropped, r2, adjustedR2, coefficients, abovePrediction, belowPrediction}:coefficientscarries one entry per predictor with a STANDARDISED beta — magnitudes are comparable across predictors of different units, the list is ordered strongest first, and each is named READABLY from the query (a coercion wrapper is unwrapped and a plainvariable.propertydrops its variable, unless that would give two predictors the same name). The two residual lists name the rows (by the label expression) whose actual outcome sits furthest ABOVE and furthest BELOW the fit’s prediction, in the outcome’s own units — one list per direction, so a divergence that runs only one way is visible as an empty other side. It returns null rather than an unreliable fit when the group is too small for its predictor count, when a predictor is constant or a copy of another, or when the outcome never varies. -
Rows where any needed operand is null or non-numeric are dropped from the fit and counted in
dropped— a thin join cannot masquerade as a strong signal. -
Grouping is the ordinary implicit GROUP-BY:
RETURN d.region, regress(…)fits one model per region; drop the key for one fit over the whole group. -
They belong in the RETURN, where an aggregation is finalized. Written into a
WITHthey type-check, compute nothing, and hand back null — which reads as “no association” when it means “not computed”. Two forms of the same mistake, both silent:WITH pt, collect(…) AS seats, correlate(pay, sigs) AS r // null: a WITH does not finalizeRETURN correlate([x IN seats | x.pay], [x IN seats | x.sigs]) // null: these are AGGREGATES// over rows, not list functionsBoth are fixed the same way — accumulate the rows and let the RETURN reduce them:
WITH pt, toFloat(cp.annualPay) AS pay, toFloat(cs.signatures) AS sigsRETURN pt.action AS petition, correlate(pay, sigs) AS payVsSignaturesIf a view needs both a collected list and a coefficient, that is two views, not one clause.
Ingested document content is aggregable. When the accumulated row expression is an ingested
document’s content (or text) — holds(d.content, '…'), summarize(d.text, '…') on a matched
Document — the aggregation reads the document’s FULL ingested text, not a stored node property.
The same document always yields the same text; a document whose text was never ingested contributes
nothing. Cost scales with document length (the reduction is chunked internally), so prefer a
narrower match when the question targets one section. Cached document summaries remain available
and cheaper, but can omit specific findings; content is the exhaustive surface.
A reduction with nothing to reduce says so. When the accumulated expression is empty for a group
— no rows matched, or the property carries no values — a PROSE reduction (summarize, synthesize,
render) answers an UNAVAILABLE: sentence saying there was nothing to aggregate, and every TYPED
reduction (a score, a verdict, a label, a list, a pick) answers null. A sentence in a score column
would sort among real scores, and no comparison could tell it from an answer; null is what an
empty group means. A typed reduction whose model fails is also null, flagged REDUCTION_FAILED
(§9), so it is never bare. Neither is ever a fabricated verdict or digest, and ask surfaces report
the result as an honest miss rather than an answer. An empty LIST accumulates as empty: collect() of zero rows never manufactures evidence.
A verdict about a NAMED thing requires the name in the corpus. When a holds verdict is
anchored through document relevance, the seed’s words must appear together somewhere in the
corpus text. A seed that appears nowhere (an address, a name, an identifier the documents never
mention) yields an honest not-in-graph miss that names the seed — never a verdict judged against
whatever document happened to be semantically nearest. Topical reductions (summaries, filters,
themes) keep full semantic reach: aboutness without the literal words remains their contract.
holds judges the claim as written. Every specific the claim itself states (a year, a figure, a
name) must be supported by the evidence, or the verdict is null (UNKNOWN) — qualifiers the claim
does not state are assumed enforced by the query’s own filters. Risk or possibility language in the
evidence (“may be present”, “high risk of”) never supports TRUE. A verdict of false requires
evidence that addresses the claim and answers no; material that does not bear on the claim cannot
veto such a grounded no, and if NOTHING bears on it the verdict is null, never a confident no.
An aggregation’s result can be filtered, ordered and grouped by. Write it as you would any other value:
MATCH (e:Electorate)WITH e, classify(e.member, 'female,male,unknown') AS genderWHERE gender = 'female'RETURN count(e) AS count, genderThe count is the database’s, over real labels. The same holds for ORDER BY score(...), for grouping
by an aggregated label in a later WITH, and for every aggregation in §5 — each reduces a group to one
cell, and a cell can be filtered on.
What this costs, and the one rule it imposes:
-
The value is computed BEFORE the query runs, and only for the groups whose value can still reach the answer. Every part of the query that does not read the aggregation narrows that set first — a
WHEREbefore the aggregatingWITH, and equally aWHEREafter it on any other column, a laterMATCHthat drops rows, a laterWITH … WHERE. InMATCH (l:Lead)WITH l, classify(l.notes, 'strategic,standard,at_risk') AS triageWHERE triage = 'at_risk' AND l.probability < 50MATCH (l)-[:OWNED_BY]->(u:User {active: true})RETURN l.nameonly the leads under 50% with an active owner are judged; the others are excluded whatever the judgement would have been, and are never sent to the model. The guarantee is one-directional: the set judged is never SMALLER than the set that can reach the answer, so the answer is exactly what judging every group would give. Where the aggregation flows into something other than a filter or a bare pass-through — an expression (
toUpper(triage)), a pattern, anUNWIND, aCALL, aUNION— the query cannot be narrowed by it and every group is judged, as before. -
A
LIMITstops the judging when the answer is full. UnderORDER BY … LIMIT n, the groups are judged toward the top of the answer a few at a time, best rows first, and judging stops the moment the firstnrows (orSKIP s LIMIT n: the firsts + n) are all judged — every group below them is never sent to the model. “The ten biggest at-risk deals” costs judging the biggest deals until ten of them are at risk, not judging every deal. The answer is exactly what judging every group would give. This holds when the judgement only FILTERS rows; where its value shapes them — anORDER BYon the judgement, aRETURNthat isDISTINCTor aggregates, a later clause grouped by it, aLIMITbefore the finalRETURN— every candidate is judged as before. One residue is the store’s own: rows that tie exactly at the boundary of the window are seated arbitrarily, as they are under anyLIMIT; such a row can carry no label, never a wrong one. A query whose filter would still need more than a few hundred model calls is REFUSED with the count, rather than sampled quietly. The refusal names the cap it hit, and a query that MEANS to spend that much says so:{ai: {maxGroups: 600}}raises it (up to 2000 — past that, compute the value once and persist it), and a smaller number LOWERS it, which is how a shipped view holds its own spending line. This is a cost guard, so it is the author’s to set; the completeness gate on a truncated fetch is a correctness guard and has no such override, by design. -
The clause must carry the node the value belongs to.
WITH e, classify(e.member, …) AS genderworks;WITH e.name AS name, classify(e.member, …) AS genderis refused, because an aggregate value belongs to a group and a group needs an identity to attach it to. The refusal says so and names the fix. Project the node itself and read its properties later. -
The value is attached to the group for the duration of the query only. It is never written to the graph: ask twice and it is computed twice, and nothing in the graph carries a stale label from a model run last week. To keep a classification, write it deliberately (§9 annotation writes) rather than relying on a query having filtered on it.
-
Merely RETURNING an aggregation is unchanged and stays cheaper —
RETURN t.name, summarize(…) AS digestreduces after the read, with no pre-pass.
Grouping follows Cypher’s own rules: the non-aggregated keys of the clause define the groups, so
WITH t, summarize(n.description, …) is one digest per t over all its ns — not one per n.
Judging nodes, not only text
Section titled “Judging nodes, not only text”relevant, classify, holds and score accept a NODE variable as their item, not only a property:
MATCH (i:Issue)<-[:RAISED]-(:Attendee)-[:WORKS_FOR]->(:CustomerAccount)-[:HAS_CASE]->(c:SupportCase)WHERE c.status IN ['open', 'pending']WITH i, relevant(c, 'the same problem as: ' + i.summary) AS trackedUNWIND tracked AS tcMATCH (tc)-[:ASSIGNED_TO]->(e:Engineer)RETURN i.summary, tc.subject, e.name- The model reads each node whole: its labels, its properties, and what the world’s types declare
they mean — the
descriptionof the type and of each property (§ types). Astatus: pendingis shown with its declared meaning, so a description written for query authors is read by the judge too; an undeclared type is shown with its labels and properties alone. Who may see a node (its scoping properties) and embeddings are never shown. relevantover nodes keeps the group’s own nodes. The result is a subset of the nodes the group matched — the model can only keep what it was shown, by its position in what it was shown — so it can beUNWINDed, traversed from and re-matched like the nodes it came from, and it is scoped as they were. A group whose candidates were all rejected keeps an empty list. A group that could not be judged is never an empty list: its cell is null with the reason in the result, or — where a later clause reads the kept nodes — the query fails and says so, since an answer built on a silently missing group would look complete.- Projected in the final
RETURN, the kept nodes come back as nodes. - A node is longer than a title, and the cost check prices what is shown: a reduction over nodes is quoted on the size of the nodes as rendered, and a query over the inline limit is refused before it runs, never cut down to fit.
- What does NOT flow on: kept nodes from a variable that no node pattern bound (an
UNWINDed list, a comprehension), and any structured result (correlate,regress,cluster,argmax). Re-binding the matched name itself (UNWIND tracked AS cafter matchingc) is refused with a request to use a new name. - Inside a subquery. A judgement may sit inside a unit
CALL { … },OPTIONAL CALL { … }orCALL () { … }, nested at any depth — the groups are read over the rest of that body. So a plan whose steps are unit subqueries can judge in one step what an earlier step projected. Refused, each for a reason and before any model call: an importing body (CALL { WITH x … },CALL (x) { … }— a node grouped under two outer rows could collect different values and keep only one answer); insideEXISTS {},COUNT {}orCOLLECT {}(no clause boundary to read the groups up to); insideCALL {} IN TRANSACTIONSor any other braces; and a judging body that writes and runs more than once (later runs would form groups the judgement never saw).
What the arguments mean
Section titled “What the arguments mean”Every signature above splits its arguments the same way, and getting the split wrong is the most common authoring error:
- The leading
textis a ROW EXPRESSION, accumulated —n.description,n.title + ' — ' + n.body,coalesce(n.a, n.b): anything Cypher can evaluate per row. It is gathered across the group exactly ascollect()gathers, so ONE cell is produced per group, not one per row. A quoted literal in this position is rejected: it would reduce the same constant N times. argmaxaccumulates TWO row expressions —argmax(key, text, criterion)collectskey(what identifies the winner: a filename, a title, a name) andtext(what is judged) together, so the answer can say WHICH item won and not merely what the winning text said. Every other function in the table accumulates exactly one.- Every argument after the accumulated ones is a LITERAL string — the instruction, goal, labels, what, focus, rubric, criterion, question — with one exception, below. A row expression there is not evaluated per row (there is nothing to evaluate it against once the group is reduced), so write the words themselves.
- The exception: a judgement’s criterion may read the GROUPING KEY.
relevant’s criterion,classify‘s rubric,holds’ question andscore’s rubric may be an expression over the clause’s grouping keys —WITH i, relevant(c, 'the same problem as: ' + i.summary) AS trackedasks one question per issue. It is evaluated once per group, from that group’s keys only. An expression that reads a variable the clause aggregates over (relevant(c, 'like ' + c.title)grouped byi) is REFUSED before the query runs: it has a different value on every row of the group, and none of them is the group’s. A group whose criterion evaluates to null is not judged — its cell is null and the result says why — because no criterion would keep everything.classify’s label set is always a literal: it is the closed set every group’s answer is checked against. - Optional arguments are POSITIONAL but type-disambiguated.
themes(text [, focus] [, count])reads a NUMERIC argument as the topic count and a string as the focus, in whichever order they arrive —themes(n.title, 5)andthemes(n.title, 'risks', 5)both mean what they look like.cluster(text [, k])takes a number only. classifylabels are a single comma-separated string —classify(n.body, 'positive,neutral,negative'), not three arguments and not a list. The set is CLOSED: the answer is always one of the labels given.- An honest miss is a value, not an error. When the accumulated text does not contain what the instruction asks about — a group of document TITLES asked for a profit figure — a grounded reduction returns the exact sentence “The provided items do not contain this information.” rather than inventing one. Treat that sentence as “no answer”, never as the answer.
Steering ONE call — the trailing {ai: {…}} map
Section titled “Steering ONE call — the trailing {ai: {…}} map”An aggregation may carry the same {ai: {…}} map as a virtual edge (§7.2), as its LAST argument. It
is steering, not data: stripped before the query runs, never part of the aggregated text.
MATCH (t:ResearchTopic {name:'retrieval augmented generation'})-[:HAS_NEWS]->(n:NewsItem)RETURN summarize(n.description, 'what is newest', {ai: {model:'chat_cheap', voice:'brisk', wordcount: 40}}) AS digestWhich keys are honoured depends on what the function returns — a list, a label, a number or a boolean has no voice and no word count:
| Key | Applies to | Effect |
|---|---|---|
model |
EVERY aggregation | the world ROLE the reduction runs on; an unknown role falls back to the default, with a warning, never a failure |
hint |
the PROSE reductions (summarize, synthesize) |
a soft steer added alongside the instruction — never a filter, and never able to override grounding |
voice |
the PROSE reductions | register/style (“second person, warm”) |
wordcount |
the PROSE reductions | a target length — a prompt-level target, never a mid-sentence cut |
punctuation |
the PROSE reductions | 'plain' (the only value): the result never contains a semicolon — the ban is enforced after the model writes, not merely requested, so it holds on every run |
sample |
the HOLISTIC judgments (score, classify) |
{size, subset} — how much of the group reaches the model, and which part |
text |
EVERY reduction | {chars, keep, clean} — how much of each ITEM reaches the model, which part, and what is stripped first |
maxGroups |
a FILTERABLE aggregation | the group cap this query intends to spend, up to 2000 |
confidence, fresh and materialize are EDGE keys (a generative floor, a producer’s cache) and an
aggregation has neither, so they are rejected here rather than accepted and ignored. A prose key on a
non-prose function, an unknown key, and a {realm: {…}} map are all rejected the same way: warned
about, never silently inert. Because a map is a value the map cannot be confused with an instruction —
summarize(n.body, {ai: {voice:'noir'}}) steers, it does not summarize toward the word “noir”.
The map NESTS: sample groups two keys that are one decision, and the interior is ordinary Cypher
(quoted or back-ticked keys, strings containing braces, any depth) because the real parser reads it,
not a scanner. A trailing map carrying NEITHER ai nor realm is not steering at all — it stays the
caller’s own positional argument.
sample — how much of a group a holistic judgment reads
Section titled “sample — how much of a group a holistic judgment reads”score and classify reduce a group to ONE number or ONE label, so their evidence must fit a single
model call — unlike summarize, themes, relevant or extract, which fold a whole group in
batches. They therefore read a bounded sample of the group, and say what they left out: a group
larger than the sample returns a PARTIAL_RESULT note naming the counts and the strategy, so a
number over 40 of 500 items never renders as a number over all 500.
Settle what you already know before the model is asked. Wrap any reduction in a two-argument
coalesce whose first argument is a plain expression over the row: where that expression is not
null it IS the group’s value and no model call is made; where it is null the reduction runs as
usual.
MATCH (a:CustomerAccount)WITH a, a.caseSubjects AS problems, coalesce(a.crmNotes, '') AS writtenRETURN a.name, coalesce(CASE WHEN written = '' THEN 'unaware' WHEN any(p IN problems WHERE toLower(written) CONTAINS toLower(p)) THEN 'aware' END, classify('OPEN: ' + problems + ' || CRM: ' + written, 'aware,unaware', 'aware: a note touches the same topic as a support item …')) AS crmAwarenessAn account sales never wrote about is unaware by definition, and one whose case subject appears
verbatim in a note is aware by the same word test an app would apply — neither needs a model, and
neither is sent one. The guard settles a GROUP only when every row of it carries the same non-null
guard value; a group with one guarded row and one unguarded, or with guards that disagree, is judged
whole by the model over its values (the guards are never part of the evidence). The guard must be a
plain expression — one that aggregates is refused — and a reduction that collects two expressions
(argmax, correlate, regress) cannot be guarded. The guarded form can be filtered, ordered and
grouped by like any other reduction (§6.4), and there too the settled groups cost nothing.
Many groups share a call. A query that classifies per row is a query of many small groups, and
classify judges up to twenty of them in one model call — RETURN c.id, classify(c.body, 'blocked,degraded,asking')
over 150 cases costs about eight calls, not 150. Nothing about the answer changes: every group is
still judged on its own evidence, its label still comes from the closed set, and a group the model’s
reply does not settle unambiguously (a number missing, given twice, or given a label off the set) is
judged again on its own rather than guessed. llmCalls in the result counts the calls actually made.
score shares a call the same way — ORDER BY score(…) over many rows is batched, not one call
per row — and keeps its contract: a batched score is the same 0–1 number, clamped, and a group
whose evidence is too long for one call is scored on its own. holds is not batched: a
verdict read from a numbered list was measured to differ from the verdict the same evidence
gets on its own (a small judge answered FALSE in a batch where it answers TRUE alone), and a
confidently wrong negative is a row silently missing from WHERE holds(…) = true — a
holds over many rows still costs one call per group.
The calls a reduction makes for independent groups (or batches of them) overlap, a few at a time,
so a query that judges many groups waits for the slowest few round trips rather than the sum of
all of them; the order of the rows and the count of the calls are unchanged by this.
A classify judgement asks the same question every run. The same evidence reaches the model
cleaned the same way and in the same order, and the answer is snapped onto the declared label set,
so a label never changes because the question did. It is not guaranteed to be identical run to run:
the host sets no sampling temperature, which current models ignore or reject. A materialised view
refreshed on its ttl keeps its labels between refreshes.
MATCH (t:ResearchTopic)-[:HAS_NEWS]->(n:NewsItem)RETURN t.name, score(n.description, 'relevance to enterprise AI', {ai: {sample: {size: 120, subset: 'longest'}}}) AS fit| Key | Effect |
|---|---|
size |
items that reach the model. Capped at 200 — the evidence has to fit one call — and clamped with a warning rather than refused. Omit it to keep the function’s own default |
subset |
which items: first (the default), last, longest, random. Omit it to keep first |
Choosing a subset is not a formality. first and last are only as meaningful as the group’s
ORDER, which is the engine’s unless the query put an ORDER BY before the clause that groups —
so longest is the order-independent choice, and usually the better one: the longest items carry
the most evidence per call. random is SEEDED from the call’s own criterion, so the same question over
the same group always reads the same items; an unseeded spread would make identical queries disagree
with nothing in the result to explain why.
sample on a folding reduction is rejected like any inapplicable key — capping summarize would
quietly throw away evidence the author expected folded.
text — how much of each ITEM is read, and which part
Section titled “text — how much of each ITEM is read, and which part”Where sample chooses the ITEMS, text chooses what each of them contributes. Every reduction cuts a
long item to a per-item budget; this makes the budget, the part kept, and the cleaning explicit.
RETURN score(n.body, 'relevance to my Series A funding round', {ai: {text: {chars: 2000, keep: 'around'}}}) AS fit| Key | Effect |
|---|---|
chars |
characters of each item that reach the model (capped at 8000 — a per-item budget multiplies by the sample). Omit to keep the reduction’s own default |
keep |
which part survives: head (the default), tail, ends (both, with a visible elision), around |
clean |
a CLEANER NAME, or auto (the default — every cleaner that detects its own noise) or none. Several compose: 'html,quoted-reply' |
around reads the passages nearest your criterion, and it is the one to reach for on documents.
Every reduction that cuts text is holding the criterion it is cutting it for — a rubric, a label set, a
relevance criterion — so “the first N characters” can be “the N characters nearest the question” at no
extra cost. On ai.relevant, where the judgment DROPS rows, that is the difference between a degraded
answer and a row wrongly excluded because its deciding sentence sat in paragraph three.
Markup is stripped by default. A field carrying HTML spends most of a character budget on tags, so
the sentence that decides the answer is the one that does not fit; clean: 'none' keeps the markup for
the rare item whose markup IS the subject.
Cleaning is a named strategy, not a fixed list. html is the one the engine ships; a world adds
another by registering a cleaner — an email’s quoted reply chain, a CMS field’s shortcodes, a log’s
escape codes — and it is then available to every reduction and every per-row judgment by name, with
nothing in the query language to change. auto runs whichever of them recognise their own noise in the
item; naming one runs it whether or not it recognised anything, which is the escape hatch for a source
the detector is deliberately too conservative for. A name that is registered nowhere cleans with
NOTHING and says so — a typo must neither silently clean with something else nor fail the query.
An item cut to its budget returns a PARTIAL_RESULT note naming the part read and how many items were
cut — cleaning alone does not, because removing markup loses nothing the model could have used.
The same {ai: {text: {…}}} map applies on a virtual EDGE, where it bounds what the per-row judgments
read: WHERE ai.relevant(n, '…'), ORDER BY ai.score(n, '…'), RETURN ai.classify(n, '…') (§7.3–7.5).
cluster — groups with sizes you can trust
Section titled “cluster — groups with sizes you can trust”cluster returns one entry per group — {label, size, share, examples} — where examples are the
most typical members of that group.
Its cost does not grow with the size of the group the way the others do: every other function in the
table is priced per batch of rows, so a few hundred items can exceed a lens’s time budget, while
cluster stays flat. Reach for it on large groups.
MATCH (scope:DiseaseScope {registryQuery:'insomnia'})-[:HAS_TRIAL_SEARCH]->(run:TrialSearchRun)MATCH (run)-[:RETURNED]->(trial:ClinicalTrial)RETURN cluster(trial.title, 6) AS clustersThree properties follow, and they are the reason to prefer it when membership matters:
- The sizes are counted, not estimated.
sizeis arithmetic over the rows the traversal returned. Every other function can describe a group but cannot say how big it is — a model asked for a count guesses. This is the difference between a claim and a number. - It is deterministic. The same rows always yield the same clusters and the same sizes, run after run — so a page built on it does not move underfoot when re-read.
- It degrades rather than fails. If a group cannot be named, it still returns with its size and examples: the grouping is true whether or not anything named it.
Being a list of maps, clusters[0].size is addressable — which is what lets a surface make a cluster
filter the rows it came from. Prefer themes when only the recurring topics matter and membership
does not; prefer cluster for “what groups are in these, and how big is each”.

