When I build search interfaces in Rails, I consistently find that a well-designed search function dramatically enhances user experience. In this article, I’ll share seven proven techniques for creating advanced search interfaces in Ruby on Rails applications that have served me well across numerous projects.
Full-Text Search Implementation
Full-text search extends beyond basic database queries to provide sophisticated text matching capabilities. In Rails applications, I typically use Elasticsearch through the Searchkick gem or PostgreSQL’s built-in full-text search capabilities.
With Searchkick, the implementation begins with model configuration:
class Product < ApplicationRecord
searchkick word_start: [:name, :description, :category],
searchable: [:name, :description, :category, :brand, :tags],
suggest: [:name, :category],
highlight: [:name, :description]
def search_data
{
name: name,
description: description,
category: category,
brand: brand,
tags: tags.map(&:name),
price: price,
rating: average_rating,
popularity: view_count,
in_stock: in_stock?,
created_at: created_at
}
end
end
For searching, I create a dedicated service to handle query building:
class ProductSearchService
def initialize(query, options = {})
@query = query
@options = options
@page = options[:page] || 1
@per_page = options[:per_page] || 24
end
def execute
search_options = {
fields: ["name^10", "description^5", "category^3", "tags"],
match: :word_start,
misspellings: {below: 5},
page: @page,
per_page: @per_page
}
# Add boosting by popularity and freshness
search_options[:boost_by] = {
popularity: {factor: 5},
created_at: {scale: "1d", decay: 0.5, factor: 5}
}
# Add highlighting
search_options[:highlight] = {
fields: {name: {}, description: {}},
tag: "<strong class='highlight'>"
}
# Execute search
Product.search(@query, search_options)
end
end
For PostgreSQL, I leverage the pg_search
gem and create scopes for flexible searching:
class Product < ApplicationRecord
include PgSearch::Model
pg_search_scope :search_full_text,
against: {
name: 'A',
description: 'B',
category: 'C'
},
using: {
tsearch: {
prefix: true,
dictionary: 'english',
any_word: true,
highlight: {
StartSel: '<strong>',
StopSel: '</strong>',
MaxWords: 123,
MinWords: 456,
ShortWord: 4,
HighlightAll: true,
MaxFragments: 3,
FragmentDelimiter: '…'
}
}
}
end
Faceted Search Architecture
I’ve found that faceted search significantly improves the user experience by allowing users to refine search results along multiple dimensions. The core of this system involves generating aggregations and applying filters.
First, I create a facet generator service:
class FacetGenerator
def initialize(base_query, current_filters = {})
@base_query = base_query
@current_filters = current_filters || {}
end
def generate
{
categories: category_facets,
price_ranges: price_range_facets,
brands: brand_facets,
ratings: rating_facets,
tags: tag_facets
}
end
private
def category_facets
results = @base_query.aggs(:category)
format_facet_results(results, :category)
end
def price_range_facets
ranges = [
{to: 25, name: "Under $25"},
{from: 25, to: 50, name: "$25 to $50"},
{from: 50, to: 100, name: "$50 to $100"},
{from: 100, to: 200, name: "$100 to $200"},
{from: 200, name: "$200 & Above"}
]
results = @base_query.aggs(:price, ranges: ranges)
format_range_facets(results, :price)
end
def brand_facets
results = @base_query.aggs(:brand, limit: 15, order: {"_count" => "desc"})
format_facet_results(results, :brand)
end
def rating_facets
results = @base_query.aggs(:rating, ranges: (1..5).map { |i| {from: i, to: i+1} })
format_range_facets(results, :rating)
end
def tag_facets
results = @base_query.aggs(:tags, limit: 20, order: {"_count" => "desc"})
format_facet_results(results, :tags)
end
def format_facet_results(results, field)
results.map do |term, count, selected|
selected = @current_filters[field.to_s]&.include?(term.to_s)
{
value: term,
count: count,
selected: !!selected
}
end
end
def format_range_facets(results, field)
results.map do |range, count, selected|
selected = false
if @current_filters[field.to_s].present?
min = @current_filters["#{field}_min"]
max = @current_filters["#{field}_max"]
selected = (min.present? && min.to_f == range[:from]) &&
(max.present? && max.to_f == range[:to])
end
{
from: range[:from],
to: range[:to],
name: range[:name],
count: count,
selected: selected
}
end
end
end
Then, I integrate facet generation into my search controller:
class SearchController < ApplicationController
def index
@search_service = ProductSearchService.new(
params[:q],
filters: filter_params,
page: params[:page],
per_page: params[:per_page]
)
@results = @search_service.execute
@facets = FacetGenerator.new(@results, filter_params).generate
respond_to do |format|
format.html
format.json { render json: { results: @results, facets: @facets } }
end
end
private
def filter_params
params.permit(
:price_min, :price_max, :rating_min,
category: [], brand: [], tags: []
).to_h
end
end
Typeahead Suggestions
Implementing typeahead suggestions creates a responsive, interactive search experience. I build this feature with a combination of server-side processing and client-side rendering.
First, I create a dedicated endpoint for suggestions:
class SuggestionsController < ApplicationController
def index
return render json: [] if params[:q].blank? || params[:q].length < 2
suggestions = Product.search(params[:q], {
fields: ["name^5", "category^2", "tags"],
match: :word_start,
limit: 10,
load: false,
misspellings: {below: 5}
})
render json: format_suggestions(suggestions)
end
private
def format_suggestions(suggestions)
suggestions.map do |suggestion|
{
id: suggestion.id,
name: suggestion.name,
category: suggestion.category,
highlight: suggestion.highlight&.name || suggestion.name,
url: product_path(suggestion)
}
end
end
end
On the client side, I implement the UI with Stimulus.js:
// app/javascript/controllers/typeahead_controller.js
import { Controller } from "stimulus"
export default class extends Controller {
static targets = ["input", "results", "selected"]
static values = { url: String, minLength: Number }
connect() {
this.results = []
this.selectedIndex = -1
this.debouncedFetch = this.debounce(this.fetchResults, 300)
}
inputChanged() {
const query = this.inputTarget.value.trim()
if (query.length < this.minLengthValue) {
this.hideResults()
return
}
this.debouncedFetch(query)
}
async fetchResults(query) {
try {
const response = await fetch(`${this.urlValue}?q=${encodeURIComponent(query)}`)
if (!response.ok) throw new Error("Network response failed")
this.results = await response.json()
this.renderResults()
} catch (error) {
console.error("Error fetching suggestions:", error)
this.hideResults()
}
}
renderResults() {
if (this.results.length === 0) {
this.hideResults()
return
}
this.resultsTarget.innerHTML = this.results.map((result, index) => `
<div class="suggestion-item" data-action="click->typeahead#selectResult" data-index="${index}">
<div class="suggestion-name">${result.highlight}</div>
<div class="suggestion-category">${result.category}</div>
</div>
`).join('')
this.resultsTarget.classList.remove('hidden')
}
hideResults() {
this.resultsTarget.innerHTML = ''
this.resultsTarget.classList.add('hidden')
this.selectedIndex = -1
}
selectResult(event) {
const index = parseInt(event.currentTarget.dataset.index)
const result = this.results[index]
this.inputTarget.value = result.name
this.selectedTarget.value = result.id
this.hideResults()
this.dispatch('selected', { detail: { result } })
}
keydown(event) {
switch(event.key) {
case 'ArrowDown':
event.preventDefault()
this.selectedIndex = Math.min(this.selectedIndex + 1, this.results.length - 1)
this.highlightSelected()
break
case 'ArrowUp':
event.preventDefault()
this.selectedIndex = Math.max(this.selectedIndex - 1, -1)
this.highlightSelected()
break
case 'Enter':
if (this.selectedIndex >= 0) {
event.preventDefault()
this.resultsTarget.querySelectorAll('.suggestion-item')[this.selectedIndex].click()
}
break
case 'Escape':
this.hideResults()
break
}
}
highlightSelected() {
this.resultsTarget.querySelectorAll('.suggestion-item').forEach((el, index) => {
if (index === this.selectedIndex) {
el.classList.add('selected')
} else {
el.classList.remove('selected')
}
})
}
debounce(func, wait) {
let timeout
return (...args) => {
clearTimeout(timeout)
timeout = setTimeout(() => func.apply(this, args), wait)
}
}
}
Search Result Highlighting
Highlighting matched terms in search results helps users quickly identify why a result appeared. I implement this using the highlighting features from search engines.
First, I ensure search results include highlighting information:
def execute_search
Product.search(@query, {
fields: ["name^10", "description^5", "category^3"],
highlight: {
fields: {
name: {number_of_fragments: 0},
description: {fragment_size: 150, number_of_fragments: 3}
},
pre_tags: ["<mark>"],
post_tags: ["</mark>"]
},
page: @page,
per_page: @per_page
})
end
Then, I create helper methods to process and display the highlights:
module SearchHelper
def highlight_field(result, field)
return result.send(field) unless result.respond_to?(:highlight) && result.highlight.present?
highlight = result.highlight[field]
return result.send(field) if highlight.blank?
if highlight.is_a?(Array)
highlight.join('... ').html_safe
else
highlight.html_safe
end
end
def snippet(result)
if result.respond_to?(:highlight) && result.highlight.present? && result.highlight[:description].present?
# Join multiple fragments with ellipses
result.highlight[:description].join(' ... ').html_safe
else
# Fallback to truncated description
truncate(result.description, length: 150)
end
end
end
Relevance Scoring and Customization
Configuring relevance scoring ensures the most important results appear first. I create a sophisticated boosting system that considers multiple factors:
class RelevanceCalculator
FIELD_WEIGHTS = {
name: 10,
category: 5,
description: 3,
tags: 2
}
BOOST_FACTORS = {
popularity: 2.0,
rating: 1.5,
in_stock: 1.2,
recent: 1.0
}
def initialize(query, options = {})
@query = query
@options = options
@custom_boosts = options[:boosts] || {}
end
def search_options
{
fields: weighted_fields,
boost_by: calculate_boosts,
boost_by_recency: recency_boost,
boost_where: calculate_boost_where,
match: @options[:match] || :word_start,
misspellings: {below: 5}
}
end
private
def weighted_fields
FIELD_WEIGHTS.map { |field, weight| "#{field}^#{weight}" }
end
def calculate_boosts
boosts = {}
# Add popularity boost
boosts[:popularity] = {factor: BOOST_FACTORS[:popularity]}
# Add rating boost
boosts[:rating] = {factor: BOOST_FACTORS[:rating]}
# Add custom boosts
@custom_boosts.each do |field, factor|
boosts[field] = {factor: factor.to_f}
end
boosts
end
def recency_boost
{
created_at: {
scale: "1w",
decay: 0.5,
factor: BOOST_FACTORS[:recent]
}
}
end
def calculate_boost_where
boost_where = {}
# Boost in-stock items
boost_where[:in_stock] = {
true => BOOST_FACTORS[:in_stock]
}
# Boost featured items
boost_where[:featured] = {
true => 1.5
}
# Seasonal boosting (example: boost winter items in winter)
if winter_season?
boost_where[:winter_product] = {
true => 1.3
}
end
boost_where
end
def winter_season?
current_month = Date.today.month
[12, 1, 2].include?(current_month)
end
end
I then integrate this into my search service:
class SearchService
def initialize(query, options = {})
@query = query
@options = options
@page = options[:page] || 1
@per_page = options[:per_page] || 20
end
def execute
return empty_results if @query.blank?
relevance_calculator = RelevanceCalculator.new(@query, {
boosts: @options[:boosts],
match: @options[:match_type]
})
search_options = relevance_calculator.search_options.merge({
where: build_filter_conditions,
page: @page,
per_page: @per_page,
highlight: highlight_options
})
Product.search(@query, search_options)
end
private
def build_filter_conditions
conditions = {}
if @options[:filters].present?
@options[:filters].each do |field, value|
next if value.blank?
conditions[field.to_sym] = value
end
end
conditions
end
def highlight_options
{
fields: {
name: {number_of_fragments: 0},
description: {fragment_size: 150, number_of_fragments: 3}
},
pre_tags: ["<mark>"],
post_tags: ["</mark>"]
}
end
def empty_results
Product.none.page(@page).per(@per_page)
end
end
Filter Combination Logic
Creating a flexible filter system that handles complex combinations requires careful design. I build a query builder that manages different filter relationships:
class SearchFilterBuilder
def initialize(base_params = {})
@filters = {}
@filter_operators = {}
@ranges = {}
@custom_filters = []
process_params(base_params)
end
def add_filter(field, values, operator = :or)
@filters[field.to_sym] = values.is_a?(Array) ? values : [values]
@filter_operators[field.to_sym] = operator
self
end
def add_range(field, min, max)
return self if min.blank? && max.blank?
range = {}
range[:gte] = min.to_f if min.present?
range[:lte] = max.to_f if max.present?
@ranges[field.to_sym] = range
self
end
def add_custom_filter(condition)
@custom_filters << condition
self
end
def build
conditions = {}
# Process standard filters
@filters.each do |field, values|
operator = @filter_operators[field]
if operator == :or
conditions[field] = values
elsif operator == :and
values.each do |value|
add_custom_filter("#{field} = '#{value}'")
end
end
end
# Process range filters
@ranges.each do |field, range|
conditions[field] = range
end
# Process custom filters
if @custom_filters.any?
conditions[:_or] = @custom_filters.map { |cf| {_script: {script: cf}} }
end
conditions
end
private
def process_params(params)
params.each do |key, value|
case key.to_s
when /(.+)_min$/
field = $1
add_range(field, value, params["#{field}_max"])
when /(.+)_max$/
# Handled with min
when /(.+)_in$/
add_filter($1, value.split(','), :or)
when /(.+)_all$/
add_filter($1, value.split(','), :and)
else
add_filter(key, value) unless value.blank?
end
end
end
end
I integrate this filter builder into my search service:
class SearchService
# Other methods...
private
def build_filter_conditions
filter_builder = SearchFilterBuilder.new(@options[:base_filters] || {})
# Add standard filters
if @options[:filters].present?
@options[:filters].each do |field, value|
next if value.blank?
filter_builder.add_filter(field, value)
end
end
# Add price range
if @options[:price_min].present? || @options[:price_max].present?
filter_builder.add_range(:price, @options[:price_min], @options[:price_max])
end
# Add rating filter
if @options[:rating_min].present?
filter_builder.add_range(:rating, @options[:rating_min], nil)
end
# Add custom availability filter
if @options[:in_stock].present? && @options[:in_stock] == '1'
filter_builder.add_filter(:in_stock, true)
end
# Add date range filter
if @options[:date_from].present? || @options[:date_to].present?
from = @options[:date_from].present? ? Date.parse(@options[:date_from]) : nil
to = @options[:date_to].present? ? Date.parse(@options[:date_to]).end_of_day : nil
filter_builder.add_range(:created_at, from, to)
end
filter_builder.build
end
end
Search Analytics Integration
Tracking and analyzing search behavior provides insights to improve the search experience. I implement analytics tracking with a dedicated service:
class SearchAnalyticsService
def initialize(user = nil)
@user = user
end
def log_search(query, filters = {}, results_count = 0, page = 1)
SearchLog.create!(
query: query,
filters: filters,
results_count: results_count,
page: page,
user: @user,
ip_address: current_request_ip,
user_agent: current_user_agent,
session_id: current_session_id
)
end
def log_click(search_log_id, product_id, position)
SearchClick.create!(
search_log_id: search_log_id,
product_id: product_id,
position: position,
user: @user,
session_id: current_session_id
)
end
def track_zero_results(query)
ZeroResultQuery.find_or_initialize_by(query: query.downcase).tap do |zr|
zr.count = zr.new_record? ? 1 : zr.count + 1
zr.last_searched_at = Time.current
zr.save!
end
end
def popular_searches(limit = 10)
SearchLog.where('created_at > ?', 30.days.ago)
.group(:query)
.select('query, COUNT(*) as count')
.order('count DESC')
.limit(limit)
end
def calculate_ctr(search_log_id)
log = SearchLog.find(search_log_id)
clicks = SearchClick.where(search_log_id: search_log_id).count
clicks.to_f / log.results_count if log.results_count > 0
end
private
def current_request_ip
RequestStore.store[:ip_address]
end
def current_user_agent
RequestStore.store[:user_agent]
end
def current_session_id
RequestStore.store[:session_id]
end
end
I integrate this into my search controller:
class SearchController < ApplicationController
def index
@search_service = SearchService.new(
params[:q],
filters: filter_params,
page: params[:page],
per_page: params[:per_page]
)
@results = @search_service.execute
@search_id = log_search_analytics(@results)
respond_to do |format|
format.html
format.json { render json: @results }
end
end
def click
analytics_service.log_click(
params[:search_id],
params[:product_id],
params[:position]
)
respond_to do |format|
format.json { head :ok }
end
end
private
def log_search_analytics(results)
search_log = analytics_service.log_search(
params[:q],
filter_params,
results.total_count,
params[:page]
)
if results.total_count == 0
analytics_service.track_zero_results(params[:q])
end
search_log.id
end
def analytics_service
@analytics_service ||= SearchAnalyticsService.new(current_user)
end
def filter_params
params.permit(
:price_min, :price_max, :rating_min, :in_stock,
:date_from, :date_to,
category: [], brand: [], tags: []
).to_h
end
end
On the client side, I track search result clicks:
// app/javascript/controllers/search_analytics_controller.js
import { Controller } from "stimulus"
export default class extends Controller {
static values = {
searchId: String,
clickUrl: String
}
connect() {
this.bindClickEvents()
}
bindClickEvents() {
const resultLinks = document.querySelectorAll('[data-product-id]')
resultLinks.forEach((link, index) => {
link.addEventListener('click', (event) => {
this.trackClick(link.dataset.productId, index + 1)
})
})
}
async trackClick(productId, position) {
try {
const response = await fetch(this.clickUrlValue, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({
search_id: this.searchIdValue,
product_id: productId,
position: position
})
})
} catch (error) {
console.error('Error tracking search click:', error)
}
}
}
By implementing these seven techniques, I’ve created search interfaces that are powerful, user-friendly, and adaptable. Each technique addresses different aspects of search functionality, from basic query execution to sophisticated user interactions and analytics.
The key to success is approaching search as a holistic system, where each component works together to create a cohesive experience. While the implementation details may vary based on specific project requirements, these core patterns have proven effective across a wide range of Rails applications.
Remember that search is iterative – gather feedback, analyze the data from your analytics integration, and continuously refine your implementation to better serve your users’ needs. With these techniques as a foundation, you’re well-equipped to build truly exceptional search experiences in your Rails applications.