Agora
Retrieval (RAG)

RAG & retrieval

Ground the agent in your own corpus — the Retriever, EmbeddingProvider, and Reranker SPIs, the memory and pgvector retrievers, and always-on "inject" retrieval that folds cited passages into the system prompt.

Retrieval-augmented generation lets the agent answer from your documents instead of just the model's training data. @adonis-agora/agent ships a small RAG stack behind three seams — a retriever, an embedding provider, and an optional reranker — plus two bundled retrievers (in-memory and pgvector) and an always-on "inject" mode wired straight into the agent loop.

The seams

Three provider-agnostic SPIs, all exported from the package root:

SPIMethodRole
Retrieverretrieve(query, options?) → Passage[]The black box the loop asks for the passages most relevant to a query — vector search, keyword, hybrid, or a remote service.
EmbeddingProviderembed(texts) → number[][]Turns text into vectors, batched (one vector per input, same order), for both ingestion and query.
Rerankerrerank(query, passages, options?) → Passage[]Re-scores first-stage passages with a stronger (usually cross-encoder) model.

A retrieved Passage is { id, text, score, source?, metadata? }source is the human/citation-facing origin and score is impl-defined relevance.

Embedding & reranker adapters are bring-your-own

The EmbeddingProvider and Reranker are SPIs — the bundled retrievers take whichever embedder you pass. There is no first-party embedding or reranking adapter: wire your own embed(...) (a few lines over the Vercel AI SDK's embedMany, or any provider's REST API), or use the deterministic FakeEmbeddingProvider / FakeReranker / inMemoryRetriever from @adonis-agora/agent/testing for offline work.

The bundled retrievers

Build one with the retrievers factory namespace (a lazy thunk, like stores):

config/agent.ts
import { defineConfig, retrievers } from '@adonis-agora/agent'

export default defineConfig({
  model: () => aiSdkModel(openai('gpt-4o-mini')),
  retriever: retrievers.memory({
    embedder: myEmbedder, // an EmbeddingProvider (or a lazy factory)
    documents: [
      { id: 'faq', text: 'Long support FAQ text…', source: 'faq.md' },
    ],
    chunkSize: 800, // default
    overlap: 100, // default
  }),
})

retrievers.memory is cosine similarity over a Map — single-process, no infrastructure. Perfect for tests and small/embedded corpora. documents are chunked, embedded, and indexed at boot; omit them to ingest at runtime instead.

Composable retrieval strategies

The retrievers.memory() / .pgvector() factories are convenience wrappers. Underneath, a Retriever is just an object with retrieve(query, options?) → Passage[], and the package exports the concrete strategies from the root so you can build and compose them yourself. Every one of them is a Retriever, so any of them (or a stack of them) can go straight into the config's retriever slot.

import {
  EmbeddingRetriever,   // embed the query, then vector-search a VectorStore
  KeywordRetriever,     // in-memory BM25 lexical search
  HybridRetriever,      // fuse several retrievers with Reciprocal Rank Fusion
  RerankingRetriever,   // over-fetch, then reorder with a stronger Reranker
  MemoryVectorStore,    // cosine similarity over a Map
  PgVectorStore,        // pgvector-backed VectorStore
  cosineSimilarity,     // the pure vector-similarity primitive
  matchesFilter,        // the metadata-filter predicate (also an ACL primitive)
  chunkDocuments,       // documents → ChunkRecord[] (aligned chunk ids)
} from '@adonis-agora/agent'

The building block behind retrievers.memory()/.pgvector(): it bridges an EmbeddingProvider and a VectorStore — embed the query, then store.search(...). Construct it directly when you want to own the store instance (e.g. to ingest into it at runtime):

const store = new MemoryVectorStore()          // or new PgVectorStore(db, { … })
const retriever = new EmbeddingRetriever(myEmbedder, store)

const passages = await retriever.retrieve('how do refunds work?', { topK: 5 })

PgVectorStore is the production VectorStore — raw pgvector SQL over the structural LucidRawRunner, with cosine/l2/inner metrics and strict identifier validation on every table/column name. See pgvector store for its schema and full options. (PgVectorRetriever, what retrievers.pgvector() builds, is EmbeddingRetriever narrowed to a PgVectorStore.)

A dependency-free, in-memory BM25 retriever: exact term matching with idf, term-frequency saturation (k1, default 1.5) and document-length normalization (b, default 0.75). It's the lexical complement to dense vector search — it nails rare tokens, ids, and exact phrases that embeddings blur. Feed it the same ChunkRecords you upsert into the vector store (both from chunkDocuments) so their chunk ids line up:

import { KeywordRetriever, chunkDocuments } from '@adonis-agora/agent'

const chunks = chunkDocuments(
  [{ id: 'policy', text: '…', source: 'policy.pdf' }],
  { chunkSize: 800, overlap: 100 },
)

const keyword = new KeywordRetriever({ k1: 1.5, b: 0.75 })
keyword.add(chunks)                       // index (re-adding an id replaces it)
const hits = await keyword.retrieve('SKU-4417 return window', { topK: 5 })

HybridRetriever — RRF fusion

Fuses several retrievers with Reciprocal Rank Fusion. RRF scores each passage by Σ weight / (k + rank) across the lists it appears in, so it needs no score normalization between incompatible scales (cosine similarity vs. BM25) — it works purely off rank. Deduplicated by passage id, so aligned chunk ids from dense and lexical retrievers reinforce each other. This is the robust default for "vector and keyword":

import { HybridRetriever } from '@adonis-agora/agent'

const hybrid = new HybridRetriever(
  [vectorRetriever, keywordRetriever],
  {
    k: 60,          // RRF constant (paper default); larger flattens rank weighting
    fetchTopK: 20,  // candidates pulled from EACH retriever before fusion
    weights: [1, 0.5], // per-retriever multipliers, same order as the array
  },
)

export default defineConfig({
  model: () => aiSdkModel(openai('gpt-4o-mini')),
  retriever: hybrid,   // itself a Retriever
})

RerankingRetriever — two-stage precision

Over-fetch cheap candidates from a base retriever, then reorder them with a stronger Reranker (usually a cross-encoder) and keep the top few. The standard precision boost: a fast first stage casts a wide net, a slow accurate second stage sharpens it. It composes over any retriever — vector, keyword, or the hybrid above — and is itself a Retriever, so you can stack it on top:

import { RerankingRetriever } from '@adonis-agora/agent'

const retriever = new RerankingRetriever(
  hybrid,        // base: fetch a wide candidate set
  myReranker,    // a Reranker SPI (bring your own, or FakeReranker in tests)
  { fetchTopK: 20 }, // candidates to pull before reranking (default 20)
)
// retrieve(query, { topK: 5 }) → the 5 best AFTER reranking

Metadata filters as an ACL

RetrieveOptions.filter is a Record<string, unknown> matched by matchesFilter (used by the memory store and KeywordRetriever; PgVectorStore runs the equivalent in SQL). It has two modes, and the array mode is a capability-style access-control primitive over a shared corpus:

  • a scalar value is exact-match — the record's value must equal it;
  • an array value is match-any (set membership / OR) — the record matches when its value for that key is one of them (or, for a multi-valued record, shares at least one element). An empty array matches nothing — the deny primitive.

So a shared store can tag each chunk with an audience and the caller passes the tokens the current actor holds; a chunk the actor can't see is simply never retrieved, and the store never has to know what a token means:

// Ingest: tag each document with who may see it — `metadata` rides onto every chunk.
await ingestDocuments(
  [{ id: 'q3-plan', text: '…', source: 'plan.md',
     metadata: { audience: ['public', 'role:ADMIN', 'base:acme'] } }],
  { embedder, store, chunkSize: 800, overlap: 100 },
)

// Retrieve: pass only the tokens THIS actor holds.
const passages = await retriever.retrieve(query, {
  topK: 5,
  filter: { audience: ['public', ...(actor.roles ?? []).map((r) => `role:${r}`), `base:${actor.tenantRef}`] },
})
// A chunk whose audience shares no token with the filter can never come back.

matchesFilter(metadata, filter) and cosineSimilarity(a, b) are exported as pure functions in their own right, handy for unit tests or for building a bespoke store.

Inject mode — always-on retrieval

Setting retriever in the config turns on inject mode: before the turn, the loop retrieves once for the user message and folds the passages into the system prompt as a numbered, citable block:

<retrieved_context>
[1] (faq.md) …passage text…
[2] (faq.md) …passage text…
</retrieved_context>
Use the retrieved context above to answer when relevant, and cite sources by their bracket number.
  • retrievalTopK controls how many passages are requested per run (default 5).
  • Retrieval runs inside hooks.step, so under the durable runner a replay reuses the exact same passages deterministically.
  • The retrieval is recorded as a synthetic auto_executed retrieve tool call on the first assistant message, so it shows up in the transcript and the governance read-model like any other tool call.

Inject vs. agentic retrieval

Inject mode retrieves every turn, up front. If you'd rather let the model decide when to search, don't set retriever — instead author a read tool that calls retriever.retrieve(...) itself. That's agentic retrieval; the loop sets nothing and the model calls the tool when it needs context. The two modes compose.

Scoping inject mode with retrievalFilter

Inject mode is UNSCOPED by default

Setting retriever alone retrieves from the whole corpus for every actor. If your corpus mixes documents that shouldn't all be visible to every caller (multiple tenants, or documents with an audience ACL), an unset retrievalFilter means every actor's system prompt can be folded in with passages another tenant uploaded. Any app that shares one corpus across callers must set retrievalFilter.

retrievalFilter derives the same RetrieveOptions.filter used above for agentic/manual retrieval, but from the run's actor, applied automatically to every inject-mode retrieval:

config/agent.ts
import { defineConfig } from '@adonis-agora/agent'

export default defineConfig({
  model: () => aiSdkModel(openai('gpt-4o-mini')),
  retriever: myRetriever,
  // Mirrors the `audience` ACL pattern: pass only the tokens THIS actor holds.
  retrievalFilter: (actor) => ({
    audience: ['public', ...(actor.roles ?? []).map((r) => `role:${r}`), `base:${actor.tenantRef}`],
  }),
})
  • The hook runs inside the same hooks.step('retrieve', …) as the retrieval call itself, so a durable replay never re-derives a filter that might have changed since — it reuses the checkpointed passages.
  • With no retrievalFilter configured, filter is omitted from the options passed to retriever.retrieve(...) entirely — not set to undefined — so existing single-tenant deployments are unaffected.
  • If the hook throws, the turn fails outright rather than falling back to unfiltered retrieval — a filter that silently fails open is worse than no filter, because the operator believes it is on.

A relevance floor — minScore

RetrieveOptions carries a third knob alongside topK and filter:

const passages = await retriever.retrieve(query, { topK: 5, minScore: 0.75 })

minScore drops every passage scoring below it. Scores are higher-is-more-relevant across this library — cosine similarity for the vector stores, a negated distance for L2 and inner product — so it reads as a similarity threshold regardless of the metric you chose.

Two properties matter:

  • The floor is applied before the top-K cut, not after, so topK: 5, minScore: 0.75 returns up to five passages that all clear the bar — never five passages of which two are noise. It may return zero, which is the point: for strict-grounding RAG, "I don't have a source for that" is a better answer than a confidently-cited weak match.
  • It pushes down into the store. The memory store filters in JS before slicing, pgvector adds a >= ? clause to the WHERE so the floor happens before LIMIT, and Qdrant sends it as score_threshold. You are not paying for passages you then discard.

Only the vector path honours it

EmbeddingRetriever (and its pgvector/Qdrant subclasses) thread minScore into the store's search. KeywordRetriever, HybridRetriever and RerankingRetriever ignore it — BM25 and RRF-fused scores are not on a similarity scale, so a threshold calibrated for cosine would mean nothing there. Filter a hybrid or reranked result yourself if you need a floor.

Inject mode also never sets it: the loop passes topK and filter only, so a floor is reachable from agentic retrieval (your own tool calling retrieve) but not from retrievalFilter.

Ingestion helpers

For runtime ingestion (outside the factory's boot-time documents), the RAG helpers are exported from the root:

import { chunkDocuments, ingestDocuments, MemoryVectorStore } from '@adonis-agora/agent'

const store = new MemoryVectorStore()
await ingestDocuments(
  [{ id: 'policy', text: '…', source: 'policy.pdf' }],
  { embedder: myEmbedder, store, chunkSize: 800, overlap: 100 },
)

To index files from a media library automatically, see RAG media ingestion.

Chunking prose vs. chunking records

chunkText(text, options) defaults to chunkSize: 800, overlap: 100, and a prose heuristic: it fills the window greedily, then backs up to the nearest paragraph → sentence → word boundary in the back half so a chunk rarely cuts mid-sentence, and carries overlap characters forward so context survives the seam.

That heuristic is wrong for machine-generated text. A JSONL export, a CSV, a log dump — these have known record boundaries, and guessing at sentence endings inside them produces chunks that open on half a field:

ty=Manchester","country":"UK"}

An embedding of that is close to useless, and it is the kind of corruption you only notice as mysteriously bad retrieval months later. Pass separator and the splitter cuts only there:

import { chunkText } from '@adonis-agora/agent'

// One JSON object per line.
const chunks = chunkText(jsonl, { separator: '\n', chunkSize: 2000, overlap: 200 })

// A custom record delimiter.
const records = chunkText(dump, { separator: '---', chunkSize: 1200 })

Records are packed whole, as many as fit, rejoined with the separator. Empty and whitespace-only records are dropped.

With a separator, `chunkSize` is a target rather than a cap

Two deliberate deviations come with the record path.

A record longer than chunkSize is emitted whole, as its own oversized chunk. It is never cut, never falls back to the prose heuristic, and never drags a neighbour over the limit — it always chunks alone. Splitting a record would defeat the reason you named a separator, so the size limit is the thing that yields. Size your chunkSize for the typical record and check that your embedding model's token limit tolerates the largest one.

overlap is spent on whole records too — a character budget filled with entire trailing records, never a partial one. So overlap: 200 over 60-character records carries three of them, and over 300-character records carries none. It also never carries so much that a chunk fails to advance, so an oversized overlap cannot stall the split.

Passing an empty string is treated as not passing one, and the prose path is byte-for-byte unchanged when separator is omitted — so existing indexes never shift under you.

On this page