Active Search, and the index it does not write
Donal McBreen presented Active Search at Rails World 2026 on 23 September, Track 1 at 11:15, and the
programme describes it as "a new Rails framework for application search" providing "a single, Active
Record-style chainable interface for searching across different search engines like Elasticsearch,
Meilisearch or Typesense and database full-text search in PostgreSQL, MySQL or SQLite". The gem is
rails-active_search, it is MIT, and the thing worth knowing before anything else is printed on the
second line of its own README:
This is alpha software. The API is not fixed and may have backward incompatible changes in future.
Everything below was produced by installing it in a scratch Rails app and running it, not by reading it. The rig: rails 8.1.4, pg 1.6.3, sqlite3 2.9.6, Ruby 4.0.5 on arm64-darwin25, PostgreSQL 17.7 (Homebrew) on port 15432, an Apple M2 Max with 12 cores. The corpus is 50001 rows of real English prose, six paragraphs of body each, 35 MB of text. Buffer counts and row counts reproduce anywhere; the millisecond figures are this laptop's and are here because the ratios between them are the point.
One version note that matters for the Postgres numbers. The README's "Verified versions" table says PostgreSQL 18. This ran on 17.7.
Where 0.1.0 actually is
rails-active_search has two releases on rubygems.org: 0.0.1 on 2026-09-17 with 1843 downloads, and
0.1.0 on 2026-09-22 with 368, both read on 2026-09-26. The public repository has seven commits, one
tag, and a HEAD of 1fb3967 dated 2026-09-22. The gemspec asks for Ruby 3.2 or newer and
rails >= 8.1, and pins that lower bound with a comment rather than a hope: "What CI verifies,
rather than a wider claim nothing tests."
The test suite is 60-odd files and it is not a token one. There are tests for thread safety, for
guard timing, for transaction indexing, for reserved field names, for what each adapter does with an
empty collection. docs/custom_adapters.md exists. The README documents nine behavioural divergences
between backends by name, including that "SQLite sorts relevance ascending" because FTS5 rank is
negative. This is a careful piece of work by someone who has clearly run it against ten stores.
That is worth saying up front, because the rest of this post is about the one axis nothing in that suite tests: the query plan.
What the two generators build
rails active_search:install ADAPTER=postgresql writes config/search.yml and config/search.rb.
Then rails generate active_search:index Article title body:text status:string views:integer
appends the index definition, adds has_search to the model, and writes a document model plus this
migration:
class CreateArticleDocuments < ActiveRecord::Migration[8.1]
def change
create_table :article_documents do |t|
t.string :article_id, null: false
t.text :title
t.text :body
t.string :status
t.bigint :views
end
add_index :article_documents, :article_id, unique: true
add_column :article_documents, :title_vector, :tsvector
add_index :article_documents, :title_vector, using: :gin
add_column :article_documents, :body_vector, :tsvector
add_index :article_documents, :body_vector, using: :gin
end
end
Note the shape: a separate table that holds a second copy of your text alongside the vectors, not a
generated column on articles. That is a deliberate choice and it is what makes one API cover
Elasticsearch and Postgres, but it is charged in bytes. After indexing 50001 rows, articles totals
36 MB and article_documents totals 97 MB, 79 MB of heap and 17 MB of indexes. The body_vector
column alone sums to 35 MB, and the copied raw text to 30 MB. The SQLite file for the same corpus,
where FTS5 keeps the text and the document table keeps only the filter columns, is 61,497,344 bytes.
Backfilling is index.batch, and what "batch" means here is worth reading before you plan a
reindex. flush at store_adapters/database.rb:50 opens one transaction and then loops, calling
write once per document. 50000 documents through batch(max_size: 1000) took 12.84 s and emitted
50159 sql.active_record events. max_size buys you a transaction boundary, not fewer round trips.
Each of those writes inlines every value. Postgresql#write builds the statement with
conn.quote and hands it to conn.execute, so there are no bind parameters and the document text
appears twice, once as the column value and once inside to_tsvector('english', ...). A 695 byte
document produced an 1852 byte INSERT, 2.66 times the text, with zero binds.
The query is over an expression and the indexes are over columns
Here is what Article.search("callback") generates, straight out of to_native_query.to_sql:
SELECT "article_documents"."article_id",
ts_rank(title_vector || body_vector, websearch_to_tsquery('english', 'callback')) AS score,
"article_documents"."title", "article_documents"."body",
"article_documents"."status", "article_documents"."views"
FROM "article_documents"
WHERE ((title_vector || body_vector) @@ websearch_to_tsquery('english', 'callback'))
ORDER BY score DESC
LIMIT 25
websearch_to_tsquery is the right choice and it is the one the four rungs of Postgres
search argued for: it parses a real search box without raising on a
stray parenthesis. apply_search in store_adapters/postgresql/query_building.rb:31 builds it,
joining every searched field's vector with ||.
The WHERE clause is a predicate over title_vector || body_vector. The migration above created one
GIN index on title_vector and one on body_vector. Neither of them indexes that expression, so
neither can answer it:
Limit (actual rows=25 loops=1)
Buffers: shared hit=14623
-> Sort (actual rows=25 loops=1)
Sort Key: (ts_rank((title_vector || body_vector), '''callback'''::tsquery)) DESC
Sort Method: top-N heapsort Memory: 26kB
-> Seq Scan on article_documents (actual rows=3266 loops=1)
Filter: ((title_vector || body_vector) @@ '''callback'''::tsquery)
Rows Removed by Filter: 46734
Buffers: shared hit=14620
Execution Time: 73.047 ms
The indexes are not merely unhelpful, they are untouched. After a full run of searches,
pg_stat_user_indexes reported idx_scan = 0 for index_article_documents_on_title_vector. It has
been built, it is 4160 kB, it is maintained on every write, and nothing has ever read it.
There is a path that does use them, and finding it is what makes the diagnosis certain rather than
plausible. Pass fields: to narrow the search to one field and the || disappears from the
predicate:
Limit (actual rows=25 loops=1)
Buffers: shared hit=3587
-> Sort (actual rows=25 loops=1)
-> Bitmap Heap Scan on article_documents (actual rows=3048 loops=1)
Recheck Cond: (body_vector @@ '''callback'''::tsquery)
-> Bitmap Index Scan on index_article_documents_on_body_vector (actual rows=3048 loops=1)
Buffers: shared hit=516
Execution Time: 16.016 ms
So the index works. It is only unreachable from the default query, which is the one every Quick
Start in the README writes and the only one a has_search model gives you without an argument. End
to end through Active Record, with the query cache off and 21 runs:
default title+body median 152.6 ms p95 157.6
fields: [:body] median 5.3 ms p95 6.8
A two-field index is the single most ordinary thing anybody asks a search feature for, and it is the shape that falls off the index.
One CREATE INDEX the generator does not write
The fix is an expression index over exactly what the query asks for:
CREATE INDEX article_documents_combined_gin
ON article_documents USING gin ((title_vector || body_vector));
Same query, same 50001 rows, nothing else changed:
Limit (actual rows=25 loops=1)
Buffers: shared hit=3233 read=7
-> Sort (actual rows=25 loops=1)
-> Bitmap Heap Scan on article_documents (actual rows=3266 loops=1)
Recheck Cond: ((title_vector || body_vector) @@ '''callback'''::tsquery)
-> Bitmap Index Scan on article_documents_combined_gin (actual rows=3266 loops=1)
Buffers: shared hit=5
Execution Time: 6.475 ms
Median through Active Record fell from 152.6 ms to 9.8 ms. Under load it is worse than that
comparison suggests, because a 150 ms query occupies a Puma thread for 150 ms. A controller doing
Article.search(params[:q]).results, booted with RAILS_ENV=production and five threads, measured
with ab -l -n 400 -c 4 on the loopback:
generated schema: 18.79 req/s p50 158 ms p95 201 ms p99 2615 ms
with the expression index: 281.51 req/s p50 13 ms p95 17 ms p99 25 ms
The p99 is the interesting column. At four concurrent clients against five threads, the unindexed version is already queueing, and one slow search is making the next three wait.
State the cost honestly. That index is 14 MB on this corpus, on top of the 4160 kB and 10 MB the
generator already built, for a total of 28 MB of GIN over a 79 MB table. Half of it is now dead
weight: once the combined index exists, the per-column indexes only serve fields:-narrowed
searches, and if you never write one you should drop them and keep a single 14 MB index. Every
document write maintains all three.
There is a second cost, which is that you are now maintaining a hand-written index against a
generated query shape in alpha software. Add a third text field to the index definition and the
predicate becomes title_vector || body_vector || summary_vector, and your expression index stops
matching it. Nothing warns you. active_search:verify will still say compatible.
Every search runs a COUNT nobody asked for
execute_query at store_adapters/database.rb:252 opens with one line:
total = raw_query.unscope(:limit, :offset, :select, :order).count(:all)
Unconditional. results.total is populated whether or not anybody reads it, which means every
search on a database adapter is two statements with the same WHERE clause. On the generated schema
that second statement is a second full scan:
Aggregate (actual rows=1 loops=1)
Buffers: shared hit=14288
-> Seq Scan on article_documents (actual rows=3266 loops=1)
Filter: ((title_vector || body_vector) @@ '''callback'''::tsquery)
Rows Removed by Filter: 46735
Execution Time: 99.349 ms
That is the missing half of the 152.6 ms: 73 ms of search plus 99 ms of counting, minus what the buffer cache gives back on the second pass. With the expression index it drops to 2879 buffers and stops mattering. Without it, the count costs more than the search, because it has no LIMIT to stop at.
Two backends, one API, two different first pages
Because the adapters are pluggable, you can point two indexes at the same model and diff them. Here
is :articles on PostgreSQL and :lite_articles on SQLite FTS5, same 50001 records, same query
string, same .search(...).limit(5).results:
"callback" postgres total=3266 sqlite total=2207 top-5 overlap 0/5
"tsvector" postgres total=542 sqlite total=542 top-5 overlap 1/5
"deployment" postgres total=3629 sqlite total=243 top-5 overlap 0/5
"deployment" is a factor of fifteen. The reason is in two lines of the gem, one per adapter.
Postgresql#write at store_adapters/postgresql.rb:86 builds every vector with
to_tsvector('english', ...), so deployment stems to deploy and matches "deploys", "deployed"
and "deploy" as well. Sqlite#search_index_lines at store_adapters/sqlite.rb:51 emits
create_virtual_table :#{table}_fts, :fts5, [...] with no tokenizer, and FTS5's default
unicode61 does not stem at all. The README says so plainly under Configuration, and tells you to
write tokenize='porter' yourself. The generator does not write it, and the migration it produces
looks finished.
This is not a corner case you have to construct. active_search:install defaults to
adapter: "sqlite" for development and test, set at
generators/active_search/install/install_generator.rb:8. A team that installs the gem, develops on
the default, and configures production: adapter: postgresql has a test suite running on a
non-stemming backend and a production app running on a stemming one, with the same assertions passing
in both.
The scores make it explicit. Same query, top hit on each:
postgres 0.082746
sqlite -4.460679
hit.score is ts_rank on one adapter and FTS5 rank on the other. Different sign, different
magnitude, different direction of "better". The README documents this. It still means that any
threshold, any "only show results above X", and any cross-index merge you write is adapter-specific
code sitting behind an adapter-independent API.
'english' is spelled into the adapter, not configured
Both halves of the Postgres text configuration are string literals in the gem. to_tsvector('english', ...)
on the write side, websearch_to_tsquery('english', ...) at
store_adapters/postgresql/query_building.rb:45 on the read side. config/search.yml takes an
adapter name and, for a database adapter, essentially nothing else.
For an English product that is the right default and it is doing real work: callbacks and
callback both returned 3266 hits here, which is the whole reason to be on full text search rather
than ILIKE. For anything else it is a wall. Indexing one French row and reading back what Postgres
stored:
'café':9 'chiffrement':4 'chiffron':13 'clés':2 'coin':11 'dan':7 'de':3
'donné':15 'du':10 'le':8 'les':1,14 'nous':12 'sont':5 'stocké':6
The English snowball stemmer has taken stockées to stocké, chiffrons to chiffron and
données to donné, which are not French stems, while every French stop word - les, de, du,
sont, nous - is kept as a lexeme because it is not an English stop word. And accents still do not
fold: données finds the row, donnees returns 0.
None of that is fixable from configuration today. The workaround is document_class:, which lets you
point the index at your own model for the document table, but the to_tsvector call is in the
adapter's write, not in the model. For a non-English application this is the blocking issue, ahead
of everything else on this page.
A blank search box returns the table
normalize_search_text at query/normalization.rb:24:
when String then query.strip.empty? ? nil : query.dup.freeze
An empty or whitespace-only string becomes nil, and a nil query is a filter-only query, which is
documented and useful. What it produces on a database adapter is this:
SELECT "article_documents"."article_id", 0 AS score, ... FROM "article_documents" LIMIT 25
No WHERE clause, total of 50001, and the unconditional COUNT from two sections ago running over
the entire table. Article.search(params[:q]) with an empty q is a plausible line in a plausible
controller, and it is a full table count per request.
What verify checks
rails active_search:verify exits non-zero when an index does not provide what its declaration
needs, which the README suggests running in CI or before a deploy. It does catch the obvious thing.
Adding text :summary to the definition without migrating:
articles incompatible: summary (searchable, tsvector, in article_documents) not found
Exit code 1, as documented.
What it does not check is whether anything can be searched efficiently. Dropping all three GIN
indexes from article_documents and running it again:
articles compatible
lite_articles compatible
Exit 0. A green verify means the columns are present with the right types. The adapter says so
itself, in a comment on search_native_type: "A tsvector column records that it is a tsvector and
not which configuration built it, so a column filled with to_tsvector('simple') verifies against one
this adapter wrote with 'english'." Index presence is one level below even that.
Two more edges worth knowing before you wire it up
ActiveSearch::ResultWindowExceeded is raised by the PostgreSQL adapter, not by PostgreSQL:
Postgresql pages to 10000 results and this query asks for 20010.
Narrow the search, or page from a sort key instead of an offset.
The 10000 comes from options.fetch(:max_result_window, 10_000) at
store_adapters/postgresql.rb:109, which is Elasticsearch's index.max_result_window default
imported for API consistency. Postgres has no such limit. It is settable in search.yml, and the
error message's advice is the right advice anyway, for the reason
pagination without a gem sets out.
scope: is on the model's .search and not on the index's. Article.search("callback", scope: ...)
works; ActiveSearch.index(:articles).search("callback", scope: ...) raises
ArgumentError: unknown keyword: :scope. The behaviour when it does work is documented and still
surprising in production: a scope filters what is loaded, not what is searched, so a query with 3266
hits whose scope excludes two thirds of them returned a page of 10 records, dropped of 15, and a
total of 3266. A search page rendering "3266 results" above ten rows is correct by the gem's
contract and wrong on screen.
One thing that is cheaper than expected: highlighting. ts_headline is applied to the selected rows
after the LIMIT, so highlight(body: { snippet: { words: 12 } }) measured 11.3 ms against 11.2 ms
for the same query with no highlight.
The measurement that was wrong by a factor of a hundred
The first set of timings for this post came out at 1.5 ms for the default search, which would have
made every claim above nonsense. They were taken inside bin/rails runner, where Active Record's
query cache is enabled for the whole process. The first call ran, the next twenty did not:
CACHE ArticleDocument Load (0.0ms) SELECT "article_documents"."article_id", ts_rank(...
ActiveRecord::Base.connection.disable_query_cache! at the top of the script turned 1.5 ms into
152.6 ms. Anything benchmarking a read path in a runner script has this problem, and the only tell
is the word CACHE in a log line most people have turned off. If a search benchmark comes out
suspiciously flat across a rare term and a common one, that is the first thing to check.
A smaller one, in case it saves somebody an afternoon: do not define a top-level method named try
in a runner script. execute_query calls record.try(:score), and a top-level def becomes a private
method on Object, so every result raises NoMethodError: private method 'try' called for an instance
of ArticleDocument.
Where this leaves it, and what would change that
Do not put this in production this week. Not because the code is bad - it is careful, well tested and better documented than most 1.0 releases - but because the version number is 0.1.0, the README says the API may change incompatibly, and the framework release the talk is pointed at is not out. Two consecutive upgrades could each rewrite your search layer.
Do install it in a branch this week if you are already running two search backends, or expect to, or
are carrying a hand-rolled abstraction over Elasticsearch and Postgres. That problem is exactly what
this gem solves and there is no other serious answer to it in Rails. The parts that are hard to get
right - the capability model, the divergence documentation, the guard and reindex semantics, the
instrumentation payloads that carry query_length instead of the query - are the parts that are
already good.
If you are on Postgres only, with one model and one search box, the honest comparison is against the
generated tsvector column from four rungs up. That is fifteen lines
of migration, no dependency, setweight for real ranking, and a GIN index that the query actually
uses. Active Search does not beat it on a single backend; it beats it the moment there are two.
What would change the recommendation, in the order it would change it: a dictionary: option on the
index definition, which is the one gap that rules the gem out entirely for non-English applications;
a generated migration that indexes the expression its own adapter searches, or an adapter that
searches the columns its own migration indexes, either way round; a plan assertion in the test suite,
since the suite tests correctness thoroughly and the query plan not at all; and a 1.0 with a
stability promise attached.
What this post does not cover
Eight of the ten adapters. Elasticsearch, OpenSearch, Solr, Meilisearch, Typesense, Manticore, Redis Search and MySQL were not run, and the divergences the README documents for them were not verified here. Everything above is PostgreSQL 17.7 and SQLite FTS5.
Also absent: polymorphic indexes, route_by on Elasticsearch, which needs a multi-shard cluster to
test honestly and returns everything on a single shard whatever route you give it; remove_by_filter
and the delete_all repair path; the Active Job side under a real queue backend, since this ran with
the inline and async adapters and the interesting failures there are about job loss; filter_any and
range semantics over collections; and the LaunchKit boilerplate this site sells, which has no search
feature, no search gem in its Gemfile and no tsvector column in its schema, so nothing here came
from it.
The Postgres figures are from 17.7, where the gem's own CI verifies against 18. The gem was loaded
from a path checkout of 1fb3967, which is v0.1.0 plus a dependabot bump, in an app that resolved
rails to 8.1.4 rather than the 8.1.3.1 this site runs.
Comments
No comments yet. Be the first.