Full-text search#
Overview#
AgensGraph is built on PostgreSQL and inherits its full-text search engine, so you can run linguistic keyword search directly over text stored in graph properties. Full-text search normalizes documents and queries into lexemes (stemming, stop-word removal, case folding). That makes it well suited to lexical retrieval: matching the words a user typed.
It is the natural complement to vector search, which matches by meaning. Hybrid Search covers combining the two. All three pages use the same movies, so you can compare the results directly.
A graph property is stored as jsonb, so a text property is cast to text (with ::text) before being converted to a tsvector.
Example data#
The examples use the example dataset: 18 films, each
with a plot and a four-dimensional embedding of that plot. Load it before going further.
Real embeddings come from a model, such as text-embedding-3-small or a local
sentence-transformers model, and they have hundreds of dimensions. These are reduced to four
so the geometry stays readable on the page.
Searching a text property#
Convert the property to a tsvector with to_tsvector, parse the user’s input with one of
the *_to_tsquery functions, and match them with the @@ operator. websearch_to_tsquery
accepts a forgiving, web-style syntax: quoted phrases, OR, and - to exclude a term. That
makes it a good default for user input.
MATCH (m:movie)
WHERE to_tsvector('english', m.plot::text) @@ websearch_to_tsquery('english', 'machines')
RETURN m.title AS title;
title
----------------------------
"The Matrix"
"The Matrix Reloaded"
"The Matrix Revolutions"
"The Matrix Resurrections"
(4 rows)
The query is matched against lexemes, not raw text. machines is reduced to the stem
machin, so the singular machine would match exactly the same rows.
Look also at what is not returned. Total Recall and The Terminator are thematically about machine-dominated futures, but their plots never use the word, so keyword search cannot find them. That recall gap is exactly where vector search helps.
Other query parsers are available depending on the input you expect:
Function |
Input style |
|---|---|
|
Web-style: |
|
Plain text, all terms |
|
Explicit operators: |
Ranking results#
ts_rank scores how well each document matches the query, so results can be ordered by
relevance. A plot that matches more of the query terms ranks higher:
MATCH (m:movie)
WHERE to_tsvector('english', m.plot::text) @@ websearch_to_tsquery('english', 'machines OR reality')
RETURN m.title AS title,
ts_rank(to_tsvector('english', m.plot::text),
websearch_to_tsquery('english', 'machines OR reality')) AS rank
ORDER BY rank DESC;
title | rank
----------------------------+-------------
"The Matrix" | 0.06079271
"The Matrix Reloaded" | 0.030396355
"The Matrix Revolutions" | 0.030396355
"The Matrix Resurrections" | 0.030396355
(4 rows)
The Matrix ranks above its sequels because its plot contains both machines and
reality, while the sequels match only machines.
Exact-term matching#
Full-text search is the right tool when the query is a specific term that must match exactly: a name, a code, a rare keyword. Asked for the film about dinosaurs, it returns precisely one:
MATCH (m:movie)
WHERE to_tsvector('english', m.plot::text) @@ websearch_to_tsquery('english', 'dinosaurs')
RETURN m.title AS title;
title
-----------------
"Jurassic Park"
(1 row)
Vector search has no notion of the literal word dinosaurs. Asked for the films
most similar to Jurassic Park, it returns The Avengers as the nearest neighbor. Both are
large-scale spectacle blockbusters, a reasonable guess by meaning, but not what someone
typing “dinosaurs” wants:
MATCH (m:movie), (q:movie {title: 'Jurassic Park'})
RETURN m.title AS title
ORDER BY m.embedding::vector(4) <=> q.embedding::vector(4)
LIMIT 3;
title
-----------------
"Jurassic Park"
"The Avengers"
"Interstellar"
(3 rows)
So the two methods have opposite blind spots. Full-text search is precise but misses synonyms and intent, while vector search captures meaning but drifts on exact terms. Hybrid Search runs both and fuses the results to get the strengths of each.
Indexing with GIN#
Recomputing to_tsvector for every row on each search does not scale. Create a GIN index on
the same to_tsvector expression so matches are found through the index. The indexed value is
an expression over a property, so use a property index and wrap the expression in
parentheses:
CREATE PROPERTY INDEX ON movie
USING gin ((to_tsvector('english', plot::text)));
The search then uses the index instead of scanning every row:
EXPLAIN
MATCH (m:movie)
WHERE to_tsvector('english', m.plot::text) @@ websearch_to_tsquery('english', 'machines')
RETURN m.title;
Bitmap Heap Scan on movie m
Recheck Cond: (to_tsvector('english'::regconfig, (properties.'plot'::text)::text) @@ '''machin'''::tsquery)
-> Bitmap Index Scan on movie_plot_idx
Index Cond: (to_tsvector('english'::regconfig, (properties.'plot'::text)::text) @@ '''machin'''::tsquery)
The text search configuration used at query time ('english' above) must match the one in
the index expression for the index to be used.
On this small demo the planner may still prefer a sequential scan because it is cheaper for so few rows; add
SET enable_seqscan = offto observe the index plan. On a real corpus the planner chooses the index automatically.
GIN property indexes over a
to_tsvector(...)expression require AgensGraph 2.17 or later.
Indexing a promoted column#
From 2.18 a property can be promoted to a real typed
column, which removes the per-row jsonb read and the
::text cast. ALTER VLABEL movie ADD COLUMN plot text GENERATED mirrors the property named
by the column, here plot; GENERATED (sourceKey) mirrors a differently named key. Queries
keep naming the property, never the column — resolution is by property key.
The index on this page needs rebuilding when you promote, because it is an expression over the property. A property index over an expression is built against the jsonb bag, and a query over a promoted key reads the column, so the two would no longer meet.
Write this one as SQL over the column. \dGi still lists it, and the query uses it:
DROP PROPERTY INDEX movie_plot_idx;
ALTER VLABEL movie ADD COLUMN plot text GENERATED;
CREATE INDEX movie_plot_idx ON moviekb.movie USING gin (to_tsvector('english', plot));
EXPLAIN (COSTS OFF)
MATCH (m:movie)
WHERE to_tsvector('english', m.plot) @@ websearch_to_tsquery('english', 'machines')
RETURN m.title;
Bitmap Heap Scan on movie m
Recheck Cond: (to_tsvector('english'::regconfig, plot) @@ '''machin'''::tsquery)
-> Bitmap Index Scan on movie_plot_idx
Index Cond: (to_tsvector('english'::regconfig, plot) @@ '''machin'''::tsquery)
m.plot needs no ::text now: the promoted column is already text. See An expression
property index is built over the
bag.
GraphRAG#
Full-text search can also seed a graph traversal. Find the movies whose text best matches the question, then expand along relationships to gather connected context for a language model.
This is the lexical counterpart to the GraphRAG example in Vector Search. It shines when the question contains specific terms (names, codes, exact phrases) that keyword search handles better than embeddings.
The example dataset already carries related_to edges
between films: franchise sequels and thematically linked titles. So the traversal below has
something to follow.
A single Cypher query ranks the movies by full-text relevance to the question (here the term
resistance), keeps the best matches, and expands them to their neighbors:
MATCH (seed:movie)
WHERE to_tsvector('english', seed.plot::text) @@ websearch_to_tsquery('english', 'resistance')
WITH seed, ts_rank(to_tsvector('english', seed.plot::text),
websearch_to_tsquery('english', 'resistance')) AS rank
ORDER BY rank DESC, seed.title
LIMIT 2
MATCH (seed)-[:related_to]-(ctx:movie)
RETURN seed.title AS seed, collect(DISTINCT ctx.title) AS related_context;
seed | related_context
------------------------------+----------------------------------------------
"Terminator 2: Judgment Day" | ["The Terminator"]
"The Terminator" | ["Terminator 2: Judgment Day", "The Matrix"]
(2 rows)
The WHERE filter and WITH … ORDER BY rank LIMIT select the movies whose text best matches
the query. Those are the seeds, here the two Terminator films. The second MATCH follows
related_to edges to gather related titles.
The seeds plus their neighbors form the context passed to the model. Notice that the traversal
reaches The Matrix. The keyword resistance never matched that film, but it is linked to
the Terminator films by their shared war-against-machines theme. To combine this lexical
retrieval with semantic (vector) retrieval before expanding, see Hybrid Search.
For the same retrieval through a framework rather than by hand, see AI and LLM integrations.