embed_sync 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 42ea81557212045ba1955a65c47be73695e265fe5d807faa862598d76edcac2a
4
+ data.tar.gz: b50f6e052155f4e38620fb04a0e4cad96cd813e97bcd6def5e564604456ef911
5
+ SHA512:
6
+ metadata.gz: 796b3a5fe7f8fd1370233162025afe79269a449d615f84f44e46ee63558a00cc1ca7b1e4d9796219d5389194a27f3bf3b3b066823200aeb1d2fff02070dd8380
7
+ data.tar.gz: 90fcc0a2dc2638d9cc0cf1045642e8920d9ac626c2ee5b890d771a978e3b5939288647435b5c1b357f825e79e2852840585fba081608c44d72ccf07b73fe4bbe
data/CHANGELOG.md ADDED
@@ -0,0 +1,18 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-10-30
4
+
5
+ First release.
6
+
7
+ - `has_embedding` macro: attribute list or lambda as the source, `model:`, `dimensions:`, `sync:`, `if:`/`unless:`
8
+ - Change detection with a digest of the text, model and dimensions (`<column>_stale?`, `<column>_missing` scope)
9
+ - Background sync after commit with `EmbedSync::EmbedJob`; `:inline` and disabled modes; `EmbedSync.without_sync`
10
+ - Generators: `embed_sync:install` and `embed_sync MODEL attrs...` (pgvector migration with HNSW/IVFFlat index)
11
+ - `EmbedSync.backfill` / `EmbedSync.stale_report` and the `embed_sync:backfill` / `embed_sync:stale` rake tasks
12
+ - Providers: `:ruby_llm` (default) and `:fake`; custom providers are any object with `#embed(texts, model:)`
13
+ - `Model.embed_query` and `Model.semantic_search` (neighbor), with an optional query cache
14
+ - `embed.embed_sync` instrumentation and log subscriber; retries with backoff; Solid Queue concurrency limit
15
+ - `EmbedSync::InvalidRequestError` for requests the provider rejects (text too long, bad key): not retried, and
16
+ backfill skips those records instead of stopping
17
+ - Provider failures in `:inline` mode are logged instead of raising from an already-committed `save`
18
+ - Embeddings computed for an older version of a record's text are discarded instead of overwriting newer ones
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Umair0092
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,187 @@
1
+ # EmbedSync
2
+
3
+ [![CI](https://github.com/Umair0092/embed_sync/actions/workflows/main.yml/badge.svg)](https://github.com/Umair0092/embed_sync/actions/workflows/main.yml)
4
+
5
+ > Keep vector embeddings in sync with your ActiveRecord models.
6
+
7
+ [neighbor](https://github.com/ankane/neighbor) lets you **search** vectors, and [ruby_llm](https://github.com/crmne/ruby_llm) lets you **generate** them. EmbedSync connects the two:
8
+
9
+ - regenerates a record's embedding in a background job when the text it's built from changes
10
+ - skips unchanged saves without calling the API again (it stores a digest of the text)
11
+ - backfills existing tables in batches, safely re-runnable, with one API call per batch
12
+ - re-embeds automatically when you switch embedding models
13
+ - one-line semantic search
14
+
15
+ ```ruby
16
+ class Product < ApplicationRecord
17
+ has_neighbors :embedding
18
+ has_embedding :embedding, from: %i[name description]
19
+ end
20
+
21
+ Product.create!(name: "Trail runner", description: "Waterproof running shoe") # embedded in the background
22
+ Product.semantic_search("shoes for rainy hikes", limit: 5)
23
+ ```
24
+
25
+ ## Requirements
26
+
27
+ - Ruby 3.2+, Rails 7.1+ (tested on 7.1, 7.2, 8.0 and 8.1)
28
+ - PostgreSQL with [pgvector](https://github.com/pgvector/pgvector), plus the `neighbor` gem for the `vector` column type and search
29
+ - An embedding provider: [ruby_llm](https://github.com/crmne/ruby_llm) (default) or your own
30
+
31
+ ## Installation
32
+
33
+ ```ruby
34
+ # Gemfile
35
+ gem "embed_sync"
36
+ gem "neighbor"
37
+ gem "ruby_llm"
38
+ ```
39
+
40
+ ```bash
41
+ bundle install
42
+ bin/rails g embed_sync:install # config/initializers/embed_sync.rb
43
+ bin/rails g embed_sync Product name description # migration + has_embedding in app/models/product.rb
44
+ bin/rails db:migrate
45
+ bin/rails embed_sync:backfill # embed existing rows
46
+ ```
47
+
48
+ Configure your API key for ruby_llm:
49
+
50
+ ```ruby
51
+ # config/initializers/ruby_llm.rb
52
+ RubyLLM.configure { |c| c.openai_api_key = Rails.application.credentials.openai_api_key }
53
+ ```
54
+
55
+ ## Usage
56
+
57
+ ### Declaring embeddings
58
+
59
+ ```ruby
60
+ class Product < ApplicationRecord
61
+ belongs_to :category
62
+
63
+ has_neighbors :embedding
64
+
65
+ # Attribute names are joined with newlines (blank values are skipped)
66
+ has_embedding :embedding, from: %i[name description]
67
+
68
+ # ...or build the text yourself; the lambda runs on the record (or takes it: ->(product) { ... })
69
+ has_embedding :embedding, from: -> { "#{name}\nCategory: #{category&.name}" }
70
+ end
71
+ ```
72
+
73
+ | Option | Default | |
74
+ |---|---|---|
75
+ | `from:` | (required) | attribute, array of attributes, or lambda |
76
+ | `model:` | `config.default_model` | embedding model name |
77
+ | `dimensions:` | `provider_options[:dimensions]` | rejects vectors of a different size (`DimensionMismatchError`); not sent to the provider |
78
+ | `sync:` | `config.sync` (`:async`) | `:async` (Active Job after commit), `:inline`, or `false` |
79
+ | `if:` / `unless:` | none | only sync matching records, as with Rails callbacks |
80
+ | `digest_column:` | `<column>_digest` | where the text digest is stored |
81
+
82
+ A model can have several embeddings (`has_embedding :title_embedding, ...`). Pass `column:` to the methods below when it does.
83
+
84
+ ### Methods
85
+
86
+ ```ruby
87
+ product.embedding_stale? # text, model or dimensions changed since the last embedding?
88
+ product.embedding_source_text # the text that gets embedded
89
+ product.refresh_embedding! # re-embed now, even if fresh
90
+
91
+ Product.embedding_missing # scope: never embedded
92
+ Product.embed_query("red shoes") # vector for a search string, using the column's model
93
+ Product.where(published: true).semantic_search("red shoes", limit: 10, distance: "cosine")
94
+ # results respond to #neighbor_distance
95
+
96
+ EmbedSync.without_sync { import_products! } # no jobs during bulk imports (also inside a transaction)
97
+ EmbedSync.backfill(Product.includes(:category)) # => number of records embedded
98
+ EmbedSync.stale_report(Product) # => { total:, missing:, stale: }
99
+ ```
100
+
101
+ ### Rake tasks
102
+
103
+ ```bash
104
+ bin/rails embed_sync:stale # all models with has_embedding
105
+ bin/rails "embed_sync:backfill[Product]"
106
+ bin/rails "embed_sync:backfill[Product,embedding]"
107
+ ```
108
+
109
+ Backfill only embeds stale records, so you can stop it and run it again at any time.
110
+
111
+ ## Configuration
112
+
113
+ ```ruby
114
+ EmbedSync.configure do |config|
115
+ config.provider = :ruby_llm # or :fake, or any object with #embed(texts, model:)
116
+ config.provider_options = { dimensions: 1536 } # passed to RubyLLM.embed
117
+ config.default_model = "text-embedding-3-small"
118
+ config.sync = :async # :async, :inline or false
119
+ config.queue = :default
120
+ config.priority = nil
121
+ config.batch_size = 100 # texts per API call when backfilling
122
+ config.concurrency_limit = nil # Solid Queue: max EmbedJobs running at once
123
+ config.query_cache = nil # e.g. Rails.cache, to cache search-query vectors
124
+ config.query_cache_expires_in = 86_400
125
+ end
126
+ ```
127
+
128
+ `:async` needs Active Job (apps created with `rails new --minimal` turn it off; use `sync: :inline` there).
129
+
130
+ ### Custom providers
131
+
132
+ ```ruby
133
+ class MyProvider
134
+ # Return one vector (Array of Floats) per text, in order.
135
+ def embed(texts, model:)
136
+ MyClient.embeddings(input: texts, model: model).map { _1["embedding"] }
137
+ end
138
+ end
139
+
140
+ EmbedSync.config.provider = MyProvider.new
141
+ ```
142
+
143
+ Raise `EmbedSync::ProviderError` for errors worth retrying (rate limits, timeouts) and `EmbedSync::InvalidRequestError` for requests that will never succeed (text too long, bad API key).
144
+
145
+ ## Production notes
146
+
147
+ - **Retries:** `EmbedSync::EmbedJob` retries `ProviderError` 5 times with polynomial backoff, and discards jobs for deleted records and for `InvalidRequestError` (retrying a rejected request doesn't help).
148
+ - **Failures don't break saves or backfills:** with `sync: :inline`, a provider error is logged and the record stays stale instead of raising from `save`. Backfill skips records the provider rejects and carries on; `embed_sync:stale` still lists them.
149
+ - **Long texts:** embedding models have an input limit (about 8k tokens for OpenAI and nomic-embed-text). Records over it are rejected and stay stale; trim them in the source, e.g. `from: -> { "#{name}\n#{description}".first(20_000) }`.
150
+ - **Concurrent edits:** if a record changes while its embedding is being generated, the older result is discarded rather than written over the newer text. Override in an initializer: `EmbedSync::EmbedJob.retry_on EmbedSync::ProviderError, wait: 1.minute, attempts: 10`.
151
+ - **Rate limits:** with Solid Queue, `config.concurrency_limit = 2` caps how many embedding jobs run at once.
152
+ - **Observability:** every provider call publishes `embed.embed_sync` (`record_class`, `column`, `model`, `count`) and is logged:
153
+ ```
154
+ EmbedSync (182.4ms) embedded 100 Product#embedding texts with text-embedding-3-small
155
+ ```
156
+ - **Changing models:** the digest covers the model and dimensions (`has_embedding dimensions:` or `provider_options[:dimensions]`), so changing either marks every row stale and gives search queries a new cache key. For live search, add a second column (`--column=embedding_v2`), backfill it, switch `semantic_search(..., column: :embedding_v2)`, then drop the old one.
157
+
158
+ ## Testing your app
159
+
160
+ Use the deterministic, offline `:fake` provider in tests. The install generator sets this up, with syncing off so tests don't embed unless they ask to:
161
+
162
+ ```ruby
163
+ # config/initializers/embed_sync.rb
164
+ config.provider = Rails.env.test? ? :fake : :ruby_llm
165
+ config.sync = Rails.env.test? ? false : :async # use :inline in tests that need real vectors
166
+ ```
167
+
168
+ `EmbedSync.provider.calls` records every call made to the fake.
169
+
170
+ ## Development
171
+
172
+ ```bash
173
+ bin/setup
174
+ bundle exec rake # specs + RuboCop
175
+
176
+ # Another Rails version
177
+ BUNDLE_GEMFILE=gemfiles/rails_7.1.gemfile bundle install
178
+ BUNDLE_GEMFILE=gemfiles/rails_7.1.gemfile bundle exec rspec
179
+
180
+ # Integration specs against PostgreSQL + pgvector
181
+ docker run -d -p 54329:5432 -e POSTGRES_PASSWORD=postgres pgvector/pgvector:pg17
182
+ DATABASE_URL=postgres://postgres:postgres@localhost:54329/postgres bundle exec rspec spec/integration
183
+ ```
184
+
185
+ ## License
186
+
187
+ MIT, see [LICENSE.txt](LICENSE.txt).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EmbedSync
4
+ # Batch operations over whole tables: embed what's missing or out of date, and report on it.
5
+ module Backfill
6
+ module_function
7
+
8
+ # Embeds every stale record in batches, one provider call per batch. Safe to re-run: records
9
+ # embedded by an earlier (possibly interrupted) run are fresh and get skipped. Records the provider
10
+ # rejects (e.g. text too long) are logged and left stale instead of stopping the backfill.
11
+ # Accepts a model or a relation, e.g. Product.includes(:category). Yields the running total.
12
+ def run(scope, column: nil, batch_size: nil, force: false)
13
+ relation = scope.all
14
+ definition = relation.klass.embed_sync_definition(column)
15
+ written = 0
16
+ relation.find_in_batches(batch_size: batch_size || EmbedSync.config.batch_size) do |batch|
17
+ written += Sync.call(batch, definition.column, force: force, skip_invalid: true)
18
+ yield written if block_given?
19
+ end
20
+ written
21
+ end
22
+
23
+ # Returns { total:, missing:, stale: }. "missing" rows were never embedded; "stale" includes
24
+ # them plus rows whose text changed since. Loads every record, so it can take a while.
25
+ def report(scope, column: nil)
26
+ relation = scope.all
27
+ definition = relation.klass.embed_sync_definition(column)
28
+ stale = 0
29
+ relation.find_each { |record| stale += 1 if definition.stale?(record) }
30
+ { total: relation.count, missing: relation.where(definition.digest_column => nil).count, stale: stale }
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EmbedSync
4
+ # Global settings, changed through EmbedSync.configure.
5
+ class Configuration
6
+ attr_accessor :default_model, :queue, :priority, :batch_size, :query_cache, :query_cache_expires_in,
7
+ :concurrency_limit
8
+ attr_reader :sync, :provider, :provider_options
9
+
10
+ def initialize
11
+ @provider = :ruby_llm
12
+ @provider_options = {}
13
+ @default_model = nil
14
+ @queue = :default
15
+ @batch_size = 100
16
+ @sync = :async
17
+ @query_cache = nil
18
+ @query_cache_expires_in = 86_400
19
+ @priority = nil
20
+ @concurrency_limit = nil
21
+ end
22
+
23
+ SYNC_MODES = [:async, :inline, false].freeze
24
+
25
+ # :async enqueues EmbedSync::EmbedJob after commit, :inline embeds right away, false turns syncing off.
26
+ def sync=(mode)
27
+ raise ConfigurationError, "sync must be one of #{SYNC_MODES.inspect}" unless SYNC_MODES.include?(mode)
28
+
29
+ @sync = mode
30
+ end
31
+
32
+ # Accepts a registered provider name (e.g. :fake) or any object that responds to #embed.
33
+ def provider=(value)
34
+ @provider = value
35
+ @resolved_provider = nil
36
+ end
37
+
38
+ def provider_options=(options)
39
+ @provider_options = (options || {}).to_h.transform_keys(&:to_sym)
40
+ @resolved_provider = nil
41
+ end
42
+
43
+ def resolved_provider
44
+ @resolved_provider ||= build_provider
45
+ end
46
+
47
+ private
48
+
49
+ def build_provider
50
+ case provider
51
+ when nil
52
+ raise ConfigurationError, "No embedding provider configured. Set EmbedSync.config.provider."
53
+ when Symbol, String
54
+ Providers.build(provider, **provider_options)
55
+ else
56
+ raise ConfigurationError, "#{provider.inspect} does not respond to #embed" unless provider.respond_to?(:embed)
57
+
58
+ provider
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module EmbedSync
6
+ # Describes one embedding declared with has_embedding: which column holds the vector,
7
+ # where its text comes from, and which column stores the digest of that text.
8
+ class Definition
9
+ attr_reader :column, :source, :digest_column, :owner
10
+
11
+ def initialize(column:, from:, model: nil, digest_column: nil, sync: nil, dimensions: nil, owner: nil)
12
+ @owner = owner
13
+ @column = column.to_sym
14
+ @source = from
15
+ @model = model
16
+ @dimensions = dimensions
17
+ @digest_column = (digest_column || "#{column}_digest").to_sym
18
+ @sync = sync
19
+ validate_source!
20
+ validate_sync!
21
+ end
22
+
23
+ def sync_mode
24
+ @sync.nil? ? EmbedSync.config.sync : @sync
25
+ end
26
+
27
+ # has_embedding's dimensions:, else the size requested from the provider (provider_options[:dimensions]).
28
+ def dimensions
29
+ @dimensions || EmbedSync.config.provider_options[:dimensions]
30
+ end
31
+
32
+ def model_name
33
+ @model || EmbedSync.config.default_model ||
34
+ raise(ConfigurationError,
35
+ "No embedding model for #{column}. Pass model: or set EmbedSync.config.default_model.")
36
+ end
37
+
38
+ def source_text(record)
39
+ text = case source
40
+ when Proc then source.arity.zero? ? record.instance_exec(&source) : record.instance_exec(record, &source)
41
+ when Array then source.filter_map { |attr| record.public_send(attr).presence }.join("\n")
42
+ else record.public_send(source)
43
+ end
44
+ text.to_s
45
+ end
46
+
47
+ # Fingerprint of everything that shapes the vector: the text, the model and the dimensions.
48
+ # Changing the model or the dimensions therefore marks every record stale.
49
+ def digest(text)
50
+ Digest::SHA256.hexdigest("#{model_name}\n#{dimensions}\n#{text}")
51
+ end
52
+
53
+ def stale?(record)
54
+ text = source_text(record)
55
+ return true if record[digest_column] != digest(text)
56
+
57
+ text.present? && record[column].nil?
58
+ end
59
+
60
+ private
61
+
62
+ def validate_source!
63
+ return if source.is_a?(Proc) || source.is_a?(Symbol) || source.is_a?(String)
64
+ return if source.is_a?(Array) && source.any?
65
+
66
+ raise ArgumentError, "has_embedding from: must be an attribute name, an array of names or a proc"
67
+ end
68
+
69
+ def validate_sync!
70
+ return if @sync.nil? || Configuration::SYNC_MODES.include?(@sync)
71
+
72
+ raise ArgumentError, "has_embedding sync: must be one of #{Configuration::SYNC_MODES.inspect}"
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+
5
+ module EmbedSync
6
+ # Regenerates one record's embedding in the background.
7
+ class EmbedJob < ActiveJob::Base
8
+ queue_as { EmbedSync.config.queue }
9
+ queue_with_priority { EmbedSync.config.priority }
10
+
11
+ # Rate limits, timeouts and provider outages: back off and try again (3s, 18s, 83s, 258s).
12
+ retry_on ProviderError, wait: :polynomially_longer, attempts: 5
13
+
14
+ # The record was deleted before the job ran; nothing to embed.
15
+ discard_on ActiveJob::DeserializationError
16
+
17
+ # The provider rejected the text (too long, bad key, unknown model): retrying gives the same answer.
18
+ # The failure is logged and the record stays stale, so embed_sync:stale still reports it.
19
+ discard_on InvalidRequestError
20
+
21
+ def perform(record, column)
22
+ Sync.call([record], column, recheck: true)
23
+ end
24
+
25
+ # With Solid Queue, caps how many EmbedJobs run at once across all workers, so a large import can't
26
+ # exceed the provider's rate limit. Called at boot when config.concurrency_limit is set.
27
+ def self.apply_concurrency_limit(limit)
28
+ unless respond_to?(:limits_concurrency)
29
+ raise ConfigurationError, "config.concurrency_limit needs Solid Queue (limits_concurrency)"
30
+ end
31
+
32
+ limits_concurrency(to: limit, key: ->(*) { "embed_sync" }, duration: ActiveSupport::Duration.minutes(15))
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EmbedSync
4
+ class Error < StandardError; end
5
+
6
+ # Raised when EmbedSync is set up incorrectly (unknown provider, missing option, ...).
7
+ class ConfigurationError < Error; end
8
+
9
+ # Raised when an embedding provider fails or returns an unexpected response. EmbedJob retries these.
10
+ class ProviderError < Error; end
11
+
12
+ # Raised when the provider rejects the request itself (text too long, bad key, unknown model).
13
+ # Retrying won't help, so EmbedJob discards these and backfill skips the record.
14
+ class InvalidRequestError < Error; end
15
+
16
+ # Raised when vectors don't match has_embedding's dimensions:. Not retried: the setup has to change.
17
+ class DimensionMismatchError < Error; end
18
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/log_subscriber"
4
+
5
+ module EmbedSync
6
+ # Logs each provider call, e.g.
7
+ # EmbedSync (182.4ms) embedded 100 Product#embedding texts with text-embedding-3-small
8
+ class LogSubscriber < ActiveSupport::LogSubscriber
9
+ def embed(event)
10
+ payload = event.payload
11
+ prefix = "EmbedSync (#{event.duration.round(1)}ms)"
12
+ target = [payload[:record_class], payload[:column]].compact.join("#")
13
+ if payload[:exception]
14
+ error { "#{prefix} failed to embed #{target}: #{payload[:exception].join(": ")}" }
15
+ else
16
+ info { "#{prefix} embedded #{payload[:count]} #{target} texts with #{payload[:model]}" }
17
+ end
18
+ end
19
+ end
20
+ end
21
+
22
+ EmbedSync::LogSubscriber.attach_to :embed_sync
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EmbedSync
4
+ # Adds the has_embedding macro to ActiveRecord models.
5
+ module Model
6
+ extend ActiveSupport::Concern
7
+
8
+ CALLBACK_OPTIONS = %i[if unless].freeze
9
+ OPTIONS = (CALLBACK_OPTIONS + %i[model dimensions digest_column sync]).freeze
10
+
11
+ included do
12
+ class_attribute :embed_sync_definitions, instance_writer: false, default: {}
13
+ end
14
+
15
+ # True when the last save happened inside EmbedSync.without_sync. Captured at save time because
16
+ # after_commit runs later, after a surrounding transaction (and the without_sync block) has ended.
17
+ def embed_sync_skipped?
18
+ @embed_sync_skipped == true
19
+ end
20
+
21
+ private
22
+
23
+ def embed_sync_capture_sync_state
24
+ @embed_sync_skipped = !EmbedSync.sync_enabled?
25
+ end
26
+
27
+ # Class methods added to every model (Concern extends the model with ClassMethods).
28
+ module ClassMethods
29
+ # has_embedding :embedding, from: %i[name description]
30
+ # has_embedding :embedding, from: -> { "#{name}\n#{category&.name}" }, model: "text-embedding-3-small",
31
+ # dimensions: 1536, sync: :inline, if: :published?
32
+ #
33
+ # Options: model:, dimensions:, digest_column:, sync: (:async, :inline, false), if:, unless:
34
+ def has_embedding(column = :embedding, from:, **options) # rubocop:disable Naming/PredicatePrefix
35
+ unknown = options.keys - OPTIONS
36
+ raise ArgumentError, "Unknown has_embedding options: #{unknown.join(", ")}" if unknown.any?
37
+
38
+ callback_options = options.slice(*CALLBACK_OPTIONS)
39
+ definition = Definition.new(column: column, from: from, owner: self, **options.except(*CALLBACK_OPTIONS))
40
+ self.embed_sync_definitions = embed_sync_definitions.merge(definition.column => definition)
41
+ define_embedding_methods(definition)
42
+ after_save :embed_sync_capture_sync_state
43
+ after_commit(on: %i[create update], **callback_options) { EmbedSync.sync_record(self, definition.column) }
44
+
45
+ definition
46
+ end
47
+
48
+ # Looks up a has_embedding by column; the column can be omitted when the model has only one.
49
+ def embed_sync_definition(column = nil)
50
+ if column
51
+ return embed_sync_definitions.fetch(column.to_sym) do
52
+ raise ArgumentError, "#{name} has no has_embedding for #{column.inspect}"
53
+ end
54
+ end
55
+
56
+ definitions = embed_sync_definitions.values
57
+ return definitions.first if definitions.one?
58
+
59
+ raise ArgumentError, "#{name} has #{definitions.size} embeddings; pass column: " \
60
+ "(one of #{definitions.map(&:column).join(", ")})"
61
+ end
62
+
63
+ # Embeds a search query with the same model as the column, so it can be compared to stored vectors.
64
+ def embed_query(text, column: nil)
65
+ EmbedSync::Query.embed(text, embed_sync_definition(column))
66
+ end
67
+
68
+ # Product.semantic_search("red running shoes", limit: 5)
69
+ # Product.where(published: true).semantic_search("waterproof", column: :embedding, distance: "euclidean")
70
+ # Needs the neighbor gem (has_neighbors :embedding). Records respond to #neighbor_distance.
71
+ def semantic_search(query, column: nil, limit: 10, distance: "cosine")
72
+ definition = embed_sync_definition(column)
73
+ unless respond_to?(:nearest_neighbors)
74
+ raise ConfigurationError, "semantic_search needs the neighbor gem. Add gem \"neighbor\" and " \
75
+ "has_neighbors :#{definition.column} to #{name}."
76
+ end
77
+
78
+ nearest_neighbors(definition.column, embed_query(query, column: definition.column), distance: distance)
79
+ .limit(limit)
80
+ end
81
+
82
+ private
83
+
84
+ def define_embedding_methods(definition)
85
+ column = definition.column
86
+ define_method(:"#{column}_source_text") { definition.source_text(self) }
87
+ define_method(:"#{column}_stale?") { definition.stale?(self) }
88
+ define_method(:"refresh_#{column}!") { Sync.call([self], column, force: true) }
89
+ scope :"#{column}_missing", -> { where(definition.digest_column => nil) }
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EmbedSync
4
+ module Providers
5
+ # Interface every provider implements.
6
+ class Base
7
+ # Returns one vector (Array of Floats) per text, in the same order as +texts+.
8
+ def embed(texts, model:)
9
+ raise NotImplementedError, "#{self.class} must implement #embed(texts, model:)"
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module EmbedSync
6
+ module Providers
7
+ # Deterministic, offline provider for tests: the same text and model always give the same
8
+ # unit-length vector. Records every call in #calls so tests can assert on API usage.
9
+ class Fake < Base
10
+ attr_reader :dimensions, :calls
11
+
12
+ def initialize(dimensions: 8)
13
+ super()
14
+ @dimensions = dimensions
15
+ @calls = []
16
+ end
17
+
18
+ def embed(texts, model:)
19
+ @calls << { texts: texts.dup, model: model }
20
+ texts.map { |text| vector_for(text, model) }
21
+ end
22
+
23
+ private
24
+
25
+ def vector_for(text, model)
26
+ bytes = Digest::SHA256.digest("#{model}\n#{text}").bytes
27
+ raw = Array.new(dimensions) { |i| (bytes[i % bytes.size] / 127.5) - 1.0 }
28
+ norm = Math.sqrt(raw.sum { |x| x * x })
29
+ norm.zero? ? raw : raw.map { |x| x / norm }
30
+ end
31
+ end
32
+ end
33
+ end