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)(-ai-sdk) implements it over any Vercel AI SDK embedding model;openAiEmbeddings({ model, baseUrl? })(-rag) over any OpenAI-compatible/embeddingsendpoint with no SDK.VectorStore(-rag) —upsert+search+remove+listDocumentsand the other maintenance methods. ShipsMemoryVectorStore(in-JS cosine),PgVectorStore/PgLexicalVectorStore(pgvector) andRedisVectorStore(RediSearch). The extension point for Qdrant/Pinecone/etc.Retriever(core SPI) —retrieve(query) → Passage[].EmbeddingRetrieverbridges 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.
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_knowledgetool'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-telescopeAgent tab. - Inject mode: the loop records a synthetic
auto_executedretrievetool call (output = passages) on the assistant message it informed — on the message'stoolCalls/toolResults, in theagent_tool_calltable, and live on the stream as the sametool-input-available+tool-outputframe pair an executed read tool writes. That is the whole delivery path an ordinary tool call has, so citations render identically.
A client detects provenance structurally — a tool output shaped { passages: [{ id, text }] } —
rather than by tool name, which is what lets both modes land in the same block even though one is
named retrieve and the other is whatever you called createRetrievalTool.
In inject mode the loop also emits an aviary:agent:retrieved diagnostics event
({ runId, query, count }) per retrieval.
For retrieval quality metrics (latency, zero-hit rate, scores, store, collection), -rag publishes
aviary:rag:retrieval. createRetrievalTool instruments its retriever by default; for inject mode,
wrap the retriever yourself with instrumentRetriever(retriever) before passing it to
forRoot({ retrieval }). Telescope renders these on its Agent tab.
See Telemetry.
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]);KeywordRetriever is in-process: it only works where ingestion and search share a process, and a
deleted document must also be dropped with keyword.remove(documentId). When the store has its own
full-text index (PgLexicalVectorStore, RedisVectorStore), use LexicalRetriever(store) as the
lexical leg instead — nothing to feed or keep in sync:
import { LexicalRetriever } from '@dudousxd/nestjs-agent-rag';
const hybrid = new HybridRetriever([
new EmbeddingRetriever(embedder, store),
new LexicalRetriever(store),
]);To let an unanswerable query return nothing, set a similarity floor on the dense leg —
new EmbeddingRetriever(embedder, store, { minScore: 0.2 }) (measure the number for your model). Fused
HybridRetriever scores are rank-based and carry no threshold.
Reranking over-fetches cheap candidates, then reorders them with a stronger model (a
cross-encoder behind the Reranker SPI). HttpReranker covers Cohere / Jina / Voyage / TEI-style
/rerank endpoints:
import { HttpReranker, RerankingRetriever } from '@dudousxd/nestjs-agent-rag';
const reranker = new HttpReranker({
url: 'https://api.cohere.com/v2/rerank',
model: 'rerank-v3.5',
apiKey: process.env.COHERE_API_KEY,
});
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) — takes a pg Pool, a Drizzle database or a postgres.js sql directly:
import { Pool } from 'pg';
import { PgLexicalVectorStore } from '@dudousxd/nestjs-agent-rag';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const store = new PgLexicalVectorStore(pool, { dimensions: 1536 }); // match your embedding model
await store.ensureSchema(); // vector extension + table + HNSW cosine index + GIN full-text indexUse PgVectorStore for vectors only; PgLexicalVectorStore adds Postgres full-text searchText for
the LexicalRetriever leg. schemaStatements() returns the DDL for a migration. Mixed embedding
widths, nullable embeddings and pgvector ≥ 0.8 iterative scans are options — see
pgvector.
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 indexSwap MemoryVectorStore for either in the wiring above; nothing else changes. For any other backend
(Qdrant, Pinecone, Weaviate), implement the VectorStore SPI (see
the package reference for the required methods).
Long documents: tree navigation
On long structured documents (regulations, contracts, 10-Ks), chunk search tends to find the right
document but not the right page. -rag ships PageIndex-style tree navigation: indexDocumentTree
builds a table-of-contents tree per long document at ingestion, and TreeNavigationRetriever has an
LLM walk it at query time. TwoStageRetriever(hybrid, navigator) keeps hybrid search as the first
stage and navigates only the top long documents; createNavigateDocumentTool(navigator) exposes it to
the model as navigate_document({ documentId, question }). It costs LLM calls per query, so it sits
behind the existing search rather than replacing it. See
Tree navigation.
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,
});Chat attachments uploaded through AgentMediaAttachmentsModule (@dudousxd/nestjs-agent/media) feed
the same pipeline with indexForRag: true — see File uploads.
See the rag-media package reference for custom extractors (PDF/DOCX), durable ingestion, and the emitted diagnostics.
Related
@dudousxd/nestjs-agent-rag— the package reference- Tools —
readtools, which is what agentic retrieval rides on - Governed SQL — the other read seam; a
VectorStorecan ride the same-datapool
Personas
Named variants of one agent — their own prompt and a narrower tool allow-list — picked per send, pinned on the thread, and resolved once per run so a parked turn resumes on the persona it started with.
Conversation history
Bound what a turn carries — a message/token window, an optional summary of what it left out, and the determinism rules a custom policy has to hold to.