ADR 0057: Search inside Fluent Cypher¶
Date: 2026-10-26. Status: Accepted on 2026-10-26, as GR-09, a could row of the GraphRAG v2 plan the owner asked to continue through.
Context¶
Graph search (graph search) queries a vector or full-text index and returns nodes with scores, but stops there: to follow relationships from the hits, an application writes native Cypher. Fluent Cypher (ADR 0047 and later) builds typed graph patterns over the EF model, but can't start from an index. GraphRAG's retriever needed both, and uses native queries marked as a workaround (ADR 0052, the gap list).
Neo4j 2026 has a SEARCH subclause of MATCH in Cypher 25. Probed on Neo4j 2026.09.0 on 26 October 2026:
- MATCH (m:Movie) SEARCH m IN (VECTOR INDEX idx FOR $v LIMIT 2) SCORE AS s works, with the label in the MATCH;
- SEARCH m IN (FULLTEXT INDEX idx FOR $q LIMIT 2) SCORE AS s works for full-text indexes;
- further clauses can follow.
Decision¶
1. The clauses¶
var movie = Graph.Node<Movie>(); var actor = Graph.Node<Person>(); var score = Graph.Score();
var rows = await db.Cypher()
.SearchVector(movie, "movie_embedding", embedding, limit: 5, score)
.Match(actor.Out<Credit>(movie))
.Return(() => new { movie.Value.Title, actor.Value.Name, Score = score.Value })
.OrderByDescending(() => score.Value).Limit(20).ToListAsync();
SearchVector<T>(node, index, IReadOnlyList<float> vector, limit, score?)andSearchFullText<T>(node, index, query, limit, score?)renderCYPHER 25 MATCH (n:Labels) SEARCH n IN (VECTOR INDEX index FOR $p LIMIT $p) SCORE AS v, orFULLTEXT INDEXfor full-text. The node pattern carries the type's labels, additional ones included (ADR 0055).Cypher.Score()is a value symbol for the native score, in scope after the search, forReturn,Withand ordering. Ordering by the score needs it in the result, as for any value (GBFC405).- Inputs are parameters and are validated as graph search validates them: an identifier for the index, 1 to 10,000 results, a vector of 1 to 4,096 finite values (floats, sent as their exact doubles), and a non-empty query of at most 16,384 characters.
2. Refused¶
- A search anywhere but first (
GBFC305).SEARCHbinds a new node; mid-query use and several searches aren't proven. - A score that isn't a fresh
Cypher.Score()(GBFC306). Wheredirectly after a search (GBFC307, coded after review): aWHEREon the search'sMATCHwould filter after the index'sLIMIT. Filter afterWith(...), where that is visible.
3. Checked before the query¶
Executing a query with a search (ToListAsync, or a prepared query) first reads, in the same transaction, what graph search reads:
- the server must be Neo4j 2026 with Cypher 25, and 2026.09 or later for full-text;
- the index must exist, be ONLINE, and be a node index of the requested kind;
- a vector index's dimensions must match the vector when the index declares them, and cosine refuses a zero vector;
- the index must cover exactly the node type's own label. So GraphRAG's chunk indexes qualify, but an index on a shared label, such as GraphRAG's entity-name index over several classes or an index on neo4j-graphrag's __Entity__, doesn't.
A failure throws before the query runs. ToQueryString, ToCommand and Inspect stay offline.
4. Statement and snapshot¶
CYPHER 25 leads the statement, after any tags (ADR 0050). The rest of the query keeps its clauses. Tested live under Cypher 25: OPTIONAL MATCH, aggregates in RETURN, EXISTS in a later MATCH, ordering and tags before the preamble, and, since the second GraphRAG review (29 October 2026), a WITH scope that groups the hits, a bounded path from a hit, and whole entities whose native reference is read with a COLLECT subquery. In the semantics snapshot, the search is a Match clause with its one node pattern; the snapshot doesn't show that the index limits it.
Explaining a search (added after review). ExplainCypherAsync refused any statement with a leading comment or a CYPHER preamble, so Verify failed every contract with a search, and every tagged contract, and left the later contracts not run. It now drops leading // tag lines and keeps a bare CYPHER <version> in front of EXPLAIN; block comments, execution modes and other query options are still refused.
Consequences¶
- An application can rank by similarity and traverse in one typed query, with the index checked for it.
- A query with a search needs Neo4j 2026; queries without one are unchanged.
- The search's
LIMITapplies before any later clause, as in Cypher. Results can be fewer than the limit once laterMATCHclauses drop rows. - The additional labels filter after the limit too. The index covers the type's own label, so a node without the type's additional labels can take one of the top places and then be dropped by the pattern's labels. A live test pins it: with the unlabelled node closest and a limit of 1, the search returns nothing.
Not adopted¶
- A search in the middle of a query, or several searches. Possible in Cypher 25, but bindings, the index preflight and scoping would need more proof than this row warrants.
- Filters inside the index (
SEARCH ... WHERE). They need properties declared when the index is created (GR-11's spike) and are a future idea (FUT-05). - Relationship indexes. Fluent search starts from a node.
Verification¶
- Offline: the rendered statement, parameters, score binding, tags before
CYPHER 25, every refusal. - Live, on Neo4j 2026.09.0: a vector search followed by a
MATCH, with scores in order; a full-text search; a search over a type with additional labels; refusals of a missing index, an index on another label and wrong dimensions, each before the query runs.