LaunchKit
← All posts
· 17 min read · by The LaunchKit team · 2 views

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:

One Article.search(&quot;callback&quot;) call, with no fields: argument, fans into two SQL statements: the SELECT of 25 rows and an unconditional COUNT, and both carry the same predicate over the expression title_vector concatenated with body_vector. Only a GIN index built on that whole expression can answer it, so the arrow lands on the hand written combined index, while the two per column GIN indexes the generator's own migration created, one on title_vector and one on body_vector, sit unreached and are reachable only from a fields: narrowed query.

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.

#rails #postgresql

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.