AshScylla.Search (AshScylla v1.9.0)

Copy Markdown View Source

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  Results

Quick 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 term or -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.

Updates a document's indexed terms.

Types

field_map()

@type field_map() :: %{optional(atom()) => String.t()}

search_result()

@type search_result() ::
  {:ok, AshScylla.Search.Query.Paginator.page()} | {:error, term()}

Functions

create_tables(repo, keyspace)

@spec create_tables(module(), String.t()) :: :ok | {:error, term()}

Creates the search engine tables in the given keyspace.

This must be called once before indexing or searching.

Returns :ok or {:error, reason}.

delete(repo, keyspace, post_id)

@spec delete(module(), String.t(), String.t()) :: :ok | {:error, term()}

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}.

drop_tables(repo, keyspace)

@spec drop_tables(module(), String.t()) :: :ok | {:error, term()}

Drops the search engine tables from the given keyspace.

index(repo, keyspace, post_id, fields, opts \\ [])

@spec index(module(), String.t(), String.t(), field_map(), keyword()) ::
  :ok | {:error, term()}

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"
})

search(repo, keyspace, query, opts \\ [])

@spec search(module(), String.t(), String.t(), keyword()) :: search_result()

Searches the inverted index for documents matching the query.

The full search pipeline:

  1. Parse query string into AST
  2. Analyze query terms
  3. Look up posting lists from ScyllaDB
  4. Apply boolean operations (AND/OR/NOT)
  5. Rank results by relevance
  6. Paginate results

Query syntax

  • word1 word2 — implicit AND
  • a AND b / a OR b / a NOT b — explicit operators (case-insensitive)
  • -term — exclusion, same as NOT
  • "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 by Analyzer.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_shards option is less than 1

search!(repo, keyspace, query, opts \\ [])

Same as search/4 but raises on error.

update(repo, keyspace, post_id, fields, opts \\ [])

@spec update(module(), String.t(), String.t(), field_map(), keyword()) ::
  :ok | {:error, term()}

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}.