Class: SemanticSearchService

Inherits:
Object
  • Object
show all
Defined in:
app/services/semantic_search_service.rb

Overview

Service for performing semantic search across all embeddable content types.
Uses pgvector for efficient vector similarity search with HNSW indexing.

Examples:

Basic search

SemanticSearchService.search("snow melting under pavers")

Search specific content types

SemanticSearchService.new("heated driveway", types: ['showcases', 'posts']).search

Convenience methods

SemanticSearchService.find_showcases("bathroom floor heating")
SemanticSearchService.find_videos("installation guide")

Constant Summary collapse

CONTENT_TYPE_MAPPING =

Map user-friendly type names to model class names

{
  'showcases' => 'Showcase',
  'showcase' => 'Showcase',
  'images' => 'Image',
  'image' => 'Image',
  'videos' => 'Video',
  'video' => 'Video',
  'posts' => 'Post',
  'post' => 'Post',
  'articles' => 'Article',
  'article' => 'Article',
  'products' => %w[Item ProductLine],
  'product' => %w[Item ProductLine],
  'items' => 'Item',
  'item' => 'Item',
  'product_lines' => 'ProductLine',
  'product_line' => 'ProductLine',
  'pages' => 'SiteMap',
  'page' => 'SiteMap',
  'reviews' => 'ReviewsIo',
  'review' => 'ReviewsIo',
  'publications' => 'Item',
  'publication' => 'Item',
  'pdfs' => 'Item',
  'pdf' => 'Item',
  'manuals' => 'Item',
  'manual' => 'Item',
  'datasheets' => 'Item',
  'datasheet' => 'Item',
  # Article STI subtypes
  'faqs' => 'ArticleFaq',
  'faq' => 'ArticleFaq',
  'technical' => 'ArticleTechnical',
  'training' => 'ArticleTraining',
  'procedures' => 'ArticleProcedure',
  'procedure' => 'ArticleProcedure',
  # Support case data (sensitive — requires exclude_sensitive: false)
  'support_notes' => 'Activity',
  'support_communications' => 'Communication'
}.freeze
TYPE_DISPLAY_NAMES =

Human-readable names for result formatting

{
  'Showcase' => 'Showcase',
  'Image' => 'Image',
  'Video' => 'Video',
  'Post' => 'Blog Post',
  'Article' => 'Article',
  'ProductLine' => 'Product Line',
  'SiteMap' => 'Page',
  'ReviewsIo' => 'Customer Review',
  'Item' => 'Publication/PDF',
  'Activity' => 'Support Note',
  'Communication' => 'Communication'
}.freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(query, options = {}) ⇒ SemanticSearchService

Returns a new instance of SemanticSearchService.

Parameters:

  • query (String)

    Natural language search query

  • options (Hash) (defaults to: {})

    Search options

Options Hash (options):

  • :limit (Integer)

    Maximum results (default: 10)

  • :types (Array<String>, String)

    Content types to search

  • :locale (String)

    Locale for content filtering (default: 'en')

  • :exclude_sensitive (Boolean)

    Whether to exclude sensitive types (default: true)



83
84
85
86
87
88
89
# File 'app/services/semantic_search_service.rb', line 83

def initialize(query, options = {})
  @query = query.to_s.strip
  @limit = options.fetch(:limit, 10)
  @types = normalize_types(options[:types])
  @locale = options.fetch(:locale, 'en')
  @exclude_sensitive = options.fetch(:exclude_sensitive, true)
end

Instance Attribute Details

#exclude_sensitiveObject (readonly)

Returns the value of attribute exclude_sensitive.



74
75
76
# File 'app/services/semantic_search_service.rb', line 74

def exclude_sensitive
  @exclude_sensitive
end

#limitObject (readonly)

Returns the value of attribute limit.



74
75
76
# File 'app/services/semantic_search_service.rb', line 74

def limit
  @limit
end

#localeObject (readonly)

Returns the value of attribute locale.



74
75
76
# File 'app/services/semantic_search_service.rb', line 74

def locale
  @locale
end

#queryObject (readonly)

Returns the value of attribute query.



74
75
76
# File 'app/services/semantic_search_service.rb', line 74

def query
  @query
end

#typesObject (readonly)

Returns the value of attribute types.



74
75
76
# File 'app/services/semantic_search_service.rb', line 74

def types
  @types
end

Class Method Details

.available?Boolean

Check if semantic search is available (has embeddings)

Returns:

  • (Boolean)

    true if embeddings exist



308
309
310
# File 'app/services/semantic_search_service.rb', line 308

def self.available?
  ContentEmbedding.exists?
end

.find_all(query, types: nil, limit: 10) ⇒ Array<ApplicationRecord>

Multi-type search returning records directly

Parameters:

  • query (String)

    Search query

  • types (Array<String>) (defaults to: nil)

    Content types to search

  • limit (Integer) (defaults to: 10)

    Maximum results

Returns:



300
301
302
# File 'app/services/semantic_search_service.rb', line 300

def self.find_all(query, types: nil, limit: 10)
  new(query, types: types, limit: limit).search.map { |r| r[:record] }
end

.find_article_faqs(query, limit: 10) ⇒ Array<Article>

Find FAQ articles matching a query

Parameters:

  • query (String)

    Search query

  • limit (Integer) (defaults to: 10)

    Maximum results

Returns:

  • (Array<Article>)

    Matching FAQ articles



249
250
251
# File 'app/services/semantic_search_service.rb', line 249

def self.find_article_faqs(query, limit: 10)
  new(query, types: ['faqs'], limit: limit).search.map { |r| r[:record] }
end

.find_article_press(query, limit: 10) ⇒ Array<Article>

Find press articles matching a query

Parameters:

  • query (String)

    Search query

  • limit (Integer) (defaults to: 10)

    Maximum results

Returns:

  • (Array<Article>)

    Matching press articles



289
290
291
# File 'app/services/semantic_search_service.rb', line 289

def self.find_article_press(query, limit: 10)
  new(query, types: ['press'], limit: limit).search.map { |r| r[:record] }
end

.find_article_procedures(query, limit: 10) ⇒ Array<Article>

Find procedure articles matching a query

Parameters:

  • query (String)

    Search query

  • limit (Integer) (defaults to: 10)

    Maximum results

Returns:

  • (Array<Article>)

    Matching procedure articles



269
270
271
# File 'app/services/semantic_search_service.rb', line 269

def self.find_article_procedures(query, limit: 10)
  new(query, types: ['procedures'], limit: limit).search.map { |r| r[:record] }
end

.find_article_technical(query, limit: 10) ⇒ Array<Article>

Find technical articles matching a query

Parameters:

  • query (String)

    Search query

  • limit (Integer) (defaults to: 10)

    Maximum results

Returns:

  • (Array<Article>)

    Matching technical articles



259
260
261
# File 'app/services/semantic_search_service.rb', line 259

def self.find_article_technical(query, limit: 10)
  new(query, types: ['technical'], limit: limit).search.map { |r| r[:record] }
end

.find_article_training(query, limit: 10) ⇒ Array<Article>

Find training articles matching a query

Parameters:

  • query (String)

    Search query

  • limit (Integer) (defaults to: 10)

    Maximum results

Returns:

  • (Array<Article>)

    Matching training articles



279
280
281
# File 'app/services/semantic_search_service.rb', line 279

def self.find_article_training(query, limit: 10)
  new(query, types: ['training'], limit: limit).search.map { |r| r[:record] }
end

.find_articles(query, limit: 10) ⇒ Array<Article>

Find articles (all types) matching a query

Parameters:

  • query (String)

    Search query

  • limit (Integer) (defaults to: 10)

    Maximum results

Returns:

  • (Array<Article>)

    Matching articles



199
200
201
# File 'app/services/semantic_search_service.rb', line 199

def self.find_articles(query, limit: 10)
  new(query, types: %w[posts articles], limit: limit).search.map { |r| r[:record] }
end

.find_images(query, limit: 10) ⇒ Array<Image>

Find images matching a query

Parameters:

  • query (String)

    Search query

  • limit (Integer) (defaults to: 10)

    Maximum results

Returns:

  • (Array<Image>)

    Matching images



169
170
171
# File 'app/services/semantic_search_service.rb', line 169

def self.find_images(query, limit: 10)
  new(query, types: ['images'], limit: limit).search.map { |r| r[:record] }
end

.find_pages(query, limit: 10) ⇒ Array<SiteMap>

Find pages matching a query

Parameters:

  • query (String)

    Search query

  • limit (Integer) (defaults to: 10)

    Maximum results

Returns:

  • (Array<SiteMap>)

    Matching static pages



219
220
221
# File 'app/services/semantic_search_service.rb', line 219

def self.find_pages(query, limit: 10)
  new(query, types: ['pages'], limit: limit).search.map { |r| r[:record] }
end

.find_posts(query, limit: 10) ⇒ Array<Post>

Find blog posts matching a query

Parameters:

  • query (String)

    Search query

  • limit (Integer) (defaults to: 10)

    Maximum results

Returns:

  • (Array<Post>)

    Matching posts



189
190
191
# File 'app/services/semantic_search_service.rb', line 189

def self.find_posts(query, limit: 10)
  new(query, types: ['posts'], limit: limit).search.map { |r| r[:record] }
end

.find_products(query, limit: 10) ⇒ Array<Item, ProductLine>

Find products (items and product lines) matching a query

Parameters:

  • query (String)

    Search query

  • limit (Integer) (defaults to: 10)

    Maximum results

Returns:



209
210
211
# File 'app/services/semantic_search_service.rb', line 209

def self.find_products(query, limit: 10)
  new(query, types: ['products'], limit: limit).search.map { |r| r[:record] }
end

.find_publications(query, limit: 10) ⇒ Array<Item>

Find publications/PDFs matching a query

Parameters:

  • query (String)

    Search query

  • limit (Integer) (defaults to: 10)

    Maximum results

Returns:

  • (Array<Item>)

    Matching publications



239
240
241
# File 'app/services/semantic_search_service.rb', line 239

def self.find_publications(query, limit: 10)
  new(query, types: ['publications'], limit: limit).search.map { |r| r[:record] }
end

.find_reviews(query, limit: 10) ⇒ Array<ReviewsIo>

Find customer reviews matching a query

Parameters:

  • query (String)

    Search query

  • limit (Integer) (defaults to: 10)

    Maximum results

Returns:



229
230
231
# File 'app/services/semantic_search_service.rb', line 229

def self.find_reviews(query, limit: 10)
  new(query, types: ['reviews'], limit: limit).search.map { |r| r[:record] }
end

.find_showcases(query, limit: 10) ⇒ Array<Showcase>

Find showcases matching a query

Parameters:

  • query (String)

    Search query

  • limit (Integer) (defaults to: 10)

    Maximum results

Returns:

  • (Array<Showcase>)

    Matching showcases



159
160
161
# File 'app/services/semantic_search_service.rb', line 159

def self.find_showcases(query, limit: 10)
  new(query, types: ['showcases'], limit: limit).search.map { |r| r[:record] }
end

.find_videos(query, limit: 10) ⇒ Array<Video>

Find videos matching a query

Parameters:

  • query (String)

    Search query

  • limit (Integer) (defaults to: 10)

    Maximum results

Returns:

  • (Array<Video>)

    Matching videos



179
180
181
# File 'app/services/semantic_search_service.rb', line 179

def self.find_videos(query, limit: 10)
  new(query, types: ['videos'], limit: limit).search.map { |r| r[:record] }
end

.search(query, **options) ⇒ Array<Hash>

Class-level search convenience method

Parameters:

  • query (String)

    Search query

  • options (Hash)

    Search options (see #initialize)

Returns:

  • (Array<Hash>)

    Search results



149
150
151
# File 'app/services/semantic_search_service.rb', line 149

def self.search(query, **options)
  new(query, options).search
end

.statsHash

Get embedding statistics

Returns:

  • (Hash)

    Statistics by content type



316
317
318
# File 'app/services/semantic_search_service.rb', line 316

def self.stats
  ContentEmbedding.group(:embeddable_type).count
end

Instance Method Details

#search(hybrid: true) ⇒ Array<Hash>

Perform semantic search

Examples:

Result format

[{
  type: 'Showcase',
  type_display: 'Showcase',
  id: 123,
  record: #<Showcase ...>,
  distance: 0.123,
  similarity: 0.877,
  content_type: 'primary'
}, ...]

Returns:

  • (Array<Hash>)

    Search results with metadata



106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
# File 'app/services/semantic_search_service.rb', line 106

def search(hybrid: true)
  return [] if query.blank?

  search_types, sti_type_filters = parse_types_with_filters

  embeddings = if hybrid
                 ContentEmbedding.hybrid_search(
                   query,
                   limit: limit * 3,
                   types: search_types,
                   locale: locale,
                   exclude_sensitive: exclude_sensitive
                 )
               else
                 ContentEmbedding.semantic_search(
                   query,
                   limit: limit * 3,
                   types: search_types,
                   locale: locale,
                   exclude_sensitive: exclude_sensitive
                 )
               end

  if sti_type_filters.any?
    embeddings = embeddings.select do |emb|
      next true unless emb.embeddable_type == 'Article'

      article = emb.embeddable
      next false unless article

      sti_type_filters.include?(article.class.name)
    end
  end

  format_results(embeddings)
end