Retrieval
Retrieval & RAG beta APIs for knowledge bases, retrievers, recipes, tools, grounding, and traceable evidence.
import {
knowledgeBase,
retriever,
retrievalRecipe,
retrievalStep,
rewriteQuery,
fanout,
retrieve,
rerank,
expandParents,
compressToBudget,
grounding,
} from "@use-crux/core/retrieval";
import type {
KnowledgeBase,
RetrievalRecipe,
RetrievalStep,
RetrievalModel,
Reranker,
RetrieveRequest,
RetrieveOptions,
Retriever,
RetrieverHit,
RecipeTrace,
StepTrace,
} from "@use-crux/core/retrieval";Overview
The Retrieval & RAG beta has four public layers:
| Layer | Use it for |
|---|---|
knowledgeBase() | Indexing lifecycle, storage, embeddings, scoped tenancy, retriever/tools/grounding factories, and inspection. |
retriever() | Direct search over indexed knowledge or a custom backend. |
retrievalRecipe() | Named, traceable retrieval composition over one or more retrievers. |
grounding() | Evidence injection, retrieval tools, grounding sessions, and citation validation. |
Each layer exposes the layer beneath it.
const docs = knowledgeBase({ id: "docs", storage, embeddings });
docs.inspect();
docs.retriever();
docs.recipe({ id: "docs-answer", steps: [retrieve({ limit: 20 })] });
docs.grounding({ query: ({ input }) => input.question as string });
docs.tools({ prefix: "docs", include: ["search", "getSource"] });knowledgeBase(config)
Creates the high-level RAG facade.
const docs = knowledgeBase({
id: "docs",
storage,
embeddings: dense,
sparseEmbeddings: sparse,
metadataSchema: z.object({
section: z.enum(["guide", "reference"]),
public: z.boolean(),
}),
lifecycle: { retention: "cleanup" },
});Core config fields are id, optional source or corpus, storage or explicit stores, dense/sparse embeddings, chunking, metadata schema, lifecycle retention, and indexing cache policy.
Handles expose index, reindex, remove, scope, retriever, recipe, grounding, tools, and inspect.
Scoped handles isolate storage keys structurally:
const tenantDocs = docs.scope({ namespace: `tenant:${tenantId}` });
await tenantDocs.reindex(documents);
await tenantDocs.remove("old-guide.md");retriever(config)
Creates a direct retrieval object.
Store-backed
const docs = retriever({
id: "docs",
namespace: "product-docs",
records,
vectors,
dense,
sparse,
search: {
mode: "hybrid",
limit: 8,
threshold: 0.2,
filter: { section: "planning" },
},
});Store-backed retrievers require id, namespace, record/vector storage, and the embeddings needed by the selected mode. indexerId points hydration at a writer id when it differs from the public retriever id.
Custom
const docs = retriever({
id: "internal-search",
namespace: "docs",
async retrieve(query, options) {
return internalSearch(query, options);
},
});Custom retrievers return RetrieverHit[]: namespace, structured source: { id, url?, path?, assetRef?, mediaType?, location? }, chunkId, content, metadata, score, optional parent data, and optional provenance.
hit.score is always the current ranking score. Raw, fused, per-source, rerank, and compression details live in hit.provenance.
Requests
Retrievers and recipes accept a string query or a canonical request object.
await docs.retrieve({
query: "refund policy",
limit: 5,
threshold: 0.2,
filter: { section: "guide" },
mode: "hybrid",
fusion: { strategy: "rrf", k: 60 },
caller: { promptId: "answer-docs" },
});RetrieveRequest<TFilter> includes query, limit, threshold, filter, mode, fusion, trace, and caller.
Observability
Retrieval uses the same canonical observability graph as generation, tools, memory, and Eval runs.
Direct retrieve() calls emit a retrieval.query span, attach a bounded retrieval.hits artifact,
and connect returned chunks with retrieval.returned edges. Recipe calls open a parent
retrieval.recipe span and record query rewrite, fanout, federated retrieval, rerank, parent
expansion, compression, and custom work as child retrieval.step spans.
The local devtools backend and TUI read those records through RunDetail; contextual retrieval folds
into the generation it informed, while operational retrieval inside a tool, flow, composition, agent,
or runtime boundary stays visible as its own branch. retrieveWithTrace() is still useful for
in-process debugging because it returns recipe and step traces directly, but it is not a separate
telemetry format.
Output capture policy applies to retrieval payloads. When observability.recordOutputs is disabled,
retrieval.hits artifacts keep stable ids, counts, hashes, and sizes but do not expose raw hit
previews to subscribers, transports, devtools persistence, or OTel export. OTel retrieval spans export
safe metadata such as recipe id, source retriever ids, step id, step kind, query counts, hit counts,
warning counts, and status; they do not export query text, hit content, filters, metadata assets, or
embeddings by default.
retrievalRecipe(config)
Creates a named retrieval composition.
const docsAnswer = retrievalRecipe({
id: "docs-answer",
retriever: docs,
model,
concurrency: 4,
steps: [
fanout({ maxQueries: 4 }),
retrieve({ limit: 30 }),
rerank({ engine: judgeReranker({ name: "docs-judge", model }), topK: 8 }),
compressToBudget({ tokens: 3500 }),
],
});Recipe config includes id, one or more retriever sources, typed steps, optional default model, optional concurrency, and optional federated source failure policy. Handles expose run, retrieve, retrieveWithTrace, asRetriever, asTools, asGrounding, and inspect.
Federated sources can be weighted:
const recipe = retrievalRecipe({
id: "support-and-docs",
retriever: [
{ retriever: docs, weight: 1 },
{ retriever: tickets, weight: 0.5 },
],
onSourceError: "skip-with-warning",
steps: [retrieve({ limit: 20 })],
});Steps
Built-ins:
| Helper | Phase | Requires model |
|---|---|---|
rewriteQuery() | queries -> queries | yes |
fanout() | queries -> queries | yes |
retrieve() | queries -> hits | no |
rerank() | hits -> hits | no (requires a named engine) |
expandParents() | hits -> hits | no |
compressToBudget() | hits -> hits | yes |
Custom steps:
const onlyPublic = retrievalStep({
id: "only-public",
kind: "filter",
phase: { in: "hits", out: "hits" },
run: ({ hits }) => ({
hits: hits.filter((hit) => hit.metadata.public === true),
}),
});Step order is checked at construction. Reserved internal ids such as retrieve and fusion cannot be reused by custom steps.
Reranking
rerank() is the single recipe step for reordering hits. It requires an explicit engine, a named Reranker. The engine's name is the canonical rag.reranker:<name> identity the Project Index and runtime evidence join on, so there is no anonymous default: build a judge engine with judgeReranker({ name, model }) or an adapter reranker() and pass it in.
const recipe = retrievalRecipe({
id: "docs-answer",
retriever: docs,
model: retrievalModel,
steps: [
retrieve({ limit: 30 }),
rerank({
engine: judgeReranker({ name: "docs-judge", model: retrievalModel }),
topK: 8,
}),
],
});Use an adapter or custom provider engine when it can return a Reranker directly:
const recipe = retrievalRecipe({
id: "docs-answer",
retriever: docs,
steps: [
retrieve({ limit: 30 }),
rerank({
engine: anthropic.reranker({
name: "anthropic-reranker",
model: "claude-haiku-4-5",
}),
topK: 8,
}),
],
});Core exports the provider-neutral contract and judge factory:
import { judgeReranker } from "@use-crux/core/retrieval";
const engine = judgeReranker({
model: retrievalModel,
name: "docs-judge",
topN: 12,
document: (hit) => `${hit.source.id}\n${hit.content}`,
});Adapter packages bind the same capability factories beside generate() and stream(): retrievalModel(config) and reranker(config). @use-crux/ai uses native AI SDK reranking models. The direct SDK adapters (@use-crux/anthropic, @use-crux/openai, @use-crux/google) use judgeReranker() over their own generation model, so those calls spend generation tokens.
Tools
asTools() and knowledgeBase().tools() return typed retrieval tools.
const tools = docs.asTools({
prefix: "docs",
include: ["search", "getSource"],
filters: ["section"],
limit: { default: 5, max: 10 },
threshold: { default: 0.2, min: 0 },
getSource: { visibility: "namespace" },
});
tools.docsSearch;
tools.docsGetSource;Tool execution returns a structured payload:
type RetrievalToolPayload = {
kind: "crux.retrieval.hits";
hits: RetrievalToolHit[];
};The model-facing output is lean text. Full provenance stays in programmatic traces and hits.
Grounding
grounding() lives in @use-crux/core/citations and is re-exported from @use-crux/core/retrieval.
const groundedDocs = grounding({
id: "docs-grounding",
retriever: docsAnswer.asRetriever(),
query: ({ input }) => input.question as string,
inject: "both",
tools: { prefix: "docs", include: ["search", "getSource"] },
citations: { required: true, quotes: "required" },
});Grounding records injected and tool-discovered hits in a per-generation session. Citation constraints read that session at validation time.
Observability
New retrieval spans use public beta nouns:
| Primitive | Meaning |
|---|---|
knowledge.lifecycle | knowledgeBase().index(), .reindex(), and .remove(). |
retrieval.retrieve | A standalone retriever call. |
retrieval.recipe | A recipe run. |
retrieval.step | A recipe step. |
citation.check | Citation validation. |
Recipe runs attach retrieval.hits artifacts with score provenance and step/source attribution.
Evals
Point evaluate() at the callable managed generation task that consumes this
retrieval behavior. The task preserves prompt, schema, tool, routing, and
retrieval identity for exact reuse; use ordinary output checks or admitted
managed scorers. See the Eval reference.
Embedding governance
embedding() (from @use-crux/core/embedding) owns the policies that affect how vectors are generated. The core option table lives in the core reference; the governance options are:
Store-backed retrievers accept a string, typed EmbeddingInput, bare media
asset, or an exact { query } / { input } request object. Text queries may run
dense, sparse, or hybrid search. Media queries run the dense branch only;
sparse/custom retrieval and text-producing recipe query steps reject them.
The retriever checks the namespace's dense space before query embedding or
vector search. A mismatch throws EmbeddingSpaceMismatchError with stored and
configured name/dimension descriptors and directs the caller to reindex or use
the embedding that built the namespace. Returned media hits keep the existing
RetrieverHit shape and expose source.assetRef for explicit asset hydration.
| Option | Type | Behavior |
|---|---|---|
batch | { maxSize?, concurrency? } | Chunking and parallelism inside one embedMany() call |
preprocess | EmbeddingPreprocessor | EmbeddingPreprocessor[] | Normalization before cache lookup and before the provider call |
truncate | { strategy: 'fail' } | { strategy: 'chars'; maxChars: number } | Input-limit policy; 'fail' is the default so truncation is never silent |
version | string | Vector-semantic revision folded into fingerprints and cache identity |
retry | { maxAttempts; baseDelayMs?; maxDelayMs?; shouldRetry? } | Provider batch retry policy |
cache | EmbeddingCache | Policy-aware per-input vector cache |
rateLimit | { concurrency: number } | Caps provider calls across overlapping calls on the same embedding instance |
batch.concurrency and rateLimit.concurrency differ in scope: batch.concurrency controls how many chunks one embedMany() call can run at once, while rateLimit.concurrency is stricter and caps provider calls across all concurrent callers of the same embedding instance.
Preprocessor fingerprints and cache identity
The embedding cache is per normalized text, not per batch. Crux builds policy-aware cache keys from the embedding kind, name, dense dimensions, maxInputTokens, preprocessing fingerprints, truncation policy, declared version, and input hash. Changing any part of the vector-producing policy creates different keys, so stale vectors are not reused accidentally.
Custom preprocessors must declare a stable fingerprint so cache keys change when the behavior changes:
import { embeddingPreprocessor, normalizeText } from "@use-crux/core/embedding";
const stripMarkdown = embeddingPreprocessor({
id: "strip-markdown",
fingerprint: "strip-markdown:v1",
run: (text) => text.replace(/[#*_`]/g, ""),
});
const dense = embedding({
// ...
preprocess: [
normalizeText({ trim: true, collapseWhitespace: true }),
stripMarkdown,
],
});Why there is no kind: 'hybrid'
embedding() intentionally supports only kind: 'dense' | 'sparse'. Hybrid is not one vector shape:
- dense output is
number[] - sparse output is
{ indices, values } - hybrid output is both, plus search-time fusion semantics
If Crux modeled hybrid as an embedding kind, the primitive would have to own search orchestration concerns that belong in stores or retrievers. Instead, hybrid is a query strategy: one dense embedding plus one sparse embedding, combined at search time through retriever() or VectorStore.search({ mode: 'hybrid' }).
This split is designed to hold as Crux grows:
- retrieval APIs compose dense and sparse embeddings on top of
VectorStore.search() - semantic cache and observational memory remain dense-oriented unless they gain a concrete sparse use case
- store adapters can expose native capabilities without forcing every feature into a dense-only contract
Future features get one clear place to reuse embedding configuration, batching, and telemetry without turning the embedding layer into the retrieval layer.
Related
- Guide: Retrieval & RAG
- Guide: Querying
- Guide: Retrieval Recipes
- Guide: Grounding And Citations
- Reference: Citations
- Reference: Indexing
- Reference: Storage