A scalable multi-word search engine built on ScyllaDB using an inverted index.
Provides Lucene/OpenSearch-style search capabilities without LIKE,
ALLOW FILTERING, or secondary indexes.
Architecture
The search engine uses an inverted index approach:
Document → Analyzer → Tokenizer → Normalizer → Stemmer
→ Stop Words → Indexer → search_post_terms table
Query → Analyzer → Planner → Boolean Engine → Ranking → ResultsQuick Start
# 1. Create search tables
Search.create_tables(MyApp.Repo, "my_keyspace")
# 2. Index a document
Search.index(MyApp.Repo, "my_keyspace", "post-uuid-here", %{
title: "Learning Elixir Phoenix Framework",
body: "Phoenix is a distributed web framework built on Elixir."
})
# 3. Search
{:ok, results} = Search.search(MyApp.Repo, "my_keyspace", "learning phoenix")
#=> %{entries: [{"post-uuid-here", 2.0}], ...}Features
- Single-word and multi-word AND/OR search
- Exclusions via
NOT termor-term - Phrase queries (
"phoenix framework") matched as an unordered AND of their words (the index does not store token positions) - Pagination with page metadata
- Relevance ranking (TF, TF-IDF, BM25)
- Document updates and deletes
- Sharded partitions with single-round-trip multi-partition lookups
- Unicode-aware tokenization
- Porter stemming
- Stop word filtering
Summary
Functions
Creates the search engine tables in the given keyspace.
Removes a document from the search index.
Drops the search engine tables from the given keyspace.
Indexes a document into the search engine.
Searches the inverted index for documents matching the query.
Same as search/4 but raises on error.
Updates a document's indexed terms.
Types
@type search_result() :: {:ok, AshScylla.Search.Query.Paginator.page()} | {:error, term()}
Functions
Creates the search engine tables in the given keyspace.
This must be called once before indexing or searching.
Returns :ok or {:error, reason}.
Removes a document from the search index.
Deletes all term entries for the given post ID from both
search_post_terms and search_post_fields.
Returns :ok or {:error, reason}.
Drops the search engine tables from the given keyspace.
Indexes a document into the search engine.
Accepts a map of field names to text values. Each field is analyzed and its terms are written to the inverted index.
Returns :ok or {:error, reason}.
Examples
Search.index(MyApp.Repo, "my_keyspace", "abc-123", %{
title: "Learning Elixir",
body: "Elixir is a functional language"
})
@spec search(module(), String.t(), String.t(), keyword()) :: search_result()
Searches the inverted index for documents matching the query.
The full search pipeline:
- Parse query string into AST
- Analyze query terms
- Look up posting lists from ScyllaDB
- Apply boolean operations (AND/OR/NOT)
- Rank results by relevance
- Paginate results
Query syntax
word1 word2— implicit ANDa AND b/a OR b/a NOT b— explicit operators (case-insensitive)-term— exclusion, same asNOT"multi words"— phrase: matches documents containing all of the phrase's analyzed words, in any order (the index does not store token positions)
A branch with no positive terms (e.g. -foo alone, or a query made only of
stop words) fails with {:error, :missing_positive_term}; in an OR
query every branch must contain at least one positive term.
Ranking options
:strategy—:tf(default),:tfidf, or:bm25
For :tfidf/:bm25, corpus statistics improve scoring quality. Any
statistic you omit is derived from the matched result set (document
frequency per term, total documents, average document length), which keeps
relative ordering sensible. For best results supply global values:
:total_docs— total number of indexed documents:doc_freqs—%{"stemmed_term" => df}map; keys must be analyzed (stemmed) terms, e.g. produced byAnalyzer.analyze_query/2:avg_doc_length— average document length across the collection:doc_lengths—%{post_id => length}map used by BM25 length normalization; without it, document length is approximated from the frequencies of the matched terms only
Other options
:page— page number, starting at 1 (default:1):page_size— results per page (default:20):num_shards— shard count used at indexing time (default:16):analyzer_opts— options passed to the analyzer (e.g.:stem)
Examples
# Basic search
{:ok, page} = Search.search(repo, keyspace, "elixir phoenix")
# With AND/OR operators
{:ok, page} = Search.search(repo, keyspace, "elixir OR phoenix")
# Exclusions: docs mentioning "framework" are removed from the results
{:ok, page} = Search.search(repo, keyspace, "phoenix -framework")
{:ok, page} = Search.search(repo, keyspace, "phoenix NOT framework")
# Phrase: matches documents containing both analyzed words (unordered)
{:ok, page} = Search.search(repo, keyspace, ~s("phoenix framework"))
# With BM25 ranking and pagination
{:ok, page} = Search.search(repo, keyspace, "distributed web",
strategy: :bm25, page: 1, page_size: 10)Returns {:ok, page} where page is a map with :entries, :page_number,
:total_count, etc., or {:error, reason}. Notable error reasons:
:empty_query— the query string is blank:missing_positive_term— no searchable positive terms remain after analysis (e.g. only stop words or only exclusions):invalid_num_shards—:num_shardsoption is less than 1
@spec search!(module(), String.t(), String.t(), keyword()) :: AshScylla.Search.Query.Paginator.page() | no_return()
Same as search/4 but raises on error.
Updates a document's indexed terms.
Computes the diff between old and new terms for each field, applying only the necessary inserts and deletes. Fields omitted from the map are left unchanged.
Returns :ok or {:error, reason}.