A full-text index is a type of index specialized for efficient searching for textual properties, especially in large text fields like descriptions, comments, or articles.
Full-text indexes work by breaking down the text into smaller segments called tokens. When a query is performed, the search engine matches specified keywords against these tokens instead of the original full text, allowing for faster retrieval of relevant results. Full-text indexes support both precise and fuzzy matches.
An analyzer determines how text is broken into tokens, normalized (lower-casing, stemming, stop-word removal), and segmented (especially for CJK - Chinese, Japanese, Korean - text, which is written without whitespace between words). The analyzer is fixed at index-creation time; the same analyzer is applied to both indexed text and query terms, so they tokenize consistently.
| Analyzer | Tokenization | Best for |
|---|---|---|
mixed | Default. Auto-detects language per segment: simple tokenization + Porter stemming + stop-word removal for English, GSE word-segmentation for CJK. | Mixed-language content (English + Chinese in the same field). The safe default. pipeline is a synonym. |
simple | Whitespace + punctuation split, lower-cased. No stemming, no stop-word removal. | Identifiers, codes, or exact-form English where stemming would cause false matches (e.g., a search for 'run' shouldn't hit a SKU literally named 'running'; under the default analyzer the Porter stemmer collapses both to the same token). |
cjk | Bigram tokenization for CJK characters; falls back to simple for non-CJK. | CJK-only content where the GSE dictionary is undesirable. |
gse | GSE word-segmentation. Honors gseMode for segmentation aggressiveness. | Chinese-heavy content where dictionary-based word segmentation is required. |
The gseMode option (mixed and gse analyzers only) controls how aggressively GSE segments Chinese text:
gseMode | Behavior |
|---|---|
precise | Default. Single best segmentation. 北京大学 → ["北京大学"]. Fewer, more specific tokens. |
search | Search-oriented overlapping segments. 北京大学 → ["北京", "大学", "北京大学"]. Recall-friendly. |
full | Every possible segmentation. Maximum recall, larger index. |
To preview how a given analyzer tokenizes a string, use the ft.analyze procedure.
Once a query's tokens are matched against the index, BM25 (Best Matching 25) ranks the hits, where higher score means more relevant. It blends three signals per query term:
When an index covers multiple properties, each property contributes its own BM25 score and the weight_<property> option is a multiplier on that contribution. For example, OPTIONS { weight_p1: 3.0, weight_p2: 1.0 }, then weighted BM25 score is computed by 3.0 * BM25(p1, query) + 1.0 * BM25(p2, query). So it makes a match in p1 count for 3× a match in p2. Weights default to 1.0 (all properties treated equally).
Retrieve full-text indexes in the current graph:
GQLSHOW FULLTEXT -- Filtered by entity type SHOW NODE FULLTEXT SHOW EDGE FULLTEXT
The result includes the following fields:
| Field | Description |
|---|---|
index_name | Full-text index name. |
entity_type | NODE or EDGE. |
schema_name | The label of the full-text index. |
properties | The indexed properties. |
analyzer | The text analyzer used. |
status | Index status — see Index States below: ready, loading, building, pending, unloaded, degraded, or error. |
doc_count | Number of documents indexed. |
progress | Build or loading progress; for a degraded index the failure count and the last failure, and for an error index the reason the build failed. |
memory | In-memory footprint of the index's posting structure, in bytes. 0 when the index is unloaded. |
| Status | Meaning | Reads of the index |
|---|---|---|
ready | Searchable. | Answered |
building | Being built by CREATE FULLTEXT, or after a restore. | 5020, retryable |
loading | Being streamed back into memory after a restart or ft.unload. | 5020, retryable |
pending | Being rebuilt once after an upgrade (see below). | 5020, retryable |
unloaded | Its in-memory structure was released; the next search or ft.load reloads it. | Answered, after a reload |
degraded | Searchable, but it is missing documents. | Answered, with a warning |
error | Its last build failed. | 5017, naming the index and the statements that rebuild it |
5020 is retryable; 5017 is not. Every read of an index that is not searchable yet — ~prop CONTAINS, ~index_name CONTAINS, CALL ft.search, CALL ft.suggest — fails with 5020 and a message saying what the index is doing and how far along it is:
[5020] fulltext search error: fulltext index loading: index "idx_doc" cannot be searched yet
because it is being built (1200 of about 5000 documents indexed); it becomes searchable when
the build finishes
The code is the same however the read is sent — on its own, in a multi-statement script, inside a transaction, as a stream, or under PROFILE / EXPLAIN ANALYZE. Match on the code rather than the text; the text fulltext index loading: is there for clients that can only see the message. No driver retries this for you: retry after a short wait, or wait for the index with ft.load('<index>', <timeoutMs>), which returns at once with the reason for an index whose build failed. A build can take minutes to hours on a large label, so bound the retries by time.
5017 means something a retry will not fix: a missing index ("no matching index found"), or one whose build failed.
degraded Index Is Missing DocumentsA write to an index can fail after the data itself was written — a disk error on the index's own store, an edge whose internal id could not be resolved. The statement succeeds, because the data is safe, but the index goes on without that document. Such an index is marked degraded and stays so across restarts:
SHOW FULLTEXT reports degraded, with the failure count and the last failure in progress.warnings field through a driver).db.validate_graph() reports it as a problem.Rebuild it when you see it — DROP FULLTEXT <name> then CREATE FULLTEXT <name> …. Nothing rebuilds it for you, because the missing documents cannot be known without a full scan.
An index in the error state is likewise not rebuilt automatically on the next open: a build is expensive and a failing one usually fails again, so the decision is left to you.
An index over two or more properties — CREATE FULLTEXT idx_doc ON NODE Document(title, body) — is cleared and rebuilt once the first time a graph is opened on this version. Such an index could previously store a document's properties in each other's slots, which ranked those documents wrongly. Single-property indexes are not affected.
While it rebuilds, the index reports pending with the documents done so far in progress, and reads of it return the retryable 5020. The rebuild costs the time and memory of a CREATE FULLTEXT over the same data, so allow for it on the first start after upgrading. Nothing else is needed.
NOTEShut the server down cleanly before downgrading. An index rebuilt since the last start keeps its new data in a separate directory until a clean shutdown moves it where an earlier version reads it. After a crash, start this version again and shut it down cleanly first — an earlier version opened straight after such a crash reports the index as ready and returns no results from it. An earlier version also does not know the
degradedmark and clears it whenever it rewrites the index's configuration, so note down any degraded index before downgrading.
You can create a full-text index using the CREATE FULLTEXT statement. The index is built asynchronously, use SHOW FULLTEXT to check build progress.
Syntax<create full-text index statement> ::= "CREATE FULLTEXT" <index name> "ON" < "NODE" | "EDGE" > <label name> "(" <property> [ { "," <property> }... ] ")" [ "OPTIONS" "{" <option> [ { "," <option> }... ] "}" ] <option> ::= "analyzer:" <analyzer name> | "gseMode:" <gse mode> | "weight_" <property> ":" <positive number>
Details
<index name> must be unique among nodes and among edges, but a node full-text index and an edge full-text index may share the same name.OPTIONS clause tunes tokenization and ranking:| Option | Default | Description |
|---|---|---|
analyzer | mixed | One of mixed, simple, cjk, gse. (pipeline is accepted as an alias for mixed.) See Analyzers. |
gseMode | precise | One of precise, search, full. Applies to the gse and mixed analyzers. See Analyzers. |
weight_<property> | 1.0 | Per-property BM25 weight for indexes covering multiple properties. Use to bias scoring. |
GQL-- Full-text index prodDesc on product nodes' description CREATE FULLTEXT prodDesc ON NODE product (description) -- Full-text index reviewText on review edges' content and excerpt CREATE FULLTEXT reviewText ON EDGE review (content, excerpt) -- English-only index, no stemming CREATE FULLTEXT skuCode ON NODE product (sku) OPTIONS { analyzer: 'simple' } -- Chinese-only, recall-oriented segmentation CREATE FULLTEXT zhArticle ON NODE article (body) OPTIONS { analyzer: 'gse', gseMode: 'search' } -- Multi-property index, title weighted 3× over body in BM25 scoring CREATE FULLTEXT articleText ON NODE article (title, body) OPTIONS { weight_title: 3.0, weight_body: 1.0 }
Dropping a full-text index does not affect the actual property values. Full-text index names are unique within a graph, so the NODE / EDGE qualifier is optional on DROP:
GQL-- Unqualified form (recommended) DROP FULLTEXT prodDesc -- Qualified forms DROP NODE FULLTEXT prodDesc DROP EDGE FULLTEXT reviewText
Use IF EXISTS to avoid errors when the index doesn't exist:
GQLDROP FULLTEXT IF EXISTS prodDesc
Use a full-text index in search conditions with the syntax WHERE ~<index name> CONTAINS "<search>":
~ symbol marks the full-text index.CONTAINS checks if the segmented tokens in the full-text index match the query.\) to escape.By default, multiple keywords separated by spaces are combined with AND (all must match). Additional operators are supported within the <search> string:
| Operator | Syntax | Description |
|---|---|---|
| AND | "graph database" | Default. Entries whose tokens include both graph and database. |
| OR | "graph OR database" | Entries whose tokens include graph or database (or both). |
| NOT | "-graph" | Entries whose tokens do not include graph. |
| Phrase | "\"graph database\"" | Entries whose tokens include graph followed immediately by database. |
| Proximity | "\"graph database\"~5" | Entries whose tokens include both graph and database within 5 token positions of each other. |
| Wildcard | "graph*" | Entries whose tokens start with graph (e.g., graph, graphics, graphdb). |
| Wildcard | "grap?" | Entries whose tokens match with ? as any single character (e.g., graph, grape). |
| Fuzzy | "graph~2" | Entries whose tokens are within N character edits of the term (N is 0–2, default 2). Catches typos: "graph~2" matches grph, garph, graphs. The exact term always matches and ranks highest, with closer edits ranked above farther ones. "graph~0" requires an exact match. |
| Grouped | "(graph OR network) AND database" | Entries matching the combined sub-expressions; parentheses control precedence. |
Notes on fuzzy matching (term~N):
"Einstien~1" matches Einstein and "teh~1" matches the.graph → hraph) is out of scope. This keeps fuzzy search fast on large term dictionaries; when the first character may vary, use a prefix search (graph*) instead.database is stored as its stem databas and edits count against that. Use the simple analyzer (no stemming) to fuzzy-match the surface word.Find nodes using the full-text index prodDesc where their tokens include graph and database:
GQLMATCH (n WHERE ~prodDesc CONTAINS "graph database") RETURN n
Find nodes using the full-text index prodDesc where their tokens include graph or database:
GQLMATCH (n WHERE ~prodDesc CONTAINS "graph OR database") RETURN n
Find edges using the full-text index reviewText where their tokens include graph and those start with ult:
GQLMATCH ()-[e WHERE ~reviewText CONTAINS "graph ult*"]-() RETURN e
Note: In path-returning queries, putting the inline form on the first node is the only supported placement.
GQLMATCH p = (WHERE ~prodDesc CONTAINS "graph")-[]-(n) RETURN p
To filter on any other element, run the full-text match in its own MATCH first, then build the path from the bound variable:
GQL-- ✗ not supported; uses full-text index on the last node MATCH p = ()-[]-(WHERE ~prodDesc CONTAINS "graph") RETURN p -- ✓ run the full-text match in its own MATCH first MATCH (n WHERE ~prodDesc CONTAINS "graph") MATCH p = ()-[]-(n) RETURN p -- ✗ not supported; uses full-text index on the edge MATCH p = ()-[WHERE ~reviewText CONTAINS "ult*"]-() RETURN p -- ✓ run the full-text match in its own MATCH first MATCH ()-[e WHERE ~reviewText CONTAINS "ult*"]-() MATCH p = ()-[e]-() RETURN p
The WHERE ~<index name> CONTAINS "<search>" form above is convenient as an inline filter inside MATCH, but for top-N ranked retrieval the ft.search procedure is the preferred entry point. It runs a BM25-ranked search over a named full-text index, and yields each matching node with its relevance score.
SyntaxCALL ft.search(<index name>, <search> [ , <options> ]) YIELD <column1>, <column2>, ...
Parameters:
| Parameter | Type | Default | Description | |
|---|---|---|---|---|
<index name> | STRING | — | Name of a full-text index. | |
<search> | STRING | — | The search string. Same syntax as the inline CONTAINS form, see Search Syntax. | |
<options>MAP | limit | INTEGER | 10 | Maximum number of ranked results to return. |
offset | INTEGER | 0 | Number of top-ranked results to skip (pagination). | |
minScore | FLOAT | 0 | Drop results whose BM25 score is below this floor. | |
operator | STRING | AND | Default combinator for the space-separated query clauses: AND (all clauses must match) or OR (any). Also applies to fuzzy (term~N) and wildcard clauses. An invalid value raises an error. | |
minShouldMatch | INTEGER | 0 | Require at least this many of the query clauses to match; overrides operator. 0 uses operator. | |
highlight | MAP | (off) |
Returns an additional highlight column containing a snippet of the matching text with the matched tokens wrapped in tags. Use it for search-result UIs, snippet previews, or to confirm visually which tokens the engine matched.
Nested keys:
| |
Return columns:
| Column | Type | Description |
|---|---|---|
node | NODE | The matching node, bindable downstream. |
score | FLOAT | BM25 relevance score; higher is more relevant. |
id | STRING | The matching node's _id. |
highlight | STRING | Highlighted fragment from the field named in highlight.field. Present only when highlighting is enabled. |
GQL-- Top 10 ranked results CALL ft.search('prodDesc', 'graph database') YIELD node, score RETURN node, score ORDER BY score DESC -- Paginated with relevance floor CALL ft.search('prodDesc', 'graph database', {limit: 20, offset: 20, minScore: 0.5}) YIELD node, score RETURN node.name, score -- Phrase and exclusion CALL ft.search('prodDesc', '"graph database" -deprecated', {limit: 10}) YIELD node, score RETURN node, score -- Match ANY of the terms (operator: OR) CALL ft.search('prodDesc', 'graph database nosql', {operator: 'OR', limit: 10}) YIELD node, score RETURN node, score ORDER BY score DESC -- Require at least 2 of the 3 terms (minShouldMatch overrides operator) CALL ft.search('prodDesc', 'graph database nosql', {minShouldMatch: 2}) YIELD node, score RETURN node, score ORDER BY score DESC -- Search then traverse CALL ft.search('prodDesc', 'graph database', {limit: 5}) YIELD node, score MATCH (node)-[:WROTE]-(a:Author) RETURN node.title, score, collect(a.name) AS authors ORDER BY score DESC -- With highlighted snippet CALL ft.search('prodDesc', 'graph database', {limit: 5, highlight: {field: 'description', preTag: '<mark>', postTag: '</mark>', fragmentSize: 150}}) YIELD node, score, highlight RETURN node._id, score, highlight
Tokenizes a string with a named analyzer and returns the resulting tokens with their positions. Use this to debug why a query term does or doesn't match indexed text. Common causes are stemming ('Running' → 'run'), stop-word removal ('the' dropped), or CJK segmentation that differs from what you expected.
SyntaxCALL ft.analyze(<analyzer>, <text>) YIELD <column1>, <column2>, ...
Parameters:
| Parameter | Type | Description |
|---|---|---|
<analyzer> | STRING | One of mixed, simple, cjk, gse. (pipeline is accepted as an alias for mixed.) |
<text> | STRING | Text to tokenize. Empty string yields zero rows. |
Return columns:
| Column | Type | Description |
|---|---|---|
token | STRING | A normalized token (after lower-casing, stemming, CJK segmentation). |
position | INTEGER | 0-based token position in the text. |
GQL-- English stemming under the default analyzer CALL ft.analyze('mixed', 'Running graphs') YIELD token, position RETURN token, position -- Same input, no stemming CALL ft.analyze('simple', 'Running graphs') YIELD token RETURN token -- CJK segmentation CALL ft.analyze('mixed', '北京大学') YIELD token, position RETURN token, position
Returns indexed terms that start with a given prefix, most frequent first. Suggestions come from the index's term dictionary, so they reflect the analyzed (lower-cased, stemmed, segmented) forms actually stored. A typical search box uses ft.suggest for the autocomplete dropdown to complete the word a user is typing.
SyntaxCALL ft.suggest(<index name>, <prefix> [ , <options> ]) YIELD <column1>, <column2>, ...
Parameters:
| Parameter | Type | Description |
|---|---|---|
<index name> | STRING | Name of a full-text index. |
<prefix> | STRING | The partial term typed so far. It is lower-cased to match indexed terms; an empty string returns the most frequent terms. |
<options> | RECORD | Optional. {limit: <n>} caps the number of suggestions (default 10, minimum 1). |
Return columns:
| Column | Type | Description |
|---|---|---|
suggestion | STRING | A matching indexed term. |
docFreq | INTEGER | Number of documents containing the term (popularity). |
GQL-- Autocomplete: terms starting with 'grap', most popular first CALL ft.suggest('prodDesc', 'grap', {limit: 5}) YIELD suggestion, docFreq RETURN suggestion ORDER BY docFreq DESC -- Works script-agnostically: a CJK prefix returns the segmented terms that start with it CALL ft.suggest('zhDesc', '社', {limit: 5}) YIELD suggestion, docFreq RETURN suggestion ORDER BY docFreq DESC
Releases a full-text index's in-memory structure (its posting lists, usually the dominant part of the index's memory) back to the operating system. The index stays registered and its on-disk data intact, so it remains searchable; the next search, or a ft.load call, streams it back into memory. SHOW FULLTEXT then reports the index with status = unloaded. This is useful for deployments with many large but rarely searched indexes.
SyntaxCALL ft.unload(<index name>) YIELD <column>
Parameters:
| Parameter | Type | Description |
|---|---|---|
<index name> | STRING | Name of a full-text index (see SHOW FULLTEXT). |
Return columns:
| Column | Type | Description |
|---|---|---|
result | STRING | Outcome message. |
GQL-- Release a rarely-used index's memory CALL ft.unload('prodDesc') YIELD result RETURN result
Loads (prewarms) a full-text index's in-memory structure and waits until it is ready to search. Use it to pay the load cost at a chosen moment rather than on the first user query, for example to restore the fast in-memory path after a ft.unload, or to warm an index before a query burst.
SyntaxCALL ft.load(<index name> [ , <timeoutMs> ]) YIELD <column>
Parameters:
| Parameter | Type | Description |
|---|---|---|
<index name> | STRING | Name of a full-text index (see SHOW FULLTEXT). |
<timeoutMs> | INTEGER | Optional. Maximum time to wait for the load, in milliseconds (default 60000, minimum 1). |
Return columns:
| Column | Type | Description |
|---|---|---|
result | STRING | Outcome message. |
GQL-- Prewarm before a query burst CALL ft.load('prodDesc') YIELD result RETURN result