LaunchKit
← All posts
· 20 min read · by The LaunchKit team · 0 views

Search in Rails, four rungs up

Type "rails search" into a search box and the autocomplete offers you searchkick, a search gem, a search form and search_field, which is four answers to three different questions. Two of them are about a text input, and this post is not about the text input. The question worth an afternoon is what runs when the form submits, and there are four plausible answers to that, each one buying something concrete and charging for it.

Every plan and every error message below was produced against PostgreSQL 17.7 on a 200001 row table, activerecord 8.1.3.1, on a local database. Where a number came from my machine and would not reproduce on yours, it is not here.

Rung one: ILIKE, and the two indexes it ignores

Article.where("title ILIKE ?", "%#{params[:q]}%") is the first thing anyone writes, and for a table of a few thousand rows it is the correct thing. It is also the rung where the most surprising result lives, so it is worth running before dismissing.

A leading wildcard cannot use a btree index, which everybody knows. Here is the plan anyway, for a term that appears in exactly one row out of 200001:

 Limit (actual rows=1 loops=1)
   Buffers: shared hit=5707
   ->  Gather (actual rows=1 loops=1)
         Workers Planned: 2
         ->  Parallel Seq Scan on articles (actual rows=0 loops=3)
               Filter: (body ~~* '%tsvector%'::text)
               Rows Removed by Filter: 66667

5707 buffers, roughly 45 MB of heap, to return one row. Fine at 5000 rows, not fine at 200000, and the failure is gradual rather than sudden, so nothing tells you when you crossed the line.

The part people do not expect is that dropping the leading wildcard does not save you. LIKE 'Article 12345%' on an indexed column planned as an Index Scan reading 6 buffers. The same prefix with ILIKE, same column, same index:

 Limit (actual rows=11 loops=1)
   Buffers: shared hit=5707
   ->  Gather (actual rows=11 loops=1)
         ->  Parallel Seq Scan on articles (actual rows=4 loops=3)
               Filter: (title ~~* 'Article 12345%'::text)

Case insensitivity is the whole cost. ~~* is a different operator from ~~ and no btree on the plain column supports it. An autocomplete endpoint typing ILIKE 'q%' because case insensitive felt safer has quietly bought a full scan per keystroke.

The collation that decides whether a prefix is indexed at all

Even LIKE 'prefix%' is not reliably indexed, and the thing that decides is a database property most people never look at.

The Index Scan above happened on a database created with datcollate = C. Same 200000 rows, same statement, column declared COLLATE "en_US.UTF-8", index built the ordinary way:

 Limit (actual rows=11 loops=1)
   Buffers: shared hit=1274
   ->  Seq Scan on coll (actual rows=11 loops=1)
         Filter: (title ~~ 'Article 12345%'::text)
         Rows Removed by Filter: 199989

Add one index with a different operator class, CREATE INDEX coll_title_pattern ON coll (title text_pattern_ops), change nothing else:

 Limit (actual rows=11 loops=1)
   Buffers: shared hit=2 read=3
   ->  Index Scan using coll_title_pattern on coll (actual rows=11 loops=1)
         Index Cond: ((title ~>=~ 'Article 12345'::text) AND (title ~<~ 'Article 12346'::text))

Note the operators in the Index Cond. ~>=~ and ~<~ are byte comparisons rather than collation aware ones, which is exactly why the pattern index works and the ordinary one does not: under a linguistic collation, "everything between 'Article 12345' and 'Article 12346'" is not a contiguous range of the index. Check SELECT datcollate FROM pg_database before assuming either way. Rails writes this as add_index :articles, :title, opclass: :text_pattern_ops.

The percent sign your user typed into your query

Before leaving this rung, one bug that is not about performance. "%#{params[:q]}%" goes into a bound parameter, so it is not SQL injection. It is pattern injection, which is smaller and real: a user searching for 100% searches for %100%%, and the trailing % is a wildcard, not a character.

Active Record has the escaper, in active_record/sanitization.rb:132:

def sanitize_sql_like(string, escape_character = "\\")
  if string.include?(escape_character) && escape_character != "%" && escape_character != "_"
    string = string.gsub(escape_character, '\0\0')
  end

  string.gsub(/(?=[%_])/, escape_character)
end

sanitize_sql_like("100% done_now") returns "100\\% done\\_now", and the two queries differ by the backslashes:

WHERE (title ILIKE '%100% done_now%')
WHERE (title ILIKE '%100\% done\_now%')

The underscore matters more than the percent in practice, because _ is a single character wildcard and it appears in every identifier your users paste into a search box.

Trigrams make a leading wildcard indexable

pg_trgm is the rung between ILIKE and full text search, and it is one extension and one index. It cuts the string into overlapping three character pieces, indexes those, and can then answer ILIKE '%anything%' from the index.

CREATE EXTENSION pg_trgm;
CREATE INDEX articles_body_trgm ON articles USING GIN (body gin_trgm_ops);

The same query that read 5707 buffers a moment ago:

 Limit (actual rows=1 loops=1)
   Buffers: shared hit=22
   ->  Bitmap Heap Scan on articles (actual rows=1 loops=1)
         Recheck Cond: (body ~~* '%tsvector%'::text)
         Heap Blocks: exact=1
         ->  Bitmap Index Scan on articles_body_trgm (actual rows=1 loops=1)

The price is on disk and it is steep. That trigram GIN index over the body column alone measured 31 MB, where the full text GIN index over title and body together measured 15 MB. You are paying close to the size of the text to index the text.

And it has a floor. A pattern with no complete three character run inside it yields no trigram to look up, so the index is skipped:

 Limit (actual rows=0 loops=1)
   Buffers: shared hit=5707
   ->  Parallel Seq Scan on articles (actual rows=0 loops=3)
         Filter: (body ~~* '%zq%'::text)
         Rows Removed by Filter: 66667

Two characters, index present, full scan. Every autocomplete has a two character prefix in it, because that is what the second keystroke produces.

Trigram is the right rung when the thing being searched is not prose: SKUs, email addresses, hostnames, serial numbers, anything where substring is what the user means and words are not a useful unit. For prose, keep climbing.

Rung two: a tsvector column Postgres maintains for you

Postgres full text search stops matching characters and starts matching words. to_tsvector normalises text into lexemes and drops stop words, to_tsquery and friends do the same to the query, and @@ asks whether they match.

SELECT to_tsvector('english', 'The operator was running through the rows');
        'oper':2 'row':7 'run':4

Seven words in, three lexemes out. "running" became "run", so a search for runs matches, which is the thing ILIKE will never do for you at any index size.

The modern way to store it is a generated column, so the database keeps it in sync and no callback can forget:

ALTER TABLE articles ADD COLUMN searchable tsvector
  GENERATED ALWAYS AS (
    setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
    setweight(to_tsvector('english', coalesce(body,  '')), 'B')
  ) STORED;
CREATE INDEX articles_searchable_gin ON articles USING GIN (searchable);

setweight tags each lexeme with a letter, which later lets ts_rank count a title hit for more than a body hit. The PostgreSQL manual is unambiguous about which index type to reach for: "GIN indexes are the preferred text search index type." The same GIN machinery behind @> on a jsonb column, which jsonb columns in Rails measures from the other side.

The lookup, against the same 200001 rows that cost 5707 buffers under ILIKE:

 Limit (actual rows=1 loops=1)
   Buffers: shared hit=5
   ->  Bitmap Heap Scan on articles (actual rows=1 loops=1)
         Recheck Cond: (searchable @@ '''tsvector'''::tsquery)
         Heap Blocks: exact=1
         ->  Bitmap Index Scan on articles_searchable_gin (actual rows=1 loops=1)

Five buffers.

What the migration looks like in Rails 8.1

Active Record has native support for all of this and it is not widely used. tsvector is a real column type in the PostgreSQL adapter, registered at postgresql_adapter.rb:156, and generated columns go through t.virtual:

create_table :docs do |t|
  t.string :title, null: false
  t.text :body
  t.virtual :searchable, type: :tsvector, stored: true, as: <<~SQL.squish
    setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
    setweight(to_tsvector('english', coalesce(body,  '')), 'B')
  SQL
  t.timestamps
end
add_index :docs, :searchable, using: :gin

That round trips into schema.rb without structure.sql, which is the part worth knowing before you decide this is too exotic:

t.virtual "searchable", type: :tsvector, as: "(setweight(to_tsvector('english'::regconfig, (COALESCE(title, ''::character varying))::text), 'A'::\"char\") || setweight(to_tsvector('english'::regconfig, COALESCE(body, ''::text)), 'B'::\"char\"))", stored: true
t.index ["searchable"], name: "index_docs_on_searchable", using: :gin

Leave out stored: true and Rails refuses at migration time rather than at the database, from postgresql/schema_creation.rb:132:

ArgumentError: PostgreSQL versions before 18 do not support VIRTUAL (not persisted) generated columns.
Specify 'stored: true' option for 'bad'

A STORED column is the one you want here regardless, since a GIN index needs something persisted to index.

The write to a generated column that silently does nothing

Assigning to a generated column is the dead end in this post, and it costs an hour because nothing complains.

d.update!(searchable: "'nope'")

No exception. Active Record knows the column is generated, Doc.columns_hash["searchable"].virtual? is true, and it simply leaves the column out of the statement:

UPDATE "docs" SET "updated_at" = $1 WHERE "docs"."id" = $2

The in-memory attribute then reads back "'nope'" for the rest of the request, because the object was assigned and the object does not know the database ignored it. A reload returns the real value. So a spec that assigns and asserts in the same example passes, and a spec that assigns, reloads and asserts fails, and the difference between the two is one line nobody thinks of as meaningful.

The right mental model is that the column is output, not state. You write title, the database writes searchable, and every backfill task you were about to write for it does not need to exist.

Turning what the user typed into a tsquery

Four functions build a tsquery and only one of them survives arbitrary user input. to_tsquery takes operator syntax, and a search box produces operator syntax by accident:

ActiveRecord::StatementInvalid: PG::SyntaxError: ERROR:  syntax error in tsquery: "rails & | search ("

e.cause is PG::SyntaxError. This is a 500 triggered by a user typing a parenthesis.

websearch_to_tsquery parses the same string without raising, and parses it the way a person expects a search box to behave. The manual lists the rules, and they are short: quoted text becomes a phrase with <->, the word OR becomes |, a leading dash becomes !, and "other punctuation is ignored". Run it and see:

'"full text"'       =>  'full' <-> 'text'
'search -postgres'  =>  'search' & !'postgr'
'postgres OR rails' =>  'postgr' | 'rail'
'rails & | search (' => 'rail' & 'search'

The one thing it will not do is prefix matching, since it "will not recognize tsquery operators, weight labels, or prefix-match labels in its input". So post does not match "Postgres", while to_tsquery('english', 'post:*') does. If the feature is an as-you-type dropdown rather than a search results page, that limitation is the feature, and the usual answer is to append :* to the final term yourself after escaping it. That is also the moment to reconsider whether the dropdown wants a trigram index instead.

Stop words are the other silent empty result. to_tsvector('english', 'the of and a') is the empty vector, and a query of nothing but stop words matches nothing while emitting a NOTICE nobody reads: text-search query contains only stop words or doesn't contain lexemes, ignored.

Ranking is the half that costs

Filtering with GIN is cheap. Ordering by relevance is not, and the two get written on the same line so the cost is easy to miss.

Here is a query matching 25000 of 200001 rows, with LIMIT 20 and no ordering:

 Limit (actual rows=20 loops=1)
   Buffers: shared read=15
   ->  Seq Scan on articles (actual rows=20 loops=1)
         Filter: (searchable @@ '''deploy'''::tsquery)
         Rows Removed by Filter: 140

Fifteen buffers, and note the planner did not even use the index: one row in eight matches, so reading pages until twenty turn up is cheaper. Now add relevance ordering and change nothing else:

 Limit (actual rows=20 loops=1)
   Buffers: shared hit=1997 read=9781
   ->  Sort (actual rows=20 loops=1)
         Sort Key: (ts_rank(searchable, '''deploy'''::tsquery)) DESC
         Sort Method: top-N heapsort  Memory: 25kB
         ->  Bitmap Heap Scan on articles (actual rows=25000 loops=1)
               Recheck Cond: (searchable @@ '''deploy'''::tsquery)
               Heap Blocks: exact=11765
               Buffers: shared hit=1994 read=9781
               ->  Bitmap Index Scan on articles_searchable_gin (actual rows=25000 loops=1)
                     Buffers: shared hit=10

Fifteen buffers became 11778. The GIN index found the 25000 matches in 10 buffers and then could not help further, because an inverted index has no notion of the order you asked for. All 25000 rows came off the heap so that ts_rank could be evaluated on each one, and then twenty of them were kept.

The manual says this plainly and it deserves to be quoted rather than paraphrased: "Ranking can be expensive since it requires consulting the tsvector of each matching document, which can be I/O bound and therefore slow. Unfortunately, it is almost impossible to avoid since practical queries often result in large numbers of matches."

Two consequences for the shape of the feature. First, a broad query is the expensive one, which is backwards from the intuition that a rare term is hard to find. Second, paging through ranked results recomputes the whole sort on every page, so page 40 of a search costs what page 1 cost plus a larger offset, and the fix is not the keyset cursor from pagination without a gem, because a rank is not a static, unique, indexed column. Capping results at a few hundred is the honest answer, and it is what the dedicated engines do too.

What the index costs on the write path

Two costs, both reproducible, both measured against two copies of the same 200001 rows. One copy carries the stored tsvector column and its GIN index, the other carries neither.

Storage first. The title and body columns hold 36 MB of text. The generated tsvector column holds 45 MB, more than the text it was derived from, because setweight keeps position information per lexeme. strip() of the same vectors measures 31 MB, so roughly a third of that column is positions you are paying for and only need if you want phrase search or ts_rank_cd. The GIN index adds another 15 MB on top.

WAL next, since that is what replication and backup are actually charged for. Discard any run whose plan shows fpi= and you get steady state figures:

with tsvector column + GIN:   WAL: records=4069  bytes=634680
plain table, one btree:       WAL: records=3058  bytes=279011

Roughly 2.3 times the WAL bytes per row written, for a column nothing in your application code writes. That is the real cost of this rung, and it is charged on inserts and on any update that touches a source column, not on searches.

One knob is worth knowing about before you tune it. GIN batches new entries into a pending list by default, and turning that off with ALTER INDEX ... SET (fastupdate = off) took the same insert to records=10080 bytes=838413. Slower writes, no pending list for searches to scan linearly, and the same knob with the same shape that jsonb columns in Rails measures from the containment side.

The accent that will not go in the generated column

Searching "cafe" does not find "café", and the fix does not compose with the generated column, which is the second dead end here.

SELECT to_tsvector('english','café') @@ websearch_to_tsquery('english','cafe');
 f

The unaccent extension solves the matching half. It does not solve the storage half:

CREATE TABLE accent_test (
  id bigserial PRIMARY KEY,
  title text,
  searchable tsvector GENERATED ALWAYS AS (to_tsvector('english', unaccent(coalesce(title,'')))) STORED
);
ERROR:  generation expression is not immutable

SELECT proname, provolatile FROM pg_proc WHERE proname = 'unaccent' returns s, for stable. Stable is not immutable, a generated column requires immutable, and PostgreSQL refuses. The extension's own dictionary can be reconfigured at runtime, which is precisely why it cannot promise the same answer forever.

The workaround everyone lands on is a wrapper function declared IMMUTABLE that calls unaccent with an explicit dictionary argument, which is a promise you are making on the database's behalf: if anybody ever changes that dictionary, your stored vectors are stale and nothing recomputes them. Take it seriously or skip accents. For an English language product, skipping is defensible. For anything with French or Spanish content in it, it is not, and this is the first real argument for moving up a rung.

Rung three: what pg_search actually generates

pg_search 2.4.0 shipped on 2026-09-07 under MIT, requires Ruby 3.3 or newer, and has 51,969,952 downloads. The repository was last pushed on 2026-09-21 with 157 open issues against 1586 stars, so the gem that looked dormant between 2022 and 2024 is being worked on again: 2.3.8 in August 2026 and 2.4.0 a month later.

What it buys is real. pg_search_scope gives you a named scope, multi-column configuration, associated-model search via PgSearch.multisearch, ts_headline highlighting, trigram and dmetaphone features you can combine with full text in one scope, and the ranking already wired up.

What it does by default is the part to run before adopting:

pg_search_scope :plain_search, against: [:title, :body]
plain_search('deployments') -> 0
plain_search('deployment')  -> 25000

Zero for the plural. The reason is one line in lib/pg_search/features/tsearch.rb:

def dictionary
  Arel::Nodes.build_quoted(options[:dictionary] || :simple)
end

The default dictionary is simple, which does not stem and does not drop stop words. Passing using: { tsearch: { dictionary: "english" } } returned 25000 for the plural. Full text search without stemming is a slower ILIKE that cannot do substrings, so this default is the single most important thing to change.

The second default is the index. Without tsvector_column, the scope computes to_tsvector at query time on every row:

->  Parallel Seq Scan on articles articles_1
      Filter: ((to_tsvector('simple'::regconfig, COALESCE(title, ''::text)) || ...) @@ ...)
      Buffers: shared hit=928 read=11428

Point it at the generated column with using: { tsearch: { tsvector_column: "searchable" } } and the plan picks up Bitmap Index Scan on articles_searchable_gin. Which means the useful configuration of pg_search sits on top of the column you built at rung two rather than replacing it.

Two more behaviours worth reading before they surprise you. The gem splits the query on whitespace and joins terms with &&, so every word is required unless you pass any_word: true. And it sanitises rather than escapes: DISALLOWED_TSQUERY_CHARACTERS = /['?\\:]/ replaces apostrophes, question marks, backslashes and colons with spaces, so o'brien is searched as o brien.

Every scope also emits an INNER JOIN (SELECT ... ts_rank ... ) ON id = pg_search_id with an ORDER BY rank DESC baked in, always. There is no unranked mode, so the ranking cost from the previous section is not optional at this rung.

Rung four: a separate search service

searchkick 6.1.2 shipped on 2026-06-05 under MIT, by Andrew Kane, with 28,613,345 downloads, 6719 stars and 9 open issues, last pushed 2026-09-17. The health of the project is not the question. The question is the sentence in its README: the current version supports Elasticsearch 8 and 9 plus OpenSearch 2 and 3, and version 5.5.2 is what you use for Elasticsearch 7 or OpenSearch 1.

That sentence is the cost. Adopting searchkick means running a second stateful service, backing it up, monitoring it, and tracking its major version separately from Postgres and from Rails, with the gem's supported matrix in between. A Rails 8 application that deliberately removed Redis to have one fewer of those, which is what running Rails 8 without Redis is about, should notice it is adding one back and bigger.

What you get in exchange is the list Postgres does not have: typo tolerance out of the box, synonyms, personalised results, aggregations for faceted filtering, and reindexing without downtime. Typo tolerance in particular has no good Postgres answer. similarity('postgres', 'postgress') returns 0.727 and the % operator matches, so trigram similarity gets you a fuzzy match, but "did you mean" ranked against an analyser is a different product.

The alternatives are lighter and less settled. meilisearch-rails is at 0.16.0, released 2025-05-21, with 965179 downloads, so it is a pre-1.0 client for a service that is itself moving fast. typesense-rails is at 1.0.0.rc8, released 2026-04-26, with 23918 downloads total, which is a release candidate and a rounding error of adoption. Both services are genuinely nicer to operate than a JVM cluster. Neither has the deployment history that makes searchkick a boring choice.

Where to stop, and what would move the line

Stop at rung two. A stored tsvector generated column with a GIN index and websearch_to_tsquery is about fifteen lines of migration, no dependency, no second service, and it handles stemming, phrases, negation, OR, and weighted ranking. For the search box on a SaaS product with a few hundred thousand rows, that is the whole feature, and the plans above are the evidence rather than an opinion.

The cost of that recommendation, stated so it can be argued with: no typo tolerance, no synonyms, no facets, accents only if you are willing to declare a function immutable and mean it, and a ranking query whose cost grows with how common the search term is. You will also write the query parsing and the highlighting yourself, or add pg_search for them.

Take rung three when the scope configuration is the work: several models, multisearch, combining trigram with full text in one query, ts_headline snippets. Configure it with dictionary: "english" and tsvector_column: on day one, which makes it a nice API over the column you already have rather than a different implementation.

Take rung four when a product person asks for typo tolerance or faceted filtering by name, when search is the primary interface rather than a convenience, or when the corpus is large enough that the ranking sort above is the thing paging out your buffer cache. Not before. "We might need it later" is how a team ends up operating an Elasticsearch cluster for a search box that gets forty queries a day.

What would move this line: a GIN opclass that could return rows in rank order would delete the ranking section and most of the argument for rung four at small scale. A pg_search release that defaulted to english and to an existing tsvector column would make rung three the obvious starting point instead of a thing to configure carefully. And unaccent becoming immutable, or to_tsvector gaining accent folding, would remove the one gap that pushes non-English products up the ladder early.

What this post does not cover

The LaunchKit boilerplate this site sells has no search feature. No search gem is in its Gemfile, no tsvector column is in its schema, its only enabled extension is pg_catalog.plpgsql, and no controller in it builds a text query. Everything above came from a scratch database, pg_search's source, searchkick's README, and the PostgreSQL manual, which is the honest provenance for a post that would otherwise be tempted to point at a product.

Also absent: vector search and pgvector, which answers a different question than any rung here and belongs in its own post; the RUM index type, which does store rank information and would change the ranking section if it were packaged the way GIN is; MySQL's FULLTEXT indexes, where the operators and the plans are not these; multi-tenant search, where every query carries a tenant predicate the GIN index cannot serve and a composite btree_gin index usually can; and any millisecond timing, because buffer counts and row counts reproduce on your hardware and timings do not.

The pg_search reproductions ran against pg_search 2.4.0, which pulled activerecord 8.1.4 as a dependency. Every other Active Record result above is from 8.1.3.1, the version 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.