Skip to main content

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.

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 $fulltext cannot read it: it yields relationships rather than nodes, which is a different query shape. The driver throws and points at em.run() / the query builder. The @fulltext GraphQL 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, and IF NOT EXISTS would 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' }) — since ensureIndexes() is IF NOT EXISTS and will not touch it.
  • No index management. ensureIndexes() is IF NOT EXISTS and never drops. Changing the properties, the weights or the analyzer of an existing index means DROP INDEX first.