Skip to content

Commit 19dc109

Browse files
Add 0092 embed chunk-and-stitch
Implement the general section 8 embed batch-chunking rule and TEI /embed chunk-and-stitch (fixture 038). TeiEmbeddingProvider.embed() now chunks by its chunk_size (TEI's max-client-batch-size) over consecutive slices, concatenates vectors in input order, and produces usage null / response_id null (TEI reports neither). Lift the shared chunk_and_stitch_embed helper into retrieval/_wire.py: the loop, per-chunk count validation, input-order concat, None-aware input_tokens sum, first-chunk response_id, and section 4 validation, driven by a per-mapping embed_chunk closure. TEI /embed and Cohere /v2/embed both adopt it; Cohere's own chunk path migrates onto it unchanged. Un-defer fixture 038 and flip conformance.toml.
1 parent c66a1d9 commit 19dc109

7 files changed

Lines changed: 312 additions & 98 deletions

File tree

conformance.toml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -883,11 +883,11 @@ note = "Retrieval-provider Cohere /v2/embed wire mapping (§8.4) -- the embed ha
883883

884884
# Spec v0.87.0 (proposal 0092). Embedding-mapping batch chunking, the
885885
# general §8 rule (consecutive <=cap chunks, one request each, vectors
886-
# concatenated in input order, usage summed). Not-yet; the TEI /embed
887-
# over-cap fixture 038 defers with it (no embedding wire mapping is
888-
# shipped).
886+
# concatenated in input order, usage combined record-aware).
889887
[proposals."0092"]
890-
status = "not-yet"
888+
status = "implemented"
889+
since = "0.16.0"
890+
note = "Retrieval-provider general §8 *Batch chunking* rule for the embedding side + fixture 038 (TEI /embed chunk-and-stitch by the construction chunk_size). The shared chunk_and_stitch_embed helper in _wire.py realizes the rule mapping-agnostically: split the inputs into consecutive <=cap slices preserving order, issue one request per slice via a per-mapping closure (every field other than the chunked input list identical across slices), concatenate the per-chunk vectors in input order (so §4's one-vector-per-input + input-order invariants hold across the whole call, enforced against the stitched result), combine the per-chunk usage record-aware (sum EmbeddingUsage.input_tokens when the provider reports usage, else usage = null -- never fabricated), take response_id from the first chunk, and set raw to the single response for a single-request call or the list of per-chunk responses in request order (proposal 0096). TeiEmbeddingProvider.embed() adopts it with cap = its construction chunk_size (TEI's max-client-batch-size, default 32): an over-cap embed call chunk-and-stitches (fixture 038: chunk_size 2 over 5 inputs -> 3 /embed requests sized 2/2/1 with identical per-call params), and since TEI /embed reports no usage and no id the stitched usage is null and response_id is null; the len(input) <= chunk_size path issues a single /embed request (the pre-0092 behavior preserved). CohereEmbeddingProvider migrates its existing 96-input chunk path (0091, fixture 037) onto the same helper (cap = 96, closure POSTs /v2/embed) with identical behavior. The per-chunk vector count is validated before stitching so a positional misalignment cannot pass on a compensating total. Conformance fixtures 037 (Cohere, unchanged) + 038 (TEI) pass via the general wire-capture harness; no retrieval fixtures remain deferred."
891891

892892
# Spec v0.88.0 (proposal 0093). Nullable provider usage records:
893893
# EmbeddingResponse.usage / RerankResponse.usage become record|null and

src/openarmature/retrieval/_wire.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,14 @@
1010

1111
from __future__ import annotations
1212

13+
from collections.abc import Awaitable, Callable
1314
from typing import Any, cast
1415

1516
from openarmature.llm.errors import ProviderInvalidResponse
1617

18+
from .provider import validate_embedding_response
19+
from .response import EmbeddingResponse, EmbeddingUsage
20+
1721

1822
def normalize_base_url(base_url: str, *, guard_prefix: str) -> str:
1923
"""Strip a trailing slash from ``base_url`` and reject a doubled version prefix.
@@ -82,6 +86,79 @@ def apply_client_side_prefix(
8286
return [prefix + s for s in input_strings]
8387

8488

89+
# retrieval-provider §8 *Batch chunking* (the general embed rule), shared by
90+
# every EmbeddingProvider whose wire enforces a per-call input cap (TEI /embed's
91+
# max-client-batch-size, Cohere /v2/embed's 96-input cap, ...). When len(input)
92+
# exceeds cap the mapping MUST split the inputs into consecutive <=cap slices
93+
# (preserving order), issue one request per slice with EVERY field other than the
94+
# chunked input list identical across slices, concatenate the per-chunk vectors
95+
# IN INPUT ORDER (so §4's one-vector-per-input + input-order invariants hold
96+
# across the whole call), and combine the per-chunk usage per §4's nullable
97+
# usage contract -- sum input_tokens when the provider reports usage, else
98+
# usage = null. response_id is the FIRST chunk's id (a single-request call uses
99+
# that request's id). raw is that one response for a single-request call, or the
100+
# LIST of per-chunk responses in request order for a chunked call (proposal
101+
# 0096). embed_chunk owns the per-mapping wire shaping / POST / parse and returns
102+
# (vectors, input_tokens, response_id, raw_body) for its slice -- so the loop /
103+
# stitch / validation is mapping-agnostic. When len(input) <= cap this issues a
104+
# single request (the single-iteration path). Valid because each input's
105+
# embedding is independent of the others in its batch.
106+
async def chunk_and_stitch_embed(
107+
input_strings: list[str],
108+
*,
109+
model: str,
110+
cap: int,
111+
embed_chunk: Callable[[list[str]], Awaitable[tuple[list[list[float]], int | None, str | None, Any]]],
112+
) -> EmbeddingResponse:
113+
"""Issue one embed request per ``<= cap`` chunk and stitch the vectors.
114+
115+
``embed_chunk`` sends one chunk's request and returns
116+
``(vectors, input_tokens, response_id, raw_body)`` for that chunk; the
117+
per-mapping wire shaping, POST, and parse live in the closure. Returns the
118+
stitched :class:`EmbeddingResponse`.
119+
"""
120+
stitched_vectors: list[list[float]] = []
121+
chunk_bodies: list[Any] = []
122+
input_tokens_total: int | None = None
123+
response_id: str | None = None
124+
for offset in range(0, len(input_strings), cap):
125+
chunk = input_strings[offset : offset + cap]
126+
chunk_vectors, chunk_tokens, chunk_id, chunk_body = await embed_chunk(chunk)
127+
# Per-chunk count MUST match the chunk's inputs before stitching: the
128+
# stitch is positional (no index re-basing), so a chunk returning the
129+
# wrong vector count would silently misalign vectors that a compensating
130+
# chunk lets the stitched total pass (§4 input-order).
131+
if len(chunk_vectors) != len(chunk):
132+
raise ProviderInvalidResponse(
133+
f"embedding response returned {len(chunk_vectors)} vectors for {len(chunk)} inputs"
134+
)
135+
stitched_vectors.extend(chunk_vectors)
136+
chunk_bodies.append(chunk_body)
137+
if offset == 0:
138+
response_id = chunk_id
139+
# Sum input_tokens across chunks; a chunk that omits usage does not
140+
# contribute, and usage stays null only when NO chunk reports it (§4 /
141+
# §8 batch-chunking step 4 -- never fabricate).
142+
if chunk_tokens is not None:
143+
input_tokens_total = (input_tokens_total or 0) + chunk_tokens
144+
# §4 cross-impl invariants (one vector per input, uniform dimensionality)
145+
# are enforced against the STITCHED result.
146+
dimensions = validate_embedding_response(stitched_vectors, len(input_strings))
147+
usage = EmbeddingUsage(input_tokens=input_tokens_total) if input_tokens_total is not None else None
148+
# raw is the verbatim deserialized response (§4 / §8 per proposal 0096, dict
149+
# | list): a single call carries that response's body; a chunked call carries
150+
# the LIST of per-chunk bodies in request order.
151+
raw: dict[str, Any] | list[Any] = chunk_bodies[0] if len(chunk_bodies) == 1 else chunk_bodies
152+
return EmbeddingResponse(
153+
vectors=stitched_vectors,
154+
model=model,
155+
usage=usage,
156+
response_id=response_id,
157+
dimensions=dimensions,
158+
raw=raw,
159+
)
160+
161+
85162
# §6 (0097): the rerank document-echo shape rule, shared by every RerankProvider
86163
# (Cohere / TEI / Jina). ScoredDocument.document carries the provider's string
87164
# echo verbatim when present, null otherwise -- never fabricated from the input
@@ -109,6 +186,7 @@ def document_echo(value: Any) -> str | None:
109186

110187
__all__ = [
111188
"apply_client_side_prefix",
189+
"chunk_and_stitch_embed",
112190
"client_side_prefix",
113191
"document_echo",
114192
"normalize_base_url",

src/openarmature/retrieval/providers/cohere.py

Lines changed: 20 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -64,17 +64,15 @@
6464
build_rerank_event,
6565
build_rerank_failed_event,
6666
)
67-
from .._wire import document_echo, normalize_base_url
67+
from .._wire import chunk_and_stitch_embed, document_echo, normalize_base_url
6868
from ..provider import (
6969
validate_embedding_input,
70-
validate_embedding_response,
7170
validate_rerank_input,
7271
validate_rerank_response,
7372
)
7473
from ..response import (
7574
EmbeddingResponse,
7675
EmbeddingRuntimeConfig,
77-
EmbeddingUsage,
7876
RerankResponse,
7977
RerankRuntimeConfig,
8078
RerankUsage,
@@ -622,61 +620,34 @@ async def _embed_chunked(
622620
request_extras: dict[str, Any],
623621
) -> EmbeddingResponse:
624622
"""Issue one /v2/embed per <=96 chunk and stitch the vectors."""
623+
625624
# THE chunk-and-stitch (§8.4 *Batch chunking (96-input cap)* / the §8
626-
# general embed rule). When len(input) <= 96 this issues a single
627-
# request; otherwise it splits the inputs into consecutive <=96 slices,
628-
# issues one /v2/embed per slice with IDENTICAL per-call params (only
629-
# texts differs), concatenates the per-chunk embeddings.float IN INPUT
630-
# ORDER, sums meta.billed_units.input_tokens across chunks, and takes
631-
# response_id from the FIRST chunk. This is valid because each input's
632-
# embedding is independent of the others in its batch.
633-
stitched_vectors: list[list[float]] = []
634-
chunk_bodies: list[Any] = []
635-
input_tokens_total: int | None = None
636-
response_id: str | None = None
637-
for offset in range(0, len(input_strings), _COHERE_EMBED_MAX_INPUTS):
638-
chunk = input_strings[offset : offset + _COHERE_EMBED_MAX_INPUTS]
625+
# general embed rule) via the shared chunk_and_stitch_embed helper. The
626+
# per-chunk closure builds the /v2/embed body with IDENTICAL per-call
627+
# params (only texts differs), POSTs, classifies the HTTP error, and
628+
# parses the chunk into (vectors, input_tokens, id, body); the helper
629+
# loops the consecutive <=96 slices, concatenates the vectors IN INPUT
630+
# ORDER, sums input_tokens, takes response_id from the FIRST chunk, and
631+
# validates the §4 invariants against the stitched result. When
632+
# len(input) <= 96 this issues a single request. Valid because each
633+
# input's embedding is independent of the others in its batch.
634+
async def _embed_one(
635+
chunk: list[str],
636+
) -> tuple[list[list[float]], int | None, str | None, dict[str, Any]]:
639637
body = self._build_request_body(chunk, input_type, dimensions, request_extras)
640638
try:
641639
resp = await self._client.post("/v2/embed", json=body)
642640
except httpx.HTTPError as exc:
643641
raise ProviderUnavailable(f"embedding request failed: {exc}") from exc
644642
if resp.status_code != 200:
645643
raise _classify_cohere_http_error(resp)
646-
chunk_vectors, chunk_tokens, chunk_id, chunk_body = self._parse_chunk(resp)
647-
# Per-chunk count MUST match the chunk's inputs before stitching: the
648-
# stitch is positional (no index re-basing), so a chunk returning the
649-
# wrong vector count would silently misalign vectors that a
650-
# compensating chunk lets the stitched total pass (§4 input-order).
651-
if len(chunk_vectors) != len(chunk):
652-
raise ProviderInvalidResponse(
653-
f"embedding response returned {len(chunk_vectors)} vectors for {len(chunk)} inputs"
654-
)
655-
stitched_vectors.extend(chunk_vectors)
656-
chunk_bodies.append(chunk_body)
657-
if offset == 0:
658-
response_id = chunk_id
659-
# Sum input_tokens across chunks; a chunk that omits usage does not
660-
# contribute, and usage stays null only when NO chunk reports it
661-
# (§4 / §8 batch-chunking step 4 -- never fabricate).
662-
if chunk_tokens is not None:
663-
input_tokens_total = (input_tokens_total or 0) + chunk_tokens
664-
# §4 cross-impl invariants (one vector per input, uniform
665-
# dimensionality) are enforced against the STITCHED result.
666-
dimensions_out = validate_embedding_response(stitched_vectors, len(input_strings))
667-
usage = EmbeddingUsage(input_tokens=input_tokens_total) if input_tokens_total is not None else None
668-
# raw is the verbatim deserialized response (§4 / §8 per proposal 0096,
669-
# dict | list): a single /v2/embed call carries that response's object;
670-
# a chunked call carries the LIST of per-chunk objects in request order,
671-
# discriminated by whether the input exceeded the 96-input cap.
672-
raw: dict[str, Any] | list[Any] = chunk_bodies[0] if len(chunk_bodies) == 1 else chunk_bodies
673-
return EmbeddingResponse(
674-
vectors=stitched_vectors,
644+
return self._parse_chunk(resp)
645+
646+
return await chunk_and_stitch_embed(
647+
input_strings,
675648
model=self.model,
676-
usage=usage,
677-
response_id=response_id,
678-
dimensions=dimensions_out,
679-
raw=raw,
649+
cap=_COHERE_EMBED_MAX_INPUTS,
650+
embed_chunk=_embed_one,
680651
)
681652

682653
def _build_request_body(

src/openarmature/retrieval/providers/tei.py

Lines changed: 57 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,9 @@
5555
build_rerank_event,
5656
build_rerank_failed_event,
5757
)
58-
from .._wire import apply_client_side_prefix, document_echo
58+
from .._wire import apply_client_side_prefix, chunk_and_stitch_embed, document_echo
5959
from ..provider import (
6060
validate_embedding_input,
61-
validate_embedding_response,
6261
validate_rerank_input,
6362
validate_rerank_response,
6463
)
@@ -178,9 +177,9 @@ def __init__(
178177
if chunk_size <= 0:
179178
raise ValueError(f"chunk_size must be positive (got {chunk_size})")
180179
# chunk_size is TEI's max-client-batch-size (default 32); it bounds the
181-
# /embed batch per the §8 batch-chunking rule. Client-side embed chunk-
182-
# and-stitch is proposal 0092, out of scope here -- chunk_size is bound
183-
# for construction parity so an operator can already pin it.
180+
# /embed batch per the §8 batch-chunking rule (proposal 0092) -- an
181+
# over-cap embed call chunk-and-stitches over consecutive <=chunk_size
182+
# slices.
184183
self._chunk_size = chunk_size
185184
# input_type -> TEI prompt_name map (server-side prompts) with an
186185
# optional client-side prefix fallback (§8.1 input_type realization).
@@ -241,14 +240,7 @@ async def embed(
241240
adapter_start = time.perf_counter()
242241
try:
243242
validate_embedding_input(input_strings)
244-
body = self._build_request_body(input_strings, input_type, dimensions, request_extras)
245-
try:
246-
resp = await self._client.post("/embed", json=body)
247-
except httpx.HTTPError as exc:
248-
raise ProviderUnavailable(f"embedding request failed: {exc}") from exc
249-
if resp.status_code != 200:
250-
raise _classify_tei_http_error(resp)
251-
response = self._parse_response(resp, input_strings)
243+
response = await self._embed_chunked(input_strings, input_type, dimensions, request_extras)
252244
except LlmProviderError as exc:
253245
latency_ms_failed = (time.perf_counter() - adapter_start) * 1000.0
254246
if dispatch is not None:
@@ -331,18 +323,64 @@ def _build_request_body(
331323
body["dimensions"] = dimensions
332324
return body
333325

334-
def _parse_response(
326+
async def _embed_chunked(
335327
self,
336-
resp: httpx.Response,
337328
input_strings: list[str],
329+
input_type: str | None,
330+
dimensions: int | None,
331+
request_extras: dict[str, Any],
338332
) -> EmbeddingResponse:
339-
"""Parse TEI's bare vector-array response into an EmbeddingResponse."""
333+
"""Issue one /embed per <=chunk_size chunk and stitch the vectors."""
334+
335+
# THE chunk-and-stitch (§8.1 /embed max-client-batch-size cap / the §8
336+
# general embed rule, proposal 0092) via the shared chunk_and_stitch_embed
337+
# helper. When len(input) <= chunk_size this issues a single request (the
338+
# helper's single-iteration path -- preserving the pre-0092 one-request
339+
# behavior); otherwise it splits the inputs into consecutive <=chunk_size
340+
# slices with IDENTICAL per-call params (the input_type realization --
341+
# prompt_name or client-side prefix -- is identical per chunk; only inputs
342+
# differ), concatenates the vectors IN INPUT ORDER, and stitches usage /
343+
# response_id. TEI /embed reports NO usage and NO id, so the per-chunk
344+
# tokens + id slots are None: the stitched usage is null (no chunk reports
345+
# tokens) and response_id is null (§8 step 4 / §4). Valid because each
346+
# input's embedding is independent of the others in its batch.
347+
async def _embed_one(
348+
chunk: list[str],
349+
) -> tuple[list[list[float]], None, None, list[Any]]:
350+
body = self._build_request_body(chunk, input_type, dimensions, request_extras)
351+
try:
352+
resp = await self._client.post("/embed", json=body)
353+
except httpx.HTTPError as exc:
354+
raise ProviderUnavailable(f"embedding request failed: {exc}") from exc
355+
if resp.status_code != 200:
356+
raise _classify_tei_http_error(resp)
357+
return self._parse_chunk(resp)
358+
359+
return await chunk_and_stitch_embed(
360+
input_strings,
361+
model=self.model,
362+
cap=self._chunk_size,
363+
embed_chunk=_embed_one,
364+
)
365+
366+
def _parse_chunk(
367+
self,
368+
resp: httpx.Response,
369+
) -> tuple[list[list[float]], None, None, list[Any]]:
370+
"""Parse one TEI /embed chunk into (vectors, None, None, raw)."""
340371
# TEI /embed returns a bare JSON array of vector arrays in input order
341372
# ([[float, ...], ...]) -- no envelope, no id, no usage object. The
342373
# vectors are already in input order (position == input index), so no
343374
# index-keyed reordering is needed (unlike the OpenAI data[] shape).
344-
# Validates the §4 invariants (count + consistent dimensionality) via
345-
# validate_embedding_response, and rejects non-numeric vector values.
375+
# Rejects non-numeric vector values. TEI reports no usage and no
376+
# response id, so the tokens + id slots are None; the shared
377+
# chunk_and_stitch_embed helper enforces the §4 count / dimensionality
378+
# invariants against the stitched result and (since no chunk reports
379+
# tokens / an id) produces usage = null + response_id = null (MUST NOT
380+
# fabricate). raw is the verbatim deserialized chunk response: TEI's bare
381+
# vector array carried as a list, not wrapped (§4 / §8.1 per proposal
382+
# 0096 -- raw is dict | list, the top-level shape the provider returned;
383+
# a chunked call stitches to the LIST of these per-chunk arrays).
346384
try:
347385
body_raw = resp.json()
348386
except ValueError as exc:
@@ -362,19 +400,7 @@ def _parse_response(
362400
if not all(isinstance(x, (int, float)) and not isinstance(x, bool) for x in values):
363401
raise ProviderInvalidResponse("TEI /embed response has a non-numeric vector value")
364402
vectors.append([float(x) for x in values])
365-
dimensions = validate_embedding_response(vectors, len(input_strings))
366-
# TEI returns no usage object, so usage = null (§4 -- MUST NOT
367-
# fabricate). raw is the verbatim deserialized response: TEI's bare
368-
# vector array carried as a list, not wrapped (§4 / §8.1 per proposal
369-
# 0096 -- raw is dict | list, the top-level shape the provider returned).
370-
return EmbeddingResponse(
371-
vectors=vectors,
372-
model=self.model,
373-
usage=None,
374-
response_id=None,
375-
dimensions=dimensions,
376-
raw=rows,
377-
)
403+
return vectors, None, None, rows
378404

379405

380406
class TeiRerankProvider:

0 commit comments

Comments
 (0)