RAG Package
RAG (Retrieval-Augmented Generation) and AI embeddings integration for Stack. Turn your application into a knowledge base with semantic search capabilities powered by vector embeddings.
Overview
The @opensaas/stack-rag package adds semantic search and RAG capabilities to your Stack application with minimal configuration. It supports multiple embedding providers (OpenAI, Ollama), stores embeddings in a native pgvector column, and integrates with the stack's access control system.
Key Features:
- ๐ค Multiple Embedding Providers - OpenAI, Ollama (local), or bring your own custom provider
- ๐๏ธ Native Vector Column - a pgvector
vector(n)column with its dimension, distance function and index declared on the field - ๐ Semantic Search - Natural language queries with relevance scoring
- ๐ Access Control Integration - All searches respect your existing access control rules
- โก Automatic Embeddings - Auto-generate embeddings when content changes with intelligent caching
- ๐ ๏ธ MCP Integration - Automatic semantic search tools for AI assistants
- ๐ Multiple Abstraction Levels - From automatic "magic" mode to low-level control
- โ๏ธ Text Chunking - Multiple strategies for splitting long documents (recursive, sentence, sliding-window, token-aware)
- ๐ Batch Processing - Rate-limited batch embedding generation with progress tracking
Installation
pnpm add @opensaas/stack-rag
# Install your chosen embedding provider
pnpm add openai # For OpenAI embeddings
# OR use Ollama (no package needed - just run Ollama locally)
Quick Start
Here's the fastest way to add semantic search to your Stack app:
// opensaas.config.ts
import { config, list } from '@opensaas/stack-core'
import { text } from '@opensaas/stack-core/fields'
import { ragPlugin, openaiEmbeddings } from '@opensaas/stack-rag'
import { searchable } from '@opensaas/stack-rag/fields'
export default config({
plugins: [
ragPlugin({
provider: openaiEmbeddings({
apiKey: process.env.OPENAI_API_KEY!,
model: 'text-embedding-3-small',
}),
}),
],
db: { provider: 'postgresql' },
lists: {
Article: list({
fields: {
title: text({ validation: { isRequired: true } }),
content: searchable(text({ validation: { isRequired: true } }), {
provider: 'openai',
dimensions: 1536,
}),
},
}),
},
})
What's happening:
- The
ragPlugin()configures the embedding provider and declares the pgvector extension pack - The
searchable()wrapper automatically creates acontentEmbeddingfield - Embeddings are auto-generated whenever
contentchanges - All searches respect your existing access control rules
Generate the schema contract:
pnpm generate
pnpm dev applies it to the database.
Now create content. You never write the embedding โ the plugin derives it from content on the way in, and create returns null when access is denied:
import { getContext } from '@/.opensaas/context'
const context = await getContext()
const article = await context.db.Article.create({
data: {
title: 'Introduction to AI',
content: 'Artificial intelligence is...',
},
})
if (article === null) {
throw new Error('Not allowed to create an article')
}
Perform semantic search. The ranking, the minScore bound, the Access Filter and Field Visibility all live inside the one nearest() query:
import { createEmbeddingProvider } from '@opensaas/stack-rag/providers'
import { getContext } from '@/.opensaas/context'
export async function searchArticles(query: string) {
const context = await getContext()
const provider = createEmbeddingProvider({
type: 'openai',
apiKey: process.env.OPENAI_API_KEY!,
})
const queryVector = await provider.embed(query)
return await context.db.Article.nearest('contentEmbedding', queryVector, {
limit: 10,
minScore: 0.25,
})
}
Core Concepts
Embedding Fields
Embedding fields store vector embeddings (arrays of numbers) that represent the semantic meaning of text. The RAG package provides two patterns for defining embedding fields:
High-Level: searchable() Wrapper (Recommended)
The easiest way to add semantic search to any text field:
import { text } from '@opensaas/stack-core/fields'
import { searchable } from '@opensaas/stack-rag/fields'
fields: {
content: searchable(text({ validation: { isRequired: true } }), {
provider: 'openai',
dimensions: 1536,
})
}
Benefits:
- Automatically creates a companion
contentEmbeddingfield - Automatically generates embeddings when content changes
- Clean, concise syntax
- Works with any base field type
Options:
type SearchableOptions = {
provider?: string // Embedding provider (e.g., 'openai', 'ollama')
dimensions?: number // Vector dimensions (default: 1536)
embeddingFieldName?: string // Custom embedding field name (default: `${fieldName}Embedding`)
}
Manual: embedding() Field
For advanced use cases where you need more control:
<!-- doc-check: whole="a bare fields: object-literal property, not a statement" -->
import { text } from '@opensaas/stack-core/fields'
import { embedding } from '@opensaas/stack-rag/fields'
fields: {
content: text({ validation: { isRequired: true } }),
contentEmbedding: embedding({
sourceField: 'content',
provider: 'openai',
dimensions: 1536,
autoGenerate: true,
})
}
When to use manual pattern:
- Need direct access to the embedding field in your schema
- Want to store embeddings without a source field
- Building custom embedding pipelines
- Need field-level hooks on the embedding field
Embedding Providers
The RAG package supports multiple embedding providers through a pluggable architecture:
OpenAI
Best for production applications. Uses OpenAI's embedding models:
ragPlugin({
provider: openaiEmbeddings({
apiKey: process.env.OPENAI_API_KEY!,
model: 'text-embedding-3-small', // or 'text-embedding-3-large'
}),
})
Models:
text-embedding-3-small- 1536 dimensions, fast, cost-effectivetext-embedding-3-large- 3072 dimensions, higher quality, more expensive
Ollama
Best for local development. No API costs, runs locally:
ragPlugin({
provider: ollamaEmbeddings({
baseURL: 'http://localhost:11434',
model: 'nomic-embed-text',
dimensions: 768,
}),
})
dimensions is required for Ollama. The dimension is a column's type, so generation has to know it โ and Ollama reports its model's output size only from a live embed call, which pnpm generate must not depend on. Omitting it fails generation with a message naming the provider.
Setup:
# Install Ollama from https://ollama.ai
ollama pull nomic-embed-text
ollama serve
Environment Variables
createProviderFromEnv() and getProviderConfigFromEnv() in @opensaas/stack-rag/runtime build a provider from the environment, for scripts and build steps that have no config to read:
| Variable | Applies to | Default | Notes |
|---|---|---|---|
EMBEDDING_PROVIDER | both | openai | openai or ollama |
OPENAI_API_KEY | OpenAI | โ | Required when the provider is openai |
OLLAMA_BASE_URL | Ollama | http://localhost:11434 | |
OLLAMA_EMBEDDING_DIMENSIONS | Ollama | 768 | The model's output size. The default is nomic-embed-text's; set it for another model |
OLLAMA_EMBEDDING_DIMENSIONS is read rather than discovered for the same reason ollamaEmbeddings({ dimensions }) is required: it is a schema fact, and Ollama only reports it from a live embed call. A value that is not a positive integer throws rather than silently falling back โ the wrong width produces a column that no query vector fits.
ragPlugin itself takes its provider from your config, not from these; these are for standalone scripts.
Multiple Providers
You can configure multiple providers and choose which to use per field:
<!-- doc-check: whole="a bare fields: object-literal property, not a statement" -->
ragPlugin({
providers: {
openai: openaiEmbeddings({
apiKey: process.env.OPENAI_API_KEY!,
model: 'text-embedding-3-small',
}),
ollama: ollamaEmbeddings({
model: 'nomic-embed-text',
dimensions: 768,
}),
},
})
// In fields
fields: {
titleEmbedding: embedding({
sourceField: 'title',
provider: 'ollama', // Use local Ollama for titles
}),
contentEmbedding: embedding({
sourceField: 'content',
provider: 'openai', // Use OpenAI for content
})
}
The Vector Column
There is no storage backend to choose. embedding() emits a native pgvector vector(n) column with its metadata in a jsonb column beside it, and the field owns the dimension, the distance function and the index:
contentEmbedding: embedding({
sourceField: 'content',
dimensions: 1536,
distanceFunction: 'cosine', // 'cosine', 'l2', or 'inner_product'
index: { method: 'hnsw', m: 16, efConstruction: 64 },
})
Because every column the plugin emits is a pgvector column, postgresql is the only datasource RAG runs on โ pnpm generate refuses any other one.
Provisioning pgvector
ragPlugin declares the pgvector extension pack, so nothing in your config names it and no SQL is yours to run. The declaration is a generator emission (ADR-0065): pnpm generate writes the extension's own migration under migrations/pgvector/, and applying the contract enables the extension ahead of your tables. Locally that is pnpm db:update with pnpm dev up in another terminal; Migrations and the dev loop covers why the command needs that loop and what --plan asks for. In a deployment there is no loop, so it is prisma migration plan once, committed, then prisma db migrate. There is no opensaas db migrate โ opensaas db carries only update.
What the deployment owns is provisioning:
- Availability. pgvector has to be present on the Postgres server. The Dev database carries it, as do Neon, Supabase and RDS; a server you run yourself needs the extension installed (
pgvector/pgvectorDocker images,brew install pgvector,postgresql-16-pgvector). - Privilege. pgvector is not a trusted extension, so the role your migration connects as needs superuser, a provider grant, or a one-off pre-create โ see Cost: pgvector needs a privilege you may not have, which also covers what a server with no pgvector at all does.
Search exactness
Two separate statements, both about how many rows a search returns.
A cost of ranking inside the access filter. An approximate HNSW scan under a selective access filter can return fewer than limit rows: the index walks a bounded candidate list, and rows the session may not see are discarded from it rather than replaced. Under an exact scan the result is exact โ limit rows whenever limit rows qualify. Under an approximate scan it is bounded by pgvector's iterative-scan budget instead (ADR-0045).
A known limit. embedding({ index }) builds no CREATE INDEX yet because the pgvector pack registers no index types, so every nearest() is an exact sequential scan today and pnpm generate warns (#1265).
Automatic Embedding Generation
The RAG plugin automatically generates embeddings when content changes using intelligent caching:
How it works:
- You create or update an item with searchable content
- The RAG plugin detects if the source field changed (via hash comparison)
- If changed, it generates a new embedding using the configured provider
- The embedding is stored with metadata (provider, model, dimensions, source hash)
- Future updates only regenerate if the source text actually changed
Source Hash Comparison:
The plugin stores a SHA-256 hash of the source text in the embedding metadata. This prevents unnecessary API calls when:
- Updating other fields on the same item
- Re-saving with identical content
- Running database migrations
Access Control Integration
All semantic searches automatically respect your existing access control rules. This ensures users can only search content they have permission to view.
A where predicate composes with the search; the session's own filter is ANDed onto it, so a user only ever ranks over articles they may read:
import { getContext } from '@/.opensaas/context'
const context = await getContext({ userId: 'user-123' })
const matches = await context.db.Article.where({
published: { equals: true },
}).nearest('contentEmbedding', queryVector)
A denied read answers [], the same value an empty scoped set gives.
nearest() is a terminal on the secured read surface, so operation-level, filter-level and field-level access control are enforced exactly as they are for all() or first(). The top-K is computed over the rows the session may see โ the ranking and the minScore bound are inside the same scoped query, not applied to a wider result afterwards (ADR-0045).
Searching a field also requires read access to it: ordering by a vector measures its contents, so a session that cannot read the column is refused.
Configuration Options
RAG Plugin Configuration
| Option | Type | Default | Description |
|---|---|---|---|
provider | EmbeddingProviderConfig | โ | The single default provider |
providers | Record<string, โฆConfig> | {} | Named providers, when fields choose between them |
enableMcpTools | boolean | true | Register a semantic_search_<list> MCP tool per searchable list |
batchSize | number | 10 | Texts per provider call during batch generation |
rateLimit | number | 100 | Provider requests per minute |
buildTime | { enabled, outputPath, โฆ } | off | Build-step embedding generation into a JSON index |
Name either provider or providers; with providers, a field selects one by key.
ragPlugin({
providers: {
openai: openaiEmbeddings({
apiKey: process.env.OPENAI_API_KEY!,
model: 'text-embedding-3-small',
}),
ollama: ollamaEmbeddings({ model: 'nomic-embed-text', dimensions: 768 }),
},
enableMcpTools: true,
rateLimit: 100,
})
Field Configuration
searchable() Wrapper
searchable(baseField, options) keeps the base field as authored and adds the companion embedding column beside it.
The wrapped field must be text(); any other type fails opensaas generate. To search rich text or JSON, keep a plain-text field in sync with a hook and wrap that.
| Option | Type | Default |
|---|---|---|
provider | string | the plugin's default |
dimensions | number | the provider's, else 1536 |
embeddingFieldName | string | the field's name plus Embedding |
content: searchable(text(), {
provider: 'openai',
dimensions: 1536,
embeddingFieldName: 'customEmbedding',
})
embedding() Field
| Option | Type | Default |
|---|---|---|
sourceField | string | โ |
provider | string | the plugin's default |
dimensions | number | the provider's, else 1536 |
distanceFunction | 'cosine' | 'l2' | 'inner_product' | 'cosine' |
index | { method, opclass?, m?, efConstruction?, lists? } | none |
allowManualWrites | boolean | false |
autoGenerate | boolean | true when sourceField is set |
ui | { showVector?, showMetadata? } | false / true |
dimensions is a schema fact: changing it is a migration, and a declared value that disagrees with a statically known provider dimension fails pnpm generate. allowManualWrites defaults to false, which makes the builder attach access: { create: () => false, update: () => false } to the field โ the embedding is a plugin output, so an ordinary write naming it is refused.
contentEmbedding: embedding({
sourceField: 'content',
provider: 'openai',
dimensions: 1536,
distanceFunction: 'cosine',
autoGenerate: true,
})
Package Exports
Main Exports (@opensaas/stack-rag)
Plugin and configuration:
ragPlugin(config)- RAG plugin for StackopenaiEmbeddings(config)- OpenAI embedding provider helperollamaEmbeddings(config)- Ollama embedding provider helper (dimensionsis required)
Field Types (@opensaas/stack-rag/fields)
embedding(options)- Vector embedding field typesearchable(baseField, options)- High-level wrapper for automatic RAG
Providers (@opensaas/stack-rag/providers)
createEmbeddingProvider(config)- Factory for creating embedding providersregisterEmbeddingProvider(type, factory)- Register custom providersEmbeddingProvider- TypeScript interface for custom providers
Runtime Utilities (@opensaas/stack-rag/runtime)
High-level utilities:
semanticSearch({ list, fieldName, query, provider, ... })- Embed a query and rank it throughnearest()findSimilar({ list, fieldName, itemId, ... })- Rank by an item's own embeddingcreateProviderFromEnv(overrides?)- Build a provider fromEMBEDDING_PROVIDERand friendsgenerateEmbedding(options)- Generate embeddings with chunking supportgenerateEmbeddings(options)- Batch embedding generationchunkText(text, options)- Text chunking utilitiesbatchProcess(options)- Batch processing with rate limiting
Utility classes:
RateLimiter- Rate limiting utilityProcessingQueue- Concurrent processing queue
Helper functions:
hashText()- Generate SHA-256 hash of textvalidateEmbeddingDimensions()- Validate embedding dimensionsmergeEmbeddings()- Merge multiple embeddings
MCP Integration
The RAG plugin automatically generates semantic search tools for the Model Context Protocol when enabled:
export default config({
plugins: [
ragPlugin({
provider: openaiEmbeddings({ apiKey: process.env.OPENAI_API_KEY! }),
enableMcpTools: true, // Enable semantic_search_article tool
}),
],
db: { provider: 'postgresql' },
mcp: {
enabled: true,
auth: { type: 'better-auth', loginPage: '/sign-in' },
},
lists: {
Article: list({
fields: {
content: text(),
contentEmbedding: embedding({
sourceField: 'content',
autoGenerate: true,
}),
},
}),
},
})
AI assistants can then use semantic search:
{
"name": "semantic_search_article",
"arguments": {
"query": "articles about machine learning",
"limit": 5
}
}
The MCP tools automatically:
- Generate query embeddings
- Perform semantic search
- Respect access control rules
- Return results with similarity scores
Data Model
StoredEmbedding Type
An embedding reads back as its vector plus its metadata. The two live in separate columns โ a pgvector vector(n) and a jsonb beside it โ and the field reassembles them into one value:
type StoredEmbedding = {
vector: number[] // The embedding vector
metadata: {
model: string // e.g., 'text-embedding-3-small'
provider: string // e.g., 'openai'
dimensions: number // e.g., 1536
generatedAt: string // ISO timestamp
sourceHash?: string // SHA-256 of source text (for change detection)
}
}
Emitted columns:
| Column | Type |
|---|---|
contentEmbedding | vector(1536) |
contentEmbeddingMetadata | jsonb |
An embedding is a plugin output, so an ordinary create or update naming it throws. Pass embedding({ allowManualWrites: true }) to maintain vectors yourself.
SearchResult Type
Search results pair the row with its score:
type SearchResult<T = unknown> = {
item: T // The matching record, through Field Visibility like any other read
score: number // Higher is more similar; the range is the column's own
}
The score's range is the column's distanceFunction, not a normalised 0โ1:
distanceFunction | score | Range |
|---|---|---|
cosine (default) | the raw cosine | [-1, 1] |
l2 | 1 / (1 + d) | (0, 1] |
inner_product | the dot product | unbounded |
minScore is read on the same scale, and is lowered into the query as a distance bound rather than applied to the results afterwards. The raw distance is not exposed.
Examples
Basic Semantic Search
import { createEmbeddingProvider } from '@opensaas/stack-rag/providers'
import { getContext } from '@/.opensaas/context'
export async function searchArticles(query: string) {
const context = await getContext()
const provider = createEmbeddingProvider({
type: 'openai',
apiKey: process.env.OPENAI_API_KEY!,
})
const queryVector = await provider.embed(query)
const matches = await context.db.Article.nearest('contentEmbedding', queryVector, {
limit: 10,
minScore: 0.25,
})
return matches.map((match) => ({
article: match.item,
similarity: match.score,
}))
}
High-Level Semantic Search API
import { semanticSearch } from '@opensaas/stack-rag/runtime'
import { createEmbeddingProvider } from '@opensaas/stack-rag/providers'
import { getContext } from '@/.opensaas/context'
const context = await getContext()
const results = await semanticSearch({
list: context.db.Article,
fieldName: 'contentEmbedding',
query: 'articles about machine learning',
provider: createEmbeddingProvider({ type: 'openai', apiKey: process.env.OPENAI_API_KEY! }),
limit: 10,
minScore: 0.25,
})
Find Similar Items
import { findSimilar } from '@opensaas/stack-rag/runtime'
import { getContext } from '@/.opensaas/context'
const context = await getContext()
const similar = await findSimilar({
list: context.db.Article,
fieldName: 'contentEmbedding',
itemId: 'article-123',
limit: 5,
excludeSelf: true,
})
Text Chunking
chunkText sizes in characters (chunkSize, chunkOverlap); only 'token-aware' reads tokenLimit. It is a runtime helper you call yourself โ neither embedding() nor searchable() chunks a source field for you. See Chunking long documents for the pattern that stores one embedding per chunk.
Recursive chunking respects paragraph and sentence boundaries:
import { chunkText } from '@opensaas/stack-rag/runtime'
const recursive = chunkText(longDocument, {
strategy: 'recursive',
chunkSize: 1000,
chunkOverlap: 200,
})
Sentence chunking keeps sentences whole:
import { chunkText } from '@opensaas/stack-rag/runtime'
const bySentence = chunkText(longDocument, {
strategy: 'sentence',
chunkSize: 500,
chunkOverlap: 100,
})
Token-aware chunking bounds each chunk by an estimated token count instead:
import { chunkText } from '@opensaas/stack-rag/runtime'
const byToken = chunkText(longDocument, {
strategy: 'token-aware',
tokenLimit: 500,
chunkOverlap: 50,
})
Batch Processing with Rate Limiting
import { batchProcess } from '@opensaas/stack-rag/runtime'
import { createEmbeddingProvider } from '@opensaas/stack-rag/providers'
const result = await batchProcess({
provider: createEmbeddingProvider({ type: 'openai', apiKey: process.env.OPENAI_API_KEY! }),
texts: largeArrayOfTexts,
batchSize: 10,
rateLimit: 60, // 60 requests per minute
onProgress: (progress) => {
console.log(`Progress: ${progress.percentage}% (${progress.processed}/${progress.total})`)
},
})
console.log(`Successfully processed: ${result.stats.successful}`)
console.log(`Failed: ${result.stats.failed}`)
Working Examples
- RAG OpenAI Chatbot - Production-ready chatbot with knowledge base, streaming responses, and source citations
- RAG Ollama Demo - Local development with Ollama embeddings over a native pgvector column
Next Steps
- RAG Setup Guide - Comprehensive setup instructions, database configuration, and example walkthroughs
- RAG Advanced Patterns - Architecture deep dive, custom providers, performance optimization, and advanced use cases
- MCP Integration Guide - Model Context Protocol integration with semantic search tools