--- title: 'stackbone.rag' description: "Parse, chunk, embed, store and retrieve documents on top of the agent's own Postgres: one database, one pool, one schema the platform provisions." position: 5 --- # 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`](/docs/sdk/data/database) and shares the same > connection pool, so touching both surfaces still opens one pool. You > reach it through the ambient [`stackbone`](/docs/sdk/agents/overview) > client, the same handle on every surface, from an > [agent tool](/docs/sdk/agents/overview) or a > [durable workflow step](/docs/sdk/workflows/overview). ```ts 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`](/docs/sdk/data/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`](/docs/sdk/platform/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](#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](https://ai-sdk.dev/providers/openai-compatible-providers) 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`](/docs/cli/reference/agent-yaml#rag-optional). 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](/docs/sdk/agents/overview): 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](https://js.langchain.com/docs/concepts/tools). - From a [durable workflow step](/docs/sdk/workflows/overview): ingest is I/O that can be slow and can fail, so put it inside a `'use step'`. The [Workflow SDK](https://workflow-sdk.dev/docs) 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: ```ts 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: ```ts 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: ```ts 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'`](https://workflow-sdk.dev/docs/how-it-works/understanding-directives) so it runs once, persists its result and auto-retries on transient failures: ```ts // 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) { '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 }`: ```ts // 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`: ```ts 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: ```ts // 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`): ```ts 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: ```ts await stackbone.rag.retrieve({ embedding: cachedQueryVector, // number[] topK: 5, }); ``` The pipeline skips the auto-embed step. ## Deletion Two atomic deletion APIs: ```ts // 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: ```ts // 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](/docs/sdk/reference/overview#the-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](#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`](/docs/sdk/data/storage#the-reserved-rag-prefix). ## 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`](/docs/sdk/data/database): ```ts 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`: 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](/docs/sdk/reference/overview#the-contract-handshake). 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`](/docs/sdk/data/database)**: the same pool RAG runs on, for your own tables and migrations. - **[Agents & sessions](/docs/sdk/agents/overview)**: where a retrieval tool lives, and how an agent grounds answers in your documents. - **[Workflows](/docs/sdk/workflows/overview)**: where a long ingest belongs, as a step that persists and auto-retries. - **[`@stackbone/sdk` overview](/docs/sdk/reference/overview)**: the rest of the surfaces: storage, AI, approval, secrets, config.