Build

Embedders

openaiEmbedder, bedrockEmbedder, geminiEmbedder, localEmbedder and staticEmbedder — ready-made Embedder implementations. What each one needs, which ones run in a browser, and what they cost.

Every semantic feature in agentfootprint — semantic retrieval, RAG, toolChoiceRecorder's relevance scoring — needs one thing from you: an embedder, something that turns text into a vector. The core ships only mockEmbedder() so it stays dependency-free. agentfootprint/providers is where the five real ones live.

The five

All five satisfy the same Embedder contract, so they are interchangeable at the call site. What differs is what they need from you, where they can run, and what they cost.

needsextra installruns in a browsercost
openaiEmbedder()an OpenAI API keynone (plain fetch)yesper token, billed by OpenAI; every text you embed leaves your machine
bedrockEmbedder()AWS credentials (the normal chain)@aws-sdk/client-bedrock-runtimeno — Node onlyper token, billed by AWS; stays inside your AWS account and region
geminiEmbedder()a Google project (ADC) or a Gemini API key@google/genaino — Node onlyper token, billed by Google
localEmbedder()nothing@huggingface/transformersyes, with backend (see below)free; ~27 MiB downloaded on first use, then cached
staticEmbedder()nothing@yarflam/potion-base-8mno — Node onlyfree; ~30 MB on disk after install, no network at all

Every peer dep is optional: installing agentfootprint installs none of them, and none is loaded unless you actually call the factory that needs it.

The type they all return is exported from the same subpath:

import { , type Embedder } from 'agentfootprint/providers';

const : Embedder = ();

openaiEmbedder() — hosted

No install: it is a fetch call. It needs a key, from OPENAI_API_KEY or passed directly.

// apiKey defaults to OPENAI_API_KEY from the environment
const  = ({
  : 'text-embedding-3-small', // the default — 1536 dimensions
});

Shortening the vectors

text-embedding-3-* models can return shorter vectors than their native size — cheaper to store, still usable. Pass dimensions and agentfootprint sends it to the API and reports it:

const  = ({ : 'text-embedding-3-large', : 256 });
// small.dimensions === 256, and the vectors really are 256 long

Two things worth knowing:

  • Nothing is sent unless you ask. OpenAI documents dimensions as "Only supported in text-embedding-3 and later models", and older models reject the parameter. Leave it unset and the request body is exactly what it was before this option existed.
  • .dimensions never guesses. Unset, it reports the model's documented native size — 1536 for text-embedding-3-small and text-embedding-ada-002, 3072 for text-embedding-3-large. For a model agentfootprint doesn't know (an Azure deployment name, a gateway behind baseURL, a model released after this version), there is no size it can know, so it asks rather than guesses:
// Throws: unknown model 'nomic-embed-text' — pass { dimensions }
// openaiEmbedder({ baseURL: 'http://localhost:11434/v1', model: 'nomic-embed-text' });

const  = ({
  : 'http://localhost:11434/v1',
  : 'not-needed-for-local',
  : 'nomic-embed-text',
  : 768, // ← stating it is what makes .dimensions true
});

A vector store that trusts .dimensions and gets a different length back corrupts silently, which is why this is an error and not a warning.

bedrockEmbedder() — hosted on AWS

Amazon Titan Text Embeddings and Cohere Embed v3 through Bedrock's InvokeModel. There is no apiKey option, deliberately: Bedrock authenticates through the normal AWS credential chain (environment, profile, instance role, SSO), and a second way to configure the same thing is a second way to get it wrong.

npm install @aws-sdk/client-bedrock-runtime
import { bedrockEmbedder } from 'agentfootprint/providers';

const embedder = bedrockEmbedder({ region: 'us-east-1' }); // 1024 dimensions

BedrockEmbedderOptions is { model?, dimensions?, family?, inputType?, maxInputChars?, region?, client? }. Titan V2 returns 1024 dimensions by default and also supports 512 and 256; the value you pass is sent to the model and reported as .dimensions, so the two can never disagree, and a size the model does not produce is refused rather than stored. A model this library does not know the size of has to say its own, the same rule openaiEmbedder applies:

bedrockEmbedder({ dimensions: 512 });                        // smaller vectors, cheaper storage
bedrockEmbedder({ model: 'amazon.titan-embed-text-v1' });    // known: 1536, fixed
bedrockEmbedder({ model: 'cohere.embed-english-v3' });       // known: 1024, fixed
bedrockEmbedder({ model: 'my-provisioned-deployment', dimensions: 768 }); // unknown: state it

One operation, two body shapes (9.3.0)

InvokeModel is a single API over vendor-specific JSON. Titan takes { inputText } and answers { embedding }; Cohere takes { texts, input_type } and answers { embeddings }. Before 9.3.0 this factory sent Titan's body to everything, so a Cohere model id constructed fine and failed at the first embed — against the real service, with a validation error from AWS rather than a sentence from here.

The model id now selects a family, and the family owns the request, the response and the batching:

modelfamilydimensionswindowone call embeds
amazon.titan-embed-text-v2:0 (default)titan1024 / 512 / 2568,192 tokens1 text
amazon.titan-embed-text-v1titan1536, fixed8,192 tokens1 text
cohere.embed-english-v3cohere1024, fixed512 tokensup to 96 texts
cohere.embed-multilingual-v3cohere1024, fixed512 tokensup to 96 texts

An id that wraps one of those — a cross-region inference profile (us.amazon.titan-embed-text-v2:0) or an ARN ending in the model id — resolves to the model it names. Anything else is a model this library has never met: pass dimensions (its length cannot be known) and family if it is not Titan-shaped. Unstated, the body is Titan's, which is what every earlier release sent.

The family is exported as the BedrockEmbeddingFamily type ('titan' | 'cohere'), which is what the family option takes.

Cohere's input_type is a real parameter, not a hint: the v3 models embed a QUERY and a DOCUMENT into deliberately different places, and the two are meant to be compared with each other. embed() sends 'search_query' and embedBatch() sends 'search_document', because that is what this library's own two call sites are — retrieval embeds one question, indexing embeds many passages. inputType pins both when your own code uses them differently — it takes a CohereInputType, 'search_document' | 'search_query'.

Titan has no batch-embed operation, so embedBatch there is honestly N sequential calls rather than a batch discount that does not exist. For Cohere it is a real batch: 500 chunks are 6 round-trips, not 500.

Pass client to share one SDK configuration with the rest of your app — the type is exported as BedrockRuntimeLikeClient (a single send), with BedrockRuntimeSdkModule for the module shape. Both are structural, so a test double satisfies them without the SDK being installed at all.

Its id carries the dimension count, and that is deliberate. Titan V2 at 512 and Titan V2 at 1024 are different embedding spaces produced by one model id; the size alone cannot separate them either, because V1 and V2 both answer at 1024. Entries store the id alone in embeddingModel, and that is the only thing the read-side embedderId filter compares — so the id is bedrock:amazon.titan-embed-text-v2:0:512, and every combination of model and size is a distinct index.

Titan has no batch-embed operation, so embedBatch is honestly N sequential calls rather than a batch discount that does not exist. indexCorpus already fans out over batches with bounded parallelism and retry, so a corpus index is still parallel where it counts.

geminiEmbedder() — hosted on Google

Google's embeddings through models.embedContent, on the same two doors as gemini(): a project (credentials from Application Default Credentials) or a Gemini API key. Neither is guessed, and configuring neither is refused by name.

import { geminiEmbedder } from 'agentfootprint/providers';

const vertex = geminiEmbedder({ project: 'my-project', location: 'us-central1' });
const studio = geminiEmbedder({ apiKey: process.env.GEMINI_API_KEY! });

The Vertex door is field-validated; the key door has a billing catch

An independent field trial, 2026-08, ran the Vertex door live — gemini-embedding-001 at 768 dimensions, three documents indexed into sqliteVectorStore and retrieved through defineRAG, with an unchanged re-index embedding zero. The Gemini API door in the same trial returned 429 RESOURCE_EXHAUSTEDprepayment credits depleted — from a valid key on a project with unspent Cloud credit, because AI Studio bills separately from Google Cloud. Neither door was misconfigured; they are two accounts. See the billing boundary.

Call it with the declared shape, embed({ text }) — not embed(text). The native Google SDK takes a string overload and this port does not; passing one surfaces as the SDK's own ContentUnion is required, which reads like a credentials problem and is not.

Two models are known by name, and the difference between them is why the input ceiling is per-model rather than per-vendor:

modeldimensionsinput windowtask_type
gemini-embedding-001 (default)3072, shortenable2,048 tokens (maxInputChars 8000)the full vocabulary
gemini-embedding-23072, shortenable8,192 tokens (maxInputChars 32000)none — Google replaced it with task instructions written into the text
geminiEmbedder({ dimensions: 768 });                       // Matryoshka: a real 768-dim space
geminiEmbedder({ model: 'gemini-embedding-2' });            // four times the window, no task types
geminiEmbedder({ model: 'gemini-embedding-99', dimensions: 512 }); // unknown: state the size

Asking for a size the model cannot produce, or a taskType on the model that takes none, is refused at construction — a wrong .dimensions is what a vector store fingerprints on, and a rejected request field is a call that was never going to work.

task_type, and the two defaults

RETRIEVAL_QUERY and RETRIEVAL_DOCUMENT embed a question and a passage into deliberately different projections that are meant to be compared with each other, so using one value for both halves is a measurable loss of retrieval quality. This library's two call sites are exactly that distinction, so the default follows it: embed() sends RETRIEVAL_QUERY (retrieval embeds one question), embedBatch() sends RETRIEVAL_DOCUMENT (indexing embeds many passages). Pin taskType when your own code uses the two calls differently, or when the objective is classification or clustering rather than search — a pinned value applies to both, so the store stays self-consistent either way.

Like bedrockEmbedder, the id carries the size — gemini:gemini-embedding-001:768 — because one model id at two sizes is two embedding spaces and embeddingModel stores the id alone.

Types

GeminiEmbedderOptions is the factory's options — the two-door connection half (shared as GoogleGenAIConnectionOptions with gemini()) plus model, dimensions, taskType, onTruncation, maxInputChars and _client. GeminiEmbeddingTaskType is the eight-value task_type union and GeminiTruncationPolicy is 'refuse' | 'allow'. GeminiEmbedClientLike, GeminiEmbedParams and GeminiEmbedResponse describe the one-method surface the _client seam accepts, so a test double can be typed without importing @google/genai.

One text per request, and a refusal when the service clips

gemini-embedding-001 accepts exactly one input text per call, so embedBatch is honestly N sequential calls rather than a batch discount that does not exist. (Libraries that batched it like an OpenAI client send oversized requests that fail on every batch of more than one.)

And this is the one embedder that can catch the silent-truncation failure the section below describes, because Vertex reports statistics.truncated when it clipped:

geminiEmbedder({ onTruncation: 'refuse' }); // the default

Over the ceiling, the refusal names the character length that was sent and both fixes — a smaller maxChunkChars, or onTruncation: 'allow' if a prefix embedding is genuinely what you want. Detection depends on the service returning statistics; when it is absent this adapter cannot tell, and says so rather than implying a guarantee.

localEmbedder() — on your device

A sentence-transformer (Xenova/all-MiniLM-L6-v2, 384 dimensions) run through @huggingface/transformers. No key, no per-call cost, and after the first run the model is cached and it works offline.

npm install @huggingface/transformers
import { localEmbedder } from 'agentfootprint/providers';

const embedder = localEmbedder(); // 384 dimensions

What the first call costs. The model is not in the npm package — it is fetched on first use, and it is not small. Measured for the default model and dtype: 'q8':

fromwhatbytes
huggingface.coonnx/model_quantized.onnx21.9 MiB (not compressed)
huggingface.cotokenizer.json + configs0.7 MiB
cdn.jsdelivr.netthe ONNX Runtime WebAssembly binary22.5 MiB, ~4.5 MiB over the wire (brotli)

That is ~27 MiB over the wire, ~45 MiB decompressed, from two third-party origins — and cdn.jsdelivr.net surprises people, because nothing in the package names it. In Node the model lands in a local cache directory (cacheDir); in a browser it goes into the Cache API. Either way it is a first-use cost, not a per-call one.

If "nothing leaves the machine" is the point, note that the first call contradicts it. Both origins can be pointed at your own host via transformers.js's env settings (env.remoteHost, env.localModelPath, env.backends.onnx.wasm.wasmPaths), which is worth doing before you claim it.

staticEmbedder() — no network, Node only

Model2Vec static vectors (potion-base-8M, 256 dimensions). There is no model to run: it is a lookup table plus pooling, so it is fast and needs no network at any point. The weights ship inside the npm package.

npm install @yarflam/potion-base-8m   # ~30 MB, weights included
import { staticEmbedder } from 'agentfootprint/providers';

const embedder = staticEmbedder(); // 256 dimensions

This one is Node-only today. @yarflam/potion-base-8m reads its weights from disk with fs and __dirname, so there is nothing a bundler can do with it — passing backend will not save it. If you need static embeddings in a browser, the working route today is transformers.js against the minishlab/potion-base-8M ONNX export wired up as your own Embedder; agentfootprint does not ship that yet.

Static vectors are weaker than a real transformer on paraphrases. Use them where "cheap, offline, good enough" beats "best" — dev, tests, first-pass filtering.

Running an embedder in a browser

openaiEmbedder() works in a browser as-is: it is a fetch, nothing to bundle. (Your key would be in the page, so this belongs in a prototype, not production.) bedrockEmbedder() does not: it needs the AWS SDK and AWS credentials, neither of which belongs in a page. Neither does geminiEmbedder()@google/genai declares node engines and the Vertex door reads Application Default Credentials, which a browser has no equivalent for.

localEmbedder() needs one extra step. To keep @huggingface/transformers optional, agentfootprint imports it through a variable specifier — and a bundler cannot see through that. The bare name survives into the bundle and the browser refuses it:

TypeError: Failed to resolve module specifier '@huggingface/transformers'

The fix is to let your bundler resolve it. Import the module yourself and hand it in as backend:

import { localEmbedder } from 'agentfootprint/providers';
import * as transformers from '@huggingface/transformers';

const embedder = localEmbedder({ backend: transformers });

const vector = await embedder.embed({ text: 'how do I cancel my subscription?' });
// vector.length === 384

That static import is one your bundler understands, so the module is really in the bundle and no specifier is left for the browser to resolve. The option is the same idea as the client option on the store adapters: the library states the surface it needs, the host owns the construction. Passing backend skips the dynamic import entirely — in Node too, if you prefer an explicit import.

staticEmbedder() takes a backend as well, for a different Model2Vec build that is browser-capable. The default one is not.

The two option types are exported so you can name what you are passing: TransformersBackend (a pipeline function, plus the optional env object cacheDir is written to) and Model2VecBackend (a batch embed or encode, on the module or on its default export). Both are structural, so the real modules satisfy them without agentfootprint taking a hard type dependency on either optional peer — and so does a stub of your own in a test:

import { , type Model2VecBackend } from 'agentfootprint/providers';

const : Model2VecBackend = { : () => .(() => [0.1, 0.2]) };
const  = ({ : , : 2 });

How much text each one reads — maxInputChars (9.1.0)

Text past an embedder's input window is not refused. It is clipped, and a full-looking vector comes back for the opening of the passage. An indexer then stores the whole chunk as the passage and the clipped vector as its index, so retrieval cannot find wording that is plainly visible in the block the model is later shown. Nothing throws; the corpus is quietly partially indexed.

So each embedder declares the longest input it reads whole, in characters — the unit a splitter cuts in:

maxInputCharswhere the number comes from
localEmbedder()2000measured: the default model's 512-wordpiece-token cliff (~1,800–2,000 characters)
openaiEmbedder()32000the documented 8,191-token window, at 4 characters a token
bedrockEmbedder()32000 (Titan) · 2000 (Cohere v3)each model's documented window — 8,192 tokens and 512 — at the same conversion
geminiEmbedder()8000 (gemini-embedding-001) · 32000 (gemini-embedding-2)2,048 and 8,192 tokens, at the same conversion
staticEmbedder()1000000no transformer, so no context window — nothing is ever clipped
mockEmbedder()1000000reads every character in a loop

indexCorpus, indexFolder and indexDocuments read this in preference to their own 2,000-character default, and an explicit maxChunkChars on the call wins over both. Two consequences worth knowing:

  • The ceiling is per MODEL, not per vendor. Titan and Cohere Embed v3 are the same runtime and the same factory, and the same 2,500-character chunk is read whole by one and truncated by the other. That gap is the whole argument for declaring the number where the knowledge is.
  • A model this library does not know declares nothing. Pass openaiEmbedder({ model: 'my-gateway-model', dimensions: 768 }) or an unrecognised Bedrock model and there is no ceiling on the returned embedder — the indexer's conservative default stands. A wrong ceiling clips in silence; an absent one only under-uses the backend. bedrockEmbedder({ maxInputChars }) is how such a model states its own.
  • The characters-per-token figure is an assumption, not a measurement. Code, tables and CJK text tokenise denser than English prose, so a corpus of those can exceed the token window inside the stated character ceiling. Pass maxChunkChars for such a corpus — it always wins.

localEmbedder({ maxInputChars }) exists because the cliff belongs to the model, not to the factory: a long-context sentence-transformer build reads far more than the default model does, and this is how you say so.

Never mix two embedders in one store

Vectors from different models live in incompatible spaces, and cosine similarity between them is noise that looks like a number. Dimensions differ too (1536 vs 384 vs 256), so a store sized for one will not take the other. See embedder identity for how to migrate when you do want to switch.

Bring your own

Embedder is a two-method interface — implement it against any provider (Voyage, Cohere, a self-hosted model, a browser Model2Vec build) and everything downstream accepts it:

import type { Embedder } from 'agentfootprint/providers';

const : Embedder = {
  : 1024,
  // Optional, but say it if you know it: the indexers read this instead of
  // their conservative default, and without it your ceiling gets discovered
  // by someone reading a retrieval result that is missing a paragraph.
  : 32000,
  async ({ ,  }) {
    const  = await ('https://api.voyageai.com/v1/embeddings', {
      : 'POST',
      : { 'content-type': 'application/json', : 'Bearer …' },
      : .({ : 'voyage-3', : [] }),
      ...( ? {  } : {}),
    });
    const  = (await .()) as { : { : number[] }[] };
    return .[0]!.;
  },
};

Next steps

On this page