RAG
Edge-safe document parsing, token-aware chunking, and hybrid (dense + BM25) retrieval — every stateful stage an injected seam.
Le contenu des pages de documentation est en anglais. La navigation, la recherche et l’interface suivent la langue choisie.
@deuz-sdk/core/rag is a zero-dependency, Web-API-only toolkit for Retrieval-Augmented Generation: magic-byte MIME sniffing, a pure CSV state machine, token-aware chunkers, an in-memory vector store, BM25 lexical search, and Reciprocal Rank Fusion. Every stateful stage — parser, embedder, vector store, reranker — is a seam you inject, so the core never imports a parser library or a vector DB.
Heavy binary parsers (PDF / DOCX / XLSX) live in the separate Node subpath @deuz-sdk/core/rag/node and lazily import optional peers (unpdf / mammoth / xlsx). The edge-safe core handles text/plain, text/markdown, and text/csv on its own.
The full document pipeline is: parse → chunk → indexChunks (embed + upsert) → retrieve (or hybridRetrieve) → rerank.
Parsing documents
Detection is by magic bytes, never the file extension (a .docx that is really a PDF is rejected). sniffMime returns the detected MIME, a confidence level, and the container kind.
import { sniffMime } from '@deuz-sdk/core/rag';
const sniff = sniffMime(bytes, { filename: 'report.pdf' });
// { mime: 'application/pdf', confidence: 'magic', container: 'none' }Core cannot tell DOCX from XLSX (both are ZIP containers) by bytes alone, so it returns application/zip and uses the filename hint to disambiguate.
parse runs sniffMime then dispatches: text formats are decoded in core (BOM-stripped; CSV rendered tab/newline), and binary formats require a parser registered in a ParserRegistry. It throws a typed RagError for the cases below.
import { parse, createParserRegistry } from '@deuz-sdk/core/rag';
// Empty registry is fine for pure-text formats.
const registry = createParserRegistry();
const doc = await parse(bytes, registry, {
hint: { filename: 'notes.md' },
});
console.log(doc.text);parse(bytes, registry, opts?) accepts:
| Option | Type | Notes |
|---|---|---|
hint.filename | string | Disambiguates ZIP→docx/xlsx and extension-less text. |
hint.declaredMime | string | Used only to break ties for text formats. |
minTextChars | number | Min characters for a non-empty text layer (default 1). |
It returns a ParsedDocument: { text, pages?, structure?, warnings? }, where structure is an array of DocBlock (heading / paragraph / list / table / code).
Typed errors
RagError extends DeuzError and carries a code plus the detected mime.
code | When |
|---|---|
rag_extension_mime_mismatch | Declared/extension type contradicts the magic bytes. |
rag_unsupported_legacy_doc | Legacy .doc (OLE) — convert to .docx or PDF. |
rag_unsupported_mime | No supported type could be determined. |
rag_parser_not_registered | A binary MIME with no parser registered. |
rag_empty_text_layer | Parser returned (near-)empty text (e.g. a scanned PDF needing OCR). |
Binary parsers (Node)
Register the optional-peer parsers from @deuz-sdk/core/rag/node. defaultNodeParserRegistry() returns a registry pre-populated with the PDF, DOCX, and XLSX parsers; you can also register them individually.
import { parse } from '@deuz-sdk/core/rag';
import { defaultNodeParserRegistry } from '@deuz-sdk/core/rag/node';
const registry = defaultNodeParserRegistry();
const doc = await parse(bytes, registry, {
hint: { filename: 'whitepaper.pdf' },
});
console.log(doc.pages, doc.text.length);| Parser | MIME | Optional peer |
|---|---|---|
pdfParser | application/pdf | unpdf (edge-friendly pdf.js) |
docxParser | OOXML wordprocessingml.document | mammoth (HTML → DocBlock[]) |
xlsxParser | OOXML spreadsheetml.sheet | xlsx (SheetJS; sheets → CSV/text) |
The peers are only loaded when a parser actually runs, so importing rag/node on the edge stays harmless until invoked.
Standalone parsing helpers
The pieces the parse pipeline uses internally are also exported directly, for when you already have text or markup in hand:
parseCsv(text, { delimiter? })→string[][]andcsvToText(rows)→ tab/newline text (@deuz-sdk/core/rag) — the pure CSV state machine (RFC-style quoting), usable without going throughparse.htmlToBlocks(html)→DocBlock[](@deuz-sdk/core/rag/node) — recover headings / paragraphs / list items from an HTML string.
Chunking
All three chunkers are pure and token-aware. They share ChunkOptions:
| Option | Type | Default | Notes |
|---|---|---|---|
size | number | 512 | Target chunk size, in the unit of countTokens. |
overlap | number | 64 | Overlap between consecutive chunks (same unit). |
countTokens | (s: string) => number | approxCountTokens | Inject a real tokenizer for accuracy. |
approxCountTokens estimates ~len/4. DEFAULT_CHUNK_OPTIONS exposes { size: 512, overlap: 64 }.
import { chunkFixed, chunkRecursive, chunkBlocks } from '@deuz-sdk/core/rag';
// Fixed sliding window with overlap; records startOffset / endOffset.
const a = chunkFixed(doc.text, { size: 400, overlap: 50 });
// Structure-aware: only recurses into the separator hierarchy when a piece
// exceeds the budget. Accepts an extra `separators` array.
const b = chunkRecursive(doc.text, { size: 400, overlap: 50 });
// Pre-parsed blocks: never packs across a heading boundary.
const c = chunkBlocks(doc.structure ?? [], { size: 400 });Each Chunk is { text, index, startOffset?, endOffset?, meta? }. chunkRecursive walks DEFAULT_SEPARATORS ('\n\n\n', '\n\n', '\n', '. ', ' ', '') and falls back to chunkFixed for an oversized atomic piece. chunkBlocks flushes before every heading.
Chunk.indexis the identity key. It must stay stable across the embedding store and the BM25 index, because RRF fuses rankings byindex. Don't renumber chunks after indexing.
The embedder seam
The RAG Embedder is a minimal seam — embed(texts: string[]) => Promise<number[][]> plus a dims field. It is intentionally not the same as the EmbeddingModel descriptor; wire a real model into the seam yourself with embedMany.
import { embedMany } from '@deuz-sdk/core';
import { createVoyage } from '@deuz-sdk/core/voyage';
import type { Embedder } from '@deuz-sdk/core/rag';
const model = createVoyage({ apiKey: process.env.VOYAGE_API_KEY! })('voyage-3.5');
const embedder: Embedder = {
dims: 1024,
async embed(texts) {
const { embeddings } = await embedMany({ model, values: texts });
return embeddings;
},
};Indexing and retrieval
createMemoryVectorStore() is a pure in-memory cosine store — a reference impl for tests and small corpora. Swap in any VectorStore (upsert / query) backed by a real database for production.
indexChunks(chunks, { embedder, store }) embeds every chunk's text and upserts it. retrieve(query, deps, opts?) embeds the query, runs store.query(topK), then reranks down to topN.
import {
chunkRecursive,
indexChunks,
retrieve,
createMemoryVectorStore,
} from '@deuz-sdk/core/rag';
const store = createMemoryVectorStore();
const chunks = chunkRecursive(doc.text, { size: 400, overlap: 50 });
await indexChunks(chunks, { embedder, store });
const hits = await retrieve('how do refunds work?', { embedder, store }, {
topK: 8,
topN: 4,
});
// hits: ScoredChunk[] — Chunk + a `score`RetrieveOptions | Type | Default | Notes |
|---|---|---|---|
topK | number | 8 | Candidates pulled from the vector store. |
topN | number | topK | Results kept after reranking. |
Hybrid retrieval (dense + BM25)
Dense embeddings catch paraphrase and semantic similarity; BM25 catches exact terms, IDs, and rare tokens a vector model blurs ("clause 17", a SKU, a name). hybridRetrieve runs both stages in parallel and fuses their rankings with Reciprocal Rank Fusion — raw scores live on incomparable scales, so only rank order is used.
Build the lexical index once with createBm25Index(chunks), then pass it alongside the embedder and store.
import {
indexChunks,
hybridRetrieve,
createBm25Index,
createMemoryVectorStore,
} from '@deuz-sdk/core/rag';
const store = createMemoryVectorStore();
await indexChunks(chunks, { embedder, store });
// Lexical index over the SAME chunk set (Chunk.index must match).
const bm25 = createBm25Index(chunks);
const hits = await hybridRetrieve(
'warm-loving animal and GDPR clause 17',
{ embedder, store, bm25 },
{ topK: 8, topN: 4 },
);hybridRetrieve extends RetrieveOptions with:
| Option | Type | Default | Notes |
|---|---|---|---|
perStageK | number | topK | Candidates pulled from EACH stage before fusion. |
rrfK | number | 60 | RRF damping constant. |
If the vector stage is empty (nothing indexed yet), hybrid degrades to lexical-only rather than returning nothing.
createBm25Index(chunks, options?) is Okapi BM25 and accepts k1 (term-frequency saturation, default 1.5), b (length normalization 0..1, default 0.75), and a custom tokenize. The returned Bm25Index has search(query, topK): ScoredChunk[] and a size. For a large or persistent corpus, back the lexical stage with your own search engine and implement the same search shape.
You can also call the primitives directly. createBm25Index(...).search(...) returns a ranking; reciprocalRankFusion(rankings, opts?) fuses several rankings by Chunk.index:
import {
createBm25Index,
createMemoryVectorStore,
reciprocalRankFusion,
type Chunk,
type Embedder,
} from '@deuz-sdk/core/rag';
declare const chunks: Chunk[];
declare const embedder: Embedder;
declare const store: ReturnType<typeof createMemoryVectorStore>;
const bm25 = createBm25Index(chunks);
const lexical = bm25.search('clause 17 erasure', 10);
const [queryVector] = await embedder.embed(['clause 17 erasure']);
const dense = queryVector ? await store.query(queryVector, 10) : [];
const fused = reciprocalRankFusion([dense, lexical], { k: 60, topN: 5 });The reranker seam
retrieve and hybridRetrieve accept an optional reranker. The default is identityReranker, which sorts by the retrieval score and truncates to topN. A cross-encoder reads the query and each candidate together, so it catches relevance that neither cosine similarity nor BM25 can see. Two hosted ones ship (2.2):
| Factory | Subpath | Endpoint | Default model |
|---|---|---|---|
createCohereReranker(settings?) | @deuz-sdk/core/rag | POST https://api.cohere.com/v2/rerank | rerank-v4.0-pro |
createVoyageReranker(settings?) | @deuz-sdk/core/voyage | POST https://api.voyageai.com/v1/rerank | rerank-2.5 |
import { retrieve, createCohereReranker } from '@deuz-sdk/core/rag';
import { createVoyageReranker } from '@deuz-sdk/core/voyage';
const reranker = createCohereReranker({ apiKey: process.env.COHERE_API_KEY });
// or: createVoyageReranker({ apiKey: process.env.VOYAGE_API_KEY, model: 'rerank-2.5-lite' })
// Over-fetch with the cheap retriever, let the cross-encoder pick the best few.
const hits = await retrieve(query, { embedder, store, reranker }, { topK: 40, topN: 5 });Both take the same settings:
| Setting | Default | Notes |
|---|---|---|
apiKey | — | deps.keyProvider.getKey('cohere' | 'voyage') outranks it when it returns a key. With neither, rerank throws AuthenticationError before any request. |
model | see above | Any rerank model id the provider accepts. |
topK | — | Upper bound per call. The reranker returns min(topN, topK) chunks. |
baseURL | provider root | /rerank is appended. For a relay or a regional endpoint. |
fetch | deps.fetch | Wins over deps.fetch. |
headers | — | Extra request headers. |
deps | — | Only fetch and keyProvider are read. |
What comes back is the original chunks — index, text, meta untouched — with score replaced by the provider's relevance_score, best first. Your input array is never mutated. An empty candidate list or topN: 0 makes no request.
Failures map onto the usual error classes: 429 is RateLimitError (with retryAfterMs), 401/403 AuthenticationError, any other 4xx InvalidRequestError, 5xx a retryable APICallError, a transport failure NetworkError. A response that points at a document index outside the list is rejected rather than guessed at. The key only ever goes into the authorization header, never into an error. Each rerank is one request with no retries; wrap it if you want them.
Any other model is a few lines behind the same seam:
import type { Reranker } from '@deuz-sdk/core/rag';
const crossEncoder: Reranker = {
async rerank(query, candidates, topN) {
const scored = await scoreWithCrossEncoder(query, candidates); // your call
return scored.sort((a, b) => b.score - a.score).slice(0, topN);
},
};
const hits = await retrieve(query, { embedder, store, reranker: crossEncoder });Native document vs. chunk-and-embed
When a model can ingest a document directly (e.g. native PDF), it is often cheaper and higher-fidelity to send the whole file rather than chunk and retrieve. RAG ships a small pure policy for that decision.
import {
estimatePdfTokens,
modelSupportsDocuments,
shouldSendWhole,
toNativeDocumentPart,
} from '@deuz-sdk/core/rag';
const estTokens = estimatePdfTokens(pages); // ~700 tokens/page
const sendWhole = shouldSendWhole({
estTokens,
modelSupportsDocuments: modelSupportsDocuments({ nativePdf: true }),
thresholdTokens: 6000, // default
});
if (sendWhole) {
const part = toNativeDocumentPart({ bytes, mime: 'application/pdf' });
// PDFs ride as an ImagePart with mediaType 'application/pdf'; text → TextPart.
}shouldSendWhole returns true only when the model supports documents, estTokens is under thresholdTokens (default 6000), and (if contextWindow is given) the estimate fits. estimateTokens(text, countTokens?) and estimatePdfTokens(pages) produce the estimate; modelSupportsDocuments(caps) reads caps.nativePdf.
End to end: PDF → chunks → hybrid retrieve → prompt
import { embedMany, generateText } from '@deuz-sdk/core';
import { createAnthropic } from '@deuz-sdk/core/anthropic';
import { createVoyage } from '@deuz-sdk/core/voyage';
import {
parse,
chunkRecursive,
indexChunks,
hybridRetrieve,
createBm25Index,
createMemoryVectorStore,
type Embedder,
} from '@deuz-sdk/core/rag';
import { defaultNodeParserRegistry } from '@deuz-sdk/core/rag/node';
// 1. Embedder seam over a real embedding model.
const embedModel = createVoyage({ apiKey: process.env.VOYAGE_API_KEY! })('voyage-3.5');
const embedder: Embedder = {
dims: 1024,
embed: async (texts) => (await embedMany({ model: embedModel, values: texts })).embeddings,
};
// 2. Parse the PDF (Node parsers) and chunk it.
const doc = await parse(bytes, defaultNodeParserRegistry(), {
hint: { filename: 'handbook.pdf' },
});
const chunks = chunkRecursive(doc.text, { size: 400, overlap: 50 });
// 3. Index dense + lexical over the SAME chunk set.
const store = createMemoryVectorStore();
await indexChunks(chunks, { embedder, store });
const bm25 = createBm25Index(chunks);
// 4. Retrieve and build a grounded prompt.
const question = 'What is the refund window for clause 17 purchases?';
const hits = await hybridRetrieve(question, { embedder, store, bm25 }, { topK: 8, topN: 4 });
const context = hits.map((h) => h.text).join('\n\n---\n\n');
const { text } = await generateText({
model: createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY! })('claude-opus-4-8'),
messages: [
{ role: 'system', content: 'Answer using only the provided context. Cite nothing you cannot find there.' },
{ role: 'user', content: `Context:\n${context}\n\nQuestion: ${question}` },
],
});Pure-edge usage (core only)
The entire retrieval layer runs on Web APIs only — no Node imports, no rag/node. As long as you parse text formats (or feed pre-extracted text) and supply an edge-safe Embedder, everything works on Cloudflare Workers, Vercel Edge, and Deno.
import {
parse,
createParserRegistry,
chunkRecursive,
indexChunks,
hybridRetrieve,
createBm25Index,
createMemoryVectorStore,
type Embedder,
} from '@deuz-sdk/core/rag';
export async function ragOnEdge(bytes: Uint8Array, question: string, embedder: Embedder) {
// Text/markdown/CSV parse purely in core — empty registry is enough.
const doc = await parse(bytes, createParserRegistry(), { hint: { filename: 'kb.md' } });
const chunks = chunkRecursive(doc.text, { size: 400, overlap: 50 });
const store = createMemoryVectorStore();
await indexChunks(chunks, { embedder, store });
const bm25 = createBm25Index(chunks);
return hybridRetrieve(question, { embedder, store, bm25 }, { topK: 6, topN: 3 });
}Token counting and accounting (2.4)
Counting. The chunkers can count through the shared token estimator of
@deuz-sdk/core/context:
ChunkOptions.estimator (used when countTokens is absent),
estimateTokens(text, estimator) and countTokensWith(estimator), which
returns a CountTokens. The default stays approxCountTokens (about 4
characters per token), so 2.3 chunk boundaries do not move.
Accounting. indexChunks, retrieve and hybridRetrieve take
{ accounting?, onWarning? } (the third argument of indexChunks is new).
With accounting: { execution, requestId, kind?, tokens?, usd? }, each
embedding call is reserved on that execution context before it runs: kind
'embedding' by default, tokens the shared estimate of the inputs, unpriced
unless you give usd. Then:
- an accounted embedder (
accounted: true, answering{ vectors, usage, modelId }) settles the reservation with the usage it reports;defineAccountedEmbedder({ embed, dims })from@deuz-sdk/core/ragmarks one (aTypeErrorwithoutembedordims); - a plain embedder (bare vectors) leaves the reservation
unknownat its estimate and reports oneunaccounted-usagewarning throughonWarning; - a failed call is marked
unknown(it may have been billed) and the error is rethrown.
Without accounting, nothing is charged, as in 2.3. Inside a V3 run, the RAG
context source passes its retrieve a fourth charge argument with the run's
accounting; spread it into these options
(hybridRetrieve(q, deps, { topK, ...charge })) and the query embedding
reaches the run's ledger and the goal supervisor's watermark. See
Context compiler.
import { createExecutionContext } from '@deuz-sdk/core/agent';
import { createMemoryVectorStore, indexChunks, retrieve } from '@deuz-sdk/core/rag';
import type { CallWarning } from '@deuz-sdk/core';
const vectorOf = (text: string) => [text.length % 7, text.includes('deploy') ? 1 : 0, 1];
const store = createMemoryVectorStore();
const execution = createExecutionContext({ budget: { tokens: 10_000 } });
const warnings: CallWarning[] = [];
// An embedder that reports its usage: `accounted: true`, and vectors with usage and modelId.
const accounted = {
dims: 3,
accounted: true as const,
async embed(texts: string[]) {
const usage = { inputTokens: 12, outputTokens: 0, reasoningTokens: 0, cachedReadTokens: 0, cacheWriteTokens: 0, cacheWrite1hTokens: 0, totalTokens: 12 };
return { vectors: texts.map(vectorOf), usage, modelId: 'text-embedding-3-small' };
},
};
await indexChunks(
[{ text: 'Every deploy needs a green CI run.', index: 0 }],
{ embedder: accounted, store },
{ accounting: { execution, requestId: 'index-1' } },
);
// A plain embedder (bare vectors) is charged at its estimate and reported as unaccounted.
const plain = { dims: 3, embed: async (texts: string[]) => texts.map(vectorOf) };
const hits = await retrieve('How do I deploy?', { embedder: plain, store }, {
accounting: { execution, requestId: 'query-1' },
onWarning: (warning) => warnings.push(warning),
});
console.log(hits.length, warnings.map((w) => w.type)); // 1 [ 'unaccounted-usage' ]
const totals = execution.ledger.totals();
console.log(totals.spent.tokens, totals.reserved.tokens, totals.unaccounted); // 12 5 1: the query's estimate stays heldAn embedder over embedMany becomes accounted by returning what it already
gets back: { vectors: embeddings, usage, modelId }. The reservation names the
embedder's modelId property when it has one, else 'rag-embedder'. Under a
budget with unknownPrice: 'reject' a plain embedder is refused before it
runs; see Unaccounted calls.
See also
- Embeddings —
embed/embedManyand theEmbeddingModelkind. - Memory — the long-term
MemoryStoreseam (shares theEmbedderpattern). - generateText — feed retrieved context into a grounded completion.
Compaction & Context Recovery
compactMessages — the loop's layered compaction as a plain function — plus the rolling summary, automatic overflow recovery, and a real tokenizer via countTokens.
Skills
Agent Skills (SKILL.md) with progressive disclosure — catalog, trigger, and bundled resources over a pluggable source seam.