Full-text search
Neo4j's full-text search runs on a fulltext index and is read through a procedure, not a WHERE
predicate. So a $fulltext condition does not filter a scan — it replaces it:
CALL db.index.fulltext.queryNodes('BookSearch', $phrase) YIELD node AS this0, score AS var1
WHERE (this0:Book AND this0.price > $param1)
RETURN this0
A search always says what it searches, never which index reads it. The field you filter on is the declaration you get: there is no way to point a query at an index and hope it covers the right properties.
Declare a search field
A full-text index is declared by a search field: a property typed as the search itself, marked
persist: false. This is PostgreSQL's shape, adapted. There, a search over several columns needs a
column of its own, so there are three pieces — a property typed FullTextType, an onUpdate
combining the others into it, and a separate fulltext index declaration:
searchableText: p.type(new FullTextType('english'))
.onUpdate((book) => ({ A: book.title, B: book.description })),
A Neo4j fulltext index spans properties natively, so the onUpdate goes away: nothing is stored, so
there are no values to combine — the type is given the property names, under the same
'A'–'D' weights. The index declaration stays, because an index is a schema object and that is
where MikroORM declares them:
import { Neo4jFullTextType } from 'mikro-orm-neo4j';
@Entity()
export class Book {
@PrimaryKey() id!: string;
@Property() title!: string;
@Property() summary!: string;
@Index({ type: 'fulltext' })
@Property({
type: new Neo4jFullTextType(
{ A: 'title', C: 'summary' }, // weight → property, or just ['title', 'summary']
{
name: 'BookSearch', // optional; derived otherwise
analyzer: 'english',
queryName: 'searchBooks', // GraphQL only
},
),
persist: false,
nullable: true,
})
readonly search?: string;
}
The two halves say different things, and both are needed: the type says which properties are
scored, the index is what ensureIndexes() creates. The declaration names the search field —
properties: ['search'] — and comes out naming the properties that field stands for:
CREATE FULLTEXT INDEX `BookSearch` IF NOT EXISTS FOR (n:`book`) ON EACH [n.`title`, n.`summary`]
OPTIONS { indexConfig: { `fulltext.analyzer`: 'english' } }
Declare it readonly and optional — nothing writes it, and em.create() should not ask for it.
persist: false is what keeps it out of writes, projections and the GraphQL type; the driver sets it
either way, and refuses a search field declared persist: true.
Being a property type rather than an option of ours, the same declaration travels through
defineEntity unchanged — @Property keeps the contract MikroORM gave it:
import { defineEntity, Neo4jFullTextType } from 'mikro-orm-neo4j';
const BookSchema = defineEntity({
name: 'Book',
indexes: [{ properties: ['search'], type: 'fulltext' }],
properties: (p) => ({
id: p.uuid().primary(),
title: p.string(),
summary: p.string(),
search: () =>
p
.type(new Neo4jFullTextType({ A: 'title', C: 'summary' }, { analyzer: 'english' }))
.persist(false)
.nullable(),
}),
});
Each half is refused without the other. A search field with no index declaration has nothing to
create, and an index pointed anywhere but at a search field could never be read — $fulltext
resolves an index from the field it filters on, and a name says nothing about what it scores.
A search field declared on an interface covers every entity implementing it. Neo4j fulltext indexes are the only kind that may span labels, so one index is created over all implementors and each of them — and the interface itself — searches it:
@Entity({ inheritance: 'interface' })
abstract class Production {
@Property() title!: string;
@Index({ type: 'fulltext' })
@Property({ type: new Neo4jFullTextType(['title']), persist: false, nullable: true })
readonly search?: string;
}
@Entity({ labels: ['Movie', 'Production'] }) class MovieProduction extends Production { … }
@Entity({ labels: ['Series', 'Production'] }) class SeriesProduction extends Production { … }
CREATE FULLTEXT INDEX `Production_title_idx` IF NOT EXISTS FOR (n:`Movie`|`Series`) ON EACH [n.`title`]
em.find(MovieProduction, …) searches it and gets movies; em.find(Production, …) searches it and
gets both, because an interface is matched through its implementors' labels.
Search
await em.find(Book, { search: { $fulltext: 'graph databases' } }); // title + summary
await em.find(Book, { titleSearch: { $fulltext: 'graph' } }); // a narrower search field
await em.find(Book, { search: { $fulltext: 'graph' }, price: { $lt: 50 } });
await em.find(Production, { search: { $fulltext: 'matrix' } }); // movies and series alike
There is no entity-wide { $fulltext: … }. That shape belongs to MongoDB, where a collection has one
text index; a Neo4j entity may have several, over different fields, and a query that does not say
which one it means is a query that can silently score the wrong properties. Filtering a scored
property ({ title: { $fulltext } }) is refused for the same reason — title may be scored by more
than one search field.
The operator's value is always a plain string. Everything else a search might want is already part of
MikroORM's own API — paging is FindOptions, the scored fields are the search field you filter on,
and the analyzer belongs to the index:
await em.find(Book, { search: { $fulltext: 'graph' } }, { limit: 10, offset: 20 });
When nothing else could drop a row — no other condition, no schema scope, no index shared with
another label — that limit also bounds the index scan itself:
CALL db.index.fulltext.queryNodes('BookSearch', $phrase, { limit: $param1 }) YIELD node AS this0, score AS var1
RETURN this0 SKIP 20 LIMIT 10
The bound is offset + limit, and the outer SKIP/LIMIT still does the windowing — the scan
simply stops ranking once it has enough. Add a filter and the bound is dropped, because inside the
procedure limit means "the best n matches", of which the filter may keep none.
Relevance and weights
The procedure yields rows in descending relevance and nothing downstream re-sorts them, so
em.find() returns the best match first. An orderBy replaces that order.
Weights ('A'–'D', as in PostgreSQL's setweight) become Lucene boosts at query time, since
Lucene stores no weights of its own:
graph → title:(graph)^10 OR summary:(graph)^2
A query that scopes its own fields — anything containing a : — is left exactly as written.
To read the score itself, use the query builder:
const hits = await em
.createQueryBuilder(Book, 'b')
.fullText('search', 'graph databases') // the field names the index
.where('price', 30)
.return(['title', 'score'])
.execute();
Where the operator may appear
A $fulltext becomes the query's opening clause, so it must sit at the top level of the filter (or
inside a top-level $and, which is the same conjunction). Nested inside $or, $not or a relation
it would have to be two starting points at once, and the driver throws instead of quietly matching
nothing. Two searches in one query throw for the same reason.
Everything else composes normally: other conditions filter the hits, populate loads relations,
em.count() counts them, em.stream() streams them, and em.nativeUpdate() / em.nativeDelete()
write to them.
GraphQL
Every fulltext index an entity has — the ones its own search fields declare and the ones it inherits
from an interface — is declared on its GraphQL type, so @neo4j/graphql generates a query that reads
the very index ensureIndexes() created:
type Book
@node
@fulltext(
indexes: [
{ indexName: "BookTitle", queryName: "booksByTitle", fields: ["title"] }
{ indexName: "BookSearch", queryName: "searchBooks", fields: ["title", "summary"] }
]
) {
id: ID! @id
title: String!
summary: String!
}
The search field itself is not a field of the type: it stores nothing, and the directive is where it
shows up. queryName comes from the search field's options; without one it is derived from the type
and the fields the index scores (booksByTitleAndSummary), which keeps two indexes on the same
entity from colliding.
An interface's index lands on every implementing type, each with a query of its own — one index, but two types cannot expose the same query:
interface Production {
id: ID! @id
title: String!
tags: [Tag!]! @declareRelationship
}
type MovieProduction implements Production
@node(labels: ["Movie", "Production"])
@fulltext(
indexes: [
{ indexName: "ProductionSearch", queryName: "movieProductionsByTitle", fields: ["title"] }
]
) {
id: ID! @id
title: String!
released: Int!
tags: [Tag!]! @relationship(type: "TAGGED_AS", direction: OUT)
}
Limitations
- Relationship entities. Neo4j can index edge properties, and
ensureIndexes()creates such an index for a@Entity({ relationship: … })declaration, but$fulltextcannot read it: it yields relationships rather than nodes, which is a different query shape. The driver throws and points atem.run()/ the query builder. The@fulltextGraphQL directive has no relationship form either. - One index per label and field set. Neo4j allows a single fulltext index over a given
(label, properties)pair, andIF NOT EXISTSwould turn a second one into a silent no-op — so the schema generator refuses it instead, naming the declaration that already covers those fields. Multi-label indexes are created for interfaces; an index created outside the ORM is searchable by giving a search field its name —new Neo4jFullTextType([…], { name: 'TheirName' })— sinceensureIndexes()isIF NOT EXISTSand will not touch it. - No index management.
ensureIndexes()isIF NOT EXISTSand never drops. Changing the properties, the weights or the analyzer of an existing index meansDROP INDEXfirst.