Skip to content

Loading Data

@Component
class PersonService @Autowired constructor(
private val graphObjectManager: StatelessGraphObjectManager
) {
fun getAllPeople(): List<PersonCareer> {
return graphObjectManager.loadAll<PersonCareer>()
}
}
fun getPerson(uuid: String): PersonCareer? {
return graphObjectManager.load<PersonCareer>(uuid)
}

count returns a Long and is consistent with loadAll — it counts exactly the objects loadAll would return for the same type and filter. There are three overloads, mirroring loadAll/deleteAll:

// 1. Count everything of a type (reified, or pass Issue::class.java)
val total: Long = graphObjectManager.count<Issue>()
// 2. Count with a simple WHERE filter (aliases match loadAll: `n` for fragments,
// the root fragment field name for views)
graphObjectManager.count<Issue>("n.state = 'open'")
// 3. Count with the type-safe DSL (generated per-view extension injects the query object)
graphObjectManager.count<RaisedAndAssignedIssue> {
where { query.issue.state eq "open" }
}

The codegen emits INSTANCE-injecting extensions for every DSL-spec method — loadAll<T> { }, deleteAll<T> { }, count<T> { }, and loadNearest<T>(…) { } (the last only for @VectorIndex-ed views). The reified count<T>() / loadNearest<T>(…) and the explicit ::class.java overloads also remain (the latter for Java).

Fragments are a straight node count of the fragment’s labels.

Views count only roots that satisfy the view’s required relationships — non-optional, non-collection @GraphRelationships — so the result equals loadAll(...).size, not a naive node count. For example, RaisedAndAssignedIssue requires raisedBy (a single, non-null RAISED_BY):

graphObjectManager.count<Issue>() // 3 — every Issue node
graphObjectManager.count<RaisedAndAssignedIssue>() // 2 — only Issues with a RAISED_BY edge

An Issue with no RAISED_BY is a valid Issue node but is not a RaisedAndAssignedIssue, so it is excluded — just as loadAll would exclude it. Optional (nullable) and collection relationships place no such constraint.

From Java, the same three overloads apply (use .count(Issue.class) etc.); the DSL overload takes the generated query object and a lambda.

loadNearest runs an approximate nearest-neighbour search and returns the hits paired with a normalized similarity score. Like loadAll, it works on both a @GraphView — searching the root fragment’s embedding and returning the fully-projected view — and a plain @NodeFragment, searching and returning the bare nodes:

// Search a view: ranks PropositionViews by their root proposition's embedding
val views: List<Scored<PropositionView>> =
graphObjectManager.loadNearest<PropositionView>(queryEmbedding, topK = 20)
// Search a fragment: ranks bare PropositionNodes
val nodes: List<Scored<PropositionNode>> =
graphObjectManager.loadNearest<PropositionNode>(queryEmbedding, topK = 20)

The embedding to search is identified by a @VectorIndex annotation on the searched fragment (the view’s root fragment, or the fragment itself) — inferred when the fragment declares one embedding, or named explicitly to pick among several. @VectorIndex is the query-side declaration of “this embedding is searchable” and is independent of how the index is created: it works whether you create the index from the annotation (SchemaCatalog.fromFragments(...)) or from an explicit spec (SchemaCatalog.of(VectorIndexSpec(...))); in the latter case, add the annotation to the fragment as well — it carries no creation side effects on its own. Drivine refuses a catalog spec over a fragment field that has no @VectorIndex, naming the class, the field and the spec: without the annotation FalkorDB writes the field as a plain list and loadNearest finds no index. The check runs when SchemaManager applies the catalog and when the fragment class is first used. A spec for a label no fragment maps is not checked.

@NodeFragment(labels = ["Proposition"])
data class PropositionNode(
@NodeId val id: String,
val text: String,
@VectorIndex(similarity = SimilarityFunction.COSINE)
val embedding: List<Float>? = null,
)
@GraphView
data class PropositionView(
@Root val proposition: PropositionNode,
@GraphRelationship(type = "HAS_MENTION", direction = Direction.OUTGOING)
val mentions: List<Mention>,
)
// Single embedding on the root fragment → inferred, no property argument
val hits: List<Scored<PropositionView>> =
graphObjectManager.loadNearest<PropositionView>(queryEmbedding, topK = 20)
hits.forEach { println("${it.score} → ${it.value.proposition.text}") }
// Disambiguate when a node carries several embeddings, and/or floor by similarity
graphObjectManager.loadNearest<PropositionView>("titleEmbedding", queryEmbedding, topK = 20, threshold = 0.8)

Each result is a Scored<T>(value, score); the score is normalized to similarity, higher = more similar on every engine, so ordering and threshold mean the same thing regardless of backend. It is in [0, 1]: (1 + cos) / 2 for a cosine index, and 1 / (1 + d²) for a Euclidean one, where d is the distance. So a cosine of 0.6 scores 0.8, and 2 * score - 1 gives the cosine back.

Changed on FalkorDB and Memgraph. Before this release FalkorDB gave the cosine itself (-1 to 1) and 1 / (1 + d), and Memgraph the absolute value of the cosine, so opposite vectors scored as high as equal ones; Memgraph’s Euclidean score was already 1 / (1 + d²). Both now give the scores above, as Neo4j, Postgres, Oracle and SQLite do. A cosine score rises from cos to (1 + cos) / 2, and a FalkorDB Euclidean score falls from 1 / (1 + d) to 1 / (1 + d²) where d is over 1 (and rises where it is under), so a threshold chosen against the old scores keeps more or fewer results than before: convert a cosine threshold t to (1 + t) / 2.

topK is the index’s k, not a guaranteed result count. When searching a view, its required relationships (and the optional threshold) are applied after the K-nearest search, so a candidate that ranks in the top K but fails the filter is dropped — meaning loadNearest can return fewer than topK results. The same holds for a root with more than one label: the index is on its first label, so a node with that label alone is a candidate, and is dropped. So is a node of a partitionLabel’s index that lacks any of the root’s labels. A fragment with one label, searched without a partition, has no such filter, so it returns the full top K (minus any threshold cut).

k is also the search beam width — this is the surprising part. On Lucene-backed engines the result queue is the HNSW candidate queue, so k does not merely truncate a ranked list; it decides how much of the graph the search explores. A small k can miss a vector that is genuinely nearest. This is measurable, not theoretical: on a 9K-vector index, a vector verified as true global rank 3 was not returned at any k ≤ 100, and came back at rank 3 at k = 200. Neo4j exposes no separate ef_search, so raising k is the only query-time lever on recall.

Use searchK to widen the search without widening the result:

// search with a beam of 200, return the best 40 that survive the filter
graphObjectManager.loadNearest<PropositionView>(dsl, queryEmbedding, topK = 40, searchK = 200) {
where { proposition.contextId eq ctx }
}

searchK is what the index is asked for; topK becomes a LIMIT applied after the filter, so over-fetching actually recovers rows the filter would otherwise have thinned away. Omit it and the emitted query is unchanged. searchK < topK throws — it can only lose results.

On Neo4j, over-fetching also re-ranks the beam by exact similarity before trimming: where the index quantizes, its own score is computed against quantized vectors and does not order identically to exact similarity, so trimming by it discards true matches the beam already found. Whether an index quantizes depends on the engine build unless you pin it.

Filtered searches dilute. Because predicates apply after the index yields, a scoped caller gets roughly k × selectivity rows — at 22% selectivity, asking for 40 returns about 9 — and those survivors are the globally-nearest that happen to be in scope, not the nearest within scope. searchK mitigates this. See 0.0.79-vector-search-k.md.

Searching a partition (partitionLabel). When the same property is indexed per partition — one vector index per corpus, tenant, or other scope — name the partition and the search runs inside it, pre-filtered by construction, so k is no longer diluted by the filter:

persistenceManager.indexes.ensure(VectorIndexSpec("Corpus_abc", "embedding", 1536))
graphObjectManager.loadNearest(
PropositionNode::class.java, null, queryEmbedding,
topK = 40, threshold = null, searchK = null, partitionLabel = "Corpus_abc",
)

The label changes only which index is located: where { } and the projection are unaffected. A node the partition’s index yields comes back only when it has every one of the fragment’s labels, here :Proposition, so a node of the partition that is no Proposition is dropped. The index name re-derives as ${label}_${property}_vector, matching what VectorIndexSpec would create — so a fragment whose @VectorIndex pins an explicit name cannot be partitioned, and says so. See 0.0.79-vector-partitioning.md.

Backends without a native vector index (Amazon Neptune) throw UnsupportedOperationException.

Pinning the physical index. Only dimensions and similarity_function are always emitted; everything else about the physical index is the engine’s choice, and engine defaults change between versions — so the same declaration can produce a different index on different servers. Pin the HNSW graph shape portably on the annotation, and anything engine-specific on a VectorIndexSpec:

@VectorIndex(similarity = SimilarityFunction.COSINE, hnswM = 32, hnswEfConstruction = 200)
val embedding: List<Float>? = null
// engine-specific options — Neo4j quantization, FalkorDB efRuntime — in a catalog spec
SchemaCatalog.of(
VectorIndexSpec("Proposition", "embedding", 1536,
engineOptions = listOf(Neo4jVectorOptions(quantizationEnabled = false))),
)

An unpinned parameter stays the engine’s to choose and is never reported as drift; a pinned one the engine contradicts is. The effective configuration is logged after every index creation either way. See 0.0.79-vector-index-tuning.md.

Filtering with where { }. A where { } block AND-s caller predicates into the same post-search filter, so you can combine vector similarity with property predicates and relationship quantifiers in one statement:

// nearest propositions in this context that mention entity X (reified form generated per @VectorIndex view)
graphObjectManager.loadNearest<PropositionView>(queryVector, topK = 20) {
where {
proposition.contextId eq ctx
proposition.status eq "active"
mentions.any { resolvedId eq entityId } // any{} / none{} over a projected relationship
}
}

The codegen emits this loadNearest<T>(vector, topK, threshold) { where { } } extension for each @VectorIndex-bearing view (its root fragment). The filtered form works on a bare fragment too, given the fragment’s generated query object — filter its own properties directly:

// filtered vector search over a bare @NodeFragment
graphObjectManager.loadNearest<ChunkNode, _>(ChunkNodeQueryDsl.INSTANCE, queryVector, topK = 20) {
where { query.containerSectionId eq "sec-1" }
}

The predicate is applied after the K-nearest search, so it really does prune — a node that ranks in the top K but fails the predicate is dropped, and topK remains the index’s k (the result may contain fewer rows). Property predicates filter the projected root; relationship quantifiers (any{}/none{}) filter the projected relationship collection (any(m IN mentions WHERE …)). Multiple any{} AND together — “mentions all of these entities” is one any{} per id. Referencing a relationship the view does not project is an error.

Nearest Neighbours Within a Set (loadNearestAmong)

Section titled “Nearest Neighbours Within a Set (loadNearestAmong)”

loadNearestAmong answers, for each of a set of ids, “which of the other ids in this set are nearest to it?” — the batched form of calling loadNearest once per id with that node’s own embedding and keeping only the hits inside the set. It runs as one statement that each store answers natively, so it needs no store-specific Cypher:

val neighbours: Map<String, List<Scored<String>>> =
graphObjectManager.loadNearestAmong<PropositionNode>(batchIds, topK = 5, threshold = 0.8)
neighbours["p-1"] // [Scored("p-7", 0.97), Scored("p-3", 0.91)]
  • Keys are the ids whose root node has the embedding, in the order the ids were given. An id with no embedding, or no node, is not a key; an id whose neighbours were all dropped maps to an empty list. An embedding of another size than the index’s is measured against none of a different size: on Postgres or Oracle with a vector index made it is not a key, and on Neo4j and FalkorDB the search fails, as their index search refuses a vector of another size.
  • Values are up to topK other ids from the set — never the id itself — most similar first, each with the same normalized score loadNearest gives that pair on the same store, so the two agree. Those below threshold are dropped.
  • It works on a @NodeFragment and on a @GraphView (searching the root fragment’s embedding), and resolves the embedding from @VectorIndex as loadNearest does; pass property when there are several. A view’s relationships play no part: the set is named by id.

searchK matters here as it does for a filtered loadNearest. Where the store searches through an approximate index — Neo4j, FalkorDB, Memgraph, and Postgres or Oracle once a vector index has been made — the index is asked for searchK candidates per id (default topK + 1: the id itself is found first), and the “among the set”, self, root label and threshold filters apply to those. An id whose nearest mostly lie outside the set can get fewer than topK back, so a selective set — a few ids among many indexed nodes — needs a larger searchK. Where the store measures every node instead (SQLite, and Postgres or Oracle with no vector index made) only the set is measured, the answer is exact, and searchK changes nothing; every pair of the set is measured, so the cost grows with the square of the number of ids.

Store How it is answered
Neo4j UNWIND the ids, db.index.vector.queryNodes from each seed’s embedding, rescored exactly with vector.similarity.*
FalkorDB UNWIND the ids, db.idx.vector.queryNodes from each seed’s embedding
Memgraph UNWIND the ids, vector_search.search from each seed’s embedding
Postgres one SQL statement: each seed’s nearest through the pgvector index, or, with none made, measured against the other seeds
Oracle one SQL statement: each seed’s nearest through the vector index (CROSS APPLY … FETCH APPROX), or, with none made, every pair of seeds measured and ranked
SQLite one SQL statement: every pair of seeds measured and ranked (exact)
Neptune refused with UnsupportedOperationException, as loadNearest is

loadMatching is the full-text mirror of loadNearest: it finds the topK nodes most relevant to a text query and returns them as scored, typed results — normalized to a consistent [0, 1] similarity across engines, with no consumer Cypher and no per-engine score wrangling.

// bare @NodeFragment
val hits: List<Scored<ChunkNode>> = graphObjectManager.loadMatching<ChunkNode>("graph databases", topK = 20)
// with a floor on the normalized relevance
graphObjectManager.loadMatching<ChunkNode>("graph databases", topK = 20, threshold = 0.5)
// a @GraphView searches its root fragment's text index and returns the projected view
graphObjectManager.loadMatching<ChunkView>("graph databases", topK = 20)

The index is resolved from a @FullTextIndex on the searched fragment (property-level for a single field, or class-level @FullTextIndex(properties = ["title", "body"]) for a multi-property index) — the same annotation the schema feature uses to create it. threshold defaults to 0.0 (keep everything). topK bounds the index call as well as the rows returned: the scores are normalized over the hits the index yields that have every one of the root’s labels, so the index is asked for its best topK hits, and an ordinary query that matches most of a corpus costs no more than those. A node with the indexed label alone is no root, so it is dropped before the scores are scaled, and the best hit a search can return scores 1.0 on every store. Each result is a Scored<T>(value, score), most relevant first, with polymorphic dispatch (a loadMatching<SealedBase> returns each hit as its concrete subtype).

The query string is passed through to the engine’s full-text language (Lucene syntax on Neo4j: AND/OR/"phrase"/field:term); raw user input is not escaped for you. Backends without a native full-text index throw UnsupportedOperationException.

Filtering with where { } works exactly like loadNearest — full-text relevance plus property predicates in one query, on a view or a bare fragment:

graphObjectManager.loadMatching<ChunkNode>("graph databases", topK = 20) {
where { query.containerSectionId eq "sec-1" }
}

The predicate, like a view’s required relationships and a root’s labels beyond the first (the one the index is on), is applied after the index yields; the labels are checked before the scores are scaled, the predicate and relationships after. Where any of them filters, the index is asked for topK × FullTextSearchLimits.FILTERED_OVER_FETCH (10) hits and the filter keeps the best topK of those, so a filter that passes fewer than one in ten of the best hits can return fewer than topK rows while the index holds more that pass it. The DEBUG line GraphObjectManager logs for a short result says how many hits the index was asked for.

Engine note: full-text search runs on Neo4j (db.index.fulltext), FalkorDB (db.idx.fulltext, by label), and Memgraph (text_search, GA — no experimental flag needed).