Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# Architecture

The package is two layers: a one-shot JSON transport, and an embedding client
built on it. There is no provider registry. Every call is HTTP POST to
`{baseURL}/embeddings`.

## Surfaces

Callers import from `@corbits/embedding`. The barrel is the public surface:

| Export | Role |
| --- | --- |
| `embedTexts` | Batch texts, post sequentially, return `number[][]` in input order |
| `probeEmbedDims` | Embed one probe string; report the vector length that came back |
| `EmbedConfig` / `EmbedConfigSchema` | Endpoint, model, and optional knobs |
| `EmbedOptions` | Harness deps plus optional retry, `Retry-After` override, abort |
| `runJSONRequest` | Classified, retried JSON POST (sibling clients reuse this) |
| `extractRetryAfterMs` | Default `Retry-After` reader (seconds or HTTP-date) |
| `ModelRequestError` | Transport, HTTP, or non-JSON 200 — `reason` is the classified `InferenceError` |

Quickstart shape (matches the README):

```ts
import { createDefaultScheduler } from "@intx/inference";
import { embedTexts, probeEmbedDims } from "@corbits/embedding";

const deps = { fetch, scheduler: createDefaultScheduler() };
const [vector] = await embedTexts(
["hello world"],
{ baseURL: "http://localhost:11434/v1", model: "nomic-embed-text" },
{ deps },
);
```

`probeEmbedDims(config, { deps })` is the same config and deps; it is not a
separate protocol.

## Request dependencies

The transport reads only `fetch` and `scheduler` from the harness
(`RequestDependencies`). That is deliberately narrower than Interchange
`Dependencies`, whose `adapters` registry a caller should not have to
assemble in order to embed a string. A real `Dependencies` still satisfies
the pick structurally.

Time for retry backoff comes from `deps.scheduler`, not wall-clock globals,
so a virtual-clock test scheduler drives the loop deterministically. The
HTTP-date form of `Retry-After` is the exception: it names an absolute
instant and is resolved against wall time.

## Control flow

```
embedTexts(texts, config, options)
→ split on batchSize (default 32)
→ for each batch, sequentially:
build POST {baseURL}/embeddings
runJSONRequest (classify + retry)
parse body: place by echoed index, reject short / duplicate / OOB
→ concatenate batch results in input order
```

`probeEmbedDims` is `embedTexts` of one fixed probe string, then
`vector.length`.

Batches are sequential, not concurrent: the retry policy is per request, not
per burst, and a rate-limited provider must not see the whole corpus at once.

## Reply integrity

The OpenAI-compatible reply is not promised in request order. Placement is
by the echoed `index`, never by array position. A reordered reply is
reordered back; a short reply, a duplicate index, or a missing slot is
rejected. Callers pair returned vectors with their own texts positionally —
a silent mispair would persist the wrong vector.

`embedding` on the wire is either `number[]` (`float`) or a base64-packed
little-endian float32 buffer (`base64`). The client always returns
`number[]`.

## Transport vs `runInference`

`runInference` is turn-shaped and streams SSE. An embedding is one-shot JSON.
What carries over is everything around the body: the single `deps.fetch`
path, the `InferenceError` classifiers (`classifyHTTPError`,
`classifyNetworkError`, `classifyAbortError`, `classifyProtocolMismatch`),
and `createDefaultRetryPolicy` (back off retryables, abort the rest). A 429
is therefore classified and backed off as a chat 429 would be; a
`credential_failure` aborts immediately.

`runJSONRequest` is duplicated, modulo comments, in `@corbits/reranking`. It
is not shared infrastructure yet — the intended home is `@intx/inference` as
a non-streaming sibling of `runInference`. Until then, `ModelRequestError` is
a distinct class in each package: `instanceof` does not hold across the two.
Catch on `error.name === "ModelRequestError"`.

## Failure modes

- Transport, HTTP status, or a 200 whose body is not JSON →
`ModelRequestError` with classified `reason` and the URL.
- Malformed embeddings JSON, wrong count, bad index → thrown `Error` (not
`ModelRequestError`); the protocol succeeded, the payload did not.
- `batchSize` not an integer `>= 1` → `RangeError` before any request
(`i += size` of 0 would never advance).
- Empty `texts` → `[]`, no request.
- Caller `signal` or per-attempt timeout (default 30s) abort the attempt;
aborting mid-delay wakes immediately and the next attempt fails its
entry-time signal check.
113 changes: 113 additions & 0 deletions IMPLEMENTATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Implementation

## Package

`@corbits/embedding` is an ESM TypeScript package. Development runtime is
Bun `>= 1.2.0`. Node `>= 24` consumes built `dist/`; native Node does not
load this package's TypeScript source.

```json
"exports": {
".": {
"intx-src": "./src/index.ts",
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
}
```

`bun run build` is `tsc -p tsconfig.build.json` (`outDir: dist`,
`rootDir: src`, tests excluded). Published files are `dist`, `README.md`,
and `LICENSE`. Install from the registry:

```bash
npm add @corbits/embedding
pnpm add @corbits/embedding
yarn add @corbits/embedding
bun add @corbits/embedding
```

## Dependencies

| Package | Use |
| --- | --- |
| `@intx/inference` | `BuiltRequest`, `createDefaultRetryPolicy`, error classifiers, `createDefaultScheduler` (caller-side) |
| `@intx/types` | `RetryPolicy`, `InferenceError` |
| `@intx/log` | Transitive Interchange surface (not imported by this package's sources) |
| `arktype` | `EmbedConfigSchema` and the embeddings reply shape |

Dev: `@intx/inference-testing`, `@types/bun`, `prettier`, `typescript` 5.9.

## HTTP

Each batch is:

- `POST {baseURL}/embeddings` (`baseURL` already includes the version
prefix, e.g. `http://localhost:11434/v1` or `https://api.openai.com/v1`)
- `content-type: application/json`
- `authorization: Bearer {apiKey}` only when `apiKey` is set
- `redirect: "manual"`
- body:

```json
{
"model": "<config.model>",
"input": ["…"],
"dimensions": 256,
"encoding_format": "float"
}
```

`dimensions` and `encoding_format` are omitted unless configured. Models
without Matryoshka reject `dimensions` outright, so an unset knob must not
appear. Client field is `encodingFormat`; wire field is `encoding_format`
(`float` | `base64`). Default `batchSize` is 32 (OpenAI rejects arrays over
2048). Default per-attempt timeout is 30s (`AbortSignal.timeout`, combined
with a caller `signal` via `AbortSignal.any`).

A 200 is read as text then `JSON.parse`. `res.json()` is not used: an HTML
interstitial or truncated body in front of a local model server must classify
as protocol mismatch, not escape as `SyntaxError`.

## arktype

`EmbedConfigSchema` (`src/embed.ts`):

- required: `baseURL: string`, `model: string`
- optional: `apiKey`, `dimensions` (`number > 0`), `encodingFormat`
(`'float'|'base64'`), `batchSize` (`number.integer >= 1`), `timeoutMs`
(`number > 0`)

Reply: `{ data: { index: number, embedding: number[] | string }[] }`.
Base64 strings decode with `atob` → `Uint8Array` → little-endian
`Float32Array` → `number[]`.

`embedTexts` also range-checks `batchSize` at runtime so a value that
bypassed the schema cannot stall `batches` (`i += size`).

## Errors and retry

`runJSONRequest` (`src/request.ts`) loops:

1. `deps.fetch` POST
2. non-OK → `classifyHTTPError(status, detail, detail, retryAfterMs)`
3. network throw → `classifyNetworkError` or `classifyAbortError`
4. JSON parse fail → `classifyProtocolMismatch`
5. `retryPolicy` (default `createDefaultRetryPolicy`) → `abort` throws
`ModelRequestError`, else sleep `delayMs` on `deps.scheduler`

`extractRetryAfterMs` reads `Retry-After` as seconds or HTTP-date, clamped
at zero. Callers override via `EmbedOptions.extractRetryAfterMs` (same
signature as Interchange's unexported extractor; re-declared so this
package depends only on the published surface).

`ModelRequestError.name` is `"ModelRequestError"`. Discriminate on the
name when catching both this package and `@corbits/reranking`.

## Tests

`bun run test` is `bun test ./src`. Coverage is the client contract: empty
input, URL and body knobs, auth header presence, batch split and order,
`batchSize` rejection, malformed / short / duplicate-index replies,
index-not-position placement, 429 retry, 401 abort, `probeEmbedDims`,
overridable `Retry-After`.
48 changes: 48 additions & 0 deletions PRODUCT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Product

`@corbits/embedding` is the client you call when you need vectors from an
OpenAI-compatible `/v1/embeddings` endpoint. Given a `baseURL`, a model, and
some texts, `embedTexts` batches them, posts each batch, validates the reply,
and returns vectors in input order.

## Why it exists

Embedding is not a chat turn. Callers persist vectors next to text and later
retrieve by similarity. A wrong pairing, an assumed dimension, or a one-off
HTTP client that does not retry a 429 the way a chat call does all corrupt
that store. This package is the one place that does the boring work: batch,
validate, place by echoed `index`, and fail loudly instead of silently
mispairing.

One wire format, not a provider switch. OpenAI, Ollama, TEI, vLLM, and Jina
all serve `/v1/embeddings`. What differs between them is parameters
(`dimensions`, `encodingFormat`, `batchSize`), not shape. Swap models without
rewriting the call site — but treat a swap as a migration, because output
dimensionality is not knowable from a model name.

## Who it is for

- Hosts that persist embeddings (memory, search, recall) and must discover
dimensionality before writing a row.
- Sibling one-shot JSON clients that want the same classified, retried request
path without assembling an inference `AdapterRegistry`.
- Anyone already holding an Interchange harness `Dependencies` — only `fetch`
and `scheduler` are required; a full harness object satisfies that.

## Goals

- Callers can `embedTexts` and `probeEmbedDims` from `@corbits/embedding`
with `{ fetch, scheduler }` and an `EmbedConfig`.
- A 429 from an embedding endpoint behaves as one from a chat endpoint; a
credential failure aborts immediately.
- Dimensionality is discovered, never assumed. `probeEmbedDims` embeds one
probe string and reports the length it got back. `dimensions` (Matryoshka
truncation) changes that length when set.
- Empty input returns no vectors and issues no request.

## Non-goals

- A provider enum or adapter per vendor. There is one `/v1/embeddings` path.
- Inferring vector width from a model name.
- Concurrent batches. Sequential posting is how a rate-limited provider is
not hit with the whole corpus at once.
Loading