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 onlymockEmbedder()so it stays dependency-free.agentfootprint/providersis 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.
| needs | extra install | runs in a browser | cost | |
|---|---|---|---|---|
openaiEmbedder() | an OpenAI API key | none (plain fetch) | yes | per token, billed by OpenAI; every text you embed leaves your machine |
bedrockEmbedder() | AWS credentials (the normal chain) | @aws-sdk/client-bedrock-runtime | no — Node only | per token, billed by AWS; stays inside your AWS account and region |
geminiEmbedder() | a Google project (ADC) or a Gemini API key | @google/genai | no — Node only | per token, billed by Google |
localEmbedder() | nothing | @huggingface/transformers | yes, with backend (see below) | free; ~27 MiB downloaded on first use, then cached |
staticEmbedder() | nothing | @yarflam/potion-base-8m | no — Node only | free; ~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 longTwo things worth knowing:
- Nothing is sent unless you ask. OpenAI documents
dimensionsas "Only supported intext-embedding-3and later models", and older models reject the parameter. Leave it unset and the request body is exactly what it was before this option existed. .dimensionsnever guesses. Unset, it reports the model's documented native size — 1536 fortext-embedding-3-smallandtext-embedding-ada-002, 3072 fortext-embedding-3-large. For a model agentfootprint doesn't know (an Azure deployment name, a gateway behindbaseURL, 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-runtimeimport { bedrockEmbedder } from 'agentfootprint/providers';
const embedder = bedrockEmbedder({ region: 'us-east-1' }); // 1024 dimensionsBedrockEmbedderOptions 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 itOne 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:
| model | family | dimensions | window | one call embeds |
|---|---|---|---|---|
amazon.titan-embed-text-v2:0 (default) | titan | 1024 / 512 / 256 | 8,192 tokens | 1 text |
amazon.titan-embed-text-v1 | titan | 1536, fixed | 8,192 tokens | 1 text |
cohere.embed-english-v3 | cohere | 1024, fixed | 512 tokens | up to 96 texts |
cohere.embed-multilingual-v3 | cohere | 1024, fixed | 512 tokens | up 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_EXHAUSTED — prepayment 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:
| model | dimensions | input window | task_type |
|---|---|---|---|
gemini-embedding-001 (default) | 3072, shortenable | 2,048 tokens (maxInputChars 8000) | the full vocabulary |
gemini-embedding-2 | 3072, shortenable | 8,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 sizeAsking 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 defaultOver 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/transformersimport { localEmbedder } from 'agentfootprint/providers';
const embedder = localEmbedder(); // 384 dimensionsWhat 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':
| from | what | bytes |
|---|---|---|
huggingface.co | onnx/model_quantized.onnx | 21.9 MiB (not compressed) |
huggingface.co | tokenizer.json + configs | 0.7 MiB |
cdn.jsdelivr.net | the ONNX Runtime WebAssembly binary | 22.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 includedimport { staticEmbedder } from 'agentfootprint/providers';
const embedder = staticEmbedder(); // 256 dimensionsThis 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 === 384That 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:
maxInputChars | where the number comes from | |
|---|---|---|
localEmbedder() | 2000 | measured: the default model's 512-wordpiece-token cliff (~1,800–2,000 characters) |
openaiEmbedder() | 32000 | the 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() | 1000000 | no transformer, so no context window — nothing is ever clipped |
mockEmbedder() | 1000000 | reads 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
maxChunkCharsfor 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
- Semantic retrieval — where the embedder is actually used
- RAG — document-corpus retrieval built on it
- Memory stores — which stores can hold vectors
- Google Cloud & Gemini — the provider column
geminiEmbedder()belongs to
Semantic retrieval (Top-K)
Cosine-similarity search over embedded entries. Returns the most relevant top-K above a strict threshold — the read-side primitive behind RAG.
RAG
defineRAG — sugar over defineMemory(SEMANTIC + TOP_K) with retrieval-friendly defaults. Chunks land in the system-prompt slot when cosine similarity clears the threshold.
