Crux
GuidesRetrieval & RAG

Embeddings

Dense, sparse, and hybrid retrieval in Crux without mixing vector generation and search orchestration.

You want to search your docs by meaning, so "docs about login problems" finds the SSO troubleshooting page even though it never says "login problems". That needs vectors, and embedding() is the Crux primitive that turns text or native media into them:

import { embedding } from "@use-crux/core/embedding";

declare const provider: {
  embedMany(texts: string[]): Promise<number[][]>;
};

const dense = embedding({
  kind: "dense",
  name: "text-embedding-3-small",
  dimensions: 1536,
  maxInputTokens: 8191,
  batch: { maxSize: 100, concurrency: 3 },
  embed: async (inputs) => ({
    embeddings: await provider.embedMany(
      inputs.map((input) => {
        if (input.type !== "text") throw new Error("text-only provider invariant");
        return input.text;
      }),
    ),
  }),
});

Define it once and reuse it everywhere Crux needs compatible vectors:

indexer({ dense, sparse }); // write-time vectors
retriever({ dense, sparse }); // query-time vectors
facts({ embed: dense }); // dense memory recall

The practical rule for picking a kind:

// Natural-language similarity: "docs about login problems"
const dense = embedding({ kind: 'dense', ... })

// Exact terms, identifiers, product names: "ERR_AUTH_401"
const sparse = embedding({ kind: 'sparse', ... })

// Production docs search: use both, then retrieve in hybrid mode
const docs = retriever({
  records,
  vectors,
  dense,
  sparse,
  search: { mode: 'hybrid' },
})

One note on the architecture: embedding() only generates vectors. Stores execute vector search, and higher-level features such as retrievers and memory decide whether a query should be dense, sparse, or hybrid. That is why there is no kind: 'hybrid'; hybrid is a query strategy, not a vector shape. The retrieval reference covers the design rationale.

Choose the right pattern

GoalUse
episodes(), facts(), procedures(), or dense similarity searchOne dense embedding
Keyword-weighted or BM25-style retrievalOne sparse embedding plus vectors.search({ mode: 'sparse', sparse })
Upstash hybrid retrievalOne dense embedding, one sparse embedding, plus vectors.search({ mode: 'hybrid', dense, sparse })
Cross-modal text/media searchOne native multimodal dense embedding and an AssetStore
Store-specific or fully custom retrievalYour own store or retriever logic

Dense: semantic similarity

Dense embeddings, like the one defined above, expose:

  • embed(input, { role? }) for one text or declared media input
  • embedMany(inputs, { role? }) for indexing or batch ingestion
  • asEmbedFn() for dense-only compatibility with legacy callback APIs

Use dense embeddings when meaning matters more than exact wording. They are the normal fit for semantic memory, semantic cache lookup, and document search over natural-language questions.

Multimodal: one declared space

Dense embeddings declare the modalities they natively encode. Provider helpers infer known model facts when possible:

import { GoogleGenAI } from "@google/genai";
import { embedding } from "@use-crux/google";

const client = new GoogleGenAI({ apiKey: process.env.GOOGLE_API_KEY });
const multimodal = embedding(client, { model: "gemini-embedding-2" });

await multimodal.embed("a dog", { role: "query" });
await multimodal.embed({ type: "image", source: dogPhoto });
await multimodal.embed(dogPhoto); // bare Asset; modality inferred from mediaType

Strings are always text. Typed parts accept image, audio, video, or document; a bare asset requires a MIME type that identifies its modality. AssetRef is never model input. Text-only models reject media with EmbeddingModalityError before provider I/O, and literal configurations also reject undeclared modalities in TypeScript.

Each dense instance exposes space, including dimensions, modalities, normalization, task mappings, and its vector-semantic fingerprint. Query and document roles may select provider task hints, but remain in one space. Sparse embeddings stay text-only and expose no dense space.

See Multimodal search for indexing, asset hydration, migration, and evaluation guidance.

Sparse: exact terms and keywords

import { embedding } from "@use-crux/core/embedding";
import type { SparseVector } from "@use-crux/core/storage";

declare function toSparseVector(text: string): SparseVector;

const sparse = embedding({
  kind: "sparse",
  name: "bm25",
  maxInputTokens: 8191,
  batch: { maxSize: 100, concurrency: 3 },
  embed: async (texts) => ({
    embeddings: texts.map(toSparseVector),
  }),
});

Sparse embeddings expose embed() and embedMany(), but not asEmbedFn(), because dense-only callback APIs expect number[].

Use sparse embeddings when exact tokens matter: error codes, symbols, SKUs, function names, product names, legal phrases, or short keyword-style queries.

Hybrid: use dense plus sparse

You do not define a hybrid embedding. You define one dense embedding and one sparse embedding, then compose them in a retriever or store query.

const docs = retriever({
  id: "docs",
  namespace: "product-docs",
  records,
  vectors,
  dense,
  sparse,
  search: {
    mode: "hybrid",
    fusion: "dbsf",
    limit: 8,
  },
});

const hits = await docs.retrieve("SAML setup error SSO_403");

Crux embeds the query with both embeddings and asks the store to search with both vectors. Stores that cannot support sparse or hybrid search throw clearly instead of silently falling back to dense-only behavior.

Governance: make embedding behavior repeatable

The same primitive owns the policies that affect how vectors are generated. Put preprocessing, truncation, caching, retries, and provider rate limits on embedding() once, then reuse that embedding in memory, indexing, retrieval, and semantic cache code.

import {
  embedding,
  embeddingCache,
  normalizeText,
} from "@use-crux/core/embedding";
import { inMemoryRecordStore } from "@use-crux/core/storage";

const store = inMemoryRecordStore();

const dense = embedding({
  kind: "dense",
  name: "docs-embedding",
  dimensions: 1536,
  maxInputTokens: 8191,
  batch: { maxSize: 100, concurrency: 3 },

  preprocess: normalizeText({
    trim: true,
    collapseWhitespace: true,
  }),

  truncate: { strategy: "fail" },
  retry: { maxAttempts: 3, baseDelayMs: 250 },
  cache: embeddingCache({ store, namespace: "embed-cache" }),
  rateLimit: { concurrency: 3 },

  embed: async (inputs) => ({
    embeddings: await provider.embedMany(inputs.map((input) => {
      if (input.type !== "text") throw new Error("text-only provider invariant");
      return input.text;
    })),
  }),
});

The behavior worth knowing day to day:

  • preprocess runs before cache lookup and before the provider call, so " refund policy " and "refund policy" can share one cache entry when your policy says they are equivalent.
  • truncate: { strategy: 'fail' } is the default because silent truncation can make retrieval worse while looking successful. Opt in explicitly with { strategy: 'chars', maxChars: 8_000 } if you want truncation.
  • cache stores one vector per normalized text, and cache keys change when the embedding policy changes, so stale vectors are never reused accidentally.
  • batch.concurrency limits chunks inside one embedMany() call; rateLimit.concurrency caps provider calls across overlapping calls on the same embedding instance.

For custom preprocessor fingerprints, the full cache-key composition, and the complete option table, see Embedding governance in the reference.

Choose the right cache layer

Crux has two complementary vector caches:

NeedConfigureReuse unit
Reuse vectors anywhere the embedding primitive is calledcache: embeddingCache(...) on embedding()One normalized text or safely identified media input
Reuse final embedding work across generations, retries, or dry runscache: true on indexer()One ordered embedding bundle per source and dense/sparse kind
Maximize reuse for both indexing and direct embedding callsConfigure bothPipeline bundles first; per-text cache for remaining provider work

The indexer cache validates complete vector count and shape before reuse. It records one privacy-safe embedding stage per source and kind. A full bundle hit does not call embedMany() at all, though generation persistence and index-store writes still run; refresh recomputes and replaces the bundle, while bypass reads and writes no bundle.

Version vector-producing behavior

An embedding fingerprint includes kind, name, dimensions, input limits, preprocessors, truncation, modalities, normalization, role-task mappings, and the optional version. It deliberately excludes batch size, concurrency, retry, rate limiting, and cache placement because those policies do not change vector values.

const dense = embedding({
  kind: "dense",
  name: "docs-embedding",
  dimensions: 1536,
  maxInputTokens: 8191,
  version: "provider-revision-2026-07",
  embed: providerEmbedMany,
});

Bump version when a provider-side setting can change vectors without changing the public model id. OpenAI, Google, and AI SDK helpers automatically include their typed model/vector request identity and append your version as a separate segment. They do not serialize headers or providerOptions; bump version when such an option changes vector semantics. A hand-written structural embedding without a fingerprint remains valid, but the indexer deliberately computes it on every run rather than guessing an identity.

Where embeddings plug in

Memory

Memory blocks that perform semantic recall are dense-oriented today. episodes(), facts(), and procedures() accept either:

  • a dense embedding object created with embedding()
  • a legacy (text: string) => Promise<number[]> callback

The recommended path is the embedding object:

import { episodes, facts } from "@use-crux/core/memory";

const history = episodes({
  id: "conversation-log",
  embed: dense,
});

const profileFacts = facts({
  id: "facts",
  embed: dense,
});

Memory does not consume sparse or hybrid embeddings directly. If you need sparse-only or hybrid recall over documents, use retriever() with a VectorStore.

Indexing and retrieval

Indexers use embeddings when writing chunks. Retrievers use the same embeddings when querying those chunks later.

const docsIndexer = indexer({
  id: "docs",
  namespace: "product-docs",
  records,
  vectors,
  dense,
  sparse,
});

const docs = retriever({
  id: "docs",
  namespace: "product-docs",
  records,
  vectors,
  dense,
  sparse,
  search: { mode: "hybrid" },
});

That symmetry is important: the query vector should be produced by the same embedding definition as the indexed vectors.

Vector stores

Vector stores receive the vector query shape directly:

const denseResults = await vectors.search({
  mode: "dense",
  dense: await dense.embed("roadmap"),
});

Sparse-only and hybrid-capable stores use the same method:

const sparseResults = await vectors.search({
  mode: "sparse",
  sparse: await sparse.embed("roadmap"),
});

const hybridResults = await vectors.search({
  mode: "hybrid",
  dense: await dense.embed("roadmap"),
  sparse: await sparse.embed("roadmap"),
  fusion: "dbsf",
});

A store that cannot support the requested mode fails explicitly instead of degrading to dense-only search.

Upstash hybrid example

Upstash is the reference hybrid-capable adapter in Crux. Users define one dense embedding and one sparse embedding once, then let the store combine them.

import { Index } from "@upstash/vector";
import { upstashVectorStore } from "@use-crux/upstash";

const vectors = upstashVectorStore({
  index: new Index({ url: "...", token: "..." }),
  namespace: "docs",
});

const results = await vectors.search({
  mode: "hybrid",
  dense: await dense.embed("latest roadmap"),
  sparse: await sparse.embed("latest roadmap"),
  fusion: "dbsf",
});

This keeps the user story clean:

  • define dense once
  • define sparse once
  • search in dense, sparse, or hybrid mode without hand-assembling provider payloads everywhere

Provider helpers

If you are using first-party adapter packages, you do not always have to wrap the provider SDK manually:

import { embedding as aiEmbedding } from "@use-crux/ai";
import { embedding as openAIEmbedding } from "@use-crux/openai";
import { embedding as googleEmbedding } from "@use-crux/google";

Provider packages export the same noun, embedding(), from their own package. Alias at import sites when a file uses multiple providers.

@use-crux/anthropic stays generation-only on the direct SDK path, so pair Anthropic generation with one of the embedding helpers above when you need indexing or retrieval.

Observability

Embedding operations emit embed:start and embed:end events and show up across the observability stack:

  • devtools timeline and dashboard
  • CLI dashboard and TUI detail views
  • @use-crux/otel as crux.embedding spans

If your embedding provider returns usage or cost metadata, Crux aggregates that across batches automatically.

Governance metrics are included on embed:end:

{
  type: 'embed:end',
  name: 'docs-embedding',
  cacheHitCount: 20,
  cacheMissCount: 5,
  retryCount: 1,
  truncatedCount: 0,
}

OTel receives the same privacy-safe counts as span attributes. It does not receive raw input text, cached content, or vectors.

Next steps

On this page