stackbone.rag

Parse, chunk, embed, store and retrieve documents on top of the agent's own Postgres. The RAG schema lives in the same database as stackbone.database and shares the same connection pool, so touching both surfaces still opens one pool. You reach it through the ambient stackbone client, the same handle on every surface, from an agent tool or a durable workflow step.

import { stackbone } from '@stackbone/sdk';

const result = await stackbone.rag.retrieve({
  text: 'how do I reset my password?',
  model: 'openai/text-embedding-3-small', // embeds the query
  topK: 5,
  namespace: 'help-center', // the collection to search
});

stackbone.rag runs the same across local development, self-host and cloud, because it sits on the agent's own Postgres: its only dependency is the STACKBONE_POSTGRES_URL the runtime injects. There is no separate hosted RAG service to provision.

Mental model

stackbone.rag is a thin façade over four canonical tables: rag_collections, rag_documents, rag_chunks and rag_jobs, in the managed stackbone_platform schema that Stackbone provisions in your agent's Postgres for you. Three properties follow from this:

  • The tables live in the agent's own Postgres (STACKBONE_POSTGRES_URL), in the stackbone_platform schema, next to your own tables. There is no separate "RAG store".
  • stackbone.rag reuses the pool that stackbone.database builds, so an agent that touches both surfaces opens exactly one connection pool.
  • The schema is already there. Stackbone provisions it on every install, so you never run a migration to create it, and there is no ad-hoc DDL on first call. You only read and write through stackbone.rag.

The agent process computes embeddings through the same OpenRouter-compatible endpoint that stackbone.ai uses. The default model is openai/text-embedding-3-small (1536 dims), frozen for schema stability: the dimensionality is part of the canonical schema, so changing it would mean re-ingesting everything.

Setup

The platform provisions the RAG schema for you: there is nothing to install. The four tables are present in your agent's database from the first boot, so you can call stackbone.rag straight away. (An operator can create collections and ingest documents from the Studio UI before your code reads them back. See Managed ingestion from Studio.)

Configure embeddings (optional)

model on the call decides which model embeds your text. It is a required field on the two shapes that embed for you: ingest with string chunks, and retrieve with text. Every example on this page passes it.

Any model id the OpenRouter-compatible embeddings endpoint resolves works, as long as it returns 1536 dimensions. The schema fixes the dimension, so changing the model on an existing collection means re-ingesting that collection.

agent.yaml accepts a rag.embeddingModel key, but nothing forwards it to the running agent, so writing it there does not change what stackbone.rag embeds with. See agent.yaml.

A query only matches chunks embedded by the same model. Keep the id you ingest with and the id you retrieve with identical.

Where RAG calls live

stackbone.rag is the ambient handle: you import it once and call it from wherever your code runs. Two places matter for an agent:

  • From an agent tool: retrieve inside a tool call and return the hits, so the model can use them to answer. This is how a deep agent grounds its replies in your documents. See LangChain's tool docs.
  • From a durable workflow step: ingest is I/O that can be slow and can fail, so put it inside a 'use step'. The Workflow SDK runs each step once, persists its result and auto-retries on failure, which is what a parse → chunk → embed → store pipeline needs.

The pure helpers parse and chunk touch nothing external, so you can also call them from a one-off script or a test.

Ingestion

Pure helpers: parse and chunk

stackbone.rag.parse(...) and stackbone.rag.chunk(...) are pure helpers. They never touch the database and never call the embedding provider, so you can use them anywhere (in a tool, a step, a script or a test) to iterate on chunking before committing to ingestion:

import { stackbone } from '@stackbone/sdk';

// `parse` accepts the document bytes (or a plain string / Blob), not a URL:
// fetch the source yourself first.
const bytes = await fetch('https://example.com/post').then((r) => r.arrayBuffer());
const text = await stackbone.rag.parse(bytes);

const chunks = stackbone.rag.chunk(text, { size: 512, overlap: 64 });

parse accepts a string, a Blob, a Uint8Array or an ArrayBuffer. Markdown and plain text come back verbatim; parse flattens a PDF to one string. The reader is text-only (no OCR): a scanned, image-only PDF has no text layer, so parse rejects it with a clear error rather than ingesting an empty document.

Synchronous ingest: stackbone.rag.ingest

For one-off ingestion (a setup script, a small upload handler) call ingest directly. The promise resolves once the SDK has embedded and persisted every chunk:

import { stackbone } from '@stackbone/sdk';

const bytes = await fetch('https://example.com/post').then((r) => r.arrayBuffer());
const text = await stackbone.rag.parse(bytes);

const result = await stackbone.rag.ingest({
  id: 'post-123',
  collection: 'help-center',
  chunks: stackbone.rag.chunk(text),
  model: 'openai/text-embedding-3-small',
  metadata: { source: 'help-center', url: 'https://example.com/post' },
});

if (result.error) {
  // SdkError: see "Errors" below.
  throw new Error(`${result.error.code}: ${result.error.message}`);
}

console.log(`Ingested ${result.data.chunks} chunks for ${result.data.id}.`);

String chunks are embedded for you by the model you name. Pass chunks as { content, embedding } objects instead when you embedded them yourself: that shape takes no model. Re-ingesting with the same id replaces all chunks for that document atomically, useful for periodic re-crawls.

You can pass onProgress to observe per-chunk progress without paying for the SQL job writer:

await stackbone.rag.ingest({
  id: 'post-123',
  collection: 'help-center',
  chunks,
  model: 'openai/text-embedding-3-small',
  onProgress: (event) => console.log(event.type, event),
});

Ingest inside a durable workflow step

A real ingest is slow and can fail mid-flight. Put it in a workflow 'use step' so it runs once, persists its result and auto-retries on transient failures:

workflows/index-docs.workflow.ts
import { stackbone, z } from '@stackbone/sdk';

export const inputSchema = z.object({ url: z.string().url(), id: z.string() });
export const outputSchema = z.object({ chunks: z.number() });

export async function indexDocsWorkflow(input: z.infer<typeof inputSchema>) {
  'use workflow';
  return ingestStep(input.url, input.id);
}

async function ingestStep(url: string, id: string) {
  'use step'; // runs once, persisted, retried on failure. Keep it idempotent
  const bytes = await fetch(url).then((r) => r.arrayBuffer());
  const text = await stackbone.rag.parse(bytes);

  const result = await stackbone.rag.ingest({
    id,
    collection: 'help-center',
    chunks: stackbone.rag.chunk(text),
    model: 'openai/text-embedding-3-small',
    metadata: { source: 'help-center', url },
  });
  if (result.error) throw new Error(result.error.code);
  return { chunks: result.data.chunks };
}

Durable ingest in one call: ingestDocuments

The step above wires the parse → chunk → embed → store pipeline by hand. To ingest a document durably from a workflow without customising the pipeline, call ingestDocuments from @stackbone/sdk/workflow. It runs the built-in ingest workflow as a durable sub-workflow and resolves once the document is stored, returning { documentId, chunks }:

workflows/index-docs.workflow.ts
import { ingestDocuments } from '@stackbone/sdk/workflow';

export async function indexDocsWorkflow(input: { markdown: string }) {
  'use workflow';

  // Hand it the text: the helper stages it to storage and ingests it durably.
  const { documentId, chunks } = await ingestDocuments({
    collection: 'docs',
    content: input.markdown,
    contentType: 'text/markdown',
  });

  return { documentId, chunks };
}

It takes one of two input shapes:

  • { content }: inline text or bytes. The helper stages it to the agent's storage for you (in a durable, replay-safe step keyed by the content hash) and then ingests it.
  • { storageKey }: a file already staged under the reserved rag/ prefix (for example one an operator uploaded from Studio). The helper ingests it directly, so a large file never travels in the workflow input.

Both must be called from a workflow body. If your workspace ships its own version of the ingest workflow, ingestDocuments uses it automatically. For a fully custom pipeline, call stackbone.rag.ingest inside your own step, as above.

Asynchronous ingest: stackbone.rag.ingestAsync

For a long-running ingest you want to observe outside a workflow (a webhook handler that must return in seconds, a backfill that takes minutes) call ingestAsync. It allocates a rag_jobs row synchronously, returns the job id immediately, and exposes both a streaming channel of progress events and the final Result:

import { stackbone } from '@stackbone/sdk';

const handle = await stackbone.rag.ingestAsync({
  id: 'post-123',
  collection: 'help-center',
  chunks,
  model: 'openai/text-embedding-3-small',
});

if (handle.error) throw new Error(handle.error.code);

// You now have a stable job id. Hand it back to the caller and return early.
const { jobId } = handle.data;

// Drain progress events into your own logs / stream when you want to follow it.
for await (const event of handle.data.events) {
  switch (event.type) {
    case 'started':
      console.log('rag.ingest.started', event);
      break;
    case 'progress':
      console.log('rag.ingest.progress', event);
      break;
    case 'completed':
      console.log('rag.ingest.completed', event);
      break;
    case 'failed':
      console.error('rag.ingest.failed', event);
      break;
  }
}

rag_jobs is the observability surface. Studio's RAG explorer reads it; during local development GET /api/rag/jobs returns it; cancelling a job from Studio (or via POST /api/rag/jobs/:jobId/cancel) flips the row's status, which the worker observes between chunk batches and exits with rag_ingest_cancelled.

Retrieval

stackbone.rag.retrieve is the only read API. Pass a text query to let the SDK embed it for you, and namespace to name the collection to search. A query is scoped to one collection. Without namespace it searches the collection called default, so a document ingested into help-center is only found when the query names it. The most common place to call it is inside an agent tool, returning the hits so the model can ground its answer:

deep-agents/support/index.ts
import { tool } from '@langchain/core/tools';
import { stackbone, z } from '@stackbone/sdk';

const searchDocs = tool(
  async ({ query }: { query: string }) => {
    const result = await stackbone.rag.retrieve({
      text: query,
      model: 'openai/text-embedding-3-small',
      topK: 5,
      namespace: 'help-center',
    });
    if (result.error) throw new Error(result.error.code);

    // Return the hits to the model so it can answer from them.
    return result.data.map((hit) => ({
      score: hit.score,
      content: hit.content,
    }));
  },
  {
    name: 'search_docs',
    description: 'Search the help-center knowledge base before answering a question.',
    schema: z.object({ query: z.string() }),
  },
);

topK defaults to 5. Each hit is { id, chunkIdx, content?, metadata?, score }, ordered by descending cosine similarity (score is in [0, 1], 1 = identical).

Filtering on metadata

Pass filter to scope the search to documents whose metadata matches a JSON sub-object (matched server-side via jsonb @> $1):

const result = await stackbone.rag.retrieve({
  text: 'pricing tiers',
  model: 'openai/text-embedding-3-small',
  namespace: 'help-center',
  filter: { source: 'help-center', locale: 'en' },
  topK: 10,
});

V1 supports exact-match equality on simple paths. Range queries and jsonpath operators are out of scope for now.

Precomputed embeddings

If you already have an embedding (e.g. from your own embedder or a cached vector), pass it directly:

await stackbone.rag.retrieve({
  embedding: cachedQueryVector, // number[]
  topK: 5,
});

The pipeline skips the auto-embed step.

Deletion

Two atomic deletion APIs:

// Delete one or many documents by id (re-ingest replaces; this removes).
await stackbone.rag.delete(['post-123', 'post-124']);

// Delete every chunk whose metadata matches a sub-object.
await stackbone.rag.deleteWhere({ source: 'help-center' });

Both run inside a single SQL statement and cascade through to chunks via the foreign key. Pass { namespace } to scope either call.

RAG calls and your own transactions

RAG and your own tables share one connection pool, but a stackbone.rag call does not join a stackbone.database.transaction(...) callback. It checks out its own connection from the pool and commits on its own, even when you call it inside the callback. Do not rely on a rollback of your transaction to undo an ingest.

To keep your own rows and the RAG store consistent, run the ingest first. Write your own row only after it succeeds:

workflows/reindex.workflow.ts
import { stackbone } from '@stackbone/sdk';
import { eq } from '@stackbone/sdk/db';
import { documents } from '../src/schema';

async function reindexStep(id: string, parsedText: string) {
  'use step';
  const result = await stackbone.rag.ingest({
    id,
    collection: 'help-center',
    chunks: stackbone.rag.chunk(parsedText),
    model: 'openai/text-embedding-3-small',
  });
  if (result.error) throw new Error(result.error.code);

  // Only mark your own row once the ingest is stored.
  await stackbone.database
    .update(documents)
    .set({ ragIndexedAt: new Date() })
    .where(eq(documents.id, id));
}

A failed step retries, and re-ingesting the same id replaces its chunks atomically, so the retry converges instead of duplicating data.

Inspecting RAG data

Two places see the same rows:

  • During local development: stackbone dev exposes GET /api/rag/collections, POST /api/rag/collections/:name/query, GET /api/rag/jobs?status=... and POST /api/rag/jobs/:jobId/cancel. Studio's RAG explorer is the primary consumer.
  • Studio's RAG explorer: gated on the rag.basic capability the agent advertises in its contract handshake. If the agent does not advertise it, Studio hides the explorer instead of showing a half-broken view. The explorer is read-write: a human can create a collection, upload a file and ingest it from the screen. See Managed ingestion from Studio below.

Managed ingestion from Studio

stackbone.rag.ingest is the code path: your agent calls it at runtime. There is also a human path: anyone who installed your agent (and, locally, you as the creator running stackbone dev) can feed a collection straight from the Studio screen, with no ingest code to write. Both paths land in the same rag_* tables, so the agent retrieves manually-uploaded chunks with stackbone.rag.retrieve() exactly as if your code had ingested them: there is no second-class "uploaded by hand" flag.

What a person can do from the screen:

  • Create an empty collection with a name and an optional description, before uploading anything.
  • Upload a file (.txt, .md or .pdf) into a collection. The upload returns immediately with a job id, and the parse, chunk, embed and store work runs in the background, so the browser never freezes on a large PDF.
  • Watch the job move through queued → running → succeeded | failed in the Jobs tab, with per-chunk progress. Retry a failed job without re-uploading, or cancel a running one (it stops with rag_ingest_cancelled, the same status your code sees).
  • Delete a single document (its chunks cascade) or delete a whole collection (documents and chunks cascade).
  • Download the original file. The platform keeps the uploaded source, linked to its document row.

Two contracts matter for your agent:

  • Manual uploads use the same embedding model as your agent. The built-in ingest path embeds them with openai/text-embedding-3-small, which is what makes a hand-uploaded document findable by stackbone.rag.retrieve(). If the two used different models the agent would never match it, so a workspace that retrieves with another model has to ingest with that model too: ship your own workflows/rag-ingest.workflow.ts and the platform uses it for uploads instead of the built-in one.
  • The schema stays with the platform. Stackbone provisions the rag_* tables on every install, so the screen works from the first boot. Studio never creates or migrates the schema itself; it only reads and writes through it.

The platform keeps files uploaded this way in object storage under a reserved, read-only rag/ prefix. See stackbone.storage.

Custom pgvector columns

If you need a vector column on your own table (e.g. an embedding field on documents you maintain by hand), vector is re-exported from @stackbone/sdk/db:

import { pgTable, text, vector } from '@stackbone/sdk/db';

export const documents = pgTable('documents', {
  id: text('id').primaryKey(),
  content: text('content').notNull(),
  embedding: vector('embedding', { dimensions: 1536 }).notNull(),
});

The first migration that introduces a vector(...) column emits a CREATE EXTENSION IF NOT EXISTS vector automatically; the RAG installer also ensures the extension exists, so the order in which you add custom and platform vector columns does not matter.

Errors

Every stackbone.rag.* call returns Result<T>: check .error and branch on error.code:

Code When Hint
rag_schema_missing The platform RAG schema is not present in this database. This should not happen on a managed install: the schema is provisioned for you. If you hit it, the install's setup did not complete; redeploy the install or contact support.
rag_embedding_model_unsupported OpenRouter resolved the model but it does not expose the /v1/embeddings endpoint. Pick an embedding-capable model (e.g. openai/text-embedding-3-small).
rag_embedding_failed The embedding provider rejected the request (auth, rate limit, transient). The message carries the provider's error; retry policy is up to the caller.
rag_invalid_request Required field missing or malformed (e.g. empty id, no chunks, topK ≤ 0). The message names the offending field.
rag_ingest_cancelled The job's row was flipped to cancelled while the worker was running. Expected when a caller cancels via Studio or the REST surface.

The contract gate adds contract_version_unsupported, capability_unavailable, contract_unreachable and contract_malformed. Every gated stackbone.rag method requires the rag.basic capability: see overview.

Pure helpers (parse, chunk) never hit the database or the embedding provider, so the gate exempts them.

Internal subpaths

@stackbone/sdk exposes one low-level subpath behind the RAG pipeline. It exists for the SDK's own use: creators usually do not import it directly. This page lists it so you can find the surface when you need something low-level.

Subpath Purpose
@stackbone/sdk/rag/schema Drizzle table definitions for rag_collections, rag_documents, rag_chunks and rag_jobs (in the stackbone_platform schema), plus job-status types. Keep your own joins against these tables read-only.

This subpath is internal to the platform contract: it may change shape between SDK majors.

Where to go next

  • stackbone.database: the same pool RAG runs on, for your own tables and migrations.
  • Agents & sessions: where a retrieval tool lives, and how an agent grounds answers in your documents.
  • Workflows: where a long ingest belongs, as a step that persists and auto-retries.
  • @stackbone/sdk overview: the rest of the surfaces: storage, AI, approval, secrets, config.
BUILT WITH ❤️ FROM CANADA AND SPAIN