Full-Text Search
patinaDB supports user-defined full-text indexes with BM25 ranking, using Neo4j-compatible syntax. You create an index over chosen string properties of a label (nodes) or relationship type (edges), then query it through the full-text procedures, getting back ranked entities and relevance scores.
Creating an index
-- Over node properties
CREATE FULLTEXT INDEX docs FOR (n:Doc) ON EACH [n.title, n.body]
-- Over relationship properties
CREATE FULLTEXT INDEX mentions FOR ()-[r:MENTIONS]-() ON EACH [r.context]
-- With an analyzer
CREATE FULLTEXT INDEX articles FOR (n:Article) ON EACH [n.body]
OPTIONS { indexConfig: { `fulltext.analyzer`: "english" } }
- Only string properties are indexed (matching Neo4j). Non-string values on the listed properties are ignored.
- The index is maintained synchronously: it is updated as part of every committed write, so it is never stale on read (no eventual consistency).
Analyzers
The analyzer controls tokenization and stemming:
| Analyzer | Behaviour |
|---|---|
standard | Lowercase, tokenize on non-alphanumerics. (Default.) |
keyword | Treat the whole value as a single token (exact-match indexing). |
english | Standard + English Snowball stemming + English stop words. |
german | Standard + German Snowball stemming + German stop words. |
Managing indexes
SHOW FULLTEXT INDEXES
DROP INDEX docs
DROP FULLTEXT INDEX <name> and DROP INDEX <name> drop a full-text index.
An unknown name is an error; add IF EXISTS for an explicit no-op when absent.
Malformed trailing text is rejected without deleting the index. Registered
ordinary indexes take precedence for the shorthand DROP INDEX <name>;
DROP INDEX ON :Label(prop) remains the ordinary-index form.
Querying
CALL db.index.fulltext.queryNodes('docs', 'graph database')
YIELD node, score
RETURN node, score ORDER BY score DESC
CALL db.index.fulltext.queryRelationships('mentions', '"exact phrase"')
YIELD relationship, score
RETURN relationship, score
The alias patinadb.fulltext.queryNodes / …queryRelationships is
equivalent. Results are ranked by BM25 (k1 = 1.2, b = 0.75).
Query syntax (Lucene subset)
| Form | Example | Meaning |
|---|---|---|
| Term | graph | Documents containing the term. |
| Implicit OR | graph database | Adjacent terms are OR’d. |
AND / OR / NOT | graph AND NOT sql | Boolean operators; AND binds tighter than OR. |
| Phrase | "graph database" | Terms in that exact order (consecutive positions). |
| Prefix | data* | Terms starting with data. |
| Fuzzy | databse~, db~2 | Edit-distance match (default / explicit distance). |
| Boost | graph^3 database | Multiply a term’s contribution to the score. |
| Field | title:graph | Restrict a term to one indexed property. |
| Grouping | (graph OR sql) AND db | Parenthesised sub-expressions. |
Prefix and fuzzy queries expand against the index dictionary and are capped at 256 expansions per term to bound cost.
In the server
CREATE FULLTEXT INDEX / DROP INDEX are replicated as Raft control
commands (like CREATE DATABASE): the definition propagates to every node and
each node builds the index from its own copy of the graph. Index definitions are
carried in Raft snapshots and rebuilt on snapshot install, so a node that joins
or restarts ends up with the same indexes. Query procedures read the local
node’s index.
This works end-to-end over Bolt, including the Neo4j Browser: create an index
and run queryNodes, and matching nodes come back as graph nodes with scores.
Limitations
Measured Jira-summary workload (0.17.0)
On 334,770 real Apache Jira summaries, standard analyzer, one indexed summary property, local Ryzen 7 3800X/NVMe, the 2026-09-10 measurement under #1261 found:
- Seventeen tested term/boolean/phrase/prefix/fuzzy/project-filtered forms had warm Bolt Top-10/20 p90 between 2.173 and 63.868 ms (20 samples per cell).
- A short 2 reads/s plus 0.1 summary updates/s run had read p90 66.816 ms and p99 122.826 ms; updates, batch changes, graceful reopen and deletion passed search-visibility checks. Exact hit sets and single-term BM25 scores were independently checked.
- Server high-water RSS reached 378.957 MiB through the batch phase; the standalone build peaked at 464.113 MiB. These exclude the Python oracle.
- Initial construction took 7.950 seconds, versus 1727.563 seconds in the retained 0.16.0 baseline: 217.31 times faster on this fixture (#1261). Allocated database-directory size after building fell from 1375772672 to 477433856 bytes; this is the whole directory, not posting-only storage.
These are summary-only, single-node measurements, not 10M-ticket, multi-region,
long-soak, ticket-body/comment or other-analyzer guarantees. Full methodology and
raw artifact locations are recorded in the repository’s
docs/fulltext-benchmark-1261.md (#1261).
Construction and resource use
Initial construction and reconstruction stage postings into sorted runs and write each term’s final list once. The existing format and synchronous DDL boundary remain unchanged; updates after construction still use per-document maintenance. Existing indexes remain compatible without rebuilding.
The builder uses the sort-run byte setting
and private temporary files under TMPDIR. Budget additional memory for merge
readers, the largest posting list, serialization and database caches; the run
buffer cap is not a total-RAM ceiling. Scratch files are removed on ordinary
completion/error/cancellation, but a hard process kill can leave temporary files.
Feature boundaries
- No phrase slop / proximity (
"a b"~3) — phrases must be exactly consecutive. - No highlighting / snippet extraction.
- No numeric or range queries inside the full-text string (full-text indexes cover string properties only).
- Postings are updated read-modify-write per document with no segment merging, so very high write throughput on a large indexed corpus is not the design target. See Limitations.