emb 0.2.1 → 0.2.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 424790b9a9b835dd7febbdfa1df2e5f8b6a2a9b89290b5e653467fcc09e4f68f
4
- data.tar.gz: d21f28bb4c79deaefa9bab91247f41ff5e84f869b422727939ab1f07a940eb34
3
+ metadata.gz: 2210ac3c666538429c3d0eccc58c26b94bc87a26f65cd6c3b4ba2f754b5de6fa
4
+ data.tar.gz: f576b3edf8bec7d38d156b35082e86ac19a80324c0f437e78197f674f0156215
5
5
  SHA512:
6
- metadata.gz: eacbde1ca9ccf217bd53cb9028f8a5192427754abcee678373d38f16b08ce4ae00b5b905909cb0d7ef0f07fa04b39c0ad112c820c6f4fd6d3c98ba06708af854
7
- data.tar.gz: 8605fe8eaede9567d6506f5247ef85dfcb32b9c1c1fa719d00ce2446233b0518cbd336e6f496c8449ac41ab33545776b05d1febe2bf3d5a576aafa47afa60d9c
6
+ metadata.gz: 3dc881cd39e7dcdf6898afa215d9d852a638a2df020d225056e861b9f79e57f13001a30c0e88b0b1cf0e997328c9682b6d4f2cdfbd97c7795e1adc6a8262a279
7
+ data.tar.gz: 93aa184f12078697b8f27946567dd3221b99d098f67f207d4cbe3842472583ae76fba8a8aa8bab5a5cba3a432c12eff2a2449d53ff93187a043a6bfd0e864c4d
data/README.md CHANGED
@@ -44,12 +44,49 @@ Emb.setup
44
44
  2. `EMB_URL` environment variable
45
45
  3. Default: `redis://localhost:6379`
46
46
 
47
+ ### Global configuration
48
+
49
+ Every client (`Emb.setup`, `Emb.new`, and the lazily-created default client) inherits a
50
+ shared global `Emb::Config` value object set with `Emb.configure`. Settings
51
+ resolve in this order — the first one wins:
52
+
53
+ 1. **Explicit per-call option** (`Emb.setup(pool: 10)`)
54
+ 2. **`Emb.configure` value**
55
+ 3. **Built-in default**
56
+
57
+ ```ruby
58
+ Emb.configure do |c|
59
+ c.pool = 8
60
+ c.batch = false # opt out of lazy batching app-wide
61
+ end
62
+
63
+ Emb.configuration # => the shared Emb::Configuration
64
+ Emb::Client.new(pool: 20) # per-call still wins
65
+ ```
66
+
67
+ `EMB_URL` remains the only environment variable (connection URL fallback, as before);
68
+ `Emb.configure { |c| c.url = ... }` or an explicit `url:` override it.
69
+
70
+ ### Out-of-the-box defaults
71
+
72
+ The shipped defaults are benchmark-derived (see `BENCHMARK.md`): **lazy batching is on by
73
+ default** (`batch: true` — each embed coalesces into one `EMB.MULTI`), pool `5`, pure-Ruby
74
+ RESP driver, `protocol: 2`, `reconnect_attempts: 3`. To keep the eager behavior (immediate
75
+ `EMB` per call), opt out globally via `Emb.configure { |c| c.batch = false }` or per client
76
+ with `Emb.new(batch: false)`.
77
+
47
78
  ### Connection pool
48
79
 
49
80
  ```ruby
50
81
  Emb.setup(url: "redis://localhost:6379", pool: 10)
51
82
  ```
52
83
 
84
+ The default pool size is **5**. The pool is usually not the bottleneck for
85
+ inference-bound workloads (small pools are fine); it becomes a knob only at high
86
+ concurrency on a multi-model box — see [Performance](#performance). If a pool checkout
87
+ would wait too long, `RedisClient`'s `connect_timeout`/`read_timeout` (above) bound the
88
+ wait.
89
+
53
90
  ### Authentication
54
91
 
55
92
  If the server is configured with a password, include it in the URL:
@@ -89,8 +126,8 @@ Emb.setup(
89
126
  ```
90
127
 
91
128
  See the [redis-client documentation](https://github.com/redis-rb/redis-client) for
92
- all available options. Only `pool` is handled by the gem — everything else passes
93
- through to `RedisClient.new`.
129
+ all available options. Only `pool` and `batch` are handled by the gem — everything
130
+ else passes through to `RedisClient.new`.
94
131
 
95
132
  ## Instance-based clients
96
133
 
@@ -177,6 +214,144 @@ client.multi do |m|
177
214
  end
178
215
  ```
179
216
 
217
+ ### Lazy batching (`Emb.batch`)
218
+
219
+ Instead of collecting pairs by hand, `Emb.batch` returns lazy embeddings that all
220
+ coalesce into a single `EMB.MULTI` round trip when the first one is used. This is
221
+ powered by the [batch-loader](https://github.com/exAspArk/batch-loader) gem.
222
+
223
+ ```ruby
224
+ users = User.all # some application objects
225
+
226
+ # Create loaders first...
227
+ l1 = Emb.batch[:minilm]["hello"]
228
+ l2 = Emb.batch[:minilm]["world"]
229
+ l3 = Emb.batch[:bge]["bonjour"]
230
+
231
+ # ...then consume them. The first use sends ONE EMB.MULTI for all three.
232
+ l1.sum # => 12.345
233
+ l2.sum # => -0.678
234
+ l3.sum # => 3.141
235
+ ```
236
+
237
+ Instance clients expose the same API:
238
+
239
+ ```ruby
240
+ client.batch[:minilm]["hello"].sum
241
+ ```
242
+
243
+ Each lazy value materializes to the same shape as the eager API: a single text
244
+ yields an `Array<Float>`, multiple texts yield `Array<Array<Float>>`:
245
+
246
+ ```ruby
247
+ vec = Emb.batch[:minilm]["hello"] # use -> Array of Float
248
+ vecs = Emb.batch[:minilm]["hello", "world"] # use -> Array of Array of Float
249
+ ```
250
+
251
+ Embeddings are cached per thread, so reusing a lazy value (or creating an
252
+ identical pair again in the same scope) is free after the first use. A pair whose
253
+ embedding fails materializes as `nil`, matching `EMB.MULTI`'s per-pair null
254
+ behavior; siblings in the same batch still succeed.
255
+
256
+ #### The create-then-consume contract
257
+
258
+ Loaders only fire when a value is **used**. Create all loaders *first*, then
259
+ consume them, so they share one round trip:
260
+
261
+ ```ruby
262
+ texts.each { |t| process(Emb.batch[:minilm][t]) } # wrong: one MULTI per item
263
+ loaders = texts.map { |t| Emb.batch[:minilm][t] } # right: ONE MULTI for all
264
+ loaders.each { |l| process(l) }
265
+ ```
266
+
267
+ A loader that is created but never used **never embeds** (unless a sibling batch
268
+ fires first) and is silently dropped when the thread's scope ends. Duration and
269
+ scope: batching is per-thread — a multithreaded app issues one `EMB.MULTI` per
270
+ thread per flush.
271
+
272
+ ### `batch` configuration option
273
+
274
+ Setting `batch: true` makes the standard proxy API lazy, so existing call sites
275
+ batch automatically without restructuring:
276
+
277
+ ```ruby
278
+ Emb.setup(url: "redis://localhost:6379", batch: true)
279
+ # or
280
+ Emb.new(url: "redis://localhost:6379", batch: true)
281
+ ```
282
+
283
+ With `batch: true`, `Emb[:minilm]["hello"]` returns a lazy embedding that sends
284
+ `EMB.MULTI` on first use. The default is `false` — the proxy API stays eager,
285
+ sending `EMB` immediately. `Emb.batch` works regardless of the option, and
286
+ `Emb.multi` remains the explicit, eager, deterministic batching API.
287
+
288
+ ### Clearing the cache per request
289
+
290
+ The per-thread batch scope holds cached embeddings for the life of the thread. In
291
+ request-shaped processes (Rails, Rack apps, Sidekiq) mount `Emb::Middleware` to
292
+ clear the scope at the end of each request:
293
+
294
+ ```ruby
295
+ # config/application.rb (Rails)
296
+ config.middleware.use Emb::Middleware
297
+
298
+ # Any Rack app
299
+ use Emb::Middleware
300
+ ```
301
+
302
+ The scope is cleared even when the app raises, and a fresh scope starts
303
+ automatically with the next request. Loaders created but never used within a
304
+ request are dropped — the create-then-consume contract applies per request.
305
+
306
+ ## Performance
307
+
308
+ ### Eager-burst pipelining (no new API)
309
+
310
+ For a burst of independent eager calls (feelers across call sites, fan-ins where
311
+ batching doesn't fit), coalesce them into one packet with `RedisClient#pipelined`:
312
+
313
+ ```ruby
314
+ client = Emb.new(url: "redis://localhost:6379")
315
+
316
+ a = client.pool.with do |conn|
317
+ conn.pipelined do |pipe|
318
+ texts.each { |t| pipe.call("EMB", "minilm", t) }
319
+ end
320
+ end
321
+ # a -> Array of Float32-binary replies; unpack each with r.unpack("e*")
322
+ ```
323
+
324
+ `Client#pool` exposes the pool's `RedisClient` connections. This measurably beats
325
+ plain per-call round trips; use it for bursts, and `Emb.batch`/`Emb.multi` when you want
326
+ server-side coalescing into one `EMB.MULTI`.
327
+
328
+ ### RESP driver: pure-Ruby default, `hiredis` on demand
329
+
330
+ The pure-Ruby RESP parser is the default. The C `hiredis` driver only meaningfully helps
331
+ the all-round-trip eager path (about +12% req/s) and is ~neutral for batched/pipelined
332
+ workloads, so it is not worth the native-build dependency by default. Enable it when
333
+ round-trip-heavy eager traffic dominates:
334
+
335
+ ```ruby
336
+ require "hiredis-client"
337
+ Emb.setup(url: "redis://localhost:6379", driver: :hiredis)
338
+ ```
339
+
340
+ ### Horizontal scaling
341
+
342
+ emb instances are stateless — the model lives in memory and the LRU cache is per
343
+ instance. Scale out without a cluster client:
344
+
345
+ - **Model sharding** — run each instance with a subset of models; route by model via
346
+ dedicated clients (`Emb.new(url: "redis://model-a:6379")`).
347
+ - **Text-keyed sharding** — several instances serving the same model behind an L4 /
348
+ load balancer for same-model scale.
349
+ - **Cache warm-up** — because the cache is per instance, each new box starts cold
350
+ (optionally seed with `-cache` and a warmup pass).
351
+
352
+ Lazy batching stays per server (a thread's loaders target one client), so batching and
353
+ horizontal scale compose: each instance receives one `EMB.MULTI` per request.
354
+
180
355
  ### Commands
181
356
 
182
357
  ```ruby
data/lib/emb/batch.rb ADDED
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'batch_loader'
4
+
5
+ module Emb
6
+ BATCH_KEY = :emb
7
+
8
+ BATCH_BLOCK = lambda do |items, loader, _args|
9
+ items.group_by(&:first).each do |client, client_items|
10
+ pairs = client_items.flat_map { |_, model, text| Array(text).flat_map { |t| [model.to_s, t] } }
11
+ results = Array(client.send_command('EMB.MULTI', *pairs))
12
+
13
+ offset = 0
14
+ client_items.each do |item|
15
+ _, _, text = item
16
+ texts = Array(text)
17
+ values = results[offset, texts.size].map { |entry| entry&.unpack('e*') }
18
+ offset += texts.size
19
+
20
+ # eager Proxy#[] shape: vector for a single text, vectors for many
21
+ loader.call(item, values.size == 1 ? values.first : values)
22
+ end
23
+ end
24
+ end
25
+
26
+ class << self
27
+ def build_batch_loader(client, model, text)
28
+ BatchLoader.for([client, model, text]).batch(key: BATCH_KEY, &BATCH_BLOCK)
29
+ end
30
+ end
31
+
32
+ class BatchProxy
33
+ def initialize(client)
34
+ @client = client
35
+ @models = {}
36
+ end
37
+
38
+ def [](name)
39
+ @models[name.to_sym] ||= BatchModelProxy.new(@client, name.to_sym)
40
+ end
41
+
42
+ def inspect
43
+ "#<Emb::BatchProxy client=#{@client.inspect}>"
44
+ end
45
+ end
46
+
47
+ class BatchModelProxy
48
+ attr_reader :name
49
+
50
+ def initialize(client, name)
51
+ @client = client
52
+ @name = name
53
+ end
54
+
55
+ def [](text, *texts)
56
+ Emb.build_batch_loader(@client, @name, texts.empty? ? text : [text, *texts])
57
+ end
58
+
59
+ def inspect
60
+ "#<Emb::BatchModelProxy #{@name}>"
61
+ end
62
+ end
63
+ end
data/lib/emb/client.rb CHANGED
@@ -4,19 +4,17 @@ require 'connection_pool'
4
4
  require 'redis_client'
5
5
 
6
6
  module Emb
7
- DEFAULTS = { host: 'localhost', port: 6379, pool: 5 }.freeze
8
-
9
7
  class Client
10
8
  attr_reader :pool
11
9
 
12
- def initialize(pool: DEFAULTS[:pool], **redis_options)
13
- url = extract_url!(redis_options)
14
- redis_options[:host] ||= DEFAULTS[:host] unless url
15
- redis_options[:port] ||= DEFAULTS[:port] unless url
16
- redis_options[:protocol] ||= 2
17
- redis_options[:reconnect_attempts] ||= 3
10
+ def initialize(pool: nil, batch: nil, **redis_options)
11
+ cfg = Emb.configuration
12
+ @batch_enabled = batch.nil? ? cfg.batch : batch
13
+ size = pool.nil? ? cfg.pool : pool
14
+ url = extract_url!(redis_options, cfg)
15
+ redis_options = merged_redis_options(redis_options, cfg, url)
18
16
 
19
- @pool = ConnectionPool.new(size: pool) do
17
+ @pool = ConnectionPool.new(size: size) do
20
18
  RedisClient.new(url: url, **redis_options)
21
19
  end
22
20
 
@@ -39,6 +37,14 @@ module Emb
39
37
  @registry[name] ||= Proxy.new(self, name.to_sym)
40
38
  end
41
39
 
40
+ def batch
41
+ @batch ||= BatchProxy.new(self)
42
+ end
43
+
44
+ def batch?
45
+ @batch_enabled
46
+ end
47
+
42
48
  def models
43
49
  raw = send_command('EMB.MODELS')
44
50
  return [] if raw.nil?
@@ -66,7 +72,7 @@ module Emb
66
72
  def ready
67
73
  send_command('EMB.READY')
68
74
 
69
- "ready"
75
+ 'ready'
70
76
  rescue RedisClient::CommandError => e
71
77
  e.message
72
78
  end
@@ -91,9 +97,23 @@ module Emb
91
97
 
92
98
  private
93
99
 
94
- def extract_url!(opts)
100
+ def merged_redis_options(opts, cfg, url)
101
+ defaults = cfg.to_h
102
+ keys = defaults.keys - %i[url pool batch]
103
+ keys -= %i[host port] if url
104
+
105
+ keys.each do |key|
106
+ opts[key] = defaults[key] if opts[key].nil? && !defaults[key].nil?
107
+ end
108
+
109
+ opts
110
+ end
111
+
112
+ def extract_url!(opts, cfg)
95
113
  url = opts.delete(:url)
96
- url.nil? ? ENV.fetch('EMB_URL', nil) : url
114
+ return url unless url.nil?
115
+
116
+ cfg.url || ENV.fetch('EMB_URL', nil)
97
117
  end
98
118
  end
99
119
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emb
4
+ class Configuration
5
+ OPTIONS = %i[
6
+ host port url pool batch driver protocol
7
+ connect_timeout read_timeout write_timeout reconnect_attempts
8
+ ].freeze
9
+
10
+ attr_accessor(*OPTIONS)
11
+
12
+ def initialize
13
+ self.host = 'localhost'
14
+ self.port = 6379
15
+ self.url = nil
16
+ self.pool = 5
17
+ self.batch = true
18
+ self.driver = nil
19
+ self.protocol = 2
20
+ self.connect_timeout = nil
21
+ self.read_timeout = nil
22
+ self.write_timeout = nil
23
+ self.reconnect_attempts = 3
24
+ end
25
+
26
+ def to_h
27
+ OPTIONS.to_h { |key| [key, public_send(key)] }
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'batch_loader'
4
+
5
+ module Emb
6
+ # Rack middleware that clears the per-thread batch scope at the end of each
7
+ # request, bounding cache growth in long-lived request threads. The scope is
8
+ # cleared even when the app raises; a fresh scope starts with the next request.
9
+ class Middleware
10
+ def initialize(app)
11
+ @app = app
12
+ end
13
+
14
+ def call(env)
15
+ @app.call(env)
16
+ ensure
17
+ BatchLoader::Executor.clear_current
18
+ end
19
+ end
20
+ end
data/lib/emb/proxy.rb CHANGED
@@ -10,6 +10,8 @@ module Emb
10
10
  end
11
11
 
12
12
  def [](text, *texts)
13
+ return Emb.build_batch_loader(@client, @name, texts.empty? ? text : [text, *texts]) if @client.batch?
14
+
13
15
  set = Array(@client.send_command('EMB', @name.to_s, text, *texts))
14
16
  result = set.map { |entry| entry.unpack('e*') }
15
17
 
data/lib/emb.rb CHANGED
@@ -1,9 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'emb/version'
4
+ require_relative 'emb/configuration'
4
5
  require_relative 'emb/client'
5
6
  require_relative 'emb/proxy'
6
7
  require_relative 'emb/multi'
8
+ require_relative 'emb/batch'
9
+ require_relative 'emb/middleware'
7
10
 
8
11
  module Emb
9
12
  class << self
@@ -15,7 +18,17 @@ module Emb
15
18
 
16
19
  alias config setup
17
20
 
21
+ def configuration
22
+ @configuration ||= Configuration.new
23
+ end
24
+
25
+ def configure
26
+ yield configuration if block_given?
27
+ configuration
28
+ end
29
+
18
30
  def [](name) = default_client[name]
31
+ def batch = default_client.batch
19
32
  def models = default_client.models
20
33
  def info(name) = default_client.info(name)
21
34
  def stats = default_client.stats
metadata CHANGED
@@ -1,15 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: emb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.2.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - elcuervo
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-02 00:00:00.000000000 Z
11
+ date: 2026-08-24 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: batch-loader
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.0'
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: connection_pool
15
29
  requirement: !ruby/object:Gem::Requirement
@@ -48,7 +62,10 @@ files:
48
62
  - Gemfile
49
63
  - README.md
50
64
  - lib/emb.rb
65
+ - lib/emb/batch.rb
51
66
  - lib/emb/client.rb
67
+ - lib/emb/configuration.rb
68
+ - lib/emb/middleware.rb
52
69
  - lib/emb/multi.rb
53
70
  - lib/emb/proxy.rb
54
71
  - lib/emb/version.rb