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 +7 -0
- data/CHANGELOG.md +18 -0
- data/LICENSE.txt +21 -0
- data/README.md +187 -0
- data/Rakefile +12 -0
- data/lib/embed_sync/backfill.rb +33 -0
- data/lib/embed_sync/configuration.rb +62 -0
- data/lib/embed_sync/definition.rb +75 -0
- data/lib/embed_sync/embed_job.rb +35 -0
- data/lib/embed_sync/errors.rb +18 -0
- data/lib/embed_sync/log_subscriber.rb +22 -0
- data/lib/embed_sync/model.rb +93 -0
- data/lib/embed_sync/providers/base.rb +13 -0
- data/lib/embed_sync/providers/fake.rb +33 -0
- data/lib/embed_sync/providers/ruby_llm.rb +56 -0
- data/lib/embed_sync/providers.rb +34 -0
- data/lib/embed_sync/query.rb +27 -0
- data/lib/embed_sync/railtie.rb +19 -0
- data/lib/embed_sync/sync.rb +90 -0
- data/lib/embed_sync/tasks/embed_sync.rake +45 -0
- data/lib/embed_sync/version.rb +5 -0
- data/lib/embed_sync.rb +98 -0
- data/lib/generators/embed_sync/embed_sync_generator.rb +89 -0
- data/lib/generators/embed_sync/install_generator.rb +18 -0
- data/lib/generators/embed_sync/templates/initializer.rb.tt +23 -0
- data/lib/generators/embed_sync/templates/migration.rb.tt +12 -0
- data/sig/embed_sync.rbs +4 -0
- metadata +155 -0
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
|
+
[](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,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
|