💎 Search Integration with TuskLang and Ruby
Search Integration with TuskLang and Ruby
This guide covers integrating search engines with TuskLang and Ruby applications for powerful full-text search capabilities.
Table of Contents
1. Overview 2. Installation 3. Basic Setup 4. Index Management 5. Search Implementation 6. Advanced Features 7. Performance Optimization 8. Testing 9. Deployment
Overview
Search integration provides powerful full-text search capabilities for applications. This guide shows how to integrate various search engines with TuskLang and Ruby applications.
Key Features
- Multiple search backends (Elasticsearch, Solr, PostgreSQL Full-Text Search) - Advanced querying with filters and aggregations - Real-time indexing and updates - Search suggestions and autocomplete - Faceted search and filtering - Search analytics and monitoring
Installation
Dependencies
Gemfile
gem 'elasticsearch'
gem 'searchkick'
gem 'pg_search'
gem 'sunspot_solr'
gem 'redis'
gem 'connection_pool'
TuskLang Configuration
config/search.tusk
search:
backend: "elasticsearch" # elasticsearch, solr, postgresql
elasticsearch:
url: "http://localhost:9200"
index_prefix: "tusk_"
number_of_shards: 1
number_of_replicas: 0
refresh_interval: "1s"
bulk_size: 1000
timeout: 30
solr:
url: "http://localhost:8983/solr"
core: "tusk_core"
timeout: 30
batch_size: 1000
postgresql:
dictionary: "english"
trigram_similarity_threshold: 0.3
full_text_search_enabled: true
indexing:
auto_index: true
batch_size: 100
background_jobs: true
real_time_updates: true
search:
default_operator: "AND"
fuzzy_matching: true
highlight_enabled: true
suggest_enabled: true
max_results: 1000
monitoring:
enabled: true
metrics_port: 9090
health_check_interval: 30
Basic Setup
Search Manager
app/search/search_manager.rb
class SearchManager
include Singleton def initialize
@config = Rails.application.config.search
@backend = create_backend
end
def index(model_class, records)
@backend.index(model_class, records)
end
def search(query, options = {})
@backend.search(query, options)
end
def suggest(query, options = {})
@backend.suggest(query, options)
end
def delete_index(index_name)
@backend.delete_index(index_name)
end
def reindex(model_class)
@backend.reindex(model_class)
end
def health_check
@backend.health_check
end
private
def create_backend
case @config[:backend]
when 'elasticsearch'
ElasticsearchBackend.new(@config[:elasticsearch])
when 'solr'
SolrBackend.new(@config[:solr])
when 'postgresql'
PostgreSQLBackend.new(@config[:postgresql])
else
raise "Unsupported search backend: #{@config[:backend]}"
end
end
end
Base Searchable
app/search/base_searchable.rb
module BaseSearchable
extend ActiveSupport::Concern included do
after_create :index_for_search
after_update :update_search_index
after_destroy :remove_from_search_index
end
def index_for_search
return unless searchable?
SearchManager.instance.index(self.class, [self])
end
def update_search_index
return unless searchable?
SearchManager.instance.index(self.class, [self])
end
def remove_from_search_index
SearchManager.instance.delete_document(self.class, id)
end
def searchable?
true
end
def search_data
raise NotImplementedError, "#{self.class} must implement search_data"
end
def search_suggestions
raise NotImplementedError, "#{self.class} must implement search_suggestions"
end
end
Index Management
Elasticsearch Backend
app/search/backends/elasticsearch_backend.rb
class ElasticsearchBackend
def initialize(config)
@config = config
@client = Elasticsearch::Client.new(url: config[:url])
@index_prefix = config[:index_prefix]
end def index(model_class, records)
index_name = index_name_for(model_class)
ensure_index_exists(index_name, model_class)
bulk_data = records.map do |record|
{
index: {
_index: index_name,
_id: record.id,
_type: '_doc',
data: record.search_data
}
}
end
@client.bulk(body: bulk_data) if bulk_data.any?
end
def search(query, options = {})
index_name = options[:index] || @index_prefix + '*'
search_params = build_search_params(query, options)
response = @client.search(index: index_name, body: search_params)
SearchResult.new(response, options[:model_class])
end
def suggest(query, options = {})
index_name = options[:index] || @index_prefix + '*'
suggest_params = {
index: index_name,
body: {
suggest: {
suggestions: {
prefix: query,
completion: {
field: 'suggest',
size: options[:size] || 10
}
}
}
}
}
response = @client.search(suggest_params)
response['suggest']['suggestions'].first['options'].map { |option| option['text'] }
end
def delete_index(index_name)
full_index_name = "#{@index_prefix}#{index_name}"
@client.indices.delete(index: full_index_name) if @client.indices.exists(index: full_index_name)
end
def reindex(model_class)
index_name = index_name_for(model_class)
temp_index_name = "#{index_name}_temp"
# Create temporary index
create_index(temp_index_name, model_class)
# Reindex all records
model_class.find_in_batches(batch_size: @config[:batch_size]) do |batch|
index(model_class, batch)
end
# Swap indices
@client.indices.delete(index: index_name) if @client.indices.exists(index: index_name)
@client.indices.put_alias(index: temp_index_name, name: index_name)
end
def health_check
begin
response = @client.cluster.health
{
status: response['status'],
number_of_nodes: response['number_of_nodes'],
active_shards: response['active_shards']
}
rescue => e
{ status: 'error', error: e.message }
end
end
private
def index_name_for(model_class)
"#{@index_prefix}#{model_class.name.underscore}"
end
def ensure_index_exists(index_name, model_class)
return if @client.indices.exists(index: index_name)
create_index(index_name, model_class)
end
def create_index(index_name, model_class)
mapping = build_mapping(model_class)
@client.indices.create(
index: index_name,
body: {
settings: {
number_of_shards: @config[:number_of_shards],
number_of_replicas: @config[:number_of_replicas],
refresh_interval: @config[:refresh_interval]
},
mappings: {
properties: mapping
}
}
)
end
def build_mapping(model_class)
# This would be customized based on the model's search_data method
{
id: { type: 'integer' },
created_at: { type: 'date' },
updated_at: { type: 'date' },
content: {
type: 'text',
analyzer: 'standard',
fields: {
keyword: { type: 'keyword' },
suggest: { type: 'completion' }
}
}
}
end
def build_search_params(query, options)
{
query: build_query(query, options),
highlight: build_highlight(options),
aggs: build_aggregations(options),
sort: build_sort(options),
from: options[:from] || 0,
size: options[:size] || @config[:search][:max_results]
}
end
def build_query(query, options)
if query.is_a?(String)
{
multi_match: {
query: query,
fields: options[:fields] || ['content^2', 'title'],
operator: @config[:search][:default_operator],
fuzziness: @config[:search][:fuzzy_matching] ? 'AUTO' : nil
}
}
else
query
end
end
def build_highlight(options)
return {} unless @config[:search][:highlight_enabled]
{
fields: {
content: {},
title: {}
}
}
end
def build_aggregations(options)
return {} unless options[:aggs]
options[:aggs].each_with_object({}) do |(name, config), aggs|
aggs[name] = {
terms: {
field: config[:field],
size: config[:size] || 10
}
}
end
end
def build_sort(options)
return [] unless options[:sort]
options[:sort].map do |field, direction|
{ field => { order: direction } }
end
end
end
Solr Backend
app/search/backends/solr_backend.rb
class SolrBackend
def initialize(config)
@config = config
@client = RSolr.connect(url: config[:url])
end def index(model_class, records)
documents = records.map { |record| build_document(record) }
@client.add(documents)
@client.commit
end
def search(query, options = {})
params = build_search_params(query, options)
response = @client.get('select', params: params)
SearchResult.new(response, options[:model_class])
end
def suggest(query, options = {})
params = {
q: query,
'suggest.dictionary': 'default',
'suggest.count': options[:size] || 10
}
response = @client.get('suggest', params: params)
response['suggest']['default'][query]['suggestions'].map { |s| s['term'] }
end
def delete_index(index_name)
@client.delete_by_query(":")
@client.commit
end
def reindex(model_class)
# Clear existing data
delete_index(model_class.name)
# Reindex all records
model_class.find_in_batches(batch_size: @config[:batch_size]) do |batch|
index(model_class, batch)
end
end
def health_check
begin
response = @client.get('admin/ping')
{ status: 'healthy' }
rescue => e
{ status: 'error', error: e.message }
end
end
private
def build_document(record)
{
id: record.id,
type: record.class.name,
created_at: record.created_at,
updated_at: record.updated_at,
**record.search_data
}
end
def build_search_params(query, options)
{
q: query,
start: options[:from] || 0,
rows: options[:size] || @config[:search][:max_results],
hl: @config[:search][:highlight_enabled],
'hl.fl': 'content,title',
sort: build_sort(options),
fq: build_filters(options)
}
end
def build_sort(options)
return nil unless options[:sort]
options[:sort].map { |field, direction| "#{field} #{direction}" }.join(',')
end
def build_filters(options)
return [] unless options[:filters]
options[:filters].map { |field, value| "#{field}:#{value}" }
end
end
PostgreSQL Backend
app/search/backends/postgresql_backend.rb
class PostgreSQLBackend
def initialize(config)
@config = config
end def index(model_class, records)
# PostgreSQL full-text search is typically handled through triggers
# This method would update the search vectors
records.each do |record|
update_search_vector(record)
end
end
def search(query, options = {})
sql = build_search_sql(query, options)
results = ActiveRecord::Base.connection.execute(sql)
SearchResult.new(results, options[:model_class])
end
def suggest(query, options = {})
sql = build_suggest_sql(query, options)
results = ActiveRecord::Base.connection.execute(sql)
results.map { |row| row['suggestion'] }
end
def delete_index(index_name)
# For PostgreSQL, this would drop the search index
ActiveRecord::Base.connection.execute("DROP INDEX IF EXISTS #{index_name}_search_idx")
end
def reindex(model_class)
# Rebuild search vectors for all records
model_class.find_in_batches(batch_size: @config[:batch_size]) do |batch|
batch.each { |record| update_search_vector(record) }
end
# Rebuild search index
rebuild_search_index(model_class)
end
def health_check
begin
ActiveRecord::Base.connection.execute("SELECT 1")
{ status: 'healthy' }
rescue => e
{ status: 'error', error: e.message }
end
end
private
def update_search_vector(record)
search_data = record.search_data
search_vector = build_search_vector(search_data)
record.class.where(id: record.id).update_all(
search_vector: search_vector,
updated_at: Time.current
)
end
def build_search_vector(search_data)
# Convert search data to PostgreSQL tsvector
content = search_data.values.compact.join(' ')
ActiveRecord::Base.connection.execute(
"SELECT to_tsvector('#{@config[:dictionary]}', #{ActiveRecord::Base.connection.quote(content)})"
).first['to_tsvector']
end
def build_search_sql(query, options)
tsquery = build_tsquery(query)
model_class = options[:model_class]
sql = "SELECT *, ts_rank(search_vector, #{tsquery}) as rank"
sql += " FROM #{model_class.table_name}"
sql += " WHERE search_vector @@ #{tsquery}"
sql += build_filters_sql(options[:filters]) if options[:filters]
sql += " ORDER BY rank DESC"
sql += " LIMIT #{options[:size] || @config[:search][:max_results]}"
sql += " OFFSET #{options[:from] || 0}"
sql
end
def build_tsquery(query)
# Convert query to PostgreSQL tsquery
ActiveRecord::Base.connection.execute(
"SELECT to_tsquery('#{@config[:dictionary]}', #{ActiveRecord::Base.connection.quote(query)})"
).first['to_tsquery']
end
def build_suggest_sql(query, options)
model_class = options[:model_class]
"SELECT DISTINCT suggestion FROM (
SELECT unnest(string_to_array(content, ' ')) as suggestion
FROM #{model_class.table_name}
WHERE content ILIKE #{ActiveRecord::Base.connection.quote("%#{query}%")}
) suggestions
WHERE similarity(suggestion, #{ActiveRecord::Base.connection.quote(query)}) > #{@config[:trigram_similarity_threshold]}
ORDER BY similarity(suggestion, #{ActiveRecord::Base.connection.quote(query)}) DESC
LIMIT #{options[:size] || 10}"
end
def build_filters_sql(filters)
return '' unless filters
conditions = filters.map do |field, value|
"#{field} = #{ActiveRecord::Base.connection.quote(value)}"
end
" AND #{conditions.join(' AND ')}"
end
def rebuild_search_index(model_class)
# Create or replace search index
index_name = "#{model_class.table_name}_search_idx"
ActiveRecord::Base.connection.execute("DROP INDEX IF EXISTS #{index_name}")
ActiveRecord::Base.connection.execute(
"CREATE INDEX #{index_name} ON #{model_class.table_name} USING gin(search_vector)"
)
end
end
Search Implementation
Search Result
app/search/search_result.rb
class SearchResult
attr_reader :total_count, :results, :aggregations, :highlights def initialize(response, model_class = nil)
@response = response
@model_class = model_class
parse_response
end
def results
@results ||= []
end
def total_count
@total_count ||= 0
end
def aggregations
@aggregations ||= {}
end
def highlights
@highlights ||= {}
end
def empty?
results.empty?
end
def any?
results.any?
end
def size
results.size
end
private
def parse_response
case @response
when Hash
parse_elasticsearch_response
when Array
parse_postgresql_response
else
parse_solr_response
end
end
def parse_elasticsearch_response
hits = @response['hits']
@total_count = hits['total']['value']
@results = hits['hits'].map { |hit| parse_elasticsearch_hit(hit) }
@aggregations = @response['aggregations'] || {}
@highlights = parse_highlights(@response['hits']['hits'])
end
def parse_elasticsearch_hit(hit)
{
id: hit['_id'],
score: hit['_score'],
source: hit['_source'],
highlights: hit['highlight']
}
end
def parse_solr_response
response = @response['response']
@total_count = response['numFound']
@results = response['docs'].map { |doc| parse_solr_doc(doc) }
@aggregations = parse_solr_facets(@response['facet_counts'])
@highlights = parse_solr_highlights(@response['highlighting'])
end
def parse_solr_doc(doc)
{
id: doc['id'],
score: doc['score'],
source: doc
}
end
def parse_postgresql_response
@total_count = @response.count
@results = @response.map { |row| parse_postgresql_row(row) }
end
def parse_postgresql_row(row)
{
id: row['id'],
score: row['rank'],
source: row
}
end
def parse_highlights(hits)
hits.each_with_object({}) do |hit, highlights|
highlights[hit['_id']] = hit['highlight'] if hit['highlight']
end
end
def parse_solr_facets(facet_counts)
return {} unless facet_counts
facet_counts['facet_fields'].each_with_object({}) do |(field, values), facets|
facets[field] = values.each_slice(2).map { |term, count| { term: term, count: count } }
end
end
def parse_solr_highlights(highlighting)
highlighting || {}
end
end
Search Service
app/search/search_service.rb
class SearchService
include Singleton def initialize
@search_manager = SearchManager.instance
@config = Rails.application.config.search
end
def search_users(query, options = {})
search(User, query, options.merge(index: 'users'))
end
def search_posts(query, options = {})
search(Post, query, options.merge(index: 'posts'))
end
def search_all(query, options = {})
search(nil, query, options)
end
def suggest_users(query, options = {})
suggest(User, query, options.merge(index: 'users'))
end
def suggest_posts(query, options = {})
suggest(Post, query, options.merge(index: 'posts'))
end
def index_user(user)
index(User, [user])
end
def index_post(post)
index(Post, [post])
end
def reindex_users
reindex(User)
end
def reindex_posts
reindex(Post)
end
private
def search(model_class, query, options = {})
Rails.logger.info "Searching #{model_class&.name || 'all'} for: #{query}"
start_time = Time.current
result = @search_manager.search(query, options.merge(model_class: model_class))
duration = Time.current - start_time
track_search_metrics(query, result.total_count, duration)
result
rescue => e
Rails.logger.error "Search error: #{e.message}"
track_search_error(query, e.message)
SearchResult.new({})
end
def suggest(model_class, query, options = {})
Rails.logger.info "Suggesting #{model_class&.name || 'all'} for: #{query}"
@search_manager.suggest(query, options.merge(model_class: model_class))
rescue => e
Rails.logger.error "Suggest error: #{e.message}"
[]
end
def index(model_class, records)
Rails.logger.info "Indexing #{records.size} #{model_class.name} records"
@search_manager.index(model_class, records)
rescue => e
Rails.logger.error "Indexing error: #{e.message}"
raise e
end
def reindex(model_class)
Rails.logger.info "Reindexing #{model_class.name}"
@search_manager.reindex(model_class)
rescue => e
Rails.logger.error "Reindexing error: #{e.message}"
raise e
end
def track_search_metrics(query, total_count, duration)
return unless @config[:monitoring][:enabled]
# Implementation would send metrics to monitoring system
Rails.logger.debug "Search metric: #{query} - #{total_count} results - #{duration * 1000}ms"
end
def track_search_error(query, error)
return unless @config[:monitoring][:enabled]
# Implementation would send error metrics to monitoring system
Rails.logger.debug "Search error metric: #{query} - #{error}"
end
end
Advanced Features
Faceted Search
app/search/faceted_search.rb
class FacetedSearch
def initialize(base_query, facets = {})
@base_query = base_query
@facets = facets
end def search(model_class, options = {})
search_options = build_search_options(options)
result = SearchService.instance.search(model_class, @base_query, search_options)
FacetedSearchResult.new(result, @facets)
end
private
def build_search_options(options)
{
aggs: build_aggregations,
filters: build_filters(options[:filters]),
sort: options[:sort],
from: options[:from],
size: options[:size]
}
end
def build_aggregations
@facets.each_with_object({}) do |(field, config), aggs|
aggs[field] = {
field: config[:field],
size: config[:size] || 10
}
end
end
def build_filters(selected_filters)
return {} unless selected_filters
selected_filters.each_with_object({}) do |(field, values), filters|
filters[field] = Array(values)
end
end
end
app/search/faceted_search_result.rb
class FacetedSearchResult
attr_reader :search_result, :facets def initialize(search_result, facet_config)
@search_result = search_result
@facet_config = facet_config
@facets = build_facets
end
def results
@search_result.results
end
def total_count
@search_result.total_count
end
def aggregations
@search_result.aggregations
end
private
def build_facets
@facet_config.each_with_object({}) do |(name, config), facets|
aggregation = @search_result.aggregations[name]
facets[name] = Facet.new(name, config, aggregation)
end
end
end
app/search/facet.rb
class Facet
attr_reader :name, :config, :aggregation def initialize(name, config, aggregation)
@name = name
@config = config
@aggregation = aggregation
end
def values
return [] unless @aggregation
@aggregation['buckets'].map do |bucket|
FacetValue.new(
value: bucket['key'],
count: bucket['doc_count'],
selected: false
)
end
end
def selected_values
values.select(&:selected?)
end
end
app/search/facet_value.rb
class FacetValue
attr_reader :value, :count, :selected def initialize(value:, count:, selected:)
@value = value
@count = count
@selected = selected
end
def selected?
@selected
end
end
Search Analytics
app/search/analytics/search_analytics.rb
class SearchAnalytics
include Singleton def initialize
@redis = Redis.new
end
def track_search(query, result_count, duration, user_id = nil)
timestamp = Time.current
# Track search query
track_query(query, timestamp)
# Track search metrics
track_metrics(query, result_count, duration, timestamp)
# Track user behavior
track_user_behavior(user_id, query, result_count, timestamp) if user_id
end
def track_click(query, document_id, position, user_id = nil)
timestamp = Time.current
# Track click
track_click_event(query, document_id, position, timestamp)
# Track user click behavior
track_user_click(user_id, query, document_id, position, timestamp) if user_id
end
def get_popular_queries(limit = 10)
@redis.zrevrange('search:queries', 0, limit - 1, withscores: true)
end
def get_zero_result_queries(limit = 10)
@redis.zrevrange('search:zero_results', 0, limit - 1, withscores: true)
end
def get_search_metrics(days = 7)
end_date = Date.current
start_date = end_date - days.days
{
total_searches: get_total_searches(start_date, end_date),
average_results: get_average_results(start_date, end_date),
average_duration: get_average_duration(start_date, end_date),
zero_result_rate: get_zero_result_rate(start_date, end_date)
}
end
private
def track_query(query, timestamp)
key = "search:queries"
@redis.zincrby(key, 1, query.downcase)
@redis.expire(key, 30.days.to_i)
end
def track_metrics(query, result_count, duration, timestamp)
# Track result count
result_key = "search:results:#{query.downcase}"
@redis.lpush(result_key, result_count)
@redis.ltrim(result_key, 0, 99) # Keep last 100 searches
@redis.expire(result_key, 30.days.to_i)
# Track duration
duration_key = "search:duration:#{query.downcase}"
@redis.lpush(duration_key, duration)
@redis.ltrim(duration_key, 0, 99)
@redis.expire(duration_key, 30.days.to_i)
# Track zero results
if result_count == 0
zero_key = "search:zero_results"
@redis.zincrby(zero_key, 1, query.downcase)
@redis.expire(zero_key, 30.days.to_i)
end
end
def track_user_behavior(user_id, query, result_count, timestamp)
key = "user:search:#{user_id}"
@redis.lpush(key, {
query: query,
result_count: result_count,
timestamp: timestamp.iso8601
}.to_json)
@redis.ltrim(key, 0, 99)
@redis.expire(key, 30.days.to_i)
end
def track_click_event(query, document_id, position, timestamp)
key = "search:clicks:#{query.downcase}"
@redis.lpush(key, {
document_id: document_id,
position: position,
timestamp: timestamp.iso8601
}.to_json)
@redis.ltrim(key, 0, 99)
@redis.expire(key, 30.days.to_i)
end
def track_user_click(user_id, query, document_id, position, timestamp)
key = "user:clicks:#{user_id}"
@redis.lpush(key, {
query: query,
document_id: document_id,
position: position,
timestamp: timestamp.iso8601
}.to_json)
@redis.ltrim(key, 0, 99)
@redis.expire(key, 30.days.to_i)
end
def get_total_searches(start_date, end_date)
# Implementation would count searches in date range
0
end
def get_average_results(start_date, end_date)
# Implementation would calculate average results in date range
0
end
def get_average_duration(start_date, end_date)
# Implementation would calculate average duration in date range
0
end
def get_zero_result_rate(start_date, end_date)
# Implementation would calculate zero result rate in date range
0
end
end
Performance Optimization
Search Caching
app/search/caching/search_cache.rb
class SearchCache
include Singleton def initialize
@redis = Redis.new
@config = Rails.application.config.search
end
def get(query, options = {})
key = cache_key(query, options)
cached = @redis.get(key)
if cached
JSON.parse(cached)
else
nil
end
end
def set(query, options = {}, result)
key = cache_key(query, options)
ttl = cache_ttl(query, options)
@redis.setex(key, ttl, result.to_json)
end
def invalidate(pattern = nil)
if pattern
keys = @redis.keys("search:cache:#{pattern}")
@redis.del(*keys) if keys.any?
else
keys = @redis.keys("search:cache:*")
@redis.del(*keys) if keys.any?
end
end
private
def cache_key(query, options)
options_str = options.sort.to_h.to_json
"search:cache:#{Digest::MD5.hexdigest("#{query}#{options_str}")}"
end
def cache_ttl(query, options)
# Longer TTL for popular queries
if popular_query?(query)
1.hour.to_i
else
15.minutes.to_i
end
end
def popular_query?(query)
# Check if query is in top searches
rank = @redis.zrevrank('search:queries', query.downcase)
rank && rank < 100
end
end
Testing
Search Test Helper
spec/support/search_helper.rb
module SearchHelper
def index_test_data
# Index test data for search tests
User.all.each { |user| SearchService.instance.index_user(user) }
Post.all.each { |post| SearchService.instance.index_post(post) }
end def clear_search_indexes
SearchManager.instance.delete_index('users')
SearchManager.instance.delete_index('posts')
end
def expect_search_results(query, expected_count)
result = SearchService.instance.search_all(query)
expect(result.total_count).to eq(expected_count)
end
def expect_search_suggestions(query, expected_suggestions)
suggestions = SearchService.instance.suggest_all(query)
expect(suggestions).to include(*expected_suggestions)
end
end
RSpec.configure do |config|
config.include SearchHelper, type: :search
config.before(:each, type: :search) do
clear_search_indexes
end
config.after(:each, type: :search) do
index_test_data
end
end
Search Tests
spec/search/search_service_spec.rb
RSpec.describe SearchService, type: :search do
let(:service) { SearchService.instance }
let(:user) { create(:user, name: 'John Doe', email: 'john@example.com') }
let(:post) { create(:post, title: 'Ruby on Rails Guide', content: 'Learn Ruby on Rails') } before do
index_test_data
end
describe '#search_users' do
it 'finds users by name' do
result = service.search_users('John')
expect(result.total_count).to eq(1)
expect(result.results.first[:source]['name']).to eq('John Doe')
end
it 'finds users by email' do
result = service.search_users('john@example.com')
expect(result.total_count).to eq(1)
expect(result.results.first[:source]['email']).to eq('john@example.com')
end
end
describe '#search_posts' do
it 'finds posts by title' do
result = service.search_posts('Rails')
expect(result.total_count).to eq(1)
expect(result.results.first[:source]['title']).to eq('Ruby on Rails Guide')
end
it 'finds posts by content' do
result = service.search_posts('Learn Ruby')
expect(result.total_count).to eq(1)
expect(result.results.first[:source]['content']).to eq('Learn Ruby on Rails')
end
end
describe '#suggest_users' do
it 'suggests user names' do
suggestions = service.suggest_users('Jo')
expect(suggestions).to include('John')
end
end
end
Deployment
Production Configuration
config/environments/production.rb
Rails.application.configure do
# Search configuration
config.search = {
backend: ENV['SEARCH_BACKEND'] || 'elasticsearch',
elasticsearch: {
url: ENV['ELASTICSEARCH_URL'] || 'http://localhost:9200',
index_prefix: ENV['ELASTICSEARCH_INDEX_PREFIX'] || 'tusk_',
number_of_shards: ENV['ELASTICSEARCH_SHARDS'] || 1,
number_of_replicas: ENV['ELASTICSEARCH_REPLICAS'] || 0,
refresh_interval: ENV['ELASTICSEARCH_REFRESH_INTERVAL'] || '1s',
bulk_size: ENV['ELASTICSEARCH_BULK_SIZE'] || 1000,
timeout: ENV['ELASTICSEARCH_TIMEOUT'] || 30
},
solr: {
url: ENV['SOLR_URL'] || 'http://localhost:8983/solr',
core: ENV['SOLR_CORE'] || 'tusk_core',
timeout: ENV['SOLR_TIMEOUT'] || 30,
batch_size: ENV['SOLR_BATCH_SIZE'] || 1000
},
postgresql: {
dictionary: ENV['POSTGRESQL_DICTIONARY'] || 'english',
trigram_similarity_threshold: ENV['POSTGRESQL_TRIGRAM_THRESHOLD'] || 0.3,
full_text_search_enabled: ENV['POSTGRESQL_FULL_TEXT_ENABLED'] != 'false'
},
indexing: {
auto_index: ENV['SEARCH_AUTO_INDEX'] != 'false',
batch_size: ENV['SEARCH_BATCH_SIZE'] || 100,
background_jobs: ENV['SEARCH_BACKGROUND_JOBS'] != 'false',
real_time_updates: ENV['SEARCH_REAL_TIME_UPDATES'] != 'false'
},
search: {
default_operator: ENV['SEARCH_DEFAULT_OPERATOR'] || 'AND',
fuzzy_matching: ENV['SEARCH_FUZZY_MATCHING'] != 'false',
highlight_enabled: ENV['SEARCH_HIGHLIGHT_ENABLED'] != 'false',
suggest_enabled: ENV['SEARCH_SUGGEST_ENABLED'] != 'false',
max_results: ENV['SEARCH_MAX_RESULTS'] || 1000
},
monitoring: {
enabled: ENV['SEARCH_MONITORING_ENABLED'] != 'false',
metrics_port: ENV['SEARCH_METRICS_PORT'] || 9090,
health_check_interval: ENV['SEARCH_HEALTH_CHECK_INTERVAL'] || 30
}
}
end
Docker Configuration
Dockerfile.search
FROM ruby:3.2-alpineRUN apk add --no-cache \
build-base \
elasticsearch
WORKDIR /app
COPY Gemfile Gemfile.lock ./
RUN bundle install --jobs 4 --retry 3
COPY . .
CMD ["bundle", "exec", "ruby", "app/search/search_runner.rb"]
docker-compose.search.yml
version: '3.8'services:
search-service:
build:
context: .
dockerfile: Dockerfile.search
environment:
- RAILS_ENV=production
- ELASTICSEARCH_URL=http://elasticsearch:9200
- SEARCH_BACKEND=elasticsearch
depends_on:
- elasticsearch
- redis
elasticsearch:
image: elasticsearch:8.8.0
environment:
- discovery.type=single-node
- xpack.security.enabled=false
volumes:
- elasticsearch_data:/usr/share/elasticsearch/data
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
volumes:
elasticsearch_data:
redis_data:
This comprehensive search integration guide provides everything needed to build powerful search capabilities with TuskLang and Ruby, including multiple backend support, advanced querying, faceted search, analytics, performance optimization, testing, and deployment strategies.