emb 0.4.0.pre1 → 0.4.0.pre4
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 +5 -0
- data/README.md +89 -12
- data/lib/emb/batch.rb +9 -2
- data/lib/emb/batch_dispatch.rb +29 -5
- data/lib/emb/commands.rb +97 -0
- data/lib/emb/proxy.rb +34 -2
- data/lib/emb/railtie.rb +5 -1
- 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 +15 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0c6f451b27da29d343a893e88a8ae949620e33213941a785088d0e66f1cbe8ab
|
|
4
|
+
data.tar.gz: 7eb7bc8fa4069bedc853d1580c5504a1a1fbbdff432ff458f61152487199de78
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 94e720e10c56ccca0655c7dab9e28ac71d1731a4da5206664338be49de3dd2756f2acc56aa938a0b949f22dbe38927e2231abfee44cdf5339646079d8bf01fd0
|
|
7
|
+
data.tar.gz: 115a9d1814d895c856f39f6a557625cdb19abb59628dca39e314f35ace1c97c171c5a44c3c06c53500a6dee4332602f43bc4da04f6dabf0da053524ca43b6030
|
data/Gemfile
CHANGED
|
@@ -9,6 +9,11 @@ gem 'redis-client'
|
|
|
9
9
|
gem 'rake', require: false
|
|
10
10
|
gem 'rspec', require: false
|
|
11
11
|
|
|
12
|
+
# Development-only: the Railtie integration suite boots a real Rails app so the
|
|
13
|
+
# real middleware-stack objects and boot order are exercised. Rails is never a
|
|
14
|
+
# runtime dependency of the gem. Select a line with RAILS_VERSION.
|
|
15
|
+
gem 'rails', ENV.fetch('RAILS_VERSION', '~> 8.0'), require: false
|
|
16
|
+
|
|
12
17
|
gem 'rubocop', require: false
|
|
13
18
|
gem 'rubocop-rake', require: false
|
|
14
19
|
gem 'rubocop-rspec', require: false
|
data/README.md
CHANGED
|
@@ -78,7 +78,7 @@ coalescing with `lazy: :multi` or concurrent fan-out with `lazy: :batch` — glo
|
|
|
78
78
|
can take over a second of inference on a shared CPU, and redis-client's silent default is
|
|
79
79
|
1.0s — a slower reply times out. The gem therefore defaults to an explicit 10s timeout
|
|
80
80
|
and `reconnect_attempts: 0`: a failing batch fails closed after one attempt and raises
|
|
81
|
-
`Emb::ServerError` (see [Lazy
|
|
81
|
+
`Emb::ServerError` (see [Lazy batching](#lazy-batching)). Set
|
|
82
82
|
`Emb.configure { |c| c.reconnect_attempts = 2 }` and redis-client re-sends
|
|
83
83
|
**connection and protocol failures** up to that many extra times before the batch fails
|
|
84
84
|
closed — each re-send re-runs server inference, so keep the budget small. Operation
|
|
@@ -291,6 +291,28 @@ exceptions (`RedisClient::CommandError`): read-only parameters (`listen`, `tls_*
|
|
|
291
291
|
`models`), invalid values, and `NOAUTH` on password-protected servers are not
|
|
292
292
|
swallowed.
|
|
293
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
|
+
|
|
294
316
|
## Usage
|
|
295
317
|
|
|
296
318
|
### Single text
|
|
@@ -307,6 +329,32 @@ client = Emb.new(url: "redis://localhost:6379")
|
|
|
307
329
|
result = client[:minilm]["hello world"]
|
|
308
330
|
```
|
|
309
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
|
+
|
|
310
358
|
### Multiple texts
|
|
311
359
|
|
|
312
360
|
```ruby
|
|
@@ -336,17 +384,37 @@ client.multi do |m|
|
|
|
336
384
|
end
|
|
337
385
|
```
|
|
338
386
|
|
|
339
|
-
###
|
|
387
|
+
### Script replies
|
|
340
388
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
the
|
|
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:
|
|
344
394
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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*")`.
|
|
416
|
+
|
|
417
|
+
### Lazy batching
|
|
350
418
|
|
|
351
419
|
In `:batch` mode the shares fan out across the configured instances (one share per
|
|
352
420
|
instance when the share count allows) or across the instance's pool connections when a
|
|
@@ -465,13 +533,22 @@ If your Gemfile loads `emb` before Rails is required (non-standard boot order),
|
|
|
465
533
|
guarded railtie require in `emb.rb` is skipped — add `require "emb/railtie"` in
|
|
466
534
|
`config/application.rb` right after `require "rails/all"` (or in an initializer).
|
|
467
535
|
|
|
468
|
-
The middleware is also safe to mount manually in any Rack app
|
|
469
|
-
|
|
536
|
+
The middleware is also safe to mount manually in any Rack app. In a Rails app,
|
|
537
|
+
set the opt-out as well so the Railtie does not add a second copy:
|
|
470
538
|
|
|
471
539
|
```ruby
|
|
540
|
+
# config/application.rb
|
|
541
|
+
config.emb.middleware = false
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
```ruby
|
|
545
|
+
# wherever you build the stack
|
|
472
546
|
use Emb::Middleware
|
|
473
547
|
```
|
|
474
548
|
|
|
549
|
+
A duplicate mount is harmless — clearing the per-thread scope is idempotent — but
|
|
550
|
+
the opt-out keeps the stack clean and makes the intended wiring explicit.
|
|
551
|
+
|
|
475
552
|
The scope is cleared even when the app raises, and a fresh scope starts
|
|
476
553
|
automatically with the next request. Loaders created but never used within a
|
|
477
554
|
request are dropped — the create-then-consume contract applies per request.
|
data/lib/emb/batch.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'batch_loader'
|
|
4
4
|
require 'redis_client'
|
|
5
5
|
require_relative 'batch_dispatch'
|
|
6
|
+
require_relative 'values_batch'
|
|
6
7
|
|
|
7
8
|
module Emb
|
|
8
9
|
extend BatchDispatch
|
|
@@ -45,11 +46,17 @@ module Emb
|
|
|
45
46
|
# explicit receiver.
|
|
46
47
|
private(*BatchDispatch.instance_methods(false))
|
|
47
48
|
|
|
48
|
-
def build_batch_loader(client, model, text)
|
|
49
|
+
def build_batch_loader(client, model, text, format: :binary)
|
|
50
|
+
unless %i[binary values].include?(format)
|
|
51
|
+
raise ArgumentError, "unknown format #{format.inspect} (expected :binary or :values)"
|
|
52
|
+
end
|
|
53
|
+
|
|
49
54
|
# default_value []: an item whose batch failed (fail-closed) resolves to
|
|
50
55
|
# an empty vector collection instead of nil, so resolver methods like
|
|
51
56
|
# `loader.sum` do not blow up with NoMethodError-on-nil.
|
|
52
|
-
|
|
57
|
+
# Items carry [client, model, text, format] so the dispatch knows how to
|
|
58
|
+
# shape and parse the reply.
|
|
59
|
+
BatchLoader.for([client, model, text, format]).batch(default_value: [], key: BATCH_KEY, &BATCH_BLOCK)
|
|
53
60
|
end
|
|
54
61
|
|
|
55
62
|
# Removes every pending item of the batch scope. batch-loader prunes
|
data/lib/emb/batch_dispatch.rb
CHANGED
|
@@ -11,17 +11,37 @@ module Emb
|
|
|
11
11
|
# the command may have been sent are terminal and propagate so the forcing
|
|
12
12
|
# thread can fail closed.
|
|
13
13
|
def dispatch_slice(client, slice)
|
|
14
|
-
models = slice.map { |_, model, _| model }.uniq
|
|
14
|
+
models = slice.map { |_, model, _, _| model }.uniq
|
|
15
15
|
args = models.size == 1 ? same_model_args(slice, models.first) : mixed_model_args(slice)
|
|
16
16
|
Array(client.send_command(*args))
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def same_model_args(slice, model)
|
|
20
|
-
|
|
20
|
+
texts = slice.flat_map { |_, _, text, _| Array(text) }
|
|
21
|
+
if slice_format(slice) == :values
|
|
22
|
+
['EMB', model.to_s, 'VALUES', *texts]
|
|
23
|
+
else
|
|
24
|
+
['EMB', model.to_s, *texts]
|
|
25
|
+
end
|
|
21
26
|
end
|
|
22
27
|
|
|
23
28
|
def mixed_model_args(slice)
|
|
24
|
-
|
|
29
|
+
pairs = slice.flat_map { |_, model, text, _| Array(text).flat_map { |t| [model.to_s, t] } }
|
|
30
|
+
if slice_format(slice) == :values
|
|
31
|
+
['EMB.MULTI', 'VALUES', *pairs]
|
|
32
|
+
else
|
|
33
|
+
['EMB.MULTI', *pairs]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# slice_format: the batch item layout is [client, model, text, format];
|
|
38
|
+
# items created before the format slot existed default to :binary.
|
|
39
|
+
def slice_format(slice)
|
|
40
|
+
formats = slice.map { |item| item[3] }.uniq
|
|
41
|
+
if formats.size > 1
|
|
42
|
+
raise ArgumentError, "cannot mix :binary and :values formats in one batch"
|
|
43
|
+
end
|
|
44
|
+
formats.first || :binary
|
|
25
45
|
end
|
|
26
46
|
|
|
27
47
|
# Maps a slice's reply entries onto its items in deferral order. Runs on
|
|
@@ -31,14 +51,18 @@ module Emb
|
|
|
31
51
|
# from a client-raised ProtocolError so it is not counted as a transport
|
|
32
52
|
# retry).
|
|
33
53
|
def resolve_slice(loader, slice, results)
|
|
34
|
-
|
|
54
|
+
if slice_format(slice) == :values
|
|
55
|
+
return ValuesBatch.resolve(loader, slice, results)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
expected = slice.sum { |_, _, text, _| Array(text).size }
|
|
35
59
|
unless results.size >= expected
|
|
36
60
|
raise ShortReplyError, "expected #{expected} reply entries, got #{results.size}"
|
|
37
61
|
end
|
|
38
62
|
|
|
39
63
|
offset = 0
|
|
40
64
|
slice.each do |item|
|
|
41
|
-
_, _, text = item
|
|
65
|
+
_, _, text, _ = item
|
|
42
66
|
texts = Array(text)
|
|
43
67
|
values = entry_values(results, offset, texts)
|
|
44
68
|
offset += texts.size
|
data/lib/emb/commands.rb
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'script_reply_decode'
|
|
4
|
+
|
|
3
5
|
module Emb
|
|
4
6
|
# Server-status and runtime-config command wrappers. They only depend on
|
|
5
7
|
# #send_command, so they live in this module (included into Emb::Client)
|
|
6
8
|
# rather than growing the client class.
|
|
7
9
|
module Commands
|
|
10
|
+
include ScriptReplyDecode
|
|
11
|
+
|
|
8
12
|
# EMB.STATS as a Symbol-keyed Hash. No client-side type layer: values are
|
|
9
13
|
# exactly what the RESP decoder returned (Integer where the server sends
|
|
10
14
|
# RESP integers, String otherwise).
|
|
@@ -19,8 +23,101 @@ module Emb
|
|
|
19
23
|
parse_info(send_command('INFO', *sections.map(&:to_s)))
|
|
20
24
|
end
|
|
21
25
|
|
|
26
|
+
# Evaluate a script against a model (EMB.EVAL). texts become the script's
|
|
27
|
+
# KEYS, args its ARGV. Replies are typed per the reply grammar: hash
|
|
28
|
+
# replies (flat field/value pair arrays under RESP2) become Ruby Hashes,
|
|
29
|
+
# nested values recurse. decode: opt-in float decoding — see
|
|
30
|
+
# parse_script_reply (decode: :f32 or decode: {field => :f32}).
|
|
31
|
+
def eval(model, script, texts, args = [], decode: nil)
|
|
32
|
+
texts = Array(texts)
|
|
33
|
+
schema = normalize_decode(decode)
|
|
34
|
+
reply = send_command('EMB.EVAL', model.to_s, script, texts.size, *texts.map(&:to_s), *args.map(&:to_s))
|
|
35
|
+
parse_script_reply(reply, multi: texts.size > 1, decode: schema)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Evaluate a previously loaded script by SHA1 (EMB.EVSHA). See #eval.
|
|
39
|
+
def evalsha(model, sha, texts, args = [], decode: nil)
|
|
40
|
+
texts = Array(texts)
|
|
41
|
+
schema = normalize_decode(decode)
|
|
42
|
+
reply = send_command('EMB.EVSHA', model.to_s, sha, texts.size, *texts.map(&:to_s), *args.map(&:to_s))
|
|
43
|
+
parse_script_reply(reply, multi: texts.size > 1, decode: schema)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# EMB.SCRIPT subcommands: a small command object so the surface reads
|
|
47
|
+
# naturally (script.load / script.exists / script.flush).
|
|
48
|
+
def script
|
|
49
|
+
@script ||= ScriptCommands.new(self)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# EMB.SCRIPT LOAD/EXISTS/FLUSH.
|
|
53
|
+
class ScriptCommands
|
|
54
|
+
def initialize(client)
|
|
55
|
+
@client = client
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Compile, cache, and return the script's SHA1 for the model.
|
|
59
|
+
def load(model, source)
|
|
60
|
+
@client.send_command('EMB.SCRIPT', 'LOAD', model.to_s, source)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Whether each SHA is cached for the model.
|
|
64
|
+
def exists(model, *shas)
|
|
65
|
+
@client.send_command('EMB.SCRIPT', 'EXISTS', model.to_s, *shas.map(&:to_s)).map { |flag| flag == 1 }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Clear the model's cached scripts, or all models when omitted.
|
|
69
|
+
def flush(model = nil)
|
|
70
|
+
args = ['EMB.SCRIPT', 'FLUSH']
|
|
71
|
+
args << model.to_s if model
|
|
72
|
+
@client.send_command(*args)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Remove all cached embeddings, or only entries belonging to +model+.
|
|
77
|
+
# Returns the server's integer removed-entry count.
|
|
78
|
+
def cache_flush(model = nil)
|
|
79
|
+
args = ['EMB.CACHE.FLUSH']
|
|
80
|
+
args << model.to_s unless model.nil?
|
|
81
|
+
send_command(*args)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Ask the server to start an asynchronous cache snapshot. Completion and
|
|
85
|
+
# failures are observable through #stats or #server_info(:cache).
|
|
86
|
+
def save_cache
|
|
87
|
+
send_command('EMB.SAVE')
|
|
88
|
+
end
|
|
89
|
+
|
|
22
90
|
private
|
|
23
91
|
|
|
92
|
+
# Converts a scripted reply (single value, or an array of per-text values
|
|
93
|
+
# for multi-text calls) through the hash grammar: a flat field/value pair
|
|
94
|
+
# array becomes a Hash, recursively. Pure even-length string lists are
|
|
95
|
+
# indistinguishable from two-field hashes on the RESP2 wire, so scripts
|
|
96
|
+
# that must return them should nest them (wrap the list in a table).
|
|
97
|
+
#
|
|
98
|
+
# decode is the normalized (see normalize_decode) opt-in float decoding:
|
|
99
|
+
# nil → no decoding, parsing exactly as before
|
|
100
|
+
# :f32 → each value position is unpack('e*')'d when
|
|
101
|
+
# it is a packed float bulk; numeric arrays
|
|
102
|
+
# pass through unchanged (element types kept)
|
|
103
|
+
# {field => :f32, ...} → after hash parsing, the named field(s) of
|
|
104
|
+
# each hash reply decode as :f32 (fields
|
|
105
|
+
# absent from a reply are left untouched)
|
|
106
|
+
def parse_script_reply(reply, multi:, decode: nil)
|
|
107
|
+
parsed = (multi ? reply : [reply]).map { |v| script_hash(v) }
|
|
108
|
+
decoded = apply_decode(parsed, decode)
|
|
109
|
+
multi ? decoded : decoded.first
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def script_hash(value)
|
|
113
|
+
return value unless value.is_a?(Array) && value.size.even?
|
|
114
|
+
|
|
115
|
+
pairs = value.each_slice(2).to_a
|
|
116
|
+
return value unless pairs.all? { |field, _| field.is_a?(String) }
|
|
117
|
+
|
|
118
|
+
pairs.to_h { |field, val| [field, script_hash(val)] }
|
|
119
|
+
end
|
|
120
|
+
|
|
24
121
|
# Parse Redis INFO section text into a nested Hash:
|
|
25
122
|
# {Server: {redis_version: "0.2.4", uptime_secs: "7"}, Cache: {…}, …}
|
|
26
123
|
# Section names and keys are Symbols; values pass through as the server
|
data/lib/emb/proxy.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'values_reply'
|
|
4
|
+
|
|
3
5
|
module Emb
|
|
4
6
|
class Proxy
|
|
5
7
|
attr_reader :name
|
|
@@ -9,8 +11,20 @@ module Emb
|
|
|
9
11
|
@name = name
|
|
10
12
|
end
|
|
11
13
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
# Queries the model for one or more texts. format: :binary (default)
|
|
15
|
+
# returns the float32-unpacked vectors from the compact BLOB wire;
|
|
16
|
+
# format: :values sends the VALUES keyword and returns the decoded
|
|
17
|
+
# envelope — a Hash with dtype:/shape:/values: (Flats, flat for a single
|
|
18
|
+
# text, grouped per text when several are requested).
|
|
19
|
+
def [](text, *texts, format: :binary)
|
|
20
|
+
format = normalize_format(format)
|
|
21
|
+
if @client.lazy?
|
|
22
|
+
return Emb.build_batch_loader(@client, @name, texts.empty? ? text : [text, *texts], format: format)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
if format == :values
|
|
26
|
+
return values_query(text, texts)
|
|
27
|
+
end
|
|
14
28
|
|
|
15
29
|
set = Array(@client.send_command('EMB', @name.to_s, text, *texts))
|
|
16
30
|
result = set.map { |entry| entry.unpack('e*') }
|
|
@@ -23,5 +37,23 @@ module Emb
|
|
|
23
37
|
def inspect
|
|
24
38
|
"#<Emb::Proxy #{@name}>"
|
|
25
39
|
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def normalize_format(format)
|
|
44
|
+
unless %i[binary values].include?(format)
|
|
45
|
+
raise ArgumentError, "unknown format #{format.inspect} (expected :binary or :values)"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
format
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def values_query(text, texts)
|
|
52
|
+
reply = @client.send_command('EMB', @name.to_s, 'VALUES', text, *texts)
|
|
53
|
+
envelope = Emb::ValuesReply.parse(reply)
|
|
54
|
+
return envelope if texts.empty?
|
|
55
|
+
|
|
56
|
+
envelope.merge(values: Emb::ValuesReply.rows(envelope[:shape], envelope[:values]))
|
|
57
|
+
end
|
|
26
58
|
end
|
|
27
59
|
end
|
data/lib/emb/railtie.rb
CHANGED
|
@@ -13,9 +13,13 @@ if defined?(Rails::Railtie)
|
|
|
13
13
|
config.emb.middleware = true
|
|
14
14
|
config.emb.job_middleware = true
|
|
15
15
|
|
|
16
|
+
# Insertion is unconditional (opt-out excepted): during initialization the
|
|
17
|
+
# stack is a Rails::Configuration::MiddlewareStackProxy, which records
|
|
18
|
+
# operations but cannot be inspected. Duplicate insertion is safe because
|
|
19
|
+
# Emb::BatchScope clearing is idempotent, so manual mounts use the opt-out
|
|
20
|
+
# instead of relying on detection.
|
|
16
21
|
initializer 'emb.middleware' do |app|
|
|
17
22
|
next if config.emb.middleware == false
|
|
18
|
-
next if app.middleware.include?(Emb::Middleware)
|
|
19
23
|
|
|
20
24
|
app.middleware.use Emb::Middleware
|
|
21
25
|
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Emb
|
|
4
|
+
# Opt-in float decoding for scripted replies — the decode: keyword on
|
|
5
|
+
# Emb::Commands#eval / #evalsha. Kept out of the Commands module so the
|
|
6
|
+
# command surface stays small. decode: is validated (normalize_decode) before
|
|
7
|
+
# any command is sent, then applied after reply parsing.
|
|
8
|
+
module ScriptReplyDecode
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# Validates an (un-normalized) decode option and canonicalizes it. Raises
|
|
12
|
+
# ArgumentError for unknown modes so callers fail before any command is
|
|
13
|
+
# sent. nil and an empty hash both mean "no decoding".
|
|
14
|
+
def normalize_decode(decode)
|
|
15
|
+
case decode
|
|
16
|
+
when nil, :f32
|
|
17
|
+
decode
|
|
18
|
+
when Hash
|
|
19
|
+
schema = decode.each_with_object({}) do |(field, mode), acc|
|
|
20
|
+
unless mode == :f32
|
|
21
|
+
raise ArgumentError, "unsupported decode mode #{mode.inspect} for field #{field.inspect} (supported: :f32)"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
acc[field.to_s] = :f32
|
|
25
|
+
end
|
|
26
|
+
schema.empty? ? nil : schema
|
|
27
|
+
else
|
|
28
|
+
raise ArgumentError, "unsupported decode mode #{decode.inspect} (supported: :f32 or {field => :f32})"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Applies a normalized decode to the parsed (script_hash'ed) reply array.
|
|
33
|
+
def apply_decode(parsed, decode)
|
|
34
|
+
return parsed if decode.nil?
|
|
35
|
+
|
|
36
|
+
case decode
|
|
37
|
+
when :f32
|
|
38
|
+
parsed.each_with_index.map { |v, i| decode_f32(v, position(i)) }
|
|
39
|
+
when Hash
|
|
40
|
+
parsed.each_with_index.map { |v, i| decode_hash_fields(v, decode, position(i)) }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def position(index)
|
|
45
|
+
index.zero? ? 'the reply' : "element #{index + 1}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# decode: :f32 — a float32-packed bulk unpacks to floats; a numeric array
|
|
49
|
+
# (the pre-float32_bytes reply style) passes through unchanged; anything
|
|
50
|
+
# else is a contract mismatch and raises.
|
|
51
|
+
def decode_f32(value, where)
|
|
52
|
+
case value
|
|
53
|
+
when String
|
|
54
|
+
unless (value.bytesize % 4).zero?
|
|
55
|
+
raise ArgumentError,
|
|
56
|
+
"decode :f32: #{where} is a #{value.bytesize}-byte bulk, not a multiple of 4 — not a float32 vector"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
value.unpack('e*')
|
|
60
|
+
when Array
|
|
61
|
+
return value if value.all?(Numeric)
|
|
62
|
+
|
|
63
|
+
raise ArgumentError, "decode :f32: #{where} is an array of non-numbers, not a float vector"
|
|
64
|
+
else
|
|
65
|
+
raise ArgumentError,
|
|
66
|
+
"decode :f32: #{where} is a #{value.class}, expected a float32-packed bulk or a numeric array"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# decode: {field => :f32} — the reply (or each per-text reply) must be a
|
|
71
|
+
# hash; present fields decode as :f32, absent fields stay untouched.
|
|
72
|
+
def decode_hash_fields(value, schema, where)
|
|
73
|
+
unless value.is_a?(Hash)
|
|
74
|
+
raise ArgumentError, "decode #{schema.inspect}: #{where} is a #{value.class}, expected a hash reply"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
schema.each_key do |field|
|
|
78
|
+
next unless value.key?(field)
|
|
79
|
+
|
|
80
|
+
value[field] = decode_f32(value[field], "field #{field.inspect} of #{where}")
|
|
81
|
+
end
|
|
82
|
+
value
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'values_reply'
|
|
4
|
+
|
|
5
|
+
module Emb
|
|
6
|
+
# VALUES-format batch resolution for the deferred path. Split out of
|
|
7
|
+
# BatchDispatch so both modules stay under the Metrics rubric.
|
|
8
|
+
module ValuesBatch
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Resolves a VALUES reply onto the slice's items. Single-model slices get
|
|
12
|
+
# ONE envelope reply carrying every text's flattened values (row-major),
|
|
13
|
+
# so the rows are re-sliced per item; mixed-model slices get per-pair
|
|
14
|
+
# envelopes and map one row each. Failures stay nil, mirroring the BLOB
|
|
15
|
+
# path's MGET semantics.
|
|
16
|
+
def resolve(loader, slice, results)
|
|
17
|
+
if single_model?(slice)
|
|
18
|
+
rows = envelope_rows(results)
|
|
19
|
+
expect_rows!(slice, rows.size)
|
|
20
|
+
assign_rows(loader, slice, rows)
|
|
21
|
+
else
|
|
22
|
+
resolve_entries(loader, slice, results)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def single_model?(slice)
|
|
27
|
+
slice.map { |item| item[1] }.uniq.size == 1
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def envelope_rows(results)
|
|
31
|
+
envelope = Emb::ValuesReply.parse(rehydrate_envelope(results))
|
|
32
|
+
Emb::ValuesReply.rows(envelope[:shape], envelope[:values])
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Under RESP3, redis-client decodes the server's single-model VALUES map
|
|
36
|
+
# into a Ruby Hash, but dispatch_slice wraps every reply in Array(), which
|
|
37
|
+
# turns that Hash into an array of [key, value] pairs. Rehydrate those
|
|
38
|
+
# pairs back into a Hash so parse sees a real envelope; RESP2 flat pair
|
|
39
|
+
# arrays (top-level strings) pass through untouched.
|
|
40
|
+
def rehydrate_envelope(results)
|
|
41
|
+
return results.to_h if results.all? { |e| e.is_a?(Array) && e.size == 2 }
|
|
42
|
+
|
|
43
|
+
results
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def expect_rows!(slice, got)
|
|
47
|
+
expected = slice.sum { |_, _, text, _| Array(text).size }
|
|
48
|
+
return if got >= expected
|
|
49
|
+
|
|
50
|
+
raise ShortReplyError, "expected #{expected} VALUES rows, got #{got}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def assign_rows(loader, slice, rows)
|
|
54
|
+
offset = 0
|
|
55
|
+
slice.each do |item|
|
|
56
|
+
_, _, text, = item
|
|
57
|
+
texts = Array(text)
|
|
58
|
+
values = rows[offset, texts.size]
|
|
59
|
+
offset += texts.size
|
|
60
|
+
loader.call(item, values.size == 1 ? values.first : values)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def resolve_entries(loader, slice, results)
|
|
65
|
+
expect_entries!(slice, results.size)
|
|
66
|
+
offset = 0
|
|
67
|
+
slice.each do |item|
|
|
68
|
+
_, _, text, = item
|
|
69
|
+
texts = Array(text)
|
|
70
|
+
values = entry_values(results, offset, texts)
|
|
71
|
+
offset += texts.size
|
|
72
|
+
loader.call(item, values.size == 1 ? values.first : values)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A short multi-model reply is a protocol violation: an explicit null slot
|
|
77
|
+
# is the only legal way to signal a missing pair (see the BLOB path).
|
|
78
|
+
def expect_entries!(slice, got)
|
|
79
|
+
expected = slice.sum { |_, _, text, _| Array(text).size }
|
|
80
|
+
return if got >= expected
|
|
81
|
+
|
|
82
|
+
raise ShortReplyError, "expected #{expected} VALUES reply entries, got #{got}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def entry_values(results, offset, texts)
|
|
86
|
+
results[offset, texts.size].map { |entry| entry && row_of(entry) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def row_of(entry)
|
|
90
|
+
envelope = Emb::ValuesReply.parse(entry)
|
|
91
|
+
Emb::ValuesReply.rows(envelope[:shape], envelope[:values]).first
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Emb
|
|
4
|
+
# Decoding for EMB VALUES envelopes (the RedisAI META+VALUES shape). The
|
|
5
|
+
# server replies with flat alternating key/value pairs under RESP2 — dtype,
|
|
6
|
+
# shape, values — where values are decimal bulk strings carrying the float64
|
|
7
|
+
# widening of each float32 dimension.
|
|
8
|
+
module ValuesReply
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Parses one envelope into a Hash. RESP2 replies arrive as a flat 6-element
|
|
12
|
+
# pair array; under `protocol: 3` redis-client already decoded the server's
|
|
13
|
+
# RESP3 map into a Ruby Hash (keys frozen strings, values already Floats),
|
|
14
|
+
# which is used directly. values are converted to Ruby Floats; an
|
|
15
|
+
# unparsable entry raises ArgumentError naming the offending value.
|
|
16
|
+
def parse(reply)
|
|
17
|
+
pairs = reply.is_a?(Hash) ? reply : reply.each_slice(2).to_h
|
|
18
|
+
shape = pairs.fetch('shape')
|
|
19
|
+
values = Array(pairs.fetch('values')).map { |v| float_value(v) }
|
|
20
|
+
{ dtype: pairs.fetch('dtype'), shape: shape, values: values }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# rows reshapes flat values into per-text rows using shape [m, dim].
|
|
24
|
+
def rows(shape, values)
|
|
25
|
+
dim = shape[1].to_i
|
|
26
|
+
values.each_slice(dim).to_a
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def float_value(v)
|
|
30
|
+
Float(v)
|
|
31
|
+
rescue ArgumentError, TypeError
|
|
32
|
+
raise ArgumentError, "EMB VALUES: unparsable decimal value #{v.inspect} (expected a numeric bulk string)"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
data/lib/emb.rb
CHANGED
|
@@ -36,14 +36,29 @@ module Emb
|
|
|
36
36
|
def info(name) = default_client.info(name)
|
|
37
37
|
def stats = default_client.stats
|
|
38
38
|
def server_info(*sections) = default_client.server_info(*sections)
|
|
39
|
+
def cache_flush(model = nil) = default_client.cache_flush(model)
|
|
40
|
+
def save_cache = default_client.save_cache
|
|
39
41
|
def config = default_client.config
|
|
40
42
|
def help = default_client.help
|
|
41
43
|
def ping = default_client.ping
|
|
42
44
|
def ready = default_client.ready
|
|
43
45
|
def ready? = default_client.ready?
|
|
44
46
|
def multi(&) = default_client.multi(&)
|
|
47
|
+
|
|
48
|
+
def eval(model, script, texts, args = [], decode: nil)
|
|
49
|
+
default_client.eval(model, script, texts, args, decode: decode)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def evalsha(model, sha, texts, args = [], decode: nil)
|
|
53
|
+
default_client.evalsha(model, sha, texts, args, decode: decode)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def script = default_client.script
|
|
57
|
+
|
|
45
58
|
def reset_registry! = default_client.reset_registry!
|
|
59
|
+
|
|
46
60
|
def debug? = @debug
|
|
61
|
+
|
|
47
62
|
def send_command(*) = default_client.send_command(*)
|
|
48
63
|
|
|
49
64
|
def debug!
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: emb
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.4.0.
|
|
4
|
+
version: 0.4.0.pre4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- elcuervo
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-10 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: batch-loader
|
|
@@ -63,6 +63,9 @@ files:
|
|
|
63
63
|
- lib/emb/railtie.rb
|
|
64
64
|
- lib/emb/round_robin_pool.rb
|
|
65
65
|
- lib/emb/runtime_config.rb
|
|
66
|
+
- lib/emb/script_reply_decode.rb
|
|
67
|
+
- lib/emb/values_batch.rb
|
|
68
|
+
- lib/emb/values_reply.rb
|
|
66
69
|
- lib/emb/version.rb
|
|
67
70
|
homepage: https://github.com/elcuervo/emb
|
|
68
71
|
licenses:
|