Crux
API Reference@use-crux/core

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:

LayerUse 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:

HelperPhaseRequires model
rewriteQuery()queries -> queriesyes
fanout()queries -> queriesyes
retrieve()queries -> hitsno
rerank()hits -> hitsno (requires a named engine)
expandParents()hits -> hitsno
compressToBudget()hits -> hitsyes

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:

PrimitiveMeaning
knowledge.lifecycleknowledgeBase().index(), .reindex(), and .remove().
retrieval.retrieveA standalone retriever call.
retrieval.recipeA recipe run.
retrieval.stepA recipe step.
citation.checkCitation 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.

OptionTypeBehavior
batch{ maxSize?, concurrency? }Chunking and parallelism inside one embedMany() call
preprocessEmbeddingPreprocessor | 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
versionstringVector-semantic revision folded into fingerprints and cache identity
retry{ maxAttempts; baseDelayMs?; maxDelayMs?; shouldRetry? }Provider batch retry policy
cacheEmbeddingCachePolicy-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.

On this page