parse-stack-next 5.5.6 → 5.6.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 +4 -4
- data/CHANGELOG.md +225 -0
- data/lib/parse/agent/metadata_dsl.rb +10 -0
- data/lib/parse/embeddings/binding_audit.rb +211 -0
- data/lib/parse/embeddings/media_file.rb +136 -0
- data/lib/parse/embeddings/provider.rb +39 -0
- data/lib/parse/embeddings/streaming_body.rb +170 -0
- data/lib/parse/embeddings/video_source.rb +120 -0
- data/lib/parse/embeddings/voyage.rb +548 -135
- data/lib/parse/embeddings.rb +55 -0
- data/lib/parse/model/core/embed_managed.rb +26 -0
- data/lib/parse/model/core/properties.rb +37 -1
- data/lib/parse/model/core/vector_searchable.rb +30 -3
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/vector_search/hybrid.rb +120 -12
- data/lib/parse/vector_search.rb +132 -7
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d7ea05ce0688ca7c1df6a1077162478dfee52e33d9fac77c7480c5ca818c9eb8
|
|
4
|
+
data.tar.gz: 71e61f7e70da94793dae9d741a3e5d29ffc7f9cd45f2adee6c96cd98b5ac2d24
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c723fbb9589ce57c7651fab142bed386f3a2070d39fc20887fc40b50f676ccb0041e9b758611e8b75d0a9202095de318ce2bbae35e41015be519209d9d2628dd
|
|
7
|
+
data.tar.gz: dbbc09ee718630415da1ab329168fc1c10d5d9cf20ad09e4129b2518087231206ca84d7d5ecd5e1a27f66e2fb75d3704b856592fcd43c6d2f7711fa20c0dba1b
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,230 @@
|
|
|
1
1
|
## parse-stack-next Changelog
|
|
2
2
|
|
|
3
|
+
### 5.6.0
|
|
4
|
+
|
|
5
|
+
#### Voyage embeddings reach the Atlas endpoint, video, and streamed media
|
|
6
|
+
|
|
7
|
+
- **NEW**: The Voyage provider now targets MongoDB's Atlas Embedding and
|
|
8
|
+
Reranking API in addition to Voyage's own. The two serve the same models
|
|
9
|
+
over an identical wire contract but do not share credentials — an Atlas
|
|
10
|
+
model API key returns 403 from Voyage's host and vice versa. A key carrying
|
|
11
|
+
the Atlas prefix routes to `https://ai.mongodb.com/v1` automatically; pass
|
|
12
|
+
`endpoint: :atlas` or `:voyage` to be explicit, or a `base_url:` to override
|
|
13
|
+
both. A named endpoint that contradicts an explicit `base_url` is rejected
|
|
14
|
+
rather than silently reconciled, so a credential is never sent to a host the
|
|
15
|
+
caller did not intend. `#endpoint` and `#atlas?` report the resolved target.
|
|
16
|
+
- **NEW**: Added `voyage-3.5`, `voyage-3.5-lite`, `voyage-code-2` (1536-dim),
|
|
17
|
+
and `voyage-multimodal-3.5`. Models the Atlas endpoint does not expose —
|
|
18
|
+
`voyage-3`, `voyage-3-lite`, `voyage-4-nano` — are refused at construction
|
|
19
|
+
when that endpoint is active, with an error naming a current replacement;
|
|
20
|
+
they remain valid against Voyage's own API.
|
|
21
|
+
- **NEW**: `Parse::Embeddings::Voyage#embed_video` embeds video through
|
|
22
|
+
`voyage-multimodal-3.5`, the only model that accepts it. Text, image, and
|
|
23
|
+
video vectors share one space, so a stored text vector is comparable against
|
|
24
|
+
a video vector without re-embedding. `#modalities` reports `[:text, :image,
|
|
25
|
+
:video]` for that model and `[:text, :image]` for `voyage-multimodal-3`.
|
|
26
|
+
- **NEW**: `Parse::Embeddings::MediaFile` wraps a local image or video and
|
|
27
|
+
streams it into the request body instead of buffering it. Serializing media
|
|
28
|
+
with `to_json` costs roughly 2.4x the file size resident — raw bytes, the
|
|
29
|
+
1.33x base64 copy, and the serialized document — which is enough to exhaust
|
|
30
|
+
a small dyno on a single moderate video. `MediaFile` reads only a 16-byte
|
|
31
|
+
header at construction; the payload is then base64-encoded into the socket
|
|
32
|
+
in fixed-size chunks by `Parse::Embeddings::StreamingBody`, so peak memory
|
|
33
|
+
is bounded by the chunk size regardless of file size and nothing spills to
|
|
34
|
+
disk. `Content-Length` is computed exactly so the request avoids chunked
|
|
35
|
+
transfer encoding, and the body replays byte-identically on retry. Passing a
|
|
36
|
+
URL instead keeps the SDK out of the transfer entirely.
|
|
37
|
+
- **FIXED**: Corrected the model dimension table. The entire v4 family
|
|
38
|
+
defaults to 1024 — `voyage-4-large`'s 2048, `voyage-4-lite`'s 512, and
|
|
39
|
+
`voyage-4-nano`'s 256 were recorded as native widths when they are
|
|
40
|
+
Matryoshka options reached only by requesting them. Because the provider
|
|
41
|
+
validates the returned vector width against the declared one, both
|
|
42
|
+
`voyage-4-large` and `voyage-4-lite` raised
|
|
43
|
+
`Parse::Embeddings::InvalidResponseError` on every call. `voyage-4-nano` is
|
|
44
|
+
1024, and `voyage-finance-2` carries a 32,000-token context rather than
|
|
45
|
+
16,000.
|
|
46
|
+
- **CHANGED**: The coarse "Matryoshka-capable models" gate is replaced by
|
|
47
|
+
per-model `MODEL_SUPPORTED_DIMENSIONS`. Any width on a model's ladder is now
|
|
48
|
+
accepted — including one wider than its default, which the old rule rejected
|
|
49
|
+
as "exceeds native" — and a width off the ladder is refused with the
|
|
50
|
+
supported set named. `output_dimension` is sent whenever the configured
|
|
51
|
+
width differs from the model's default, so `voyage-4-lite` at 512 and
|
|
52
|
+
`voyage-4-large` at 2048 both work.
|
|
53
|
+
- **FIXED**: A caller-supplied URL can no longer capture a local file's bytes.
|
|
54
|
+
The streamed body previously marked each payload with a sentinel token and
|
|
55
|
+
located it by searching the serialized JSON, so a URL containing that token
|
|
56
|
+
matched first: the file's base64 was spliced into the URL slot and forwarded
|
|
57
|
+
to the provider as a URL to fetch, disclosing local file contents, while the
|
|
58
|
+
intended slot kept the literal token. Request bodies are now assembled
|
|
59
|
+
structurally — each fragment serialized independently and concatenated in
|
|
60
|
+
order — so payloads are placed by position and caller data is never
|
|
61
|
+
searched.
|
|
62
|
+
- **FIXED**: Mixed `image_url` / `image_base64` batches no longer violate
|
|
63
|
+
Voyage's request contract, which requires a single representation per
|
|
64
|
+
request. A mixed batch is split into one request per representation and
|
|
65
|
+
reassembled into the caller's original order, preserving the 1:1 alignment
|
|
66
|
+
between inputs and returned vectors.
|
|
67
|
+
- **FIXED**: Video validation no longer accepts containers the provider
|
|
68
|
+
rejects. MP4 is the only format Voyage supports, and WebM and QuickTime
|
|
69
|
+
payloads are refused by the API, so both are out of the default allowlist.
|
|
70
|
+
An `ftyp` box no longer implies MP4 on its own — QuickTime and the
|
|
71
|
+
audio-only profiles share the ISO base media container — so major brands are
|
|
72
|
+
matched explicitly and an unrecognized brand is refused rather than assumed.
|
|
73
|
+
Apple's audio-only `M4A` brand is excluded, closing a type confusion in
|
|
74
|
+
which an audio file passed as video.
|
|
75
|
+
- **NEW**: `Parse::Embeddings.max_media_bytes` caps streamed media per file,
|
|
76
|
+
defaulting to the 20 MB Voyage documents. Streaming already prevents an
|
|
77
|
+
oversized file from exhausting memory, but the provider still rejects it, so
|
|
78
|
+
failing locally turns a wasted upload into an immediate error. The Voyage
|
|
79
|
+
adapter enforces the 20 MB ceiling independently, so raising the global knob
|
|
80
|
+
for another provider cannot push an oversized payload onto Voyage.
|
|
81
|
+
|
|
82
|
+
#### Breaking
|
|
83
|
+
|
|
84
|
+
- **BREAKING**: The Voyage provider's default model moves from `voyage-3` to
|
|
85
|
+
`voyage-3.5`. `voyage-3` is retired from the Atlas endpoint, so the old
|
|
86
|
+
default made an Atlas key fail at construction whenever no model was named.
|
|
87
|
+
Vectors from the two models are not comparable. **Migration:** code relying
|
|
88
|
+
on the default must pin `model: "voyage-3"` explicitly to keep existing
|
|
89
|
+
embeddings valid, or re-embed against the new default. A `:vector` property
|
|
90
|
+
that declares `model:` is unaffected — the new binding audit catches the
|
|
91
|
+
mismatch before any request rather than letting the two mix silently.
|
|
92
|
+
|
|
93
|
+
#### Vector search no longer returns fewer results than requested
|
|
94
|
+
|
|
95
|
+
- **FIXED**: `$vectorSearch` set its `limit` to `k`, but Atlas applies that
|
|
96
|
+
limit before the SDK's ACL `$match`, `protectedFields` redaction,
|
|
97
|
+
pointer-field filtering, and any caller-supplied `filter` — so a scoped
|
|
98
|
+
caller who could read 2 of the top 10 documents asked for 10 and received 2,
|
|
99
|
+
even when hundreds of readable matches existed further down the ranking.
|
|
100
|
+
The search now requests a wider internal candidate window, applies every
|
|
101
|
+
enforcement layer, and only then trims to `k`. The window is raised only
|
|
102
|
+
when something can actually drop rows, so a master-key call with no filter
|
|
103
|
+
keeps its previous one-for-one cost. A `candidate_limit:` option on
|
|
104
|
+
`VectorSearch.search` and `find_similar` tunes the window for principals
|
|
105
|
+
whose visibility is unusually narrow. This is a mitigation, not a guarantee:
|
|
106
|
+
a sufficiently selective ACL can still exhaust any finite window, which is
|
|
107
|
+
why the attrition counts below exist. HNSW width stays anchored to `k`, so
|
|
108
|
+
the wider window does not widen the ANN search.
|
|
109
|
+
- **NEW**: `VectorSearch.search` emits a `parse.vector_search.search`
|
|
110
|
+
`ActiveSupport::Notifications` event carrying `candidate_limit`,
|
|
111
|
+
`num_candidates`, `post_filter_count`, `post_pointer_count`,
|
|
112
|
+
`returned_count`, `pointer_attrition`, and `underfilled`. The counts are
|
|
113
|
+
named for where they are measured — obtaining a true pre-`$match` count
|
|
114
|
+
would require a `$facet` — so an underfill is observable rather than silent.
|
|
115
|
+
|
|
116
|
+
#### Vector properties are checked against the provider actually registered
|
|
117
|
+
|
|
118
|
+
- **FIXED**: A `:vector` property's `model:` was recorded and never enforced.
|
|
119
|
+
Because models in the same family usually share a width (`voyage-3` and
|
|
120
|
+
`voyage-3.5` are both 1024), swapping the registered provider's model
|
|
121
|
+
silently mixed incomparable vectors into one index — no error, just
|
|
122
|
+
degraded recall, repairable only by re-embedding. `dimensions:` was
|
|
123
|
+
verified, but only against a vector the provider had already returned and
|
|
124
|
+
billed for. Both are now checked by `Parse::Embeddings::BindingAudit`
|
|
125
|
+
before any request is issued, on the managed-write path and the
|
|
126
|
+
query-embedding path alike. The audit runs ahead of the digest short-circuit
|
|
127
|
+
so an unchanged record still surfaces a drifted binding, and it fails closed:
|
|
128
|
+
a provider that cannot report `#model_name` or `#dimensions` is refused
|
|
129
|
+
rather than skipped, since a declaration that cannot be verified is not a
|
|
130
|
+
declaration that has been satisfied.
|
|
131
|
+
- **NEW**: `Parse::Embeddings::BindingAudit.audit_all!` and
|
|
132
|
+
`.audit_all_or_raise!` check every declared binding at once, for a boot-time
|
|
133
|
+
or CI gate rather than waiting for the first save that happens to touch one.
|
|
134
|
+
- **NEW**: `:vector` properties validate `similarity:` against the functions
|
|
135
|
+
Atlas accepts (`euclidean`, `cosine`, `dotProduct`) at declaration time
|
|
136
|
+
instead of surfacing a typo as an index error later.
|
|
137
|
+
- **FIXED**: Hybrid search applies the same candidate window as the plain
|
|
138
|
+
vector search, so opting into hybrid no longer underfills where a straight
|
|
139
|
+
vector search would not. Each branch's limit is applied before ACL
|
|
140
|
+
enforcement and was narrower than the plain path's window.
|
|
141
|
+
- **FIXED**: Hybrid search separates the rows each branch retains for fusion
|
|
142
|
+
from the rows Atlas considers before ACL. Conflating them meant the branch
|
|
143
|
+
limit was passed as the vector branch's `k` and then multiplied a second
|
|
144
|
+
time by the plain search's own window derivation — a hybrid `k: 10` asked
|
|
145
|
+
Atlas for 1,000 rows instead of the intended 100, an explicit
|
|
146
|
+
`candidate_limit: 250` became 2,500 on the client path while staying 250 on
|
|
147
|
+
the native path, and any `k` above 100 produced a branch `k` beyond
|
|
148
|
+
`VectorSearch::MAX_K` that failed outright. The fusion depth is now bounded
|
|
149
|
+
by `MAX_K` and the candidate window by Atlas's 10,000 ceiling, with both
|
|
150
|
+
paths using the same window. A `candidate_limit` outside that range is
|
|
151
|
+
refused rather than clamped, and `vector: { candidate_limit: }` is forwarded
|
|
152
|
+
through `hybrid_search`.
|
|
153
|
+
- **FIXED**: The native `$rankFusion` pipeline trimmed to `k` in a `$limit`
|
|
154
|
+
stage that runs after its ACL `$match`, reintroducing the underfill the
|
|
155
|
+
candidate window exists to prevent. It now limits to the candidate window
|
|
156
|
+
and trims to `k` once enforcement has run.
|
|
157
|
+
- **NEW**: A `parse.vector_search.hybrid` notification reports `method`,
|
|
158
|
+
`branch_depth`, `candidate_window`, `post_filter_count`, `returned_count`,
|
|
159
|
+
and `underfilled` for both fusion paths. `branch_depth` differs by method by
|
|
160
|
+
design: the client path enforces ACL inside each branch and can retain the
|
|
161
|
+
narrower fusion depth, while the native path enforces after `$rankFusion`
|
|
162
|
+
and must retain the full window. The value reported is the one that ran.
|
|
163
|
+
- **NEW**: A `:vector` property wider than the Atlas vectorSearch index cap is
|
|
164
|
+
refused unless it declares `searchable: false`. `Parse::Vector` tolerates up
|
|
165
|
+
to 16384 dimensions while an Atlas index caps at 8192, so such a property
|
|
166
|
+
was previously declarable, storable, and permanently unsearchable, with the
|
|
167
|
+
failure appearing only at query time. The two limits remain distinct — they
|
|
168
|
+
govern storage and indexing respectively — but the combination now has to be
|
|
169
|
+
acknowledged.
|
|
170
|
+
- **NEW**: `searchable: false` makes a `:vector` property genuinely
|
|
171
|
+
storage-only, whether it opted out to clear the index cap or by choice. It
|
|
172
|
+
is excluded from `find_similar` / `hybrid_search` field resolution, refused
|
|
173
|
+
with an explanation when named directly, and rejected by `agent_searchable`
|
|
174
|
+
at class load rather than at an agent's first query.
|
|
175
|
+
|
|
176
|
+
#### Provider protocol
|
|
177
|
+
|
|
178
|
+
- **NEW**: `Parse::Embeddings::Provider#embed_video` joins `#embed_image` in
|
|
179
|
+
the base protocol with the same `NotImplementedError` default, so video is a
|
|
180
|
+
declared capability rather than a Voyage-only method. `#supports_modality?`
|
|
181
|
+
answers the capability question without rescuing.
|
|
182
|
+
- **NEW**: `rake test:contract` runs live, billable provider contract tests
|
|
183
|
+
that pin request routing, native dimensions, the Matryoshka ladder, accepted
|
|
184
|
+
media, size limits, model availability, and response shape. They skip unless
|
|
185
|
+
`VOYAGE_CONTRACT_KEY` is set and are excluded from both `rake test` and
|
|
186
|
+
`rake test:unit`, so no ordinary run becomes billable because a key happens
|
|
187
|
+
to be exported. Probes that assert a refusal issue raw requests rather than
|
|
188
|
+
going through the SDK — a local guard asserted against itself proves nothing
|
|
189
|
+
about the contract it encodes — and distinguish a genuine refusal from an
|
|
190
|
+
authentication, rate-limit, or 5xx failure so infrastructure trouble cannot
|
|
191
|
+
read as a contract verdict. Mocked tests assert what the SDK believes the
|
|
192
|
+
API does and therefore cannot detect provider drift: every dimension and
|
|
193
|
+
media-format correction in this release was invisible to a fully green
|
|
194
|
+
mocked suite.
|
|
195
|
+
|
|
196
|
+
### Behavior Notes
|
|
197
|
+
|
|
198
|
+
- Audio is not offered by any Voyage model, and neither PDF nor DOCX is
|
|
199
|
+
accepted as a content type. Render document pages to images and embed those;
|
|
200
|
+
the SDK does not perform that conversion.
|
|
201
|
+
- Deterministic result fill under highly selective ACLs would require the
|
|
202
|
+
authorization predicate to run inside the Atlas prefilter rather than after
|
|
203
|
+
it. The post-search `$match` remains the enforcement boundary regardless.
|
|
204
|
+
- `voyage-4-nano` is served by neither hosted endpoint — it is open-weight and
|
|
205
|
+
meant to be self-hosted. It is refused against Voyage's and Atlas's hosts
|
|
206
|
+
with a message pointing at a self-hosted `base_url:` or
|
|
207
|
+
`Parse::Embeddings::LocalHTTP`, and remains usable through either.
|
|
208
|
+
|
|
209
|
+
### Code Example
|
|
210
|
+
|
|
211
|
+
```ruby
|
|
212
|
+
# Endpoint inferred from the key prefix — no base_url needed.
|
|
213
|
+
provider = Parse::Embeddings::Voyage.new(
|
|
214
|
+
api_key: ENV.fetch("ATLAS_MODEL_API_KEY"),
|
|
215
|
+
model: "voyage-multimodal-3.5",
|
|
216
|
+
)
|
|
217
|
+
provider.endpoint # => :atlas
|
|
218
|
+
provider.modalities # => [:text, :image, :video]
|
|
219
|
+
|
|
220
|
+
# Local media streams into the request; the bytes are never held in memory.
|
|
221
|
+
provider.embed_image([Parse::Embeddings::MediaFile.image("page.png")])
|
|
222
|
+
provider.embed_video([Parse::Embeddings::MediaFile.video("demo.mp4")])
|
|
223
|
+
|
|
224
|
+
# A URL keeps the SDK out of the transfer entirely — the provider fetches it.
|
|
225
|
+
provider.embed_image(["https://cdn.example.com/page.png"])
|
|
226
|
+
```
|
|
227
|
+
|
|
3
228
|
### 5.5.6
|
|
4
229
|
|
|
5
230
|
#### MCP clients now receive the SSE response instead of hanging
|
|
@@ -634,6 +634,16 @@ module Parse
|
|
|
634
634
|
"agent_searchable field: :#{field_sym} is not a declared :vector property " \
|
|
635
635
|
"on #{parse_class_name} (declared: #{vector_properties.keys.inspect})."
|
|
636
636
|
end
|
|
637
|
+
# A storage-only property is not eligible for search, so the
|
|
638
|
+
# tool this registers could never run. Refuse at class load
|
|
639
|
+
# rather than at the first agent query.
|
|
640
|
+
if respond_to?(:vector_properties) &&
|
|
641
|
+
vector_properties.dig(field_sym, :searchable) == false
|
|
642
|
+
raise ArgumentError,
|
|
643
|
+
"agent_searchable field: :#{field_sym} on #{parse_class_name} is declared " \
|
|
644
|
+
"`searchable: false` (storage-only, not eligible for search) and cannot " \
|
|
645
|
+
"back a search tool."
|
|
646
|
+
end
|
|
637
647
|
filters = Array(filter_fields).map(&:to_sym)
|
|
638
648
|
@agent_searchable_field = field_sym
|
|
639
649
|
@agent_searchable_filter_fields = filters
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Parse
|
|
5
|
+
module Embeddings
|
|
6
|
+
# Checks that a `:vector` property's declared provider binding
|
|
7
|
+
# still matches the provider actually registered under that name.
|
|
8
|
+
#
|
|
9
|
+
# A `:vector` property may declare `provider:`, `model:`, and
|
|
10
|
+
# `dimensions:`. Only `provider:` was ever enforced. `dimensions:`
|
|
11
|
+
# is verified — but only against the vector a provider already
|
|
12
|
+
# returned, i.e. after the call has been made and paid for. And
|
|
13
|
+
# `model:` was never checked at all, which is the dangerous one: two
|
|
14
|
+
# generations of the same model family usually share a width
|
|
15
|
+
# (`voyage-3` and `voyage-3.5` are both 1024), so swapping the
|
|
16
|
+
# registered provider's model silently mixes incompatible
|
|
17
|
+
# embeddings into one index. Nothing raises, recall just quietly
|
|
18
|
+
# degrades, and the damage is only repairable by re-embedding.
|
|
19
|
+
#
|
|
20
|
+
# This module closes both gaps by comparing the declaration against
|
|
21
|
+
# the live provider BEFORE any request is issued.
|
|
22
|
+
#
|
|
23
|
+
# Auditing cannot happen at class-definition time: providers are
|
|
24
|
+
# registered by name and, as {Parse::Core::EmbedManaged} documents,
|
|
25
|
+
# registration may legitimately happen any time before the first
|
|
26
|
+
# save. So the audit runs lazily on each use and is also exposed as
|
|
27
|
+
# {.audit_all!} for an explicit boot-time or CI check.
|
|
28
|
+
module BindingAudit
|
|
29
|
+
# Raised when a property's declared binding disagrees with the
|
|
30
|
+
# registered provider.
|
|
31
|
+
class BindingMismatch < Parse::Embeddings::Error; end
|
|
32
|
+
|
|
33
|
+
# Raised when the audit cannot enumerate the classes it is meant
|
|
34
|
+
# to check. Distinct from {BindingMismatch}: nothing was found to
|
|
35
|
+
# be wrong, but nothing was verified either.
|
|
36
|
+
class DiscoveryFailed < Parse::Embeddings::Error; end
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
# Verify one property binding against a resolved provider.
|
|
40
|
+
#
|
|
41
|
+
# Deliberately NOT memoized. The check is a pair of comparisons
|
|
42
|
+
# against values already in memory, so caching it saves nothing
|
|
43
|
+
# measurable — while any cache key cheap enough to be worth
|
|
44
|
+
# computing (class name, provider object id) can go stale when a
|
|
45
|
+
# class is unloaded and redefined with a changed declaration, or
|
|
46
|
+
# when object ids are recycled. A validator that silently skips
|
|
47
|
+
# after a reload is worse than no validator, so correctness wins
|
|
48
|
+
# over an optimization with no observable benefit.
|
|
49
|
+
#
|
|
50
|
+
# @param klass [Class] the Parse::Object subclass.
|
|
51
|
+
# @param field [Symbol] the `:vector` property name.
|
|
52
|
+
# @param provider [Parse::Embeddings::Provider]
|
|
53
|
+
# @raise [BindingMismatch]
|
|
54
|
+
# @return [void]
|
|
55
|
+
def verify!(klass, field, provider)
|
|
56
|
+
declared = klass.vector_properties[field.to_sym]
|
|
57
|
+
return if declared.nil?
|
|
58
|
+
|
|
59
|
+
check!(klass, field, provider, declared)
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Audit every declared binding whose provider is registered.
|
|
64
|
+
# Intended for boot or CI: it surfaces a drifted declaration
|
|
65
|
+
# before a single embedding is written, rather than on the
|
|
66
|
+
# first save that happens to touch it.
|
|
67
|
+
#
|
|
68
|
+
# @param classes [Array<Class>, nil] defaults to every
|
|
69
|
+
# Parse::Object subclass carrying `:vector` properties.
|
|
70
|
+
# @param strict [Boolean] when true, an unregistered provider
|
|
71
|
+
# is itself a failure; otherwise those bindings are skipped
|
|
72
|
+
# (a provider may be registered later in boot).
|
|
73
|
+
# @return [Array<String>] human-readable problems, empty when clean.
|
|
74
|
+
def audit_all!(classes: nil, strict: false)
|
|
75
|
+
problems = []
|
|
76
|
+
begin
|
|
77
|
+
bindings = collect_bindings(classes)
|
|
78
|
+
rescue DiscoveryFailed => e
|
|
79
|
+
return [e.message]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
bindings.each do |klass, field, declared|
|
|
83
|
+
provider_name = declared[:provider]
|
|
84
|
+
next if provider_name.nil?
|
|
85
|
+
|
|
86
|
+
begin
|
|
87
|
+
provider = Parse::Embeddings.provider(provider_name)
|
|
88
|
+
rescue Parse::Embeddings::ProviderNotRegistered => e
|
|
89
|
+
problems << "#{klass}##{field}: #{e.message}" if strict
|
|
90
|
+
next
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
begin
|
|
94
|
+
check!(klass, field, provider, declared)
|
|
95
|
+
rescue BindingMismatch => e
|
|
96
|
+
problems << e.message
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
problems
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# {.audit_all!} that raises instead of returning problems.
|
|
103
|
+
#
|
|
104
|
+
# @raise [BindingMismatch] when any binding disagrees.
|
|
105
|
+
# @return [void]
|
|
106
|
+
def audit_all_or_raise!(classes: nil, strict: false)
|
|
107
|
+
problems = audit_all!(classes: classes, strict: strict)
|
|
108
|
+
return if problems.empty?
|
|
109
|
+
|
|
110
|
+
raise BindingMismatch,
|
|
111
|
+
"Parse::Embeddings binding audit failed:\n - #{problems.join("\n - ")}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Retained as a no-op for callers that invoked it when this
|
|
115
|
+
# module memoized verdicts.
|
|
116
|
+
# @return [void]
|
|
117
|
+
def reset!
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
# Read a provider accessor, distinguishing "not implemented"
|
|
124
|
+
# from a legitimate nil. Returns `:unavailable` for the former
|
|
125
|
+
# so {#check!} can fail closed rather than skip the comparison.
|
|
126
|
+
def accessor(provider, method)
|
|
127
|
+
value = provider.public_send(method)
|
|
128
|
+
value.nil? ? :unavailable : value
|
|
129
|
+
rescue NotImplementedError, NoMethodError
|
|
130
|
+
:unavailable
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def check!(klass, field, provider, declared)
|
|
134
|
+
declared_model = declared[:model]
|
|
135
|
+
if declared_model
|
|
136
|
+
actual_model = accessor(provider, :model_name)
|
|
137
|
+
# A declaration states a requirement. A provider that cannot
|
|
138
|
+
# answer what model it runs cannot satisfy it, so this fails
|
|
139
|
+
# closed — otherwise a custom provider without `model_name`
|
|
140
|
+
# would write same-width embeddings that are never checked
|
|
141
|
+
# against the declaration at all.
|
|
142
|
+
if actual_model == :unavailable
|
|
143
|
+
raise BindingMismatch,
|
|
144
|
+
"#{klass}##{field} declares model: #{declared_model.inspect} but the " \
|
|
145
|
+
"provider registered as #{declared[:provider].inspect} " \
|
|
146
|
+
"(#{provider.class}) does not report a usable #model_name, so the " \
|
|
147
|
+
"binding cannot be verified. Implement #model_name on the provider, " \
|
|
148
|
+
"or drop `model:` from the property to opt out of the check."
|
|
149
|
+
end
|
|
150
|
+
if declared_model.to_s != actual_model.to_s
|
|
151
|
+
raise BindingMismatch,
|
|
152
|
+
"#{klass}##{field} declares model: #{declared_model.inspect} but the " \
|
|
153
|
+
"provider registered as #{declared[:provider].inspect} is running " \
|
|
154
|
+
"#{actual_model.inspect}. Vectors from different models are not " \
|
|
155
|
+
"comparable; embedding with the current provider would corrupt this " \
|
|
156
|
+
"index. Update the declaration and re-embed, or register the declared " \
|
|
157
|
+
"model."
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
declared_dims = declared[:dimensions]
|
|
162
|
+
if declared_dims
|
|
163
|
+
actual_dims = accessor(provider, :dimensions)
|
|
164
|
+
if actual_dims == :unavailable
|
|
165
|
+
raise BindingMismatch,
|
|
166
|
+
"#{klass}##{field} declares dimensions: #{declared_dims} but the " \
|
|
167
|
+
"provider registered as #{declared[:provider].inspect} " \
|
|
168
|
+
"(#{provider.class}) does not report a usable #dimensions, so the " \
|
|
169
|
+
"binding cannot be verified. #dimensions is required by the provider " \
|
|
170
|
+
"protocol."
|
|
171
|
+
end
|
|
172
|
+
if declared_dims != actual_dims
|
|
173
|
+
raise BindingMismatch,
|
|
174
|
+
"#{klass}##{field} declares dimensions: #{declared_dims} but the provider " \
|
|
175
|
+
"registered as #{declared[:provider].inspect} emits #{actual_dims}-dim " \
|
|
176
|
+
"vectors. Fix the declaration or configure the provider's width before " \
|
|
177
|
+
"embedding."
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
nil
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def collect_bindings(classes)
|
|
184
|
+
list = classes || default_classes
|
|
185
|
+
list.flat_map do |klass|
|
|
186
|
+
next [] unless klass.respond_to?(:vector_properties)
|
|
187
|
+
klass.vector_properties.map { |field, declared| [klass, field, declared] }
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Discovery must NOT swallow its own failure: an empty list from
|
|
192
|
+
# a crashed sweep is indistinguishable from a clean audit, so
|
|
193
|
+
# `audit_all_or_raise!` would report success having checked
|
|
194
|
+
# nothing. Failures propagate as {DiscoveryFailed} and
|
|
195
|
+
# {.audit_all!} converts them into a reported problem.
|
|
196
|
+
def default_classes
|
|
197
|
+
return [] unless defined?(Parse::Object)
|
|
198
|
+
ObjectSpace.each_object(Class).select do |k|
|
|
199
|
+
k < Parse::Object && k.respond_to?(:vector_properties) &&
|
|
200
|
+
!k.vector_properties.empty?
|
|
201
|
+
end
|
|
202
|
+
rescue StandardError => e
|
|
203
|
+
raise DiscoveryFailed,
|
|
204
|
+
"Parse::Embeddings::BindingAudit could not enumerate Parse::Object " \
|
|
205
|
+
"subclasses (#{e.class}: #{e.message}); the audit checked nothing. " \
|
|
206
|
+
"Pass `classes:` explicitly to audit a known set."
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Parse
|
|
5
|
+
module Embeddings
|
|
6
|
+
# A file-backed image or video input that is **streamed** into the
|
|
7
|
+
# request body rather than read into memory.
|
|
8
|
+
#
|
|
9
|
+
# This is the memory-safe counterpart to
|
|
10
|
+
# {ImageFetch::FetchedImage}, which holds raw bytes. A MediaFile
|
|
11
|
+
# holds only a path, a sniffed MIME type, and a byte count; the
|
|
12
|
+
# bytes are read and base64-encoded incrementally by
|
|
13
|
+
# {StreamingBody} while the request is being written to the socket.
|
|
14
|
+
# Peak memory stays at {StreamingBody::READ_CHUNK} no matter how
|
|
15
|
+
# large the file is, which is what makes video viable on a small
|
|
16
|
+
# dyno.
|
|
17
|
+
#
|
|
18
|
+
# Only the first 16 bytes are read at construction time, to sniff
|
|
19
|
+
# the container. The `Content-Type` header and the filename
|
|
20
|
+
# extension are never consulted.
|
|
21
|
+
#
|
|
22
|
+
# @example stream a local image
|
|
23
|
+
# img = Parse::Embeddings::MediaFile.image("diagram.png")
|
|
24
|
+
# provider.embed_image([img])
|
|
25
|
+
#
|
|
26
|
+
# @example stream a local video
|
|
27
|
+
# clip = Parse::Embeddings::MediaFile.video("demo.mp4")
|
|
28
|
+
# provider.embed_video([clip])
|
|
29
|
+
class MediaFile
|
|
30
|
+
# Voyage documents 20 MB per image and 20 MB per video.
|
|
31
|
+
# Overridable via {Parse::Embeddings.max_media_bytes=}.
|
|
32
|
+
DEFAULT_MAX_MEDIA_BYTES = 20 * 1024 * 1024
|
|
33
|
+
|
|
34
|
+
# Raised when a file exceeds {Parse::Embeddings.max_media_bytes}.
|
|
35
|
+
class TooLarge < Parse::Embeddings::Error; end
|
|
36
|
+
|
|
37
|
+
# @return [String] absolute path to the backing file.
|
|
38
|
+
attr_reader :path
|
|
39
|
+
# @return [String] sniffed MIME type.
|
|
40
|
+
attr_reader :mime_type
|
|
41
|
+
# @return [Integer] size in bytes, captured at construction.
|
|
42
|
+
attr_reader :byte_size
|
|
43
|
+
# @return [Symbol] `:image` or `:video`.
|
|
44
|
+
attr_reader :kind
|
|
45
|
+
|
|
46
|
+
class << self
|
|
47
|
+
# Wrap a local image file. Verifies the magic bytes against
|
|
48
|
+
# {Parse::Embeddings.allowed_image_types}.
|
|
49
|
+
#
|
|
50
|
+
# @param path [String]
|
|
51
|
+
# @return [MediaFile]
|
|
52
|
+
# @raise [ImageFetch::InvalidImageType]
|
|
53
|
+
def image(path)
|
|
54
|
+
header = read_header(path)
|
|
55
|
+
mime = ImageFetch.sniff_mime(header)
|
|
56
|
+
if mime.nil?
|
|
57
|
+
raise ImageFetch::InvalidImageType.new(:unknown_magic,
|
|
58
|
+
"Parse::Embeddings::MediaFile.image: #{path} matches no supported image " \
|
|
59
|
+
"format (JPEG/PNG/GIF/WebP).")
|
|
60
|
+
end
|
|
61
|
+
allowed = Parse::Embeddings.allowed_image_types
|
|
62
|
+
unless allowed.include?(mime)
|
|
63
|
+
raise ImageFetch::InvalidImageType.new(:type_not_allowed,
|
|
64
|
+
"Parse::Embeddings::MediaFile.image: sniffed type #{mime.inspect} is not in " \
|
|
65
|
+
"Parse::Embeddings.allowed_image_types (#{allowed.inspect}).")
|
|
66
|
+
end
|
|
67
|
+
new(path: path, mime_type: mime, kind: :image)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Wrap a local video file. Verifies the magic bytes against
|
|
71
|
+
# {Parse::Embeddings.allowed_video_types}.
|
|
72
|
+
#
|
|
73
|
+
# @param path [String]
|
|
74
|
+
# @return [MediaFile]
|
|
75
|
+
# @raise [VideoSource::InvalidVideoType]
|
|
76
|
+
def video(path)
|
|
77
|
+
header = read_header(path)
|
|
78
|
+
new(path: path, mime_type: VideoSource.verify!(header), kind: :video)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# Read only enough bytes to sniff a container. Deliberately
|
|
84
|
+
# tiny — the whole point of this class is to never hold the
|
|
85
|
+
# file. A file shorter than this is passed through as-is so the
|
|
86
|
+
# sniffers can reject it with their own error.
|
|
87
|
+
def read_header(path)
|
|
88
|
+
unless ::File.file?(path)
|
|
89
|
+
raise ArgumentError,
|
|
90
|
+
"Parse::Embeddings::MediaFile: #{path.inspect} is not a readable file."
|
|
91
|
+
end
|
|
92
|
+
::File.open(path, "rb") { |f| f.read(16).to_s }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def initialize(path:, mime_type:, kind:)
|
|
97
|
+
@path = ::File.expand_path(path)
|
|
98
|
+
@mime_type = mime_type
|
|
99
|
+
@kind = kind
|
|
100
|
+
@byte_size = ::File.size(@path)
|
|
101
|
+
if @byte_size.zero?
|
|
102
|
+
raise ArgumentError,
|
|
103
|
+
"Parse::Embeddings::MediaFile: #{path.inspect} is empty."
|
|
104
|
+
end
|
|
105
|
+
cap = Parse::Embeddings.max_media_bytes
|
|
106
|
+
if @byte_size > cap
|
|
107
|
+
raise TooLarge,
|
|
108
|
+
"Parse::Embeddings::MediaFile: #{path.inspect} is #{@byte_size} bytes, over " \
|
|
109
|
+
"the #{cap}-byte limit (Parse::Embeddings.max_media_bytes). Voyage rejects " \
|
|
110
|
+
"media above 20 MB — downscale or re-encode before embedding."
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The `data:` URI prefix that precedes the streamed base64 in the
|
|
115
|
+
# wire body. The payload itself is never concatenated here.
|
|
116
|
+
#
|
|
117
|
+
# @return [String]
|
|
118
|
+
def data_uri_prefix
|
|
119
|
+
"data:#{mime_type};base64,"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Segment descriptor consumed by {StreamingBody}.
|
|
123
|
+
#
|
|
124
|
+
# @return [Hash]
|
|
125
|
+
def stream_segment
|
|
126
|
+
{ path: @path, size: @byte_size }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def inspect
|
|
130
|
+
"#<Parse::Embeddings::MediaFile kind=#{@kind} mime_type=#{@mime_type.inspect} " \
|
|
131
|
+
"bytes=#{@byte_size} path=#{@path.inspect}>"
|
|
132
|
+
end
|
|
133
|
+
alias_method :to_s, :inspect
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -24,6 +24,7 @@ module Parse
|
|
|
24
24
|
# Subclasses MAY override:
|
|
25
25
|
#
|
|
26
26
|
# * {#embed_image} — v5.1 (multimodal); default `NotImplementedError`
|
|
27
|
+
# * {#embed_video} — v5.6 (multimodal); default `NotImplementedError`
|
|
27
28
|
# * {#embed_batch_size} — provider-recommended batch size hint
|
|
28
29
|
# * {#max_input_tokens} — chunker hint
|
|
29
30
|
# * {#normalize?} — whether output is unit-normalized
|
|
@@ -76,6 +77,44 @@ module Parse
|
|
|
76
77
|
raise NotImplementedError, "#{self.class} does not support image embedding"
|
|
77
78
|
end
|
|
78
79
|
|
|
80
|
+
# Embed video sources. Same contract and default posture as
|
|
81
|
+
# {#embed_image}: providers that do not offer video leave this
|
|
82
|
+
# raising, and callers discover support through {#modalities}
|
|
83
|
+
# rather than by rescuing.
|
|
84
|
+
#
|
|
85
|
+
# Video payloads are large enough that holding one in memory is a
|
|
86
|
+
# real operational risk, so the two source forms concrete
|
|
87
|
+
# providers should accept are:
|
|
88
|
+
#
|
|
89
|
+
# * a URL String, forwarded for provider-side fetch after
|
|
90
|
+
# {Parse::Embeddings.validate_image_url!} screens it (the SDK
|
|
91
|
+
# never downloads the video), and
|
|
92
|
+
# * a {Parse::Embeddings::MediaFile}, streamed into the request
|
|
93
|
+
# body by {Parse::Embeddings::StreamingBody} without ever being
|
|
94
|
+
# fully resident.
|
|
95
|
+
#
|
|
96
|
+
# Concrete overrides must accept `allow_insecure:` explicitly or
|
|
97
|
+
# absorb it via `**opts` — see {#embed_image} for why.
|
|
98
|
+
#
|
|
99
|
+
# @param sources [Array<String, Parse::Embeddings::MediaFile>]
|
|
100
|
+
# @param input_type [Symbol] `:search_query` or `:search_document`.
|
|
101
|
+
# @param allow_insecure [Boolean] forwarded to the URL validator.
|
|
102
|
+
# @param opts [Hash] provider-specific options.
|
|
103
|
+
# @return [Array<Array<Float>>] vectors aligned 1:1 with `sources`.
|
|
104
|
+
# @raise [NotImplementedError] unless the provider offers video.
|
|
105
|
+
def embed_video(sources, input_type: :search_document, allow_insecure: false, **opts)
|
|
106
|
+
raise NotImplementedError, "#{self.class} does not support video embedding"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @return [Boolean] whether this provider accepts `modality`.
|
|
110
|
+
# Prefer this over rescuing {NotImplementedError} — it answers
|
|
111
|
+
# the question without issuing a call.
|
|
112
|
+
#
|
|
113
|
+
# @param modality [Symbol] one of `:text`, `:image`, `:video`.
|
|
114
|
+
def supports_modality?(modality)
|
|
115
|
+
modalities.include?(modality.to_sym)
|
|
116
|
+
end
|
|
117
|
+
|
|
79
118
|
# Batched text embedding. Splits `strings` into chunks of size
|
|
80
119
|
# {#embed_batch_size} (or returns a single-shot call when nil) and
|
|
81
120
|
# concatenates results. Concrete providers should override only
|