Aviary
Guides

RAG

Ground the agent in your documents — agentic retrieval as a tool (default) or always-on prompt injection, with citations flowing through the tool-call mechanism.

Retrieval-Augmented Generation grounds the agent's answers in your own documents: retrieve the passages relevant to a question, and let the model answer from them. The @dudousxd/nestjs-agent-rag package ships the batteries — chunking, ingestion, an embedding-backed retriever, and vector stores — while the retrieval seam itself lives in -core, so the agent runtime stays storage-agnostic.

The pieces

  • EmbeddingProvider (core SPI) — text → vectors. aiSdkEmbedding(model) implements it over any Vercel AI SDK embedding model.
  • VectorStore (-rag) — upsert + search + remove + listDocuments. Ships MemoryVectorStore (in-JS cosine) and PgVectorStore (pgvector). The extension point for Qdrant/Pinecone/etc.
  • Retriever (core SPI) — retrieve(query) → Passage[]. EmbeddingRetriever bridges an embedder + a vector store into one.
import { EmbeddingRetriever, MemoryVectorStore, ingestDocuments } from '@dudousxd/nestjs-agent-rag';
import { aiSdkEmbedding } from '@dudousxd/nestjs-agent-ai-sdk';
import { openai } from '@ai-sdk/openai';

const embedder = aiSdkEmbedding(openai.embedding('text-embedding-3-small'));
const store = new MemoryVectorStore();

// chunk → embed → upsert. Chunk ids are `${doc.id}#${n}`, so re-ingesting overwrites in place.
await ingestDocuments(
  [{ id: 'refunds', text: refundPolicyText, source: 'docs/refunds' }],
  { embedder, store, chunkSize: 800 },
);

const retriever = new EmbeddingRetriever(embedder, store);

Two modes

The retriever becomes a search_knowledge(query) tool. Each turn the model decides whether and what to search — no wasted embedding on "hi, thanks!", refined queries, multiple searches when it needs them. This is the modern default.

import { createRetrievalTool } from '@dudousxd/nestjs-agent-rag';
import { provideAgentTool } from '@dudousxd/nestjs-agent';

@Module({
  imports: [AgentModule.forRoot({ /* … */ })],
  providers: [provideAgentTool(createRetrievalTool(retriever))],
})
export class AppModule {}

Best for chat assistants and mixed conversations, and when you're running a capable model that tool-calls reliably.

Before the model runs, the loop embeds the user's message verbatim, retrieves the top-K passages, and folds them into the system prompt. The model always sees retrieved context — no decision, guaranteed grounding.

AgentModule.forRoot({
  // …model, store, actorResolver…
  retrieval: { mode: 'inject', retriever, topK: 5 },
});

Best for "answer strictly from these docs" bots (support KBs, document Q&A), weaker models that won't reliably call a tool, or when you must guarantee retrieval every turn. The cost: it embeds and searches on every message, using the raw message as the query.

Which one?

Rule of thumb: tool = the model searches when it decides it needs to, with the query it forms; inject = the library always searches, using the user's message as the query, before generating. Default to tool; reach for inject when you need deterministic grounding.

Citations

Retrieved passages carry a source, and they surface as citations through the tool-call mechanism — no new message field:

  • Agentic mode: the search_knowledge tool's output is the passages. It persists as a tool call and renders as a tool part in the chat UI, and shows up in the -telescope Agent tab.
  • Inject mode: the loop records a synthetic auto_executed retrieve tool call (output = passages) against the assistant message it informed — the same surface, so citations render identically.

Either way, each retrieval also emits an aviary:agent:retrieved diagnostics event ({ runId, query, count }) — subscribe for retrieval metrics without instrumenting your code.

Better relevance: hybrid + reranking

Because every retriever — vector, keyword, fused, reranked — is a core Retriever, they compose. Two standard precision upgrades:

Hybrid search fuses dense vector similarity with lexical BM25 (so exact keyword matches aren't lost to fuzzy embeddings). HybridRetriever uses Reciprocal Rank Fusion — no score normalization needed between the two scales. Feed both retrievers the same chunks so their ids line up:

import {
  EmbeddingRetriever, KeywordRetriever, HybridRetriever,
  chunkDocuments, ingestChunks,
} from '@dudousxd/nestjs-agent-rag';

const chunks = chunkDocuments(docs, { chunkSize: 800 });
await ingestChunks(chunks, { embedder, store });   // dense
const keyword = new KeywordRetriever();
keyword.add(chunks);                                // lexical BM25 (same chunk ids)

const hybrid = new HybridRetriever([new EmbeddingRetriever(embedder, store), keyword]);

Reranking over-fetches cheap candidates, then reorders them with a stronger model (a cross-encoder / Cohere / Voyage rerank endpoint behind the Reranker SPI):

import { RerankingRetriever } from '@dudousxd/nestjs-agent-rag';

const retriever = new RerankingRetriever(hybrid, reranker, { fetchTopK: 20 });
// fetch 20 candidates → rerank → return the top few. Still just a `Retriever`.

Any of these drops straight into createRetrievalTool(retriever) or forRoot({ retrieval }).

Production storage: pgvector or Redis

MemoryVectorStore is for tests and small corpora. For scale, two shipped adapters — both take an injected client (no driver dependency) and expose ensureSchema():

pgvector (Postgres):

import { Pool } from 'pg';
import { PgVectorStore } from '@dudousxd/nestjs-agent-rag';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const store = new PgVectorStore(
  { query: (sql, params) => pool.query(sql, params).then((r) => r.rows) },
  { dimensions: 1536 }, // match your embedding model
);
await store.ensureSchema(); // vector extension + table + HNSW cosine index

Redis (RediSearch — Redis Stack / Redis 8+), the ecosystem-native option if you already run Redis:

import { createClient } from 'redis';
import { RedisVectorStore } from '@dudousxd/nestjs-agent-rag';

const client = createClient({ url: process.env.REDIS_URL });
await client.connect();
const store = new RedisVectorStore(
  { sendCommand: (args) => client.sendCommand(args) },
  { dimensions: 1536, filterableFields: ['owner'] }, // TAG fields you can filter by
);
await store.ensureSchema(); // FT.CREATE — HNSW cosine index

Swap MemoryVectorStore for either in the wiring above; nothing else changes. For any other backend (Qdrant, Pinecone, Weaviate), implement the VectorStore SPI — upsert + search + remove + listDocuments.

Ingesting uploads automatically

If your app already stores files with @dudousxd/nestjs-media, the @dudousxd/nestjs-agent-rag-media package turns every upload into searchable knowledge with no glue: attach a file and it's extracted, chunked, embedded, and indexed — scoped to its owner, and removed again on delete.

import { AgentMediaIngestionModule } from '@dudousxd/nestjs-agent-rag-media';

AgentMediaIngestionModule.forRoot({
  store,                           // the same VectorStore you retrieve from
  embedder,
  collections: ['knowledge-base'],
  readFile: (disk, path) => media.disk(disk).get(path),
});

It couples to media purely through the aviary:media:* diagnostics channel (no hard dependency), and stamps each chunk with the owner from the server-side attach record. That last part is what makes per-user RAG a real boundary — wrap the retriever with FilteredRetriever:

import { EmbeddingRetriever, FilteredRetriever } from '@dudousxd/nestjs-agent-rag';

const forThisUser = new FilteredRetriever(new EmbeddingRetriever(embedder, store), {
  ownerId: actor.id,
});

See the rag-media package reference for custom extractors (PDF/DOCX), durable ingestion, and the emitted diagnostics.

On this page