diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..fbb1eff --- /dev/null +++ b/ARCHITECTURE.md @@ -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. diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md new file mode 100644 index 0000000..a86cfb4 --- /dev/null +++ b/IMPLEMENTATION.md @@ -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": "", + "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`. diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..62f961f --- /dev/null +++ b/PRODUCT.md @@ -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.