emb 0.3.0 → 0.4.0.pre3
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 +4 -4
- data/Gemfile +0 -1
- data/README.md +291 -56
- data/lib/emb/batch.rb +92 -38
- data/lib/emb/batch_dispatch.rb +171 -0
- data/lib/emb/batch_scope.rb +20 -0
- data/lib/emb/client.rb +30 -27
- data/lib/emb/commands.rb +97 -0
- data/lib/emb/configuration.rb +31 -5
- data/lib/emb/connection_router.rb +72 -0
- data/lib/emb/errors.rb +17 -0
- data/lib/emb/job_middleware.rb +16 -0
- data/lib/emb/middleware.rb +4 -6
- data/lib/emb/proxy.rb +34 -2
- data/lib/emb/railtie.rb +49 -0
- data/lib/emb/round_robin_pool.rb +100 -0
- data/lib/emb/script_reply_decode.rb +85 -0
- data/lib/emb/values_batch.rb +94 -0
- data/lib/emb/values_reply.rb +35 -0
- data/lib/emb.rb +20 -2
- metadata +12 -16
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7f3eb540be54e104585249da101c6dd0a77c22c5f7ace642be44b11927d71643
|
|
4
|
+
data.tar.gz: c963843c42d593640a11201abbb64b698e353c3d674df53d027bbce363ee2df1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cd19e8b5017cfa0d377a8940a6ed44ecdc78bb5ac5e0905cff9e364ff02d49f68714b4542d2af0fb5f5b89254c8736294c7a62e0ba1506138fa04c5b5ac6f645
|
|
7
|
+
data.tar.gz: 3a66bb58df4664a86ccaddf991d584de31b7b7ffce1aacf5c4d1f8f9151e252e4409719fc20af54d2f52aabae6f66df9092546e8c91d19eda52b4804a9017dd4
|
data/Gemfile
CHANGED
data/README.md
CHANGED
|
@@ -55,7 +55,7 @@ resolve in this order — the first one wins:
|
|
|
55
55
|
```ruby
|
|
56
56
|
Emb.configure do |c|
|
|
57
57
|
c.pool = 8
|
|
58
|
-
c.
|
|
58
|
+
c.lazy = :multi # defer and coalesce embed calls app-wide
|
|
59
59
|
end
|
|
60
60
|
|
|
61
61
|
Emb.configuration # => the shared Emb::Configuration
|
|
@@ -67,11 +67,28 @@ Emb::Client.new(pool: 20) # per-call still wins
|
|
|
67
67
|
|
|
68
68
|
### Out-of-the-box defaults
|
|
69
69
|
|
|
70
|
-
The shipped defaults are benchmark-derived (see `BENCHMARK.md`)
|
|
71
|
-
default** (`
|
|
72
|
-
|
|
73
|
-
`
|
|
74
|
-
with `
|
|
70
|
+
The shipped defaults are benchmark-derived (see `BENCHMARK.md`) with secure-by-default
|
|
71
|
+
network behavior: **eager execution by default** (`lazy: false` — every `Emb[:model][t]`
|
|
72
|
+
sends one `EMB` round trip), pool `5`, pure-Ruby RESP driver, `protocol: 2`,
|
|
73
|
+
`read_timeout`/`write_timeout` both **10s**, and `reconnect_attempts: 0`. Opt into
|
|
74
|
+
coalescing with `lazy: :multi` or concurrent fan-out with `lazy: :batch` — globally via
|
|
75
|
+
`Emb.configure { |c| c.lazy = :batch }` or per client with `Emb.new(lazy: :batch)`.
|
|
76
|
+
|
|
77
|
+
**Why the timeout and reconnect defaults matter:** batched `EMB.MULTI` (up to 512 pairs)
|
|
78
|
+
can take over a second of inference on a shared CPU, and redis-client's silent default is
|
|
79
|
+
1.0s — a slower reply times out. The gem therefore defaults to an explicit 10s timeout
|
|
80
|
+
and `reconnect_attempts: 0`: a failing batch fails closed after one attempt and raises
|
|
81
|
+
`Emb::ServerError` (see [Lazy batching](#lazy-batching)). Set
|
|
82
|
+
`Emb.configure { |c| c.reconnect_attempts = 2 }` and redis-client re-sends
|
|
83
|
+
**connection and protocol failures** up to that many extra times before the batch fails
|
|
84
|
+
closed — each re-send re-runs server inference, so keep the budget small. Operation
|
|
85
|
+
errors (unknown model, auth, bad pairs) are never retried, and neither are **read
|
|
86
|
+
timeouts**: redis-client treats `ReadTimeoutError` as terminal, and re-sending a timed-out
|
|
87
|
+
command could duplicate inference the server already did. `lazy: :batch` shares
|
|
88
|
+
additionally retry a refused connection on the next configured instance. Raise the
|
|
89
|
+
timeouts if you raise `batch_size`. `reconnect_attempts` also accepts an Array of
|
|
90
|
+
per-retry delays (a redis-client passthrough); each entry grants one retry and counts
|
|
91
|
+
toward `Emb::ServerError#attempts`.
|
|
75
92
|
|
|
76
93
|
### Connection pool
|
|
77
94
|
|
|
@@ -79,11 +96,48 @@ with `Emb.new(batch: false)`.
|
|
|
79
96
|
Emb.setup(url: "redis://localhost:6379", pool: 10)
|
|
80
97
|
```
|
|
81
98
|
|
|
82
|
-
The default pool size is **5**. The
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
99
|
+
The default pool size is **5**. The client keeps `pool` persistent connections per
|
|
100
|
+
emb **instance** and routes commands through them **in round-robin order** —
|
|
101
|
+
consecutive commands use different connections instead of reusing one hot connection.
|
|
102
|
+
|
|
103
|
+
`url` also accepts an **array of instances** (interchangeable replicas serving the same
|
|
104
|
+
model set); commands then round-robin across instances first, then across connections
|
|
105
|
+
within the selected instance:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
Emb.setup(url: ["redis://emb-a:6379", "redis://emb-b:6379", "redis://emb-c:6379"], pool: 5)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Each url gets its own pool of `pool` connections, so `pool: 5` with three urls maintains
|
|
112
|
+
15 connections total. If an instance refuses a connection **before a command is sent**
|
|
113
|
+
(connection refused/unreachable), the command is retried on the next instance — this is
|
|
114
|
+
why `reconnect_attempts` stays `0`: a retry after a *sent* command could duplicate
|
|
115
|
+
inference, but a never-sent command is safe to move elsewhere.
|
|
116
|
+
|
|
117
|
+
This matters when emb runs as a pool of instances behind a **connection-level load
|
|
118
|
+
balancer** (AWS Service Connect, an NLB, or Envoy): the balancer assigns each
|
|
119
|
+
keep-alive connection to exactly one instance for its lifetime, so relying on a
|
|
120
|
+
single connection would pin all of a process's embedding traffic to one instance —
|
|
121
|
+
and queue it there even while other instances sit idle. Round-robin spreading across
|
|
122
|
+
`pool` connections spreads traffic across up to `pool` instances, so set `pool` to
|
|
123
|
+
**at least your expected emb instance count** (e.g. `pool: 10` for 10 instances).
|
|
124
|
+
|
|
125
|
+
Commands beyond the pool's parallelism do **not** time out: when `pool` commands are
|
|
126
|
+
already in flight, later commands wait on the shared connections until one frees
|
|
127
|
+
(there is no checkout timeout — unlike the previous pool's 5s
|
|
128
|
+
`ConnectionPool::TimeoutError`). Only the wait for a free pool connection is
|
|
129
|
+
unbounded — once a command runs, `RedisClient`'s `connect_timeout`,
|
|
130
|
+
`read_timeout`, and `write_timeout` still apply. For inference workloads this
|
|
131
|
+
lets commands ride out token-budget batching instead of erroring.
|
|
132
|
+
|
|
133
|
+
Locally (a single server) only the backend selection is identical whatever the
|
|
134
|
+
pool size — every command reaches the same server. The pool size still controls
|
|
135
|
+
how many persistent connections the client holds and how many commands run in
|
|
136
|
+
parallel. The pool is usually not the bottleneck for
|
|
137
|
+
inference-bound workloads (small pools are fine); it becomes a knob at high
|
|
138
|
+
concurrency on a multi-model box — see [Performance](#performance). With
|
|
139
|
+
round-robin selection up to `pool` commands run in parallel; beyond that, commands
|
|
140
|
+
share connections and serialize on them.
|
|
87
141
|
|
|
88
142
|
### Authentication
|
|
89
143
|
|
|
@@ -124,8 +178,8 @@ Emb.setup(
|
|
|
124
178
|
```
|
|
125
179
|
|
|
126
180
|
See the [redis-client documentation](https://github.com/redis-rb/redis-client) for
|
|
127
|
-
all available options. Only `pool` and `
|
|
128
|
-
else passes through to `RedisClient.new`.
|
|
181
|
+
all available options. Only `pool`, `lazy`, and `batch_size` are handled by the
|
|
182
|
+
gem — everything else passes through to `RedisClient.new`.
|
|
129
183
|
|
|
130
184
|
## Instance-based clients
|
|
131
185
|
|
|
@@ -169,6 +223,12 @@ automatically.
|
|
|
169
223
|
|
|
170
224
|
## Server info & config
|
|
171
225
|
|
|
226
|
+
> **Per-instance data.** `Emb.server_info`, `stats`, `info`, and `config` read
|
|
227
|
+
> from ONE instance, and with round-robin connection selection that instance
|
|
228
|
+
> may change between calls (with `pool: 1` it never does) — each call is a
|
|
229
|
+
> sample. `Emb.config[key] = value` mutates one (arbitrary) instance. Use
|
|
230
|
+
> per-connection queries if you need a specific or aggregated view.
|
|
231
|
+
|
|
172
232
|
The server exposes Redis-style `INFO` and `CONFIG` commands; the gem wraps them.
|
|
173
233
|
|
|
174
234
|
### `Emb.stats` — server statistics as a hash
|
|
@@ -190,15 +250,18 @@ Emb.stats
|
|
|
190
250
|
### `Emb.server_info` — sectioned INFO, parsed
|
|
191
251
|
|
|
192
252
|
The Redis-style `INFO` reply is parsed into a nested Hash. **No arguments = all
|
|
193
|
-
sections**; pass section names to filter (`:server`, `:cache`, `:keyspace`, `:stats`, `:clients`):
|
|
253
|
+
sections**; pass section names to filter (`:server`, `:cache`, `:keyspace`, `:stats`, `:memory`, `:cpu`, `:clients`):
|
|
194
254
|
|
|
195
255
|
```ruby
|
|
196
256
|
Emb.server_info
|
|
197
257
|
# => {Server: {redis_version: "0.2.4", emb_version: "0.2.4", uptime_secs: "7", ...},
|
|
198
258
|
# Cache: {cache_hits: 0, cache_misses: 0, cache_hit_rate: "0.0%", ...},
|
|
199
|
-
# Keyspace: {db0: "model=minilm,keys=0,hits=0,misses=0,hit_rate=0.0%"},
|
|
259
|
+
# Keyspace: {db0: "model=minilm,keys=0,hits=0,misses=0,hit_rate=0.0%"},
|
|
260
|
+
# Memory: {used_memory_rss_bytes: 262438912, used_memory_heap_bytes: 154960,
|
|
261
|
+
# goroutines: 4, total_system_memory_bytes: 25769803776},
|
|
262
|
+
# CPU: {used_cpu_user_usec: 188900, used_cpu_sys_usec: 50462, gomaxprocs: 10}, ...}
|
|
200
263
|
|
|
201
|
-
Emb.server_info(:
|
|
264
|
+
Emb.server_info(:memory, :cpu) # live process resources only
|
|
202
265
|
```
|
|
203
266
|
|
|
204
267
|
### `Emb.config` — hot config read & change
|
|
@@ -228,6 +291,28 @@ exceptions (`RedisClient::CommandError`): read-only parameters (`listen`, `tls_*
|
|
|
228
291
|
`models`), invalid values, and `NOAUTH` on password-protected servers are not
|
|
229
292
|
swallowed.
|
|
230
293
|
|
|
294
|
+
### Cache lifecycle commands
|
|
295
|
+
|
|
296
|
+
When server-side caching is enabled, the Ruby client exposes the lifecycle
|
|
297
|
+
commands on module, instance, pooled, and round-robin clients:
|
|
298
|
+
|
|
299
|
+
```ruby
|
|
300
|
+
Emb.cache_flush # all models; => removed entry count
|
|
301
|
+
Emb.cache_flush(:minilm) # one model; => removed entry count
|
|
302
|
+
Emb.save_cache # => "OK" once the background save is accepted
|
|
303
|
+
|
|
304
|
+
Emb.stats[:cache_snapshot_in_progress] # 0 or 1
|
|
305
|
+
Emb.server_info(:cache) # completion, failure, restore limits
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
Snapshot persistence is configured on the server with `cache_file`,
|
|
309
|
+
`cache_load`, `cache_save`, `cache_save_on_shutdown`, `cache_restore_limit`,
|
|
310
|
+
`cache_restore_reserve`, and `cache_save_rate_limit`. Automatic saves keep
|
|
311
|
+
serving inference and control commands while encoding and I/O run in the
|
|
312
|
+
background. Snapshot files include original input text and embeddings; use
|
|
313
|
+
protected/encrypted storage where appropriate and treat them as disposable
|
|
314
|
+
warm-start data rather than a durable database.
|
|
315
|
+
|
|
231
316
|
## Usage
|
|
232
317
|
|
|
233
318
|
### Single text
|
|
@@ -244,6 +329,32 @@ client = Emb.new(url: "redis://localhost:6379")
|
|
|
244
329
|
result = client[:minilm]["hello world"]
|
|
245
330
|
```
|
|
246
331
|
|
|
332
|
+
### VALUE format (RESP decimal reply)
|
|
333
|
+
|
|
334
|
+
By default the server replies with the compact float32 binary wire and the gem
|
|
335
|
+
unpacks it. Pass `format: :values` to send the RedisAI-style `VALUES` keyword
|
|
336
|
+
and get the server's self-describing envelope back: `dtype`, `shape`, and the
|
|
337
|
+
embedding values as decimal floats (the float64 widening the server stores, so
|
|
338
|
+
`unpack` is not needed). Single texts return the envelope with flat `values`;
|
|
339
|
+
multiple texts group the values per text (rows of `shape`).
|
|
340
|
+
|
|
341
|
+
```ruby
|
|
342
|
+
Emb[:minilm]["hello world", format: :values]
|
|
343
|
+
# => { dtype: "FLOAT", shape: [1, 384], values: [0.0123, -0.0456, ...] }
|
|
344
|
+
|
|
345
|
+
Emb[:minilm]["hello", "world", format: :values]
|
|
346
|
+
# => { dtype: "FLOAT", shape: [2, 384], values: [[...], [...]] }
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
The batch loaders accept the same keyword; mixed formats within one batch
|
|
350
|
+
raise `ArgumentError`.
|
|
351
|
+
|
|
352
|
+
> **RESP3 note:** the server emits the decimal values as typed RESP3 doubles when
|
|
353
|
+
> the connection negotiated `HELLO 3` and as decimal bulk strings otherwise.
|
|
354
|
+
> The gem speaks RESP2 (binary default, decimal `values` opt-in) and does not
|
|
355
|
+
> parse RESP3 replies itself — connect with a RESP3-capable client to use the
|
|
356
|
+
> typed doubles on the wire.
|
|
357
|
+
|
|
247
358
|
### Multiple texts
|
|
248
359
|
|
|
249
360
|
```ruby
|
|
@@ -273,38 +384,93 @@ client.multi do |m|
|
|
|
273
384
|
end
|
|
274
385
|
```
|
|
275
386
|
|
|
276
|
-
###
|
|
387
|
+
### Script replies
|
|
388
|
+
|
|
389
|
+
`Emb.eval` / `Emb.evalsha` run a Lua script against a model (KEYS = texts,
|
|
390
|
+
ARGV = args) and parse replies through the RESP grammar — hashes, arrays,
|
|
391
|
+
strings, errors. Unlike the embed path, a scripted reply has no fixed shape,
|
|
392
|
+
so packed vectors are **not** auto-decoded: a `float32_bytes` reply comes back
|
|
393
|
+
as the raw bulk String. Decode it per call with the `decode:` keyword:
|
|
394
|
+
|
|
395
|
+
```ruby
|
|
396
|
+
sha = client.script.load(:siglip2, siglip_source)
|
|
397
|
+
|
|
398
|
+
# decode: :f32 — the reply is a packed float32 vector (or a numeric array)
|
|
399
|
+
vec = client.evalsha(:siglip2, sha, ["a photo of a cat"], ["normalize"], decode: :f32)
|
|
400
|
+
# => [0.0123, -0.0456, ...] 768 floats
|
|
401
|
+
|
|
402
|
+
# multi-text replies decode each element
|
|
403
|
+
vecs = client.evalsha(:siglip2, sha, ["a", "b"], ["normalize"], decode: :f32)
|
|
404
|
+
# => [[0.0123, ...], [-0.0456, ...]]
|
|
405
|
+
|
|
406
|
+
# decode: {field => :f32} — structured replies decode a named field
|
|
407
|
+
out = client.evalsha(:siglip2, sha, ["a"], ["normalize"], decode: { embedding: :f32 })
|
|
408
|
+
# => {"dim" => 768, "embedding" => [0.0123, ...]}
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
`decode:` defaults to `nil` (no decoding, exactly today's behavior). Supported
|
|
412
|
+
modes are `:f32` and `{field => :f32}`; anything else, or a reply that is not
|
|
413
|
+
actually a float vector / hash at the decodable position, raises
|
|
414
|
+
`ArgumentError`. A raw bulk can always be decoded by hand with
|
|
415
|
+
`reply.unpack("e*")`.
|
|
277
416
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
417
|
+
### Lazy batching
|
|
418
|
+
|
|
419
|
+
In `:batch` mode the shares fan out across the configured instances (one share per
|
|
420
|
+
instance when the share count allows) or across the instance's pool connections when a
|
|
421
|
+
single url is configured — a batch whose pieces take 60ms and 10ms completes in roughly
|
|
422
|
+
the slowest share (~60ms) instead of the sum (~70ms). Deferred work is powered by the
|
|
423
|
+
[batch-loader](https://github.com/exAspArk/batch-loader) gem:
|
|
281
424
|
|
|
282
425
|
```ruby
|
|
426
|
+
Emb.setup(lazy: :multi) # or :batch — Emb[:minilm] now defers
|
|
427
|
+
|
|
283
428
|
users = User.all # some application objects
|
|
284
429
|
|
|
285
430
|
# Create loaders first...
|
|
286
|
-
l1 = Emb
|
|
287
|
-
l2 = Emb
|
|
288
|
-
l3 = Emb
|
|
431
|
+
l1 = Emb[:minilm]["hello"]
|
|
432
|
+
l2 = Emb[:minilm]["world"]
|
|
433
|
+
l3 = Emb[:bge]["bonjour"]
|
|
289
434
|
|
|
290
|
-
# ...then consume them. The first use sends ONE EMB
|
|
435
|
+
# ...then consume them. The first use sends ONE EMB (single-model scope)
|
|
436
|
+
# or ONE EMB.MULTI (mixed-model scope) for all three.
|
|
291
437
|
l1.sum # => 12.345
|
|
292
438
|
l2.sum # => -0.678
|
|
293
439
|
l3.sum # => 3.141
|
|
294
440
|
```
|
|
295
441
|
|
|
296
|
-
Instance clients expose the same API:
|
|
297
|
-
|
|
298
|
-
```ruby
|
|
299
|
-
client.batch[:minilm]["hello"].sum
|
|
300
|
-
```
|
|
301
|
-
|
|
302
442
|
Each lazy value materializes to the same shape as the eager API: a single text
|
|
303
|
-
yields an `Array<Float>`, multiple texts yield `Array<Array<Float
|
|
443
|
+
yields an `Array<Float>`, multiple texts yield `Array<Array<Float>>`. For explicit
|
|
444
|
+
composition that always sends immediately regardless of mode, use `Emb.multi { }`
|
|
445
|
+
(see [Multi-model queries](#multi-model-queries)).
|
|
446
|
+
|
|
447
|
+
The batch executes in the current thread's scope; the Rack/ActiveJob middleware
|
|
448
|
+
clears that scope after every request/job (`Emb::BatchScope`), so deferred work
|
|
449
|
+
can never accumulate across requests (in eager mode there is nothing to clear).
|
|
450
|
+
|
|
451
|
+
**Fail-closed batches.** If a batch command fails — a timeout, an operation error, or in
|
|
452
|
+
`:batch` mode a share failure after pre-send retries are exhausted — the batch raises
|
|
453
|
+
**`Emb::ServerError`** to the code that first used it, and every deferred item of that
|
|
454
|
+
batch is removed from the scope — retrying (or using other items of the failed batch)
|
|
455
|
+
**does not re-send the batch** and resolves to `[]` instead. The error's `cause` is the
|
|
456
|
+
underlying redis error (`RedisClient::ReadTimeoutError`, `RedisClient::CommandError`, ...)
|
|
457
|
+
and its message includes the model(s), text count, and attempt count. Transient failures
|
|
458
|
+
(timeout, connection error, protocol error) re-send up to `reconnect_attempts` extra
|
|
459
|
+
times when that option is set above 0 (default 0 = a single attempt); operation errors
|
|
460
|
+
are never retried. A non-redis error raised by the batch call itself (e.g. a `TypeError`
|
|
461
|
+
from bad arguments) is a local bug, not a server failure: the batch's pending items are
|
|
462
|
+
still cleared, but the original error is re-raised. This
|
|
463
|
+
prevents a slow server from turning one failed batch into endless duplicate work
|
|
464
|
+
(retries re-running the whole batch) or growth of the pending set across retries.
|
|
465
|
+
Pair-level failures the server reports as `null` (MGET semantics) are unaffected.
|
|
466
|
+
|
|
467
|
+
> **Breaking change (gem ≥ next release):** a failed batch raises `Emb::ServerError`
|
|
468
|
+
> instead of the raw `RedisClient::*` error. Rescue `Emb::ServerError` and read `cause`
|
|
469
|
+
> for the original error. Eager `Emb.multi` and the `lazy: false` path are unchanged.
|
|
304
470
|
|
|
305
471
|
```ruby
|
|
306
|
-
vec = Emb
|
|
307
|
-
vecs = Emb
|
|
472
|
+
vec = Emb[:minilm]["hello"] # use -> Array of Float
|
|
473
|
+
vecs = Emb[:minilm]["hello", "world"] # use -> Array of Array of Float
|
|
308
474
|
```
|
|
309
475
|
|
|
310
476
|
Embeddings are cached per thread, so reusing a lazy value (or creating an
|
|
@@ -318,43 +484,59 @@ Loaders only fire when a value is **used**. Create all loaders *first*, then
|
|
|
318
484
|
consume them, so they share one round trip:
|
|
319
485
|
|
|
320
486
|
```ruby
|
|
321
|
-
texts.each { |t| process(Emb
|
|
322
|
-
loaders = texts.map { |t| Emb
|
|
487
|
+
texts.each { |t| process(Emb[:minilm][t]) } # wrong: one EMB per item
|
|
488
|
+
loaders = texts.map { |t| Emb[:minilm][t] } # right: ONE EMB for all
|
|
323
489
|
loaders.each { |l| process(l) }
|
|
324
490
|
```
|
|
325
491
|
|
|
326
492
|
A loader that is created but never used **never embeds** (unless a sibling batch
|
|
327
493
|
fires first) and is silently dropped when the thread's scope ends. Duration and
|
|
328
|
-
scope: batching is per-thread — a multithreaded app issues one `EMB.MULTI` per
|
|
494
|
+
scope: batching is per-thread — a multithreaded app issues one `EMB`/`EMB.MULTI` per
|
|
329
495
|
thread per flush.
|
|
330
496
|
|
|
331
|
-
### `
|
|
497
|
+
### `lazy` configuration option
|
|
332
498
|
|
|
333
|
-
Setting `
|
|
334
|
-
batch automatically without restructuring:
|
|
499
|
+
Setting `lazy: :multi` or `lazy: :batch` makes the standard proxy API defer, so
|
|
500
|
+
existing call sites batch automatically without restructuring:
|
|
335
501
|
|
|
336
502
|
```ruby
|
|
337
|
-
Emb.setup(url: "redis://localhost:6379",
|
|
503
|
+
Emb.setup(url: "redis://localhost:6379", lazy: :multi)
|
|
338
504
|
# or
|
|
339
|
-
Emb.new(url: "redis://localhost:6379",
|
|
505
|
+
Emb.new(url: "redis://localhost:6379", lazy: :batch)
|
|
340
506
|
```
|
|
341
507
|
|
|
342
|
-
|
|
343
|
-
`EMB
|
|
344
|
-
|
|
345
|
-
`Emb.multi` remains the explicit, eager,
|
|
508
|
+
Under `lazy: :multi`, `Emb[:minilm]["hello"]` returns a lazy embedding that sends
|
|
509
|
+
`EMB` on first use (serial chunks; `EMB.MULTI` only for mixed-model scopes). Under `lazy: :batch`, the chunk shares
|
|
510
|
+
execute concurrently — with multiple `url`s they fan out across instances. The
|
|
511
|
+
default is eager (`lazy: false`). `Emb.multi` remains the explicit, eager,
|
|
512
|
+
deterministic composition API in every mode.
|
|
346
513
|
|
|
347
514
|
### Clearing the cache per request
|
|
348
515
|
|
|
349
|
-
The per-thread batch scope holds cached embeddings for the life of the thread.
|
|
350
|
-
|
|
351
|
-
|
|
516
|
+
The per-thread batch scope holds cached embeddings for the life of the thread.
|
|
517
|
+
In a Rails application the bundled Railtie mounts `Emb::Middleware`
|
|
518
|
+
automatically, so the scope is cleared at the end of every request with no
|
|
519
|
+
configuration:
|
|
352
520
|
|
|
353
521
|
```ruby
|
|
354
|
-
#
|
|
355
|
-
|
|
522
|
+
# nothing to do — Emb::Railtie inserts Emb::Middleware for you
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
Opt out if you want to manage the stack yourself:
|
|
356
526
|
|
|
357
|
-
|
|
527
|
+
```ruby
|
|
528
|
+
# config/application.rb
|
|
529
|
+
config.emb.middleware = false
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
If your Gemfile loads `emb` before Rails is required (non-standard boot order), the
|
|
533
|
+
guarded railtie require in `emb.rb` is skipped — add `require "emb/railtie"` in
|
|
534
|
+
`config/application.rb` right after `require "rails/all"` (or in an initializer).
|
|
535
|
+
|
|
536
|
+
The middleware is also safe to mount manually in any Rack app (the Railtie
|
|
537
|
+
skips insertion when it is already present):
|
|
538
|
+
|
|
539
|
+
```ruby
|
|
358
540
|
use Emb::Middleware
|
|
359
541
|
```
|
|
360
542
|
|
|
@@ -362,6 +544,46 @@ The scope is cleared even when the app raises, and a fresh scope starts
|
|
|
362
544
|
automatically with the next request. Loaders created but never used within a
|
|
363
545
|
request are dropped — the create-then-consume contract applies per request.
|
|
364
546
|
|
|
547
|
+
### Job-scoped cache clearing
|
|
548
|
+
|
|
549
|
+
The Railtie also registers `Emb::JobMiddleware` — the same per-scope clearing
|
|
550
|
+
for background work — for every job framework present. Each job execution
|
|
551
|
+
starts with a fresh batch scope: cached embeddings and loaders created but
|
|
552
|
+
never used are dropped at the end of the job (even when it raises), so worker
|
|
553
|
+
threads never leak scope state between jobs.
|
|
554
|
+
|
|
555
|
+
- **ActiveJob** (SolidQueue, Sidekiq, Shoryuken, async, test adapters): an
|
|
556
|
+
`around_perform` callback on `ActiveJob::Base` registered by the Railtie.
|
|
557
|
+
- **Plain Sidekiq workers** (non-ActiveJob): a server middleware added by the
|
|
558
|
+
Railtie.
|
|
559
|
+
- **Plain Shoryuken workers** (non-ActiveJob): a server middleware added by the
|
|
560
|
+
Railtie.
|
|
561
|
+
|
|
562
|
+
```ruby
|
|
563
|
+
# opt out of all job-scope protection
|
|
564
|
+
config.emb.job_middleware = false
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
In a non-Rails process, register the middleware manually:
|
|
568
|
+
|
|
569
|
+
```ruby
|
|
570
|
+
# sidekiq.rb / an initializer
|
|
571
|
+
Sidekiq.configure_server do |config|
|
|
572
|
+
config.server_middleware { |chain| chain.add Emb::JobMiddleware }
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
# Shoryuken
|
|
576
|
+
Shoryuken.configure_server do |config|
|
|
577
|
+
config.server_middleware { |chain| chain.add Emb::JobMiddleware }
|
|
578
|
+
end
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
The Railtie also registers `Emb::JobMiddleware` into the `Sidekiq::Testing` middleware
|
|
582
|
+
chain when `Sidekiq::Testing` is loaded *before the app finishes booting* (e.g. a dev
|
|
583
|
+
`INLINE_SIDEKIQ` initializer). For RSpec test modes, `sidekiq/testing` typically loads
|
|
584
|
+
after the app boots, so require it before your environment to get per-job clearing in
|
|
585
|
+
fake/inline tests — e.g. `gem "sidekiq", require: ["sidekiq", "sidekiq/testing"]`.
|
|
586
|
+
|
|
365
587
|
## Performance
|
|
366
588
|
|
|
367
589
|
### Eager-burst pipelining (no new API)
|
|
@@ -372,7 +594,7 @@ batching doesn't fit), coalesce them into one packet with `RedisClient#pipelined
|
|
|
372
594
|
```ruby
|
|
373
595
|
client = Emb.new(url: "redis://localhost:6379")
|
|
374
596
|
|
|
375
|
-
a = client.
|
|
597
|
+
a = client.pools.first.with do |conn|
|
|
376
598
|
conn.pipelined do |pipe|
|
|
377
599
|
texts.each { |t| pipe.call("EMB", "minilm", t) }
|
|
378
600
|
end
|
|
@@ -380,9 +602,22 @@ end
|
|
|
380
602
|
# a -> Array of Float32-binary replies; unpack each with r.unpack("e*")
|
|
381
603
|
```
|
|
382
604
|
|
|
383
|
-
`Client#
|
|
384
|
-
|
|
385
|
-
|
|
605
|
+
`Client#pools` exposes the per-instance pools' `RedisClient` connections (the
|
|
606
|
+
first pool for a single-instance client). This measurably beats
|
|
607
|
+
plain per-call round trips; use it for bursts, and `Emb.multi` or a deferred `lazy`
|
|
608
|
+
mode when you want
|
|
609
|
+
server-side coalescing into one packed inference.
|
|
610
|
+
|
|
611
|
+
### Benchmark harness coverage
|
|
612
|
+
|
|
613
|
+
The client benchmark harness (`gems/emb/bench/bench.rb`, `just bench-ruby`)
|
|
614
|
+
reports one row per execution mechanism: `eager` (one `EMB` per call), `multi`
|
|
615
|
+
(coalesced `EMB`, or `EMB.MULTI` for mixed models), `batch` (concurrent `EMB` chunk shares),
|
|
616
|
+
`pipelined` (raw RESP pipelining), and `threaded` (eager across threads).
|
|
617
|
+
`just bench-ruby-multi` starts two partitioned emb instances and adds the
|
|
618
|
+
url-array rows `eager-2node` (round-robin distribution) and `batch-2node`
|
|
619
|
+
(concurrent fan-out across instances); the two-node rows are skipped unless a
|
|
620
|
+
second instance is reachable (`EMB_BENCH_PORT2`).
|
|
386
621
|
|
|
387
622
|
### RESP driver: pure-Ruby default, `hiredis` on demand
|
|
388
623
|
|
|
@@ -409,7 +644,7 @@ instance. Scale out without a cluster client:
|
|
|
409
644
|
(optionally seed with `-cache` and a warmup pass).
|
|
410
645
|
|
|
411
646
|
Lazy batching stays per server (a thread's loaders target one client), so batching and
|
|
412
|
-
horizontal scale compose: each instance receives one `EMB.MULTI` per request.
|
|
647
|
+
horizontal scale compose: each instance receives one `EMB` (single model) or `EMB.MULTI` (mixed) share per request.
|
|
413
648
|
|
|
414
649
|
### Commands
|
|
415
650
|
|