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.

@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.

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. A fragment search has no relationship 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: the node still carries its own :Proposition label, so where { } and the projection are unaffected. 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.

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 is applied as a trailing LIMIT. 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" }
}

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).