Boost Your Rails App: Implement Full-Text Search with PostgreSQL and pg_search Gem

Full-text search with Rails and PostgreSQL using pg_search enhances user experience. It enables quick, precise searches across multiple models, with customizable ranking, highlighting, and suggestions. Performance optimization and analytics further improve functionality.

Boost Your Rails App: Implement Full-Text Search with PostgreSQL and pg_search Gem

Full-text search is a game-changer for any Rails app. It lets users find exactly what they’re looking for quickly and easily. I’ve implemented it on several projects, and it always gives the app that extra polish users love.

Let’s dive into how to set up full-text search with Rails and PostgreSQL using the pg_search gem. This combo is powerful and surprisingly simple to get going.

First things first, add the pg_search gem to your Gemfile:

gem 'pg_search'

Run bundle install to get it set up. Now, let’s say we have a Post model for our blog. We’ll add full-text search to this model.

In your post.rb file, include the PgSearch module and set up the search:

class Post < ApplicationRecord
  include PgSearch::Model
  
  pg_search_scope :search_full_text,
    against: {
      title: 'A',
      content: 'B'
    },
    using: {
      tsearch: { prefix: true }
    }
end

This sets up a search_full_text method on your Post model. The ‘against’ option specifies which fields to search, and the letters ‘A’ and ‘B’ set the priority (A is higher priority than B). The ‘using’ option configures the search to use PostgreSQL’s full-text search with prefix matching.

Now, you can use this in your controller:

def index
  @posts = params[:query].present? ? Post.search_full_text(params[:query]) : Post.all
end

This will search posts if a query is present, otherwise it’ll return all posts.

But what if you want to search across multiple models? Pg_search has you covered with multisearch. Let’s say we want to search posts and comments.

First, enable multisearch in an initializer:

# config/initializers/pg_search.rb
PgSearch.multisearch_options = {
  using: { tsearch: { prefix: true } }
}

Then, in your models:

class Post < ApplicationRecord
  include PgSearch::Model
  multisearchable against: [:title, :content]
end

class Comment < ApplicationRecord
  include PgSearch::Model
  multisearchable against: [:content]
end

Now you can search across both models:

results = PgSearch.multisearch('ruby on rails')

This returns a collection of PgSearch::Document objects. You can access the original record with result.searchable.

One cool thing about pg_search is its support for different search features. For example, you can use trigram similarity for fuzzy matching:

pg_search_scope :search_full_text,
  against: [:title, :content],
  using: {
    tsearch: { prefix: true },
    trigram: { threshold: 0.1 }
  }

This combines full-text search with trigram matching, which can catch misspellings and variations.

Another neat feature is the ability to rank results. You can customize this with a tsvector_column:

class AddSearchableToPosts < ActiveRecord::Migration[6.1]
  def up
    execute <<-SQL
      ALTER TABLE posts
      ADD COLUMN searchable tsvector GENERATED ALWAYS AS (
        setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
        setweight(to_tsvector('english', coalesce(content, '')), 'B')
      ) STORED;
    SQL
  end

  def down
    remove_column :posts, :searchable
  end
end

Then in your model:

pg_search_scope :search_full_text,
  against: 'searchable',
  using: {
    tsearch: { 
      dictionary: 'english',
      tsvector_column: 'searchable'
    }
  }

This can significantly speed up your searches, especially for large datasets.

Now, let’s talk about making your search results more relevant. One way to do this is by using weights:

pg_search_scope :search_full_text,
  against: {
    title: 'A',
    content: 'B',
    author: 'C'
  },
  using: {
    tsearch: {
      dictionary: 'english',
      any_word: true,
      prefix: true
    }
  }

This setup gives more importance to matches in the title than in the content or author fields.

You can also boost the relevance of more recent posts:

pg_search_scope :search_full_text,
  against: [:title, :content],
  using: {
    tsearch: { prefix: true }
  },
  order_within_rank: "posts.created_at DESC"

This ensures that, within posts with the same relevance score, more recent ones appear first.

One thing to keep in mind is performance. As your dataset grows, you might need to add indexes to speed things up:

class AddIndexesToPosts < ActiveRecord::Migration[6.1]
  def change
    add_index :posts, :title
    add_index :posts, :content
    execute "CREATE INDEX posts_full_text_search ON posts USING gin(to_tsvector('english', title || ' ' || content))"
  end
end

This creates a GIN (Generalized Inverted Index) which can significantly speed up full-text searches.

Now, let’s talk about handling search results in the view. You might want to highlight the matching terms in your search results. Pg_search doesn’t do this out of the box, but you can achieve it with a bit of Ruby:

def highlight(text, query)
  query.split.each do |word|
    text.gsub!(/(#{Regexp.escape(word)})/i, '<mark>\1</mark>')
  end
  text.html_safe
end

Use this in your view like so:

<% @posts.each do |post| %>
  <h2><%= highlight(post.title, params[:query]) %></h2>
  <p><%= highlight(truncate(post.content, length: 200), params[:query]) %></p>
<% end %>

This will wrap matching terms in tags, which you can style with CSS to highlight them.

Another cool feature you might want to add is search suggestions. You can implement this with a bit of JavaScript and an additional endpoint in your controller:

# posts_controller.rb
def suggest
  render json: Post.search_full_text(params[:term]).limit(5).pluck(:title)
end

Then in your JavaScript:

$('#search_input').autocomplete({
  source: '/posts/suggest',
  minLength: 2
});

This will show a dropdown of suggestions as the user types.

One thing I’ve found really useful is adding search analytics. You can log searches to understand what users are looking for:

class SearchLog < ApplicationRecord
  belongs_to :user, optional: true
end

class PostsController < ApplicationController
  def index
    @posts = params[:query].present? ? Post.search_full_text(params[:query]) : Post.all
    
    if params[:query].present?
      SearchLog.create(
        query: params[:query],
        results_count: @posts.count,
        user: current_user
      )
    end
  end
end

This can give you valuable insights into what your users are searching for, which can inform your content strategy or help you identify gaps in your site’s information architecture.

Lastly, don’t forget about internationalization. If your site supports multiple languages, you might need to adjust your search setup:

pg_search_scope :search_full_text,
  against: [:title, :content],
  using: {
    tsearch: {
      dictionary: 'simple'
    }
  }

Using the ‘simple’ dictionary instead of ‘english’ can work better for multi-language content.

Implementing full-text search with Rails and PostgreSQL using pg_search is a powerful way to enhance your app’s user experience. It’s flexible, performant, and can be customized to fit a wide variety of use cases. Whether you’re building a blog, an e-commerce site, or any other kind of web application, good search functionality can make a huge difference to your users.

Remember, the key to great search is not just in the implementation, but in continually refining and improving it based on user behavior and feedback. Keep an eye on your search logs, talk to your users, and don’t be afraid to experiment with different configurations to find what works best for your specific use case.