Was this page helpful?
Working with Full Text Search¶
Full text search retrieves rows by matching the terms in a query against the text stored in a column, and ranks the matches by relevance using the BM25 algorithm. It is the right tool when the exact words matter — searching articles, product descriptions, support tickets, or log messages.
Full text search is one half of ScyllaDB’s Vector and Text Search feature. It runs on the same indexing nodes as vector search and is enabled by the same deployment steps, so everything in Vector and Text Search Deployments applies here as well.
Note
Full text search requires ScyllaDB 2026.3.0 or later. Vector search is available on clusters running ScyllaDB 2025.4.3 or later. See Feature Compatibility Matrix.
Prerequisites¶
A cluster with Vector and Text Search enabled, running ScyllaDB 2026.3.0 or later. See Creating a New Cluster with Vector and Text Search Enabled to create one, or Enabling Vector and Text Search on an Existing Cluster to add indexing nodes to a cluster you already have.
cqlsh, the web-based CQL console in the ScyllaDB Cloud UI, or any CQL driver.
Workflow¶
Create a table with a
text,varchar, orasciicolumn holding the text you want to search.Create a full-text index on that column.
Wait for the index to finish building.
Run
BM25()queries against the indexed column.
Creating a Full-Text Index¶
Create a full-text index with CREATE CUSTOM INDEX and the
fulltext_index class:
CREATE KEYSPACE blog
WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': 3};
CREATE TABLE blog.articles (
id int PRIMARY KEY,
author text,
body text
);
CREATE CUSTOM INDEX articles_body_fts ON blog.articles(body)
USING 'fulltext_index';
IF NOT EXISTS is supported, and the index name may be omitted — ScyllaDB
then derives one from the table and column names, such as
articles_body_idx.
Use DESCRIBE INDEX to inspect an existing index:
DESCRIBE INDEX blog.articles_body_fts;
Which Columns Can Be Indexed¶
A full-text index can only be created on a text-typed column:
Column |
Supported |
|---|---|
|
Yes. |
Clustering key column |
Yes. |
Partition key column |
No. ScyllaDB rejects the statement with |
Any non-text type, such as |
No. ScyllaDB rejects the statement with |
A single table can carry several full-text indexes, as long as each one targets a different column. A full-text index and a vector index can also coexist on the same table — see Combining Full Text and Vector Search.
Index Options¶
Two options control how the index treats text. Both are set at creation time
with WITH OPTIONS and cannot be changed afterwards:
Option |
Default |
Description |
|---|---|---|
|
|
How text is split into terms and normalized. See Choosing an Analyzer. |
|
|
Whether token positions are stored. Phrase queries need them: an
index created with |
CREATE CUSTOM INDEX articles_body_fts ON blog.articles(body)
USING 'fulltext_index'
WITH OPTIONS = {'analyzer': 'english'};
Option values are case-insensitive, so 'english' and 'ENGLISH' are
equivalent. An unknown option, or an unsupported value, is rejected when the
index is created:
Invalid value in option 'analyzer' for fulltext index: 'klingon'. Supported
are case-insensitive: standard, english, german, french, spanish, italian,
portuguese, russian, simple, whitespace
Invalid value in option 'positions' for fulltext index: 'sometimes'.
Supported are case-insensitive: false, true
Unsupported option nonsense for fulltext index
There is no ALTER INDEX statement in CQL, so changing either option means
dropping the index and creating it again. The replacement index is rebuilt from
the base table, and queries against the column fail until it is serving again.
Index Building¶
Full-text indexes are populated through Change Data Capture, exactly like vector indexes, so the same requirements apply: the keyspace must use tablets and CDC is enabled automatically on the base table. See Tablets Requirement and CDC Requirements.
After you create the index, the indexing node discovers it and scans the base table. Queries fail until that scan completes:
# Immediately after CREATE, before the indexing node has picked up the index
Vector Store error: HTTP status 404 Not Found, message: missing index: blog.articles_body_fts
# While the base table is being scanned
Vector Store error: HTTP status 503 Service Unavailable, message: Index
blog.articles_body_fts is not available yet as it is still being constructed,
progress: 39.804%
Retry until the query succeeds. Build time scales with the number of rows and the size of the indexed text.
Note
Vector Store in these messages refers to the indexing node that
serves both vector and full-text indexes. The messages are reproduced here
as the server emits them.
Running Full Text Search Queries¶
A full text search query has exactly one valid shape:
SELECT <columns> FROM <table>
WHERE BM25(<column>, '<query>') > 0
ORDER BY BM25(<column>, '<query>')
LIMIT <n>;
For example:
SELECT id, body FROM blog.articles
WHERE BM25(body, 'photosynthesis') > 0
ORDER BY BM25(body, 'photosynthesis')
LIMIT 10;
Every part of that shape is mandatory, and ScyllaDB rejects any deviation. The rules are:
Rule |
Error when violated |
|---|---|
The query must include |
|
The query must include a |
|
|
|
The search term must be identical in |
|
The comparison value must be the literal |
|
The only supported operator is |
|
|
|
No other |
|
Only one |
|
|
|
The targeted column must have a full-text index. |
|
The query must include a |
|
|
|
|
|
Aggregate functions and |
|
|
|
ORDER BY BM25() does not accept ASC or DESC; results always come
back with the highest relevance first.
Note
Because a full text search query cannot carry any additional WHERE
restriction, it cannot be narrowed to a partition or filtered on another
column. Retrieve the top matches first, then apply any further filtering in
your application.
Result Size and Paging¶
LIMIT is capped at 1000, and full text search results are never paged —
the server always returns the whole result set in a single response. If the
result set is larger than the page size your driver requested, ScyllaDB
returns all of the rows anyway and attaches a warning to the response:
Paging is not supported for Full-Text Search queries. The entire result set
has been returned.
Size the LIMIT to what your application will actually use, since every
matching row is transferred at once. To reach beyond 1000 matches, narrow the
query with additional terms rather than trying to page through it.
Text Query Language¶
The string you pass to BM25() is written in its own text query language —
it is not CQL, and it is not treated as a literal phrase. The following
constructs are supported.
Terms¶
A bare term matches rows whose indexed text contains that term. Under the
default standard analyzer — and every analyzer except whitespace —
matching is case-insensitive, because both the stored text and the query are
lowercased:
-- These two queries return the same rows.
SELECT id FROM blog.articles
WHERE BM25(body, 'photosynthesis') > 0
ORDER BY BM25(body, 'photosynthesis') LIMIT 10;
SELECT id FROM blog.articles
WHERE BM25(body, 'PHOTOSYNTHESIS') > 0
ORDER BY BM25(body, 'PHOTOSYNTHESIS') LIMIT 10;
With the whitespace analyzer, which lowercases nothing, the two queries
match different rows. See Choosing an Analyzer.
Several terms separated by whitespace are combined with an implicit OR,
so a row matching any one of them is returned — jupiter saturn is
equivalent to jupiter OR saturn. Rows matching more of the terms rank
higher. Use an explicit AND when every term must be
present.
Boolean Operators¶
AND, OR, and NOT combine terms, and parentheses group them.
Important
Operators must be uppercase. Unlike CQL, which is case-insensitive, the
text query language only recognizes AND, OR, and NOT in upper
case. A lowercase and, or, or not is not an operator — it is
treated as an ordinary search term. Under standard and english it is
then dropped as a stop word; under simple, whitespace, and the
non-English language analyzers it stays in the query as a term to match.
Either way the effect is the same and it fails silently: the terms end up
joined by the implicit OR, so database and distributed does not
narrow anything — it returns the same rows as
database OR distributed.
-- Rows that contain both terms.
SELECT id FROM blog.articles
WHERE BM25(body, 'database AND distributed') > 0
ORDER BY BM25(body, 'database AND distributed') LIMIT 10;
-- Rows that contain either term.
SELECT id FROM blog.articles
WHERE BM25(body, 'jupiter OR saturn') > 0
ORDER BY BM25(body, 'jupiter OR saturn') LIMIT 10;
-- Rows that contain the first term but not the second.
SELECT id FROM blog.articles
WHERE BM25(body, 'python NOT snake') > 0
ORDER BY BM25(body, 'python NOT snake') LIMIT 10;
-- Grouping.
SELECT id FROM blog.articles
WHERE BM25(body, '(jupiter OR saturn) AND planet NOT rings') > 0
ORDER BY BM25(body, '(jupiter OR saturn) AND planet NOT rings') LIMIT 10;
Phrases¶
Double quotes match a sequence of adjacent terms in order. Because the CQL string itself is single-quoted, the double quotes go inside it:
SELECT id FROM blog.articles
WHERE BM25(body, '"theory of relativity"') > 0
ORDER BY BM25(body, '"theory of relativity"') LIMIT 10;
Without the quotes the words become independent terms combined with an
implicit OR, so word order and adjacency stop mattering —
theory of relativity also matches text that says relativity theory,
while the quoted phrase does not.
Phrase matching relies on the positions option, which is on by default. An
index created with 'positions': 'false' rejects phrase queries — see
Phrase Queries and the positions Option.
How Text Is Analyzed¶
The index applies the same analysis to the stored text when it is indexed and to your query when it is parsed — which is why the two always agree on what a term is. Analysis is made up of up to four steps:
Step |
Behavior |
|---|---|
Tokenization |
Splits text into terms. Every analyzer except |
Lowercasing |
Makes matching case-insensitive, so |
Stop words |
Drops very common words that carry little search signal. Performed by
|
Stemming |
Reduces words to a common root so that different inflections of the
same word match each other, such as |
Which of those steps run — and in which language — is set by the analyzer.
Choosing an Analyzer¶
The analyzer option selects the pipeline. The default, standard, is
English-only: it removes English stop words and does no stemming at all,
which makes it a reasonable choice for English text that should match on exact
word forms — and a poor one for any other language. For text in another
language, pick that language’s analyzer:
Analyzer |
Splits on |
Lowercases |
Stop words |
Stemming |
|---|---|---|---|---|
|
punctuation |
Yes |
English |
No |
|
punctuation |
Yes |
None |
No |
|
whitespace only |
No |
None |
No |
|
punctuation |
Yes |
that language |
that language |
Pick by what you need:
standard— the default. Exact word forms, English stop words removed.- A language analyzer — when you want different inflections of a word to match each other. With
english, a search forrunmatches running and runners, andwalkmatches walks:CREATE CUSTOM INDEX articles_body_fts ON blog.articles(body) USING 'fulltext_index' WITH OPTIONS = {'analyzer': 'english'};
simple— likestandardbut keeps stop words, so a query for a common word such asthecan still match.whitespace— no normalization at all beyond splitting on whitespace. Case and punctuation are preserved, soQuickmatches only Quick, andhellodoes not match hello, — the stored term includes the comma. Use it for text where case or symbols are meaningful, such as identifiers or codes.
Important
Choose the analyzer that matches the language of your text. The default
standard analyzer uses English stop words and does no stemming, so
on German text it indexes die (not an English stop word) and does not
match lauf against laufen. The german analyzer drops die and
stems laufen to lauf.
Because the analyzer is fixed for the life of the index, and because changing it means a full rebuild, it is worth deciding before you load data.
Phrase Queries and the positions Option¶
Phrase queries match on the recorded position of each
term, so they need the positions option, which is on by default. Setting it
to false makes the index smaller, at the cost of no longer being able to
answer phrase queries:
CREATE CUSTOM INDEX articles_body_fts ON blog.articles(body)
USING 'fulltext_index'
WITH OPTIONS = {'positions': 'false'};
Term and boolean queries work as usual against such an index, but a phrase query is rejected rather than silently returning nothing:
Vector Store error: HTTP status 400 Bad Request, message: index.bm25 request
error: fts: failed to parse query: The field 'body' does not have positions
indexed
Leave positions at its default unless you have measured the memory saving
and know that no query will ever need a phrase.
Unsupported Query Syntax¶
Fuzzy matching (term~1) and prefix or wildcard matching (term*) are
parsed without error but match nothing. Do not rely on them.
Some characters are meaningful to the query parser and make the query fail rather than match literally:
Input |
Result |
|---|---|
|
Read as a field separator. |
|
Grouping. Balanced parentheses are valid syntax, but an unbalanced one
is rejected with |
|
Rejected with |
A bare |
Rejected with |
Of the ASCII punctuation characters, exactly the eleven in "'():[]^`{} are
rejected when they appear inside a term. The rest are passed through to the
analyzer, which splits on them.
Warning
Passing raw end-user input straight into BM25() can fail on these
characters. Using a bound parameter protects you from CQL injection, but the
value is still parsed as a query expression — so strip or replace the
characters above before searching. See Using Full Text Search from a Driver.
Relevance Ranking¶
Matches are ordered by their BM25 score, highest first. BM25 scores a row on how often the query terms occur in it, offset by how common those terms are across the whole index and by the length of the indexed text: a term that appears in nearly every row contributes little, and a short row that mentions the term as often as a long one scores higher.
Scores are relative to the current contents of the index and are not
exposed — BM25() cannot appear in the SELECT list, so you get the
ordering rather than the numbers.
Note
Returning BM25 relevance scores in query results is planned for ScyllaDB 2026.4.0.
Write-to-Query Latency¶
Inserts, updates, and deletes reach the full-text index asynchronously through CDC, so a write becomes searchable a short time after it is acknowledged — expect a few seconds. Deleting a row removes it from the index, and updating an indexed column replaces the old text, so the previous terms stop matching.
If a query must reflect a write immediately, read the row by primary key instead.
Warning
Cell-level TTL leaves stale entries in the index. Expiration through
USING TTL on an INSERT or UPDATE, or through the
default_time_to_live table property, does not generate a CDC event. The
value becomes unreadable at its deadline but the indexing node never hears
about it, so the index keeps an entry for the expired row.
Query results stay correct — ScyllaDB filters the expired rows out — but
the stale entries still occupy memory and, more visibly, consume candidate
slots against your LIMIT. A query may then return fewer rows than both
the LIMIT and the number of live matches, and where expired rows
outnumber live ones it may return only a small fraction of them.
Use per-row TTL instead. It stores an absolute expiration time in a dedicated column, and ScyllaDB deletes the row explicitly when that time passes, which does emit a CDC event and does remove the entry from the index. See Cell-Level TTL Is Not Supported.
Combining Full Text and Vector Search¶
A table can carry both a full-text index and a vector index, which is what makes hybrid search possible:
CREATE TABLE blog.docs (
id int PRIMARY KEY,
body text,
embedding vector<float, 3>
);
CREATE CUSTOM INDEX docs_body_fts ON blog.docs(body) USING 'fulltext_index';
CREATE INDEX docs_embedding_ann ON blog.docs(embedding) USING 'vector_index';
The two indexes are queried separately — BM25() and ANN OF cannot
appear in the same statement:
-- Term matching.
SELECT id, body FROM blog.docs
WHERE BM25(body, 'apple') > 0
ORDER BY BM25(body, 'apple') LIMIT 10;
-- Semantic similarity.
SELECT id, body FROM blog.docs
ORDER BY embedding ANN OF [1.0, 0.1, 0.0] LIMIT 10;
To build hybrid search, run both queries and merge the two result sets in your application, for example with Reciprocal Rank Fusion, which combines the rank of each row in each list without needing comparable scores.
Note
Native hybrid search — combining term matching and vector similarity in a single query, with the fusion done by the database — is planned for ScyllaDB 2026.4.0.
Using Full Text Search from a Driver¶
Full text search needs no driver-side support beyond ordinary CQL. Prepared
statements accept bind markers for the search term — in both the WHERE
and ORDER BY positions — and for the LIMIT.
The example below assumes an open session. Connecting to a ScyllaDB Cloud
cluster requires authentication, TLS, and a datacenter-aware load balancing
policy — see Driver Examples for a complete connection
example.
search = session.prepare(
"SELECT id, body FROM articles "
"WHERE BM25(body, ?) > 0 "
"ORDER BY BM25(body, ?) "
"LIMIT ?"
)
def full_text_search(query, limit=10):
# The same term has to be bound to both markers; the server rejects the
# statement if the two values differ.
return session.execute(search, (query, query, limit))
for row in full_text_search("database AND distributed"):
print(row.id, row.body)
Because the bound value is parsed as a query expression, sanitize end-user input before passing it in — see Unsupported Query Syntax. To search for the words a user typed without interpreting them as query syntax, keep only word characters and whitespace, then drop anything the parser would read as an operator:
import re
# Keep letters, digits and underscore; replace every other character with a
# space. A whitelist is safer here than a list of reserved characters: it
# cannot fall behind the parser, and it cannot leave an unbalanced
# parenthesis or a stray operator behind.
NON_WORD = re.compile(r"[^\w\s]", re.UNICODE)
OPERATORS = {"AND", "OR", "NOT"}
def as_terms(user_input):
"""Reduce free-form input to a list of plain, safe search terms."""
cleaned = NON_WORD.sub(" ", user_input)
return [term for term in cleaned.split() if term not in OPERATORS]
terms = as_terms(user_input)
if terms:
# Whitespace joins the terms with an implicit OR, which keeps the search
# forgiving; join with " AND " instead when every term must be present.
for row in full_text_search(" ".join(terms)):
print(row.id, row.body)
Order matters in that function: characters are replaced before the operator
check. Stripping first turns AND) into AND, which the operator filter
then removes — whereas filtering first would leave AND) in place, and the
query would fail on the unbalanced parenthesis. Because \w is
Unicode-aware, accented and non-Latin words survive intact.
Stripping punctuation can split a word — don't becomes the two
terms don and t — but under the default standard analyzer that
costs you nothing on its own, because the indexed text is tokenized the same
way: a row containing don’t is indexed under don and t too, so it
still matches. Note that this does not hold for the whitespace analyzer,
which keeps punctuation, so there the indexed term is don't and neither
don nor don t matches it.
The reason to prefer the implicit OR for free-form input is that AND
requires every term to be present, so a single word the user typed that
appears in no document reduces the whole result set to nothing:
-- 'unicorn' appears in no document, so the AND query matches nothing
-- while the implicit OR still returns the rows that mention a database.
SELECT id FROM blog.articles
WHERE BM25(body, 'database AND unicorn') > 0
ORDER BY BM25(body, 'database AND unicorn') LIMIT 10;
SELECT id FROM blog.articles
WHERE BM25(body, 'database unicorn') > 0
ORDER BY BM25(body, 'database unicorn') LIMIT 10;
Note also that sanitizing changes only the surface form of the words, not their meaning: a search for don’t will not reach text that says do not, because those are different terms. Matching by meaning rather than by word is what vector search is for, and Combining Full Text and Vector Search combines the two.
See ScyllaDB Drivers for the list of supported drivers and connection details.
Dropping a Full-Text Index¶
DROP INDEX blog.articles_body_fts;
Dropping the index releases the memory it used on the indexing nodes and leaves
the base table untouched. Full text search queries against the column then fail
with No fulltext index found for full-text search query.
Limitations¶
Full text search requires ScyllaDB 2026.3.0 or later.
A full-text index can only target a
text,varchar, orasciicolumn, and not a partition key column.LIMITis mandatory and must not exceed 1000. Results are not paged.A full text search query cannot carry any other
WHERErestriction, and cannot be combined with anANN OFclause.Only one
BM25()restriction is allowed per query.BM25 scores cannot be projected in the
SELECTlist.Fuzzy matching (
term~1) and prefix matching (term*) are not available.Stemming is available only through the language analyzers, and the analyzer applies to the whole index — it cannot be chosen per query.
Index options cannot be changed after creation — there is no
ALTER INDEXstatement. Drop the index and create it again instead.Cell-level TTL (
USING TTL,default_time_to_live) is not supported: expired rows leave stale entries in the index. Use per-row TTL instead.
Next Steps¶
Working with Vector and Text Search — the vector search half of the feature.
Full Text Search CQL Reference — the statements and index options on one page.
Troubleshooting — common full text search problems.