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.
Your support agent confidently tells a customer the refund window is 30 days. The actual policy says 14. The LLM is hallucinating from training data because you never gave it the source. RAG is how you stop the hallucination at its root — embed the user's question, retrieve the actual policy chunks, inject them into the system-prompt slot, and the LLM answers from the SOURCE instead of memory.
What RAG is
RAG = retrieval-augmented generation. Conceptually:
- Embed the user's query into a vector
- Search a vector store for top-K most-similar document chunks
- Inject those chunks into the next LLM call's system-prompt slot, as one system message
- The LLM answers using the retrieved chunks as context
In agentfootprint, RAG runs on the same machinery as defineMemory({ type: SEMANTIC, strategy: TOP_K }) — the same engine, the same three slots, the same agentfootprint.context.injected event. defineRAG differs from it in three ways that are not cosmetic, and each one is there because a corpus is not a conversation:
defineRAG | defineMemory | |
|---|---|---|
| Writes | Never. A corpus is read-only. | Yes — the conversation is what it stores. |
| Namespace | Its own corpus, shared by every run. Defaults to { conversationId: '_global' }. | The identity passed to agent.run(). |
| Rendering | <source id="…" doc="…" score="…"> — citable. | <memory role="user" turn="5"> — conversational. |
Changed in 8.8.0. Before 8.8.0 defineRAG also mounted the write half, so every conversation turn was embedded into the same namespace as your documents — and the user's own question, re-embedded, scored 1.0 against itself and came back as retrieval hit #1. It also read under the run's identity, so the example in this very page retrieved nothing at all unless you passed a matching identity by hand. Both are fixed. If you want conversation memory alongside a corpus, register both — see Pairing a corpus with conversation memory.
Define a retriever, attach to an agent
defineRAG({ id, store, embedder, description?, embedderId?, corpus?, topK?, threshold?, maxChars?, retrieval? }) returns a MemoryDefinition. agent.rag(definition) attaches it (alias for .memory(definition) — same plumbing, clearer intent). It throws at construction time if store lacks search(). Pair embedderId here with the same value passed to indexDocuments so a later embedder swap is filtered out of results:
const docs = defineRAG({ id: 'product-docs', description: 'Product documentation chunks', store, embedder, topK: 2, // up to 2 most-relevant docs per query threshold: 0.5, // strict — drop weak matches});// Matches land in the SYSTEM-PROMPT slot as one system message carrying// every chunk as a `<source id=… doc=… score=…>` block the model can// cite. (`asRole` was removed in 7.20.0 — it was never read, so it// described a placement that never happened.)const agent = Agent.create({ provider: provider ?? mock({ reply: 'Refunds are processed within 3 business days.' }), model: 'mock', maxIterations: 1,}) .system('You answer support questions using the retrieved docs.') .rag(docs) .build();The store is a MemoryStore with vector-search support (it must implement search() — defineRAG throws at construction time if it doesn't). InMemoryStore works for dev. Durable vector backends are not yet shipped — wire your own MemoryStore adapter against src/memory/store/types.ts until then.
The corpus namespace
A corpus does not belong to a conversation, so it does not read under the run's identity. corpus names the namespace it lives in, and it defaults to { conversationId: '_global' } — the same default indexDocuments writes to, so index with no options and retrieve with no options and the two meet.
For a per-tenant corpus, pass the same identity to both sides:
await indexDocuments(store, embedder, docs, { identity: { tenant: 'acme' } });
const acmeDocs = defineRAG({ id: 'docs', store, embedder, corpus: { tenant: 'acme' } });If they ever disagree, you no longer find out by getting a vague answer: the retrieval reports corpusEmpty: true on agentfootprint.memory.retrieved, and the first time it happens in a process a warning names the namespace it searched.
Strict threshold semantics
threshold is strict. When no chunk meets the threshold, NO injection happens — the LLM gets no context and answers from its own weights. This is intentional: weak low-confidence chunks pollute the prompt and make a confident wrong answer MORE likely, not less. Nothing throws; the turn simply proceeds without context.
What changed in 8.8.0 is that "no context" is now a readable outcome instead of an absence. The near-misses and their scores are on the agentfootprint.memory.retrieved event, so the right threshold is a number you read off a run rather than one you guess:
agent.on('agentfootprint.memory.retrieved', (e) => {
if (e.payload.admittedCount === 0) {
console.log('nothing cleared', e.payload.threshold, '— best was', e.payload.candidates?.[0]);
}
});The right threshold is a property of the embedder, not of this library. The default 0.7 suits some score distributions and silently starves others:
| embedder family | relevant chunks score | at the 0.7 default |
|---|---|---|
OpenAI text-embedding-3-* | comfortably ≥ 0.7 | works as-is |
sentence-transformers (all-MiniLM-L6-v2 and relatives — localEmbedder's default) | 0.4–0.6 | many real hits rejected |
Amazon Titan Text V2 (bedrockEmbedder's default) — field-measured | 0.55–0.57 direct hit · ~0.49 right section, diluted · 0.36–0.42 noise | retrieves nothing, silently |
The Titan numbers are one vendor's measured example of the general rule: read your embedder's actual score bands off a run before trusting any default. The rejected candidates and their scores are on every agentfootprint.memory.retrieved event, so the right threshold is a number you read, not one you guess — for Titan V2, ~0.5 separates its signal from its noise.
How much text, not just how many chunks — maxChars
topK bounds how MANY passages reach the prompt and says nothing about how long
they are. A count bound is not a size bound, and the gap between them is
where a corpus over-runs its slot with nothing but defaults on either side. Ten
chunks cut by byHeading() off ordinary documentation measured 11,153
characters in a production deployment, against a systemPrompt slot whose
default budget is 4,000.
Nothing truncates when that happens — the slot warns once and emits
agentfootprint.context.budget_pressure, so the run is honest about the
over-run. What it had no way to do was bound it. maxChars is that bound:
defineRAG({ id: 'docs', store, embedder, topK: 5, maxChars: 2000 });The two numbers that meet, side by side — they live on different objects, which is exactly why the arithmetic between them is easy to miss:
| knob | default | bounds |
|---|---|---|
defineRAG({ topK }) | 3 | how MANY passages |
defineRAG({ maxChars }) | none | how much TEXT they may add up to |
Agent.create({ contextBudget: { systemPrompt } }) | 4000 chars | the whole slot they land in |
Retrieved passages share that slot with the system prompt, steering, facts and
skill bodies, so roughly half the slot is a sane starting budget:
maxChars: 2000 against the 4,000-char default.
maxChars has no default, deliberately. Defaulting it would mean this
release silently stops injecting passages that the last one injected — a
retrieval regression that reads to a user as "the model doesn't know that",
which is the failure class this library exists to make loud. And nothing here
can know your slot budget: you may have raised contextBudget.systemPrompt.
So the bound is yours to state, and the table above is the arithmetic.
The spend is recorded, never silent. The budget is spent in rank order and
the tail is dropped — passages past it are refused with
reason: 'over-char-budget', and the record carries maxChars and charsUsed:
agent.on('agentfootprint.memory.retrieved', (e) => {
console.log(`${e.payload.charsUsed}/${e.payload.maxChars} chars`);
for (const c of e.payload.candidates ?? []) {
if (c.reason === 'over-char-budget') console.log('dropped for size:', c.id);
}
});Rank order and drop, never "skip the big one and take the next": skipping would silently reorder relevance by length, and a reader of the record could not tell a budget drop from a bad score. A budget smaller than the best-scoring passage therefore admits nothing — and says so, per candidate, instead of injecting half a passage.
maxChars composes with retrieval (unlike topK/threshold, which exclude
it): the strategy picks the candidates, this bounds their size. It counts
passage characters, not rendered prompt bytes — the <source …> wrapper and the
block header are added afterwards by the formatter.
Not the same maxChars as the splitters'. byHeading({ maxChars }) and
byParagraph({ maxChars }) bound ONE chunk at index time (default 1000);
defineRAG({ maxChars }) bounds the WHOLE retrieved set at query time. The
first is why the arithmetic above lands where it does: ten chunks off a
1000-char splitter is ten thousand characters before a single tag is added.
The three numbers, and the one place they collide
There is a third, at index time, and it is the one that fails silently. Read them together:
| number | where | bounds |
|---|---|---|
byHeading({ maxChars }) | splitter | how big one chunk is cut (default 1000) |
maxChunkChars | indexCorpus / indexFolder / indexDocuments | how much of that chunk the embedder reads |
defineRAG({ maxChars }) | query time | how much retrieved text reaches the prompt |
At the defaults the first two compose safely — 1,000 cut, at least 2,000 read.
The trap opens when you raise the splitter's ceiling. byHeading({ maxChars: 2500 }) against a 2,000-character reading ceiling stores each chunk whole as
the passage and indexes it by its opening: retrieval cannot find wording
that is plainly visible in the <source> block the model is shown, and nothing
throws. Measured in a production corpus: 6 of 26 chunks.
Since 9.1.0 the reading ceiling comes from the embedder itself
(Embedder.maxInputChars — 2,000 for localEmbedder, 32,000 for the hosted
ones), an explicit maxChunkChars still wins over it, and a run that clipped
anything says so once on console.warn with the count and the fix. Full table
on the indexing page.
Indexing — indexDocuments
Before queries can return chunks, the store needs documents. indexDocuments(store, embedder, docs, options?) is the seeding helper — embeds each doc (via embedder.embedBatch when available, else capped-concurrency single calls), then batches into store.putMany(). Returns the number of docs indexed. Each RagDocument is { id, content, metadata? } — or { id, text, metadata? }, since 8.19.0, because that is how a chunk from the rag door spells its passage and both are now read. A document carrying neither is refused before anything is embedded: an unrenderable passage and an absent one are different facts, and a citation wrapped around an empty body is the one that costs you a customer.
import { indexDocuments } from 'agentfootprint';
const count = await indexDocuments(store, embedder, [
{ id: 'doc1', content: 'Refund policy: 14 days from delivery for full refund.' },
{ id: 'doc2', content: 'Pro plan costs $20/month and includes priority support.', metadata: { topic: 'plans' } },
]);The optional fourth argument is an IndexDocumentsOptions bag: identity, embedderId, tier, ttlMs, signal, and maxConcurrency (caps concurrent embed calls at 8 by default to avoid embedder rate limits; ignored when the embedder implements embedBatch):
await indexDocuments(store, embedder, docs, {
identity: { tenant: 'acme' }, // scope the corpus to one tenant
embedderId: 'openai-3-small', // tag entries so a later embedder swap is filtered out
maxConcurrency: 4,
});Identity defaults to { conversationId: '_global' } — matching defineRAG's corpus default, so the plain path needs no argument on either side. Index per tenant and you must name the same tenant on the retriever; see The corpus namespace.
The corpus as a build artifact — exportCorpus / staticVectorStore
An immutable or serverless runtime loses its disk between invocations and often holds no embedding-API credentials — while the build machine has both. So build the index where the credentials live, ship it with the deploy, and serve it read-only where the process runs (8.20.0):
// build step (CI, cron, deploy hook) — credentials and durable disk live here
import { indexFolder, exportCorpus } from 'agentfootprint/rag';
const store = new InMemoryStore();
await indexFolder('./docs', { to: store, embedder });
writeFileSync('corpus.json', JSON.stringify(await exportCorpus(store)));// runtime — no disk, no writes, no drift
import { staticVectorStore } from 'agentfootprint/memory';
const corpus = staticVectorStore(JSON.parse(readFileSync('corpus.json', 'utf8')), embedder);
const docs = defineRAG({ id: 'docs', store: corpus, embedder });The bundle is plain JSON — { entries: [{ id, text, vector, metadata }], embedder: { id, dimensions }, namespace } — so it survives any transport a deploy already has: a file next to the code, a bundler import, a KV fetch. Three properties are load-bearing:
- Read-only, loudly. Every write method refuses with the fix named (re-export at build time, or
importCorpus(store, bundle)into a writable store). A static corpus that silently accepted writes would lose them with the process. - The wrong embedder is refused at LOAD. The bundle records the embedder id and dimensions it was built with; pass your runtime embedder as the second argument and a mismatch throws at startup — the same fingerprint rule the durable store enforces (dimensions always decide; ids decide only when both sides named themselves) — instead of surfacing as an empty retrieval at the first question.
- Entries are served in the exact shape the formatter reads — passage on
value.content, provenance undervalue.metadata— so a bundled corpus renders citations identically to a live-indexed one.
importCorpus(store, bundle) is the inverse: seed a writable store from a bundle at boot, or migrate a corpus between machines without re-embedding (and re-billing) anything. The CLI can produce a bundle directly: npx agentfootprint-index ./docs --to ./corpus.json --embedder local.
Mocks-first development
mockEmbedder() is deterministic and free, and it is plumbing-only: it is a 32-dimension character-frequency hash, so it will happily rank an off-topic sentence above the right one. Use it to prove the pipeline runs; never to judge whether retrieval is any good.
Four real embedders ship, all from agentfootprint/providers:
| key needed | network | notes | |
|---|---|---|---|
staticEmbedder() | no | no | bundled Model2Vec weights, 256-d. The first rung where ranking is real. |
localEmbedder() | no | one-time model fetch | on-device sentence-transformer, 384-d. Truncates input at 512 tokens (~1,800 characters) — chunk below that or the tail is silently dropped. Declares maxInputChars: 2000. |
openaiEmbedder() | yes | yes | hosted, 1536-d by default. Declares maxInputChars: 32000. |
bedrockEmbedder() | AWS credentials | yes | hosted on AWS, Titan V2 at 1024-d (512 / 256 on request). Declares maxInputChars: 32000. See embedders. |
Each one declares the longest input it reads whole, and the indexing doors use that instead of guessing — which is why the same corpus can be split into much larger chunks for a hosted embedder than for the on-device one.
Never mix two in one store: dimensions differ, and cross-model similarity scores are not comparable. Tag both sides with the same embedderId and the mismatch is filtered instead of silently scored.
Pairing a corpus with conversation memory
They are two registrations over two stores, and that separation is the point — one remembers what the user said, the other retrieves what the corpus says:
import { Agent, defineRAG } from 'agentfootprint';
import { defineMemory, MEMORY_TYPES, MEMORY_STRATEGIES, InMemoryStore } from 'agentfootprint/memory';
const agent = Agent.create({ provider })
.rag(defineRAG({ id: 'product-docs', store: corpusStore, embedder }))
.memory(defineMemory({
id: 'chat',
type: MEMORY_TYPES.EPISODIC,
strategy: { kind: MEMORY_STRATEGIES.WINDOW, size: 10 },
store: conversationStore,
}))
.build();Why did the agent read this passage?
Every retrieval leaves a record, and it names what it rejected as well as what it used.
On the event stream — agentfootprint.memory.retrieved fires once per retrieval with every candidate, and agentfootprint.memory.attached once per chunk that reached the prompt:
agent.on('agentfootprint.memory.retrieved', (e) => {
for (const c of e.payload.candidates ?? []) {
console.log(c.admitted ? '✓' : '✗', c.id, c.score.toFixed(2), c.reason ?? '');
}
});
// ✓ refunds.md#3 0.81
// ✓ security.pdf#7 0.72
// ✗ pricing.md#0 0.68 below-thresholdOn each injection — one InjectionRecord per chunk rather than one per retrieval, so agentfootprint.context.injected carries source: 'rag', sourceId (the chunk id), retrievalScore, rankPosition and threshold for that one passage.
In the trace — the record is lifted onto root state as retrievalEvidence_<id>, so a backward slice from the answer reaches the passage instead of stopping at the memory subflow boundary.
Two orderings are recorded and they are not the same thing: rank is how the chunk scored, promptPosition is where the budget picker put it in the message. Under the default recency ordering the best-scoring chunk can land last.
Replacing the retrieval rule
topK + threshold are shorthand for the rule; retrieval is that rule spelled out. Passing both is refused — they could disagree, and the recording would then name a k the run did not use.
import { topK } from 'agentfootprint';
defineRAG({ id: 'docs', store, embedder, retrieval: topK({ k: 5, threshold: 0.55 }) });A cross-encoder re-ranker and a diversity (MMR) selector are the next two adapters behind this same interface. Neither ships yet; the seam exists so that when they do, nothing else moves.
Reference — the retrieval record
Types for reading a retrieval back. The record types are on the main barrel (a consumer handling agentfootprint.memory.retrieved needs them); the rest are on agentfootprint/memory.
| Name | What it is |
|---|---|
RetrievalEvidence | The whole record for one retrieval: queryHash, k, threshold, maxChars / charsUsed (when a size budget was set), embedderId, dimensions, selectionOrder, the counts, candidates, candidatesComplete, corpusEmpty, namespace. |
RetrievedCandidate | One candidate: id, score, rank, admitted, reason, docUri, page, heading, plus promptFragment / promptPosition for admitted ones. |
RetrievalRejectReason | Why a candidate was refused: 'below-threshold' · 'over-budget' · 'over-max-entries' · 'over-char-budget' (the maxChars size budget was already spent by better-ranked passages). |
RetrievalStrategy | The seam: { name, k, threshold?, rejectWindow, select(pool) }. Given a score-descending pool it returns one RetrievalVerdict per candidate, in the same order. It never touches the store and never embeds. |
ScoredCandidate / RetrievalVerdict | A strategy's input row and its ruling. |
topK(options) / TopKOptions | The shipped strategy — { k?, threshold?, rejectWindow? }. threshold: null means no floor. |
TopKShorthandStrategy / TopKRetrievalStrategy | The two arms of TopKStrategy. They exclude: { topK, threshold } or { retrieval }, never both. |
DEFAULT_CORPUS_IDENTITY | { conversationId: '_global' } — the namespace both defineRAG and indexDocuments default to. |
MemoryFlavor | `'memory' |
retrievalEvidenceKey(id) / isRetrievalEvidenceKey(key) / RETRIEVAL_EVIDENCE_KEY_PREFIX | The root-state key convention (retrievalEvidence_<id>) a slice or a recorder reads the record from. |
chunkProvenance(value) / chunkText(value) / ChunkProvenance | Read a stored entry's coordinates (docUri, page, heading) and its passage back out. The one place that knows which keys mean what, so the record and the citation cannot disagree. chunkText reads content or text (8.19.0) — a chat message and an indexed document use the first, a Chunk from indexCorpus uses the second, and reading only one rendered a perfect citation around an empty body. |
Anti-patterns
- Don't fall back to top-K-anyway when the threshold returns nothing. Read the rejected candidates and set a threshold that fits your embedder instead.
- Don't change embedders between writes and reads — entries are tagged with the embedder used at index time. A silent swap corrupts retrieval.
- Don't judge retrieval quality with
mockEmbedder. It measures letter frequency, not meaning. - Don't pass huge documents (50KB) — chunk first. RAG quality is dominated by chunk size and chunk-boundary choice, and
localEmbeddertruncates at 512 tokens regardless.
Next steps
- Memory guide —
defineMemorycovers all 4 types × 7 strategies including RAG - Memory store adapters —
RedisStore·AgentCoreStore· planned vector-capable backends
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.
Indexing a corpus
agentfootprint/rag — loaders, splitters and the indexing chart. A folder of documents becomes a searchable index, and the run explains itself.
