@lunora/ai is a small helper over the Vercel AI SDK and
Cloudflare's workers-ai-provider. Call
generateText / streamText / generateObject / embed / tool from any
function. Workers AI is the zero-config default, but every call is
provider-agnostic: pass a Workers AI model id (a string) or any AI SDK model
object (@ai-sdk/openai, @ai-sdk/anthropic, OpenRouter, …).
pnpm add @lunora/aiWhen a function uses AI, the dev server / lunora prepare reconciles the ai
binding into wrangler.jsonc for you ({ "ai": { "binding": "AI" } }), and
codegen wires a typed ctx.ai onto your action contexts.
ctx.ai in an action
Inference is an external, non-deterministic call, so like ctx.fetch, ctx.ai
lives on actions, not queries or mutations.
import { action, v } from "@/lunora/_generated/server";
import { generateText } from "@lunora/ai";
export const summarize = action.input({ text: v.string() }).action(async ({ ctx, args: { text } }) => {
const { text: summary } = await generateText({
model: ctx.ai.model("@cf/meta/llama-3.3-70b-instruct-fp8-fast"),
prompt: `Summarize:\n\n${text}`,
});
return summary;
});ctx.ai.model(id) resolves a Workers AI model from the binding. Pass the
resolved model to the AI SDK functions re-exported from @lunora/ai
(generateText, streamText, generateObject, streamObject, embed,
embedMany, tool).
Any provider, same call
A string id resolves Workers AI; an AI SDK model object passes straight through.
import { streamText } from "@lunora/ai";
import { openai } from "@ai-sdk/openai"; // optional, bring-your-own
const result = streamText({ model: openai("gpt-5"), messages });Install the provider you want (@ai-sdk/openai, @ai-sdk/anthropic, …)
alongside @lunora/ai; route through a Cloudflare AI
Gateway by passing gateway to
createAi.
Structured output
import { generateObject } from "@lunora/ai";
import { z } from "zod";
const { object } = await generateObject({
model: ctx.ai.model("@cf/meta/llama-3.3-70b-instruct-fp8-fast"),
schema: z.object({ sentiment: z.enum(["positive", "neutral", "negative"]) }),
prompt: review,
});RAG — defineRag (@lunora/ai/rag)
defineRag composes ctx.ai (embeddings) with ctx.vectors (Vectorize) into a
declared index → retrieve pipeline: chunk → embed → upsert on the write side,
embed → query → assemble on the read side. It's a thin library over the two
facades every action already has, with no new binding and no codegen.
// lunora/rag.ts
import { defineRag } from "@lunora/ai/rag";
export const docs = defineRag({
embeddingModel: "@cf/baai/bge-base-en-v1.5", // declared once → index + retrieve embed identically
index: "docs", // a ctx.vectors index binding key
});// inside an action:
import { docs } from "@/lunora/rag";
await docs(ctx).index({ id: doc._id, metadata: { title: doc.title }, namespace: ctx.shardKey, text: doc.body });
const { chunks, context, sources } = await docs(ctx).retrieve(question, { namespace: ctx.shardKey, topK: 5 });
// `context` is prompt-ready; `chunks` are ranked; `sources` are deduped refs.What you get beyond the manual loop:
- Deterministic chunk ids (
${sourceId}#${n}): re-indexing a source replaces its chunks; shrinking documents have stale trailing chunks deleted automatically. - Content-hash short-circuit: re-indexing unchanged text skips chunking,
embedding, and every write (
{ unchanged: true }), so periodic re-syncs are free. - Tenant isolation: thread
namespace(your shard/tenant key) through both sides; a namespace-less call gets a one-time dev warning (Vectorize indexes are account-global). Multi-tenant apps should setrequireNamespace: trueto turn the warning into a hard error; single-tenant apps suppress it withallowSharedNamespace. - Ranking controls:
minScorethreshold, per-sourceimportanceweighting (0 to 1, multiplied into scores), andchunkContext: { before, after }to stitch neighbouring chunks around each match ("embed small, retrieve big"). asTool(): expose retrieval as an AI SDK tool so a model can decide to search the index itself:tools: { searchDocs: docs(ctx).asTool() }.- Traced: when the bound context carries
ctx.trace, each embedding call is agenerationspan (gen_ai.operation.name: "embeddings",gen_ai.request.model), so RAG shows up on the trace waterfall. A hand-built context withoutctx.traceembeds untraced.
Chunk text lives in vector metadata by default (returnMetadata: "all", topK
capped at 50, 10 KiB of metadata per vector). For long documents or deeper
retrieval, supply a textStore ({ put, getMany, remove? }: a DO table, KV,
…): text moves out of metadata and the topK ceiling lifts to 100. The default
chunker is a fixed 1000-char window with 200 overlap; pass chunk for the
sentence, Markdown, or token strategies below.
The 10 KiB is Vectorize's, and it covers the whole metadata object: chunk
text, Lunora's bookkeeping keys, and any metadata you attach. defineRag
measures each chunk's metadata as it is assembled and refuses one that would not
fit, rather than letting the upsert fail at Vectorize with nothing naming the
cause. It also rejects an unworkable chunkSize up front, though that earlier
check can only compare characters against a byte ceiling; multibyte text costs
up to three bytes each, so the index-time measurement is the one that holds.
The topK 50 is Vectorize V2's own ceiling for full-metadata queries. Legacy
V1 indexes cap at 20 and will reject a larger topK remotely; a binding
handle does not expose its index version, so the check cannot branch on it.
Chunking strategies
chunkSize/chunkOverlap drive the built-in fixed character window. For real
documents, pass chunk one of the structure-aware chunkers instead — a chunk
that starts mid-clause embeds to a worse vector than the same prose split where
the author ended a thought:
import { defineRag, markdownChunker, sentenceChunker, tokenChunker } from "@lunora/ai/rag";
export const docs = defineRag({
chunk: markdownChunker({ overlap: 200, size: 1000 }),
index: "docs",
});sentenceChunker({ size, overlap })— packs whole sentences intosize-bounded windows. The default choice for prose.markdownChunker({ size, overlap })— splits at ATX headings (code fences are tracked, so a#comment inside a fence never starts a section), then packs each section's sentences. Every chunk is prefixed with its heading trail (# Guide > ## Auth > ### OAuth), so a chunk from deep inside a long document still carries what it is about — which is what makes it retrievable by a query naming its section rather than its prose.tokenChunker({ countTokens, maxTokens, overlapTokens })— bounds chunks by a real token count, for when the embedding model's context window is the binding constraint (anything longer is silently truncated, so the tail is embedded as if it were never written).countTokensis required and injected:@lunora/aiwill not add a tokenizer dependency or pretend a characters-per-token constant is a token count. Passjs-tiktoken,gpt-tokenizer, or your provider's counter.
None of them can exceed size: an atom too large to fit falls back to a hard
character split rather than emitting a chunk the store would reject.
Embedding batching and caching
Indexing embeds a whole document in one embedMany call rather than one
call per chunk: the per-chunk embed callbacks ctx.vectors.upsert invokes
resolve from that batch. A 200-chunk document collapses from 200 round-trips to
a handful. It is best-effort — a provider that rejects the batch falls back to
per-chunk embeds rather than failing the index — and identical chunks dedupe to
a single embed.
Retrieval re-embeds the query on every call. Set cacheEmbeddings to retain
embeddings across calls on a bound context:
export const docs = defineRag({ cacheEmbeddings: 32, index: "docs" });Sized in entries but budget in bytes: one 1536-dimension embedding is ~12 KB, so 100 entries is over a megabyte held in the isolate. Keep it small. The cache is scoped to the bound context, never module level, so it cannot outlive the request that built it.
Embedding dimensions
Vectorize stores at most 1536 dimensions per vector, which rules out most
current large embedding models (text-embedding-3-large and Gemini embedding at
3072, Qwen3-Embedding at 4096). defineRag measures the first embedding each
bound context produces and refuses a wider one, naming the ceiling and both
escapes — rather than letting the upsert fail at Vectorize with nothing saying
why:
import { openai } from "@ai-sdk/openai";
export const docs = defineRag({
// Matryoshka truncation keeps a large model under the ceiling:
embeddingModel: openai.textEmbeddingModel("text-embedding-3-large", { dimensions: 1536 }),
index: "docs",
});Set maxEmbeddingDimensions: false to disable the check when the index is not
Vectorize-backed, or give it a number to enforce a different store's ceiling.
The check runs once per bound context, not once per chunk.
Bring your own vector store
defineRag used to hard-code Vectorize's limits — its topK ceilings, its
10 KiB metadata budget, its 1536-dimension cap — which made every RAG index a
Cloudflare index. A store now declares its own capabilities and defineRag
reads them, so a backend without those constraints is not held to them:
import { defineRag } from "@lunora/ai/rag";
import type { RagVectorStore } from "@lunora/ai/rag";
const pgStore = (ctx: { sql: SqlClient }): RagVectorStore => ({
capabilities: {
maxDimensions: false, // pgvector has no 1536 ceiling
maxMetadataBytes: false, // nor a 10 KiB metadata budget
maxTopK: 1000,
maxTopKWithMetadata: 1000,
},
deleteByIds: (ids, namespace) => ctx.sql`delete from chunks where id = any(${ids}) ...`,
getByIds: (ids, namespace) => ctx.sql`select ... `,
query: (input) => ctx.sql`select ... order by embedding <=> ${vector} limit ${input.topK}`,
upsert: (input) => ctx.sql`insert into chunks ... on conflict (id) do update ...`,
});
export const docs = defineRag({ index: "docs", store: (ctx) => pgStore(ctx) });store is called once per bound context with that context, so a store
needing per-request state — a Hyperdrive connection off ctx.sql, a shard's own
SQLite — builds itself from it.
Omit store and the bound ctx.vectors is wrapped by vectorizeStore, which
declares exactly the limits listed above. That is the default and its behaviour
is unchanged.
An explicit maxEmbeddingDimensions still wins over the store's own
maxDimensions, so you can tighten a permissive backend; leave it unset and the
store speaks for itself.
Shipped: a SQLite-backed store
sqliteVectorStore runs a RAG index on any SQL engine reachable through an
injected executor — a Durable Object's SQLite, D1, or node:sqlite. No
Vectorize, no extra Cloudflare product.
import type { RagSqlExec } from "@lunora/ai/rag";
import { defineRag, sqliteVectorStore } from "@lunora/ai/rag";
// Whatever your engine exposes. On D1:
const exec: RagSqlExec = async (sql, parameters) =>
(
await env.DB.prepare(sql)
.bind(...parameters)
.all()
).results;
// Inside a Durable Object it is `(sql, parameters) => state.storage.sql.exec(sql, ...parameters).toArray()`.
export const docs = defineRag({
index: "docs",
store: () => sqliteVectorStore({ exec }),
});ctx.vectors is not needed here — with a store configured it is never read,
and RagContext.vectors is optional, so an app with no Vectorize index type-
checks.
When the executor is a shard's own SQLite, the shard is the tenant boundary — so the account-global-namespace hazard that Vectorize forces you to guard against does not exist here, rather than being filtered away.
Search is brute force. Every vector in the namespace is read and scored in
JS; there is no ANN index, because SQLite has no vector type and sqlite-vec is
not loadable inside workerd. That is linear in namespace size, so this suits
many small per-tenant indexes — the shape most sharded apps already have —
and not one large shared corpus. A namespace past maxScan (default 50,000)
throws a named error rather than letting a Worker get killed on CPU with nothing
explaining why.
Bring your own embeddings — no env.AI binding
embeddingModel takes a Workers AI model id (a string, resolved through
ctx.ai, so it needs the env.AI binding) or a ready-made AI SDK
EmbeddingModel object. Pass an object and the helper embeds through it
directly, never touching ctx.ai, so a RAG index over OpenAI (or any provider)
needs no Workers AI binding at all. ctx.vectors (Vectorize) is still
required: it's the store.
import { openai } from "@ai-sdk/openai";
import { defineRag } from "@lunora/ai/rag";
// A model *object*, not an id → embeds without ctx.ai / env.AI.
export const docs = defineRag({
embeddingModel: openai.textEmbeddingModel("text-embedding-3-small"),
index: "docs",
});Because the object path skips ctx.ai, you can even bind a hand-built context
carrying only vectors (e.g. in a test or a non-action caller):
docs({ vectors: ctx.vectors }).retrieve(question). A model-id string with
no ctx.ai present throws a directed error telling you to pass a model object or
wire ctx.ai.
Embedding-model versioning
Embeddings from different models are not comparable: swap the model and every
old vector becomes noise a nearest-neighbour query still happily returns. Set an
opt-in embeddingModelVersion discriminator (^[A-Za-z0-9._-]{1,40}$) and it is
folded into the Vectorize namespace, so bumping the tag re-partitions the
index: new writes and reads share a fresh partition and the old vectors become
unreachable to new queries (an empty result beats a wrong one). Chunk #0 also
stamps the tag in metadata for auditability.
export const docs = defineRag({
embeddingModel: "@cf/baai/bge-large-en-v1.5",
embeddingModelVersion: "bge-large-v1.5", // bump when you change embeddingModel
index: "docs",
});Leaving embeddingModelVersion unset is byte-identical to before; existing
indexes are untouched. The discriminator must live in the namespace (not just the
id prefix): a Vectorize id prefix does not partition nearest-neighbour results.
Hybrid search (vector + lexical)
Dense retrieval misses exact keywords, rare tokens, and identifiers. Supply a
lexicalStore and retrieve runs a keyword leg alongside the vector leg and
fuses the two rankings with Reciprocal Rank Fusion. That combines semantic
recall with lexical precision and needs no reranker call.
import { bm25LexicalStore, defineRag } from "@lunora/ai/rag";
export const docs = defineRag({
embeddingModel: "@cf/baai/bge-base-en-v1.5",
index: "docs",
lexicalStore: bm25LexicalStore(), // Okapi BM25 keyword leg
lexicalTopK: 20, // fanout of the lexical leg (defaults to the query topK)
});Both legs fetch a deeper candidate pool than topK (topK × 4 by default,
tunable with candidates) and the fused list is trimmed to topK afterwards.
That widening is what makes the lexical leg useful at all: its job is to surface
a chunk the vector leg ranked below topK, which it cannot do if it was never
asked for more than topK.
Two lexical stores ship:
bm25LexicalStore()— in-memory. It lives in the worker isolate, so it is not durable and not shared across isolates: restart the isolate and the keyword leg silently returns nothing until every source is re-indexed. Use it for tests, local dev, and single-isolate workloads.sqlLexicalStore({ exec })— a durable inverted index over the same injected SQL executorsqliteVectorStoretakes, so hybrid search survives a deploy.
import { defineRag, sqlLexicalStore } from "@lunora/ai/rag";
export const docs = defineRag({
index: "docs",
lexicalStore: sqlLexicalStore({ exec }), // the same `exec` as above
});Attaching a lexical store to a corpus that is already indexed needs one pass
with index({ …, reindex: true }): an ordinary re-sync short-circuits on the
content hash before it reaches the new store, so the keyword leg would stay
empty.
Both score through the same BM25 kernel, so swapping one for the other does not move the ranking — asserted directly in the test suite. Indexing mirrors chunks into the lexical store automatically, and removals fan out to it too.
Reranking
Vector search embeds a passage without ever seeing the query, so it cannot tell
which of two topically-similar chunks actually answers this question. A
cross-encoder sees both at once. rerank runs over the candidate pool after
fusion and before the trim to topK, and its output order is final:
import { defineRag, scoreReranker } from "@lunora/ai/rag";
export const docs = defineRag({
candidates: 40, // pool the reranker gets to work with (default: topK × 4)
index: "docs",
rerank: scoreReranker({
minScore: 0.2, // also *reject* weak matches, not just reorder
score: async (query, text) => {
const result = await ctx.ai.run("@cf/baai/bge-reranker-base", { contexts: [{ text }], query });
return result.response[0].score;
},
}),
});The scorer is injected — @lunora/ai takes no provider dependency to make a
model call. scoreReranker calls it once per candidate with bounded
concurrency; batchReranker hands the whole pool to a batch endpoint in one
call (and refuses a score list that does not line up with the passages, rather
than zipping scores onto the wrong ones). Pass { rerank: false } on a single
retrieve() to skip the round-trip on a latency-sensitive path.
Query transformation
The raw user query is often the worst possible search string. A conversational follow-up ("what about the other one?") carries its meaning in the preceding turns, and a short question shares few terms with the long passage answering it.
transformQuery rewrites it — or expands it into several, each searched
independently and fused with RRF, recovering passages any single phrasing would
miss:
export const docs = defineRag({
index: "docs",
// Return a string to rewrite, or an array for multi-query retrieval.
transformQuery: async (query, { conversationId }) => {
const { text } = await generateText({
model: ctx.ai.model("@cf/meta/llama-3.3-70b-instruct-fp8-fast"),
prompt: `Rewrite this search query to be self-contained: ${query}`,
});
return text;
},
});It receives conversationId from the bound context, so a follow-up can be
rewritten against its thread. Also injected, for the same reason as rerank:
every useful strategy (HyDE, multi-query expansion, follow-up rewriting) needs a
language model, and this package does not pick one for you. Returning the query
unchanged — or nothing usable — falls back to the original rather than searching
for an empty string. Pass { transformQuery: false } to skip it per call.
RLS-filtered retrieval
rlsFilter derives a metadata filter from the retrieval identity so per-request
row-level security applies without every call site remembering to pass it. It
receives ctx.auth (an action's ctx satisfies RagContext.auth structurally)
and returns a Vectorize metadata filter, which is merged over any explicit
filter with the RLS keys winning, so a caller can never widen past the
tenant/RBAC scope.
export const docs = defineRag({
embeddingModel: "@cf/baai/bge-base-en-v1.5",
index: "docs",
rlsFilter: (auth) => ({ orgId: (auth as { orgId: string }).orgId }),
});
// the retrieval is transparently scoped to the caller's org:
const { chunks } = await docs(ctx).retrieve(question, { topK: 5 });The filter applies to both the vector and lexical legs, and only to retrieval; indexing stays a trusted server path.
Each chunk's source metadata is mirrored into the lexical store at index time
(StoredRagChunk.metadata), so bm25LexicalStore evaluates the same
predicate the vector leg is given — hybrid search and metadata-based RLS
compose. A lexical hit the filter excludes never reaches fusion, which matters
because the filter carries the tenant/RBAC scope: a lexical leg that ignored it
would leak excluded chunk text no matter what the vector leg returned.
matchesMetadataFilter is exported if you are writing your own store. It
covers implicit equality, $eq / $ne / $lt / $lte / $gt / $gte,
$in / $nin, and dot-notation paths into nested objects — and fails
closed on anything it does not recognise, including a range predicate over an
incomparable value and a chunk indexed with no metadata at all. Guessing at an
unknown operator risks admitting a row RLS meant to exclude, and the failure
mode there is a cross-tenant leak, not a missing result.
Bulk ingestion — defineRagSource
rag.index() takes one document's text, which leaves the whole crawl — list the
objects, fetch each, extract text, index it, notice the ones that were deleted —
as something every app writes for itself. This is the one axis on which
Cloudflare's managed AutoRAG pipeline is genuinely more convenient.
import { defineRagSource } from "@lunora/ai/rag";
const ingest = defineRagSource(docs(ctx), { namespace: ctx.shardKey });
const report = await ingest.sync(
{
list: async function* () {
for await (const object of bucket.list()) {
yield { key: object.key, contentType: object.httpMetadata?.contentType, metadata: { url: object.key } };
}
},
get: async (object) => (await bucket.get(object.key))?.text(),
},
// Optional. What you already indexed — anything here that this pass does
// not list is deleted from the index.
{ knownKeys: await loadIndexedKeys() },
);
// → { indexed: [...], unchanged: [...], skipped: [...], pruned: [...] }The object source is injected, so this runs over an R2 bucket, S3, a
filesystem, or a database table without @lunora/ai depending on any of them.
list may be an async generator, so the caller decides how to page a large
bucket. One pass collects the keys it yields — it needs the full current set to
work out what disappeared — but never more than one object's body at a time.
Re-syncing is free. rag.index short-circuits on a content hash, so an
unchanged object costs one get and no embedding — which makes running this on
a cron the normal way to use it.
Pruning keeps the index a mirror, and it is the caller's set. Pass
knownKeys — the keys you believe the index holds — and anything missing from
this pass's list() is removed, because a document deleted upstream but left
indexed keeps being retrieved and cited. Omit it and nothing is pruned.
It is explicit because nothing else can hold that set honestly: the shape above
builds the source per request, from a per-request docs(ctx), so a set
remembered inside the instance would be empty every time — a prune that
defaulted on would in practice never run and never say so. Persist the keys (a
table, a KV entry, the bucket listing itself) and hand them in.
Extractors are injected too, keyed by content type with "*" as a fallback.
Parsing PDF is a large dependency, and pulling one in for everybody to serve the
users who need it is the wrong trade. Plain-text types (text/plain,
text/markdown, text/csv, application/json) need no extractor; anything
else without one is skipped, never indexed as raw bytes — a PDF's binary or
an HTML file's markup embeds to something that matches nothing.
Cost telemetry without an AI Gateway
Per-request dollar cost used to reach a span only when a Cloudflare AI Gateway
put it in providerMetadata. Call the same model through @ai-sdk/openai
directly, or run on a non-Cloudflare host, and spend visibility disappeared.
estimateModelCost derives it from token usage and a price table instead, and
defineRag's embed span falls back to it automatically:
import { estimateModelCost } from "@lunora/ai";
estimateModelCost("text-embedding-3-small", { inputTokens: 1_000_000 }); // → 0.02An estimate is never presented as a measurement. A provider-reported cost always wins, and the span records which it got:
| Attribute | Value |
|---|---|
gen_ai.usage.cost | the cost in USD |
lunora.usage.cost.source | "provider" or "estimated" |
The shipped table is indicative, not authoritative — a hand-maintained
snapshot, and providers change prices without warning. It is deliberately small,
because a table trying to cover every model is a table that is wrong about most
of them; anything it does not cover returns undefined rather than a guess, and
an unpriced model yields no attribute rather than a 0 that would quietly sum
into a total. Pass your own prices for anything you are invoicing against.
Outside an action
ctx.ai is only wired onto action contexts. In the worker entry, a Durable
Object, or a queue / scheduled handler, build the helper directly from the
binding:
import { createAi } from "@lunora/ai";
const ai = createAi({ binding: env.AI });The raw binding escape hatch, ctx.ai.run(model, inputs) (or ai.run(...)),
covers Workers-AI-only model families (image, ASR, translation) that aren't
surfaced through the AI SDK provider.