Database-native full-text search for Rails 8+ using SQLite FTS5, with zero external dependencies.
- Zero Dependencies: Uses SQLite's built-in FTS5 extension
- Rails Native: Feels like ActiveRecord, works with existing scopes
- Automatic Sync: Search index updates via callbacks, always consistent
- Query Sanitization: Handles invalid FTS syntax gracefully
- Highlighting & Snippets: Built-in support for match highlighting
- BM25 Ranking: Relevance scoring with configurable field weights
- Rails 8.0+ (for
create_virtual_tablesupport) - Ruby 3.2+
- SQLite 3.8.0+ with FTS5 extension
Add to your Gemfile:
gem "activerecord-searchable"Run:
bundle installclass Article < ApplicationRecord
include ActiveRecord::Searchable
searchable do
field :title, weight: 2.0 # Title matches ranked 2x higher
field :body
end
endrails generate searchable:migration ArticleThis creates:
class CreateArticleSearchIndex < ActiveRecord::Migration[8.0]
def change
create_virtual_table :article_search_index, :fts5, [
"title",
"body",
"tokenize='porter'"
]
Article.reindex_all
end
endrails db:migrate# Basic search
Article.search("ruby on rails")
# With match highlighting
results = Article.search("ruby on rails", matches: true)
results.first.title_highlight # => "Building with <mark>Ruby on Rails</mark>"
results.first.body_snippet # => "...learn <mark>Ruby on Rails</mark>..."
# Chain with other scopes
Article.published.search("rails").where(author: current_user).limit(10)searchable do
# Basic field
field :title
end
searchable do
# Field with weight (for ranking)
field :title, weight: 2.0
# Field with custom extraction method
field :body, via: :searchable_content
end
searchable do
# Multiple fields
field :title, weight: 2.0
field :body
field :author_name, via: :author_full_name
endUse via: to extract content from a method instead of an attribute:
class Message < ApplicationRecord
include ActiveRecord::Searchable
has_rich_text :body
searchable do
field :body, via: :plain_text_body
end
def plain_text_body
body.to_plain_text
end
endDefine a searchable? method to control which records get indexed:
class Article < ApplicationRecord
include ActiveRecord::Searchable
searchable do
field :title
field :body
end
def searchable?
published? && body.present?
end
endArticle.search("ruby programming")Returns an ActiveRecord::Relation with matching records, ordered by relevance (BM25).
# Smart defaults - first field highlighted, second field snippeted
results = Article.search("ruby programming", matches: true)
results.first.title_highlight # Full content with <mark> tags
results.first.body_snippet # ~20 word excerpt with <mark> tags
# Explicit control - specify which fields use highlight vs snippet
results = Article.search("ruby programming", matches: {
highlight: [:title, :tags],
snippet: [:body, :comments]
})
results.first.title_highlight # Full title with <mark> tags
results.first.tags_highlight # Full tags with <mark> tags
results.first.body_snippet # Excerpt of body with <mark> tags
results.first.comments_snippet # Excerpt of comments with <mark> tags
# Only highlights, no snippets
results = Article.search("ruby", matches: { highlight: [:title] })
results.first.title_highlight # Full title with <mark> tags
# No snippet columns generated
# No matches
results = Article.search("ruby")
# No highlight or snippet columns, just filtered/ranked recordsMatch columns use different suffixes based on the function:
_highlight- Full content with<mark>tags (SQLitehighlight()function)_snippet- Excerpt (~20 words) with<mark>tags (SQLitesnippet()function)
Smart defaults (matches: true):
- First field →
_highlight - Second field →
_snippet - Other fields → no match columns
The gem automatically sanitizes queries to prevent FTS syntax errors:
Article.search('"unbalanced quote') # Works - quotes removed
Article.search('test & invalid') # Works - invalid chars removed
Article.search('') # Returns none (empty relation)Search integrates seamlessly with ActiveRecord:
Article.published
.search("rails")
.where(author: current_user)
.order(created_at: :desc)
.limit(20)The search index updates automatically via callbacks:
article = Article.create!(title: "Test", body: "Content") # Indexed
article.update!(title: "Updated") # Re-indexed
article.destroy # Removed from indexReindex a single record:
article.reindexReindex all records:
Article.reindex_allThe reindex_all method processes records in batches (1000 at a time by default) to prevent memory issues with large datasets. This is safe for tables with millions of records.
For advanced needs like background jobs, progress tracking, or custom batch sizes, override reindex_all:
class Article < ApplicationRecord
include ActiveRecord::Searchable
searchable do
field :title
field :body
end
# Queue reindexing jobs in the background
def self.reindex_all
find_each do |article|
ReindexJob.perform_later(article.id)
end
end
endThe gem creates a virtual table using SQLite's FTS5 extension:
CREATE VIRTUAL TABLE article_search_index USING fts5(
title,
body,
tokenize='porter'
);When you include ActiveRecord::Searchable, the gem adds callbacks:
after_create_commit→ Insert into FTS tableafter_update_commit→ Update FTS table (or insert if missing)after_destroy_commit→ Delete from FTS table
Search generates SQL like:
SELECT articles.*
FROM articles
JOIN article_search_index ON articles.id = article_search_index.rowid
WHERE article_search_index MATCH 'ruby programming'
ORDER BY bm25(article_search_index, 2.0, 1.0)With matches:
SELECT articles.*,
highlight(article_search_index, 0, '<mark>', '</mark>') as title_highlight,
snippet(article_search_index, 1, '<mark>', '</mark>', '...', 20) as body_snippet
FROM articles
JOIN article_search_index ON articles.id = article_search_index.rowid
WHERE article_search_index MATCH 'ruby programming'
ORDER BY bm25(article_search_index, 2.0, 1.0)Field weights control relevance ranking. Higher weights = higher rank for matches:
searchable do
field :title, weight: 3.0 # Title matches rank highest
field :summary, weight: 2.0 # Summary matches rank medium
field :body, weight: 1.0 # Body matches rank lowest
endUses SQLite's BM25 algorithm for scoring.
The gem uses Porter stemming by default (via tokenize='porter'). This means:
- "running" matches "run"
- "developer" matches "develop"
- "testing" matches "test"
Index updates happen in after_commit callbacks, ensuring the FTS table stays consistent with your data even if transactions roll back.
- PostgreSQL adapter (using
tsvectorandtsquery) - MySQL adapter (using
FULLTEXTindexes) - Advanced query syntax support (FTS5 operators: AND, OR, NOT, prefix matching)
- Multi-language support
Inspired by 37signals ONCE products:
Bug reports and pull requests welcome on GitHub.
MIT License. See MIT-LICENSE for details.