/** * defineRAG — retrieval-augmented generation over a document corpus. * * Embed the user's question, retrieve the most similar chunks from a * vector store, inject them into the system-prompt slot of the next LLM * call. It is the same machinery as `defineMemory({ type: SEMANTIC, * strategy: TOP_K })` — with three differences that are not cosmetic: * * defineMemory ─┬─► EPISODIC (raw conversation) * ├─► SEMANTIC (extracted facts / RAG chunks) * ├─► NARRATIVE (beats / summaries) * └─► CAUSAL (footprintjs decision snapshots) * * defineRAG ─► SEMANTIC + TOP_K, READ-ONLY, over a NAMED CORPUS, * rendered as citable blocks. * * ─── What changed in 8.8.0, and why ──────────────────────────────────── * * **1. A corpus is read-only.** Until 8.8.0 `defineRAG` also mounted the * semantic pipeline's WRITE half, which embedded every conversation turn * and stored it in the same namespace as the documents. The consequence * was measurable and bad: the user's own question, re-embedded, is the * single best-scoring "document" in the corpus (cosine 1.0 against * itself), so it came back as retrieval hit #1 and consumed a slot in * the top-K budget that a real passage should have had. A corpus is not * a conversation log. Nothing this retriever reads is written back. * * If you want conversation memory ALONGSIDE a corpus, that is what * `defineMemory` is for, and the two are registered separately, each * with its own store: * * ```ts * 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(); * ``` * * **2. The corpus has its own namespace.** A memory reads under the * identity passed to `agent.run()`. For conversation recall that is * right. For a shared corpus it was the reason the documented example * retrieved NOTHING: `indexDocuments` writes under * `{ conversationId: '_global' }` by default, while a run with no * explicit identity gets `{ conversationId: 'run--' }` — * so the write side and the read side never met, silently. `corpus` * defaults to the same `'_global'` the indexer uses, so the documented * example works with no extra arguments; pass it explicitly when you * index per tenant. * * **3. Chunks are citable.** Retrieved passages render as * `` under a * corpus header, instead of the conversation-recall shape that printed * `role="unknown" turn="0"` on a page of a PDF and gave the model * nothing to cite. * * Pattern: Composition over duplication — defineRAG returns a * MemoryDefinition produced by defineMemory. No new engine * code, no new slot subflow, no new stage. * * Role: Layer-3 context-engineering primitive. Lives next to * defineSkill / defineSteering / defineInstruction / defineFact * but resolves to a memory subflow rather than an Injection * (RAG content is computed at runtime via async retrieval — * can't fit the synchronous Injection.inject shape). * * Emits: `agentfootprint.memory.retrieved` (once per retrieval, with * every candidate and its score — including the rejected ones), * `agentfootprint.memory.attached` (once per chunk that reached * the prompt), and `agentfootprint.context.injected` with * `source: 'rag'` (once per chunk). * * @see ./indexDocuments.ts for the seeding helper * @see ../../memory/define.ts for the underlying factory * @see ../../memory/retrieval/ for the retrieval seam * @see ../../memory/asRoleRefusal.ts for why `asRole` is refused, not honoured * * @example Basic usage — works with no identity argument anywhere * ```ts * import { Agent, defineRAG, indexDocuments } from 'agentfootprint'; * import { InMemoryStore, mockEmbedder } from 'agentfootprint/memory'; * import { mock } from 'agentfootprint/providers'; * * const embedder = mockEmbedder(); * const store = new InMemoryStore(); * * // Seed the corpus once at startup (defaults to the '_global' namespace). * await indexDocuments(store, embedder, [ * { id: 'refunds.md#0', content: 'Refunds are processed within 3 business days.', * metadata: { source: 'refunds.md' } }, * { id: 'pricing.md#0', content: 'Pro plan costs $20/month.', * metadata: { source: 'pricing.md' } }, * ]); * * const docs = defineRAG({ * id: 'product-docs', * description: 'Retrieve product documentation chunks', * store, * embedder, * topK: 3, * threshold: 0.6, * }); * * const agent = Agent.create({ provider: mock({ reply: 'ok' }), model: 'mock' }) * .rag(docs) * .build(); * * agent.on('agentfootprint.memory.retrieved', (e) => { * // every candidate, admitted or not, with its score and its reason * console.log(e.payload.candidates); * }); * * await agent.run({ message: 'How long do refunds take?' }); * ``` */ import type { Embedder } from '../../memory/embedding/index.js'; import type { MemoryStore } from '../../memory/store/index.js'; import type { MemoryIdentity } from '../../memory/identity/index.js'; import type { MemoryDefinition } from '../../memory/define.types.js'; import type { RetrievalStrategy } from '../../memory/retrieval/index.js'; /** * The namespace a corpus lives in unless told otherwise — the same one * `indexDocuments` writes to by default. The two defaults are one value * on purpose: index with no options, retrieve with no options, and the * documents are found. */ export declare const DEFAULT_CORPUS_IDENTITY: MemoryIdentity; export interface DefineRAGOptions { /** Stable id. Becomes the scope-key suffix and the Lens label. */ readonly id: string; /** * Human-readable description. Surfaces in narrative + Lens hover. * Recommend describing the *corpus* (e.g., "Product documentation * chunks indexed weekly from docs.example.com"). */ readonly description?: string; /** * Store containing the indexed corpus. Must implement `search()`. Use * `indexDocuments(store, embedder, docs)` at startup to populate it. Ships * with `InMemoryStore` for dev/tests; swap to a durable adapter in * production. * * A store that declares `ranksBy: 'server-text'` is served by the backend's * own index rather than one built here — see {@link embedder}. */ readonly store: MemoryStore; /** * Embedder used for the read-side query. Pass the SAME embedder * instance (or one with the same `embedderId`) that was used for * indexing — cross-model similarity scores are not comparable. * * **Optional since 9.3.0, for one case only.** A store that declares * `ranksBy: 'server-text'` (see {@link MemoryStore.ranksBy}) takes the * question as WORDS and ranks it on the backend's side; there is nothing * here for an embedder to do, and embedding the query anyway would be spend * on a vector discarded on arrival. Against such a store this must be * OMITTED — passing one is refused rather than ignored, because an ignored * embedder reads, from the wiring, exactly like a working one. * * Against every other store it is still required. */ readonly embedder?: Embedder; /** * Stable id of the embedder. Stored on entries during indexing * (via `indexDocuments`) and filtered at search time so a later * embedder swap doesn't pollute results. * * Refused alongside a `'server-text'` store for the same reason * {@link embedder} is: the backend's records were never written here and * carry no `embeddingModel` to filter on, so the option would name a filter * that filtered nothing. */ readonly embedderId?: string; /** * The namespace this corpus lives in. Default * `{ conversationId: '_global' }` — the same default `indexDocuments` * writes to, so the plain path needs no argument on either side. * * A corpus is deliberately NOT scoped to the run's identity: it is * shared by every conversation, and reading it under a per-run * conversation id is the bug this default fixes. * * **Multi-tenant:** pass the tenant's identity here AND the same one to * `indexDocuments({ identity })`. A namespace that holds nothing is now * reported (`corpusEmpty` on `agentfootprint.memory.retrieved`, plus a * one-time warning naming the namespace), so a mismatch is loud rather * than an empty answer. */ readonly corpus?: MemoryIdentity; /** * Top-K chunks to retrieve per turn. Default 3 (balanced — * defends against lost-in-the-middle while giving multiple * perspectives). Increase for richer context, decrease for cost. * * Shorthand for `retrieval: topK({ k })`; the two EXCLUDE. */ readonly topK?: number; /** * Minimum cosine similarity to inject. **Strict** — when no chunk * meets the threshold, NO injection happens (no fallback that would * pollute the prompt with weak matches). Default 0.7. * * Tuning note: the right threshold is a property of the EMBEDDER. 0.7 * is a high bar for some of them. Sentence-BERT relatives * (`all-MiniLM-L6-v2`, etc.) often score 0.4–0.6 even on relevant * chunks; Amazon Titan Text V2 was field-measured at 0.55–0.57 for a * direct hit, ~0.49 for the right section diluted, 0.36–0.42 for * noise — on that embedder the 0.7 default retrieves NOTHING, * silently. You no longer have to guess: the rejected candidates and * their scores are on every `agentfootprint.memory.retrieved` event, * so the right threshold is a number you can read off a run. * * Shorthand for `retrieval: topK({ threshold })`; the two EXCLUDE. */ readonly threshold?: number; /** * A character budget for the retrieved passages, spent across them in * RANK order (8.19.0). Default: none — `topK` stays the only bound, so * nothing changes for a retriever that does not ask for this. * * **A count bound is not a size bound.** `topK` says how many passages * may reach the prompt and nothing about how long they are: ten chunks * cut by `byHeading()` off ordinary documentation measured 11,153 * characters in the field, against a `systemPrompt` slot whose default * budget is 4,000 — an overflow produced entirely by defaults on both * sides. Nothing truncates (the slot warns and emits * `agentfootprint.context.budget_pressure`), so the run was honest about * the over-run and had no way to BOUND it. This is that bound. * * The two numbers, side by side, because they are the ones that meet: * * | knob | default | what it bounds | * |---|---|---| * | `defineRAG({ topK })` | 3 | how MANY passages | * | `defineRAG({ maxChars })` | none | how much TEXT they may be | * | `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 a budget of roughly half the slot is a * sane starting point: `maxChars: 2000` with the 4,000-char default. * * The spend is RECORDED, never silent: passages past the budget are * refused with `reason: 'over-char-budget'` on * `agentfootprint.memory.retrieved`, and the record carries `maxChars` * and `charsUsed`. Rank order, tail dropped — so a budget smaller than * the best-scoring passage admits nothing and says so per candidate, * rather than quietly injecting half a passage. * * NOT the splitters' `maxChars`: `byHeading({ maxChars })` bounds ONE * chunk at index time (default 1000), this 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. * * Composes with `retrieval` (unlike `topK`/`threshold`, which exclude * it): the strategy picks the candidates, this bounds their size. */ readonly maxChars?: number; /** * The retrieval rule, spelled out. Replaces `topK` + `threshold` * entirely — passing both is refused, because they could disagree * and the recording would then name a `k` the run did not use. * * ```ts * import { topK } from 'agentfootprint/memory'; * defineRAG({ id, 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 in * 8.8.0, and the seam exists so that when they do, nothing else moves. */ readonly retrieval?: RetrievalStrategy; } /** * Build a RAG context-engineering definition. The returned * `MemoryDefinition` is registered on the Agent via `.rag(definition)` * (or, equivalently, `.memory(definition)` — same plumbing). * * @throws when `store` does not implement `search()`. RAG requires a * store that can retrieve. * @throws when `embedder` is missing and the store does not rank text * server-side — somebody has to turn the question into a vector. * @throws when `embedder`/`embedderId` is passed to a store that DOES rank * text server-side — the option would be read by nothing. * @throws when `retrieval` is combined with `topK` or `threshold`. */ export declare function defineRAG(opts: DefineRAGOptions): MemoryDefinition; //# sourceMappingURL=defineRAG.d.ts.map