Property lookups and CONTAINS filters only go so far. When users type free text, with typos, partial words, and multiple fields, you need real search relevance. Neo4j ships full-text indexes backed by Apache Lucene, so you can rank matches and then traverse the graph from the best hits, without adding a separate search cluster.
Prerequisites
- A running Neo4j 5.x or 2025+ instance (see our Docker setup tutorial)
- Sample data:
Productnodes withnameanddescriptionproperties, linked toBrandandCategorynodes
1. Create a full-text index
CREATE FULLTEXT INDEX productText IF NOT EXISTS
FOR (p:Product) ON EACH [p.name, p.description]
OPTIONS { indexConfig: { `fulltext.analyzer`: 'english', `fulltext.eventually_consistent`: false } }
A full-text index can span several labels and properties, for example FOR (n:Product|Brand) ON EACH [n.name]. Wait for it to come online before querying:
SHOW FULLTEXT INDEXES YIELD name, state, populationPercent
2. Query the index
CALL db.index.fulltext.queryNodes('productText', 'wireless headphones')
YIELD node, score
RETURN node.name AS name, score
ORDER BY score DESC
LIMIT 10
The score is a Lucene relevance score. It is only comparable within a single query, so don't store it or compare it across different queries.
3. Choose an analyzer deliberately
The analyzer decides how text is tokenized at index time and at query time. List what is available:
CALL db.index.fulltext.listAvailableAnalyzers()
YIELD analyzer, description
RETURN analyzer, description
Rules of thumb:
standard-no-stop-words: a safe default that keeps words like "the" and "not".english(or another language analyzer): stems words, so "running" matches "run". Good for prose, risky for product codes and names.keyword: treats the whole value as one token. Use it for SKUs and identifiers.simpleorwhitespace: cheap, predictable tokenizing when you don't want stemming.
Changing the analyzer means dropping and recreating the index, so test it on a copy of your data first.
4. Lucene query syntax: fuzzy, phrase, boost
The query string is parsed as Lucene syntax, which gives you a lot of control:
// typo tolerance: edit distance of up to 1
CALL db.index.fulltext.queryNodes('productText', 'headfones~1') YIELD node, score
RETURN node.name, score LIMIT 5;
// exact phrase
CALL db.index.fulltext.queryNodes('productText', '"noise cancelling"') YIELD node, score
RETURN node.name, score LIMIT 5;
// boost the name field over description, require one term, exclude another
CALL db.index.fulltext.queryNodes('productText', 'name:headphones^3 OR description:headphones +bluetooth -refurbished')
YIELD node, score
RETURN node.name, score LIMIT 5;
5. Never pass raw user input
Characters such as + - && || ! ( ) { } [ ] ^ " ~ * ? : \ / are Lucene operators, and unescaped input will throw a parse error or change the meaning of the query. Escape them in your application before sending the query as a parameter:
import re
LUCENE_SPECIAL = r'([+\-&|!(){}\[\]^"~*?:\\/])'
def to_fuzzy_query(user_text: str) -> str:
cleaned = re.sub(LUCENE_SPECIAL, r'\\\1', user_text.strip())
terms = [t for t in cleaned.split() if t]
# fuzzy on every term longer than 3 characters
return ' '.join(f'{t}~1' if len(t) > 3 else t for t in terms)
records, _, _ = driver.execute_query(
"""
CALL db.index.fulltext.queryNodes('productText', $q) YIELD node, score
RETURN node.name AS name, score ORDER BY score DESC LIMIT 10
""",
q=to_fuzzy_query(user_input),
)
6. Combine relevance with the graph
This is where search in Neo4j beats a standalone engine. Take the top text matches, then use relationships to filter, re-rank, or enrich:
CALL db.index.fulltext.queryNodes('productText', $q) YIELD node AS p, score
WITH p, score ORDER BY score DESC LIMIT 50
MATCH (p)-[:MADE_BY]->(b:Brand)
OPTIONAL MATCH (p)<-[r:PURCHASED]-(:Customer)
WITH p, b, score, count(r) AS purchases
RETURN p.name AS product, b.name AS brand,
score * (1 + log10(1 + purchases)) AS rank
ORDER BY rank DESC
LIMIT 10
Cap the text-match stage with LIMIT before traversing. That keeps the expensive part bounded, however common the search term is.
7. Operational notes
- Consistency: by default, full-text indexes are updated at commit. Setting
fulltext.eventually_consistenttotruespeeds up write-heavy loads, but searches can briefly miss recent writes. - Verify the plan: use
PROFILEon the combined query and confirm the traversal starts from the index results, not a label scan. - Full-text vs. vector: full-text wins on exact terms, names, and codes. Vector search wins on meaning. For many applications a hybrid of both works best, and the GraphRAG tutorial shows a hybrid retriever.
Checklist
- Pick the analyzer per field type: language for prose,
keywordfor identifiers. - Escape user input and add fuzziness in code.
- Limit text hits first, then traverse.
- Boost important fields instead of post-filtering.
- Re-test relevance with a fixed set of real queries after any index change.
Need help designing search, relevance, or hybrid retrieval on a Neo4j graph? Get in touch and our senior Neo4j developers can help.