Module: Models::Embeddable
- Extended by:
- ActiveSupport::Concern
- Included in:
- Activity, Article, AssistantBrainEntry, CallRecord, Communication, Image, Item, ProductLine, ReviewsIo, Showcase, SiteMap, Video
- Defined in:
- app/concerns/models/embeddable.rb
Overview
Concern for models that support vector embeddings for semantic search.
Include this in any model that should be searchable via AI-powered
semantic search.
Constant Summary collapse
- MAX_CONTENT_LENGTH =
Maximum content length for embedding (roughly 30k chars, within the
Gemini text window). 30_000
Has many collapse
-
#content_embeddings ⇒ ActiveRecord::Associations::CollectionProxy<ContentEmbedding>
Embedding rows for this record.
Class Method Summary collapse
-
.embeddable_content_types ⇒ Array<Symbol>
Override in model to define what content types are embeddable.
-
.embedding_partition_class ⇒ Class?
Returns the partition embedding class for this model.
-
.regenerate_all_embeddings(batch_size: 100, scope: nil) ⇒ Integer
Batch regenerate embeddings for all records by enqueueing
EmbeddingWorkerfor each record in scope. -
.semantic_search(query, limit: 10) ⇒ Array<ApplicationRecord>
Semantic search within this model type, over the unified Gemini space.
-
.with_shape_lock(embeddable_type:, embeddable_id:, content_type:, locale:) ⇒ Object
Serialize one embedding storage-shape swap across the live and legacy writers.
Instance Method Summary collapse
-
#content_for_embedding(_content_type = :primary) ⇒ String
Override in model to provide content for embedding.
-
#embeddable_locales ⇒ Array<String>
Override in model to specify all locales that should have embeddings.
-
#embedding_content_hash(content_type = :primary, locale: nil) ⇒ String
Generate content hash for change detection.
-
#embedding_current?(embedding, content_type = :primary, locale: nil) ⇒ Boolean
Verify that an already-loaded single embedding row matches the record's current source content without querying for that row again.
-
#embedding_eligible? ⇒ Boolean
Whether this record is currently allowed to retain embeddings.
-
#embedding_stale?(content_type = :primary, locale: nil) ⇒ Boolean
Check whether the embedding for
content_type/localeneeds regeneration. -
#embedding_type_name ⇒ String
Returns the type name to use for
content_embeddings. -
#embedding_vector ⇒ Array<Float>?
Returns the primary embedding vector for this record.
-
#find_content_embedding(content_type = :primary, locale: nil) ⇒ ContentEmbedding?
Find a content embedding using the correct type name for STI models.
-
#find_similar(limit: 5, same_type_only: true) ⇒ Array<ApplicationRecord>
Find content similar to this record via the shared
ContentEmbeddingsimilarity index. -
#generate_all_embeddings!(force: false) ⇒ Array<ContentEmbedding>
Generate embeddings for all content types declared by
embeddable_content_types. -
#generate_chunked_embeddings!(content_type = :primary, force: false, locale: nil) ⇒ Array<ContentEmbedding>
Generate chunked embeddings for long content.
-
#generate_embedding!(content_type = :primary, force: false, locale: nil) ⇒ ContentEmbedding, ...
Generate or update an embedding for this record.
-
#has_embedding?(content_type = :primary, locale: nil) ⇒ Boolean
Check whether this record has an embedding for the given content type and locale.
-
#locale_for_embedding ⇒ String
Override in model to specify the locale for embedding content.
-
#needs_chunking?(content_type = :primary) ⇒ Boolean
Check whether content for
content_typewould need chunking before embedding (i.e. exceeds the token limit).
Class Method Details
.embeddable_content_types ⇒ Array<Symbol>
Override in model to define what content types are embeddable.
Common types: :primary, :visual, :transcript, :specifications.
70 71 72 |
# File 'app/concerns/models/embeddable.rb', line 70 def [:primary] end |
.embedding_partition_class ⇒ Class?
Returns the partition embedding class for this model. Maps model
names to their ContentEmbedding partition subclasses by convention.
121 122 123 124 |
# File 'app/concerns/models/embeddable.rb', line 121 def partition_class_name = "ContentEmbedding::#{name}Embedding" partition_class_name.safe_constantize end |
.regenerate_all_embeddings(batch_size: 100, scope: nil) ⇒ Integer
Batch regenerate embeddings for all records by enqueueing
EmbeddingWorker for each record in scope.
85 86 87 88 89 90 91 92 93 94 95 96 |
# File 'app/concerns/models/embeddable.rb', line 85 def (batch_size: 100, scope: nil) records = scope || all count = 0 records.find_each(batch_size: batch_size) do |record| EmbeddingWorker.perform_async(record.class.name, record.id) count += 1 end Rails.logger.info "Queued #{count} #{name} records for embedding generation" count end |
.semantic_search(query, limit: 10) ⇒ Array<ApplicationRecord>
Semantic search within this model type, over the unified Gemini space.
Extra keyword args (e.g. +:locale+, +:exclude_sensitive+) are forwarded to
ContentEmbedding.unified_hybrid_search.
108 109 110 111 |
# File 'app/concerns/models/embeddable.rb', line 108 def semantic_search(query, limit: 10, **) ContentEmbedding.unified_hybrid_search(query, limit: limit, types: [name], **) .map(&:embeddable) end |
.with_shape_lock(embeddable_type:, embeddable_id:, content_type:, locale:) ⇒ Object
Serialize one embedding storage-shape swap across the live and legacy
writers. Gemini calls stay outside this lock; only the final recheck and
database write are protected.
47 48 49 50 |
# File 'app/concerns/models/embeddable.rb', line 47 def self.with_shape_lock(embeddable_type:, embeddable_id:, content_type:, locale:, &) key = ['embedding_swap', , , content_type, locale].join('/') ContentEmbedding.with_advisory_lock!(key, timeout_seconds: 30, &) end |
Instance Method Details
#content_embeddings ⇒ ActiveRecord::Associations::CollectionProxy<ContentEmbedding>
Returns embedding rows for this record.
55 |
# File 'app/concerns/models/embeddable.rb', line 55 has_many :content_embeddings, as: :embeddable, dependent: :destroy |
#content_for_embedding(_content_type = :primary) ⇒ String
Override in model to provide content for embedding. The returned
string is what gets converted to a vector embedding.
136 137 138 |
# File 'app/concerns/models/embeddable.rb', line 136 def (_content_type = :primary) raise NotImplementedError, "#{self.class} must implement #content_for_embedding" end |
#embeddable_locales ⇒ Array<String>
Override in model to specify all locales that should have embeddings.
Return an array if content exists in multiple languages. Defaults to
only the primary locale.
166 167 168 |
# File 'app/concerns/models/embeddable.rb', line 166 def [] end |
#embedding_content_hash(content_type = :primary, locale: nil) ⇒ String
Generate content hash for change detection.
When a model's content_for_embedding accepts a locale: keyword
(e.g. Post, where Liquid rendering varies per locale) the hash is
computed for that locale so stale-detection is also per-locale.
Models that do not declare a locale: keyword receive the same hash
regardless of locale, preserving their existing behaviour.
182 183 184 185 |
# File 'app/concerns/models/embeddable.rb', line 182 def (content_type = :primary, locale: nil) content = (content_type, locale || ).to_s Digest::SHA256.hexdigest(content)[0..31] end |
#embedding_current?(embedding, content_type = :primary, locale: nil) ⇒ Boolean
Verify that an already-loaded single embedding row matches the record's
current source content without querying for that row again. A single row
can never prove a chunked embedding set is complete, so chunked records
deliberately return false and must use #embedding_stale?.
213 214 215 216 217 218 219 220 221 |
# File 'app/concerns/models/embeddable.rb', line 213 def (, content_type = :primary, locale: nil) return false if locale ||= !( , expected_hash: (content_type, locale:) ) end |
#embedding_eligible? ⇒ Boolean
Whether this record is currently allowed to retain embeddings. Models with
lifecycle or corpus rules override this; unrestricted embeddables remain
eligible by default.
MUST stay public. It is called with an explicit receiver on a different
object — with_embedding_write_lock re-reads the row under a lock and asks
locked_record.embedding_eligible? — which Ruby forbids for a private
method even between instances of the same class. Living below private made
that raise NoMethodError: private method 'embedding_eligible?' called for
every embeddable except Article, which happened to declare its own public
override (AppSignal #3972).
476 477 478 |
# File 'app/concerns/models/embeddable.rb', line 476 def true end |
#embedding_stale?(content_type = :primary, locale: nil) ⇒ Boolean
Check whether the embedding for content_type/locale needs
regeneration.
194 195 196 197 198 199 200 201 202 |
# File 'app/concerns/models/embeddable.rb', line 194 def (content_type = :primary, locale: nil) locale ||= return (content_type, locale:) if ( (content_type, locale:), expected_hash: (content_type, locale:) ) end |
#embedding_type_name ⇒ String
Returns the type name to use for content_embeddings. Uses the actual
class name instead of the base class for STI models, so semantic
searches can filter by type.
308 309 310 |
# File 'app/concerns/models/embeddable.rb', line 308 def self.class.name end |
#embedding_vector ⇒ Array<Float>?
Returns the primary embedding vector for this record.
459 460 461 |
# File 'app/concerns/models/embeddable.rb', line 459 def (:primary)&. end |
#find_content_embedding(content_type = :primary, locale: nil) ⇒ ContentEmbedding?
Find a content embedding using the correct type name for STI models.
The logical content_type is mapped to its canonical Gemini "unified"
row (see #unified_content_type), since that is where vectors now live.
244 245 246 247 248 249 250 251 252 253 |
# File 'app/concerns/models/embeddable.rb', line 244 def (content_type = :primary, locale: nil) locale ||= ContentEmbedding.find_by( embeddable_type: , embeddable_id: id, content_type: unified_content_type(content_type), locale: locale.to_s ) end |
#find_similar(limit: 5, same_type_only: true) ⇒ Array<ApplicationRecord>
Find content similar to this record via the shared ContentEmbedding
similarity index.
450 451 452 453 |
# File 'app/concerns/models/embeddable.rb', line 450 def find_similar(limit: 5, same_type_only: true) ContentEmbedding.find_similar(self, limit: limit, same_type_only: same_type_only) .map(&:embeddable) end |
#generate_all_embeddings!(force: false) ⇒ Array<ContentEmbedding>
Generate embeddings for all content types declared by
embeddable_content_types.
433 434 435 436 437 |
# File 'app/concerns/models/embeddable.rb', line 433 def (force: false) self.class..flat_map do |content_type| Array.wrap((content_type, force: force)) end end |
#generate_chunked_embeddings!(content_type = :primary, force: false, locale: nil) ⇒ Array<ContentEmbedding>
Generate chunked embeddings for long content. Splits content into
overlapping chunks and creates an embedding for each. Use this for
documents that exceed the token limit (e.g. long articles, PDFs).
324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 |
# File 'app/concerns/models/embeddable.rb', line 324 def (content_type = :primary, force: false, locale: nil) unless .delete_all return [] end locale ||= locale_str = locale.to_s return [] unless force || (content_type, locale: locale) content = (content_type, locale_str) if content.blank? (content_type, locale: locale_str) return [] end chunker = Embedding::ContentChunker.new(content) chunk_prefix = unified_content_type(content_type) # A record must be represented by EITHER a single `primary` row OR # `primary_chunk_*` rows — never both, or retrieval would carry a stale # duplicate vector for it. In BOTH branches the stale shape is purged only # AFTER the replacement embeddings succeed, so a transient Gemini failure # can never delete a record's vectors and leave it unsearchable. unless chunker.needs_chunking? vector = (content, content_type:) return (content_type, locale: locale_str) do # A concurrent worker may have repaired this slot while Gemini ran. next [] unless force || (content_type, locale:) row = ( content_type, locale: locale_str, vector:, content_hash: Digest::SHA256.hexdigest(content.to_s)[0..31] ) # Only once the single row lands do we drop any prior chunk rows from a # longer previous version. if row ContentEmbedding.where( embeddable_type: , embeddable_id: id, locale: locale_str ).where('content_type LIKE ?', "#{chunk_prefix}_chunk_%").delete_all end [row].compact end || [] end # Long content: embed FIRST (batched Gemini call). If it fails, leave the # existing vectors untouched and let EmbeddingWorker's rescue retry. chunks = chunker.chunks vectors = Embedding::Gemini.(chunks, dimensions: ContentEmbedding::UNIFIED_DIMENSIONS) fresh = chunks.each_index.select { |i| vectors[i].present? } if fresh.empty? Rails.logger.warn "Chunk embedding produced no vectors for #{self.class}##{id}; leaving existing rows intact" return [] end # Atomic swap: drop the stale shape (plain primary + prior chunk rows) and # write the new chunk rows in one transaction. The shared advisory lock # serializes live and legacy writers for the same slot; without it two jobs # can interleave delete+insert and the loser hits the unique index # (AppSignal #3591). Timeout raises so Sidekiq retries the whole job. = (content_type, locale: locale_str) do # A concurrent worker may have repaired this slot while Gemini ran. next [] unless force || (content_type, locale:) ContentEmbedding.transaction do ContentEmbedding.where( embeddable_type: , embeddable_id: id, locale: locale_str ).where('content_type = ? OR content_type LIKE ?', chunk_prefix, "#{chunk_prefix}_chunk_%").delete_all fresh.map do |index| ContentEmbedding.create!( embeddable_type: , embeddable_id: id, content_type: "#{chunk_prefix}_chunk_#{index}", locale: locale_str, unified_embedding: vectors[index], embedding_model: ContentEmbedding::UNIFIED_MODEL, embedding_dimensions: ContentEmbedding::UNIFIED_DIMENSIONS, content_hash: Digest::SHA256.hexdigest(chunks[index])[0..31] ) end end end || [] Rails.logger.info "Generated #{.size} chunk embeddings for #{self.class}##{id} (locale: #{locale_str})" end |
#generate_embedding!(content_type = :primary, force: false, locale: nil) ⇒ ContentEmbedding, ...
Generate or update an embedding for this record.
Chunk-enabled records are automatically routed through
#generate_chunked_embeddings! so this entry point cannot create a
conflicting single row.
273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 |
# File 'app/concerns/models/embeddable.rb', line 273 def (content_type = :primary, force: false, locale: nil) unless .delete_all return end return (content_type, force:, locale:) if locale ||= locale_str = locale.to_s return unless force || (content_type, locale: locale) content = (content_type, locale_str) if content.blank? (content_type, locale: locale_str) return end vector = (content, content_type:) (content_type, locale: locale_str) do ( content_type, locale: locale_str, vector:, content_hash: Digest::SHA256.hexdigest(content.to_s)[0..31] ) end end |
#has_embedding?(content_type = :primary, locale: nil) ⇒ Boolean
Check whether this record has an embedding for the given content type
and locale.
230 231 232 233 234 |
# File 'app/concerns/models/embeddable.rb', line 230 def (content_type = :primary, locale: nil) locale ||= (content_type, locale:).present? || (content_type, locale:).exists? end |
#locale_for_embedding ⇒ String
Override in model to specify the locale for embedding content. This
determines which locale's content is embedded and enables
locale-filtered searches.
153 154 155 |
# File 'app/concerns/models/embeddable.rb', line 153 def 'en' # Default to English end |
#needs_chunking?(content_type = :primary) ⇒ Boolean
Check whether content for content_type would need chunking before
embedding (i.e. exceeds the token limit).
421 422 423 424 |
# File 'app/concerns/models/embeddable.rb', line 421 def needs_chunking?(content_type = :primary) content = (content_type, ).to_s Embedding::ContentChunker.new(content).needs_chunking? end |