emb 0.2.1 → 0.2.2

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: ff0fe54e3a671a1df06f90f6da9317e1173f5df811bb5c95d4374de4487fd8fb
4
+ data.tar.gz: 9f9bfbde342b771c876f6ef8327b672a85c17c82c889f7ca35d3d4bdbe238cf8
5
5
  SHA512:
6
- metadata.gz: eacbde1ca9ccf217bd53cb9028f8a5192427754abcee678373d38f16b08ce4ae00b5b905909cb0d7ef0f07fa04b39c0ad112c820c6f4fd6d3c98ba06708af854
7
- data.tar.gz: 8605fe8eaede9567d6506f5247ef85dfcb32b9c1c1fa719d00ce2446233b0518cbd336e6f496c8449ac41ab33545776b05d1febe2bf3d5a576aafa47afa60d9c
6
+ metadata.gz: 0dc5d526c1e0314d7a41c9455fb9129c15f63a61d293eadde62aeb9c9b3f79aa40bad32351564e7b69ec9baf9524d74669129f3f46db8abc7d9eaccb7137145c
7
+ data.tar.gz: ac676d326318efc040277f7adbd0b8b064d2e563dc2f2516dbe6d6bccb96f9e468a35b0aed937b351af7e53cd87ff63128b5660d90a566201e5b665df5aba600
data/README.md CHANGED
@@ -50,6 +50,12 @@ Emb.setup
50
50
  Emb.setup(url: "redis://localhost:6379", pool: 10)
51
51
  ```
52
52
 
53
+ The default pool size is **5**. The pool is usually not the bottleneck for
54
+ inference-bound workloads (small pools are fine); it becomes a knob only at high
55
+ concurrency on a multi-model box — see [Performance](#performance). If a pool checkout
56
+ would wait too long, `RedisClient`'s `connect_timeout`/`read_timeout` (above) bound the
57
+ wait.
58
+
53
59
  ### Authentication
54
60
 
55
61
  If the server is configured with a password, include it in the URL:
@@ -89,8 +95,8 @@ Emb.setup(
89
95
  ```
90
96
 
91
97
  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`.
98
+ all available options. Only `pool` and `batch` are handled by the gem — everything
99
+ else passes through to `RedisClient.new`.
94
100
 
95
101
  ## Instance-based clients
96
102
 
@@ -177,6 +183,144 @@ client.multi do |m|
177
183
  end
178
184
  ```
179
185
 
186
+ ### Lazy batching (`Emb.batch`)
187
+
188
+ Instead of collecting pairs by hand, `Emb.batch` returns lazy embeddings that all
189
+ coalesce into a single `EMB.MULTI` round trip when the first one is used. This is
190
+ powered by the [batch-loader](https://github.com/exAspArk/batch-loader) gem.
191
+
192
+ ```ruby
193
+ users = User.all # some application objects
194
+
195
+ # Create loaders first...
196
+ l1 = Emb.batch[:minilm]["hello"]
197
+ l2 = Emb.batch[:minilm]["world"]
198
+ l3 = Emb.batch[:bge]["bonjour"]
199
+
200
+ # ...then consume them. The first use sends ONE EMB.MULTI for all three.
201
+ l1.sum # => 12.345
202
+ l2.sum # => -0.678
203
+ l3.sum # => 3.141
204
+ ```
205
+
206
+ Instance clients expose the same API:
207
+
208
+ ```ruby
209
+ client.batch[:minilm]["hello"].sum
210
+ ```
211
+
212
+ Each lazy value materializes to the same shape as the eager API: a single text
213
+ yields an `Array<Float>`, multiple texts yield `Array<Array<Float>>`:
214
+
215
+ ```ruby
216
+ vec = Emb.batch[:minilm]["hello"] # use -> Array of Float
217
+ vecs = Emb.batch[:minilm]["hello", "world"] # use -> Array of Array of Float
218
+ ```
219
+
220
+ Embeddings are cached per thread, so reusing a lazy value (or creating an
221
+ identical pair again in the same scope) is free after the first use. A pair whose
222
+ embedding fails materializes as `nil`, matching `EMB.MULTI`'s per-pair null
223
+ behavior; siblings in the same batch still succeed.
224
+
225
+ #### The create-then-consume contract
226
+
227
+ Loaders only fire when a value is **used**. Create all loaders *first*, then
228
+ consume them, so they share one round trip:
229
+
230
+ ```ruby
231
+ texts.each { |t| process(Emb.batch[:minilm][t]) } # wrong: one MULTI per item
232
+ loaders = texts.map { |t| Emb.batch[:minilm][t] } # right: ONE MULTI for all
233
+ loaders.each { |l| process(l) }
234
+ ```
235
+
236
+ A loader that is created but never used **never embeds** (unless a sibling batch
237
+ fires first) and is silently dropped when the thread's scope ends. Duration and
238
+ scope: batching is per-thread — a multithreaded app issues one `EMB.MULTI` per
239
+ thread per flush.
240
+
241
+ ### `batch` configuration option
242
+
243
+ Setting `batch: true` makes the standard proxy API lazy, so existing call sites
244
+ batch automatically without restructuring:
245
+
246
+ ```ruby
247
+ Emb.setup(url: "redis://localhost:6379", batch: true)
248
+ # or
249
+ Emb.new(url: "redis://localhost:6379", batch: true)
250
+ ```
251
+
252
+ With `batch: true`, `Emb[:minilm]["hello"]` returns a lazy embedding that sends
253
+ `EMB.MULTI` on first use. The default is `false` — the proxy API stays eager,
254
+ sending `EMB` immediately. `Emb.batch` works regardless of the option, and
255
+ `Emb.multi` remains the explicit, eager, deterministic batching API.
256
+
257
+ ### Clearing the cache per request
258
+
259
+ The per-thread batch scope holds cached embeddings for the life of the thread. In
260
+ request-shaped processes (Rails, Rack apps, Sidekiq) mount `Emb::Middleware` to
261
+ clear the scope at the end of each request:
262
+
263
+ ```ruby
264
+ # config/application.rb (Rails)
265
+ config.middleware.use Emb::Middleware
266
+
267
+ # Any Rack app
268
+ use Emb::Middleware
269
+ ```
270
+
271
+ The scope is cleared even when the app raises, and a fresh scope starts
272
+ automatically with the next request. Loaders created but never used within a
273
+ request are dropped — the create-then-consume contract applies per request.
274
+
275
+ ## Performance
276
+
277
+ ### Eager-burst pipelining (no new API)
278
+
279
+ For a burst of independent eager calls (feelers across call sites, fan-ins where
280
+ batching doesn't fit), coalesce them into one packet with `RedisClient#pipelined`:
281
+
282
+ ```ruby
283
+ client = Emb.new(url: "redis://localhost:6379")
284
+
285
+ a = client.pool.with do |conn|
286
+ conn.pipelined do |pipe|
287
+ texts.each { |t| pipe.call("EMB", "minilm", t) }
288
+ end
289
+ end
290
+ # a -> Array of Float32-binary replies; unpack each with r.unpack("e*")
291
+ ```
292
+
293
+ `Client#pool` exposes the pool's `RedisClient` connections. This measurably beats
294
+ plain per-call round trips; use it for bursts, and `Emb.batch`/`Emb.multi` when you want
295
+ server-side coalescing into one `EMB.MULTI`.
296
+
297
+ ### RESP driver: pure-Ruby default, `hiredis` on demand
298
+
299
+ The pure-Ruby RESP parser is the default. The C `hiredis` driver only meaningfully helps
300
+ the all-round-trip eager path (about +12% req/s) and is ~neutral for batched/pipelined
301
+ workloads, so it is not worth the native-build dependency by default. Enable it when
302
+ round-trip-heavy eager traffic dominates:
303
+
304
+ ```ruby
305
+ require "hiredis-client"
306
+ Emb.setup(url: "redis://localhost:6379", driver: :hiredis)
307
+ ```
308
+
309
+ ### Horizontal scaling
310
+
311
+ emb instances are stateless — the model lives in memory and the LRU cache is per
312
+ instance. Scale out without a cluster client:
313
+
314
+ - **Model sharding** — run each instance with a subset of models; route by model via
315
+ dedicated clients (`Emb.new(url: "redis://model-a:6379")`).
316
+ - **Text-keyed sharding** — several instances serving the same model behind an L4 /
317
+ load balancer for same-model scale.
318
+ - **Cache warm-up** — because the cache is per instance, each new box starts cold
319
+ (optionally seed with `-cache` and a warmup pass).
320
+
321
+ Lazy batching stays per server (a thread's loaders target one client), so batching and
322
+ horizontal scale compose: each instance receives one `EMB.MULTI` per request.
323
+
180
324
  ### Commands
181
325
 
182
326
  ```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
@@ -9,7 +9,9 @@ module Emb
9
9
  class Client
10
10
  attr_reader :pool
11
11
 
12
- def initialize(pool: DEFAULTS[:pool], **redis_options)
12
+ def initialize(pool: DEFAULTS[:pool], batch: false, **redis_options)
13
+ @batch_enabled = batch
14
+
13
15
  url = extract_url!(redis_options)
14
16
  redis_options[:host] ||= DEFAULTS[:host] unless url
15
17
  redis_options[:port] ||= DEFAULTS[:port] unless url
@@ -39,6 +41,14 @@ module Emb
39
41
  @registry[name] ||= Proxy.new(self, name.to_sym)
40
42
  end
41
43
 
44
+ def batch
45
+ @batch ||= BatchProxy.new(self)
46
+ end
47
+
48
+ def batch?
49
+ @batch_enabled
50
+ end
51
+
42
52
  def models
43
53
  raw = send_command('EMB.MODELS')
44
54
  return [] if raw.nil?
@@ -66,7 +76,7 @@ module Emb
66
76
  def ready
67
77
  send_command('EMB.READY')
68
78
 
69
- "ready"
79
+ 'ready'
70
80
  rescue RedisClient::CommandError => e
71
81
  e.message
72
82
  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
@@ -4,6 +4,8 @@ require_relative 'emb/version'
4
4
  require_relative 'emb/client'
5
5
  require_relative 'emb/proxy'
6
6
  require_relative 'emb/multi'
7
+ require_relative 'emb/batch'
8
+ require_relative 'emb/middleware'
7
9
 
8
10
  module Emb
9
11
  class << self
@@ -16,6 +18,7 @@ module Emb
16
18
  alias config setup
17
19
 
18
20
  def [](name) = default_client[name]
21
+ def batch = default_client.batch
19
22
  def models = default_client.models
20
23
  def info(name) = default_client.info(name)
21
24
  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.2
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,9 @@ 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/middleware.rb
52
68
  - lib/emb/multi.rb
53
69
  - lib/emb/proxy.rb
54
70
  - lib/emb/version.rb