Skip to content

Commit af228ad

Browse files
Add 0092 embed chunk-and-stitch (TEI + shared helper) (#217)
* 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. * Guard chunk_and_stitch_embed against a non-positive cap The shared helper assumed cap was positive; a cap <= 0 (a caller misconfiguration) surfaced as a raw range() error (0) or a misleading empty-stitch validation failure (< 0). Raise ValueError eagerly with a clear message, mirroring the chunk_size guard TeiEmbeddingProvider already applies at construction. * Validate input in chunk_and_stitch_embed An empty input reaching the shared helper fell through to validate_embedding_response and raised provider_invalid_response ("provider returned no vectors"), misclassifying a caller-side invalid request as an invalid response. Call validate_embedding_input up front so an empty (or non-string) input raises provider_invalid_request. Providers already validate before the helper; this protects a direct/future caller. * Fix an inaccurate comment in chunk_and_stitch_embed The up-front validate_embedding_input() comment claimed the guard covers empty and non-string inputs, but the validator only rejects an empty list. Drop the non-string claim so the comment matches the code.
1 parent c66a1d9 commit af228ad

7 files changed

Lines changed: 348 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: 89 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_input, 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,90 @@ 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+
# cap is the provider's per-call input limit; a non-positive cap is a caller
121+
# misconfiguration -- fail loudly rather than surfacing a raw range() error
122+
# (cap == 0) or a misleading empty-stitched validation failure (cap < 0).
123+
if cap <= 0:
124+
raise ValueError(f"cap must be positive (got {cap})")
125+
# Validate the input up front so an empty input raises
126+
# provider_invalid_request -- the caller-side contract error -- rather than
127+
# falling through to a misclassified provider_invalid_response from the
128+
# empty stitched-count check. Providers already call this before the helper;
129+
# this protects a direct / future caller.
130+
validate_embedding_input(input_strings)
131+
stitched_vectors: list[list[float]] = []
132+
chunk_bodies: list[Any] = []
133+
input_tokens_total: int | None = None
134+
response_id: str | None = None
135+
for offset in range(0, len(input_strings), cap):
136+
chunk = input_strings[offset : offset + cap]
137+
chunk_vectors, chunk_tokens, chunk_id, chunk_body = await embed_chunk(chunk)
138+
# Per-chunk count MUST match the chunk's inputs before stitching: the
139+
# stitch is positional (no index re-basing), so a chunk returning the
140+
# wrong vector count would silently misalign vectors that a compensating
141+
# chunk lets the stitched total pass (§4 input-order).
142+
if len(chunk_vectors) != len(chunk):
143+
raise ProviderInvalidResponse(
144+
f"embedding response returned {len(chunk_vectors)} vectors for {len(chunk)} inputs"
145+
)
146+
stitched_vectors.extend(chunk_vectors)
147+
chunk_bodies.append(chunk_body)
148+
if offset == 0:
149+
response_id = chunk_id
150+
# Sum input_tokens across chunks; a chunk that omits usage does not
151+
# contribute, and usage stays null only when NO chunk reports it (§4 /
152+
# §8 batch-chunking step 4 -- never fabricate).
153+
if chunk_tokens is not None:
154+
input_tokens_total = (input_tokens_total or 0) + chunk_tokens
155+
# §4 cross-impl invariants (one vector per input, uniform dimensionality)
156+
# are enforced against the STITCHED result.
157+
dimensions = validate_embedding_response(stitched_vectors, len(input_strings))
158+
usage = EmbeddingUsage(input_tokens=input_tokens_total) if input_tokens_total is not None else None
159+
# raw is the verbatim deserialized response (§4 / §8 per proposal 0096, dict
160+
# | list): a single call carries that response's body; a chunked call carries
161+
# the LIST of per-chunk bodies in request order.
162+
raw: dict[str, Any] | list[Any] = chunk_bodies[0] if len(chunk_bodies) == 1 else chunk_bodies
163+
return EmbeddingResponse(
164+
vectors=stitched_vectors,
165+
model=model,
166+
usage=usage,
167+
response_id=response_id,
168+
dimensions=dimensions,
169+
raw=raw,
170+
)
171+
172+
85173
# §6 (0097): the rerank document-echo shape rule, shared by every RerankProvider
86174
# (Cohere / TEI / Jina). ScoredDocument.document carries the provider's string
87175
# echo verbatim when present, null otherwise -- never fabricated from the input
@@ -109,6 +197,7 @@ def document_echo(value: Any) -> str | None:
109197

110198
__all__ = [
111199
"apply_client_side_prefix",
200+
"chunk_and_stitch_embed",
112201
"client_side_prefix",
113202
"document_echo",
114203
"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(

0 commit comments

Comments
 (0)