Building a RAG Workflow in n8n
n8n rag pipelines let you wire up retrieval augmented generation without writing a backend service from scratch. You get document ingestion, chunking, embeddings, vector storage, and an LLM answer step as connected nodes in a visual canvas, and you can trigger the whole thing from a webhook, a chat UI, a form, or a schedule. This guide builds a working n8n rag workflow end to end: an ingestion pipeline that indexes documents into a vector store, and a query pipeline that retrieves relevant chunks and asks an LLM to answer only from that context.
If you already run n8n self-hosted or on n8n Cloud, you can follow along and have a working pipeline in under an hour. Everything below uses nodes available in current n8n releases: the LangChain-based AI nodes (Embeddings, Vector Store, Text Splitter, Retriever, and the Basic LLM Chain / AI Agent nodes), plus standard HTTP and trigger nodes.
Why build RAG in n8n instead of a custom script
Retrieval augmented generation solves a specific problem: LLMs don't know your private documents, and fine-tuning is slow and expensive to keep current. RAG fixes this by retrieving relevant text chunks at query time and stuffing them into the prompt, so the model answers from your actual data instead of guessing.
You could build this with LangChain or LlamaIndex in Python, and for complex logic that's still the right call. But n8n earns its place when:
- You need the pipeline triggered by non-developers (a Slack command, a form submission, a scheduled crawl).
- You want ingestion and querying to live next to your other automations (CRM syncs, ticket routing, email parsing) in one place.
- You want to swap the LLM provider, vector store, or embedding model by changing a dropdown instead of editing code.
- You need visibility into every step for debugging, since n8n keeps execution history with the exact input and output of each node.
The tradeoff is that very custom chunking logic or exotic retrieval strategies (hybrid search with re-ranking, multi-hop retrieval) are more awkward in a visual canvas. For a solid single-hop or hybrid n8n rag setup, the built-in nodes cover almost everything you need.
Architecture: two workflows, not one
A common mistake is trying to cram ingestion and querying into a single n8n workflow. Split them:
- Ingestion workflow: runs whenever new documents arrive (upload, webhook, scheduled crawl). Reads files, splits them into chunks, generates embeddings, and writes vectors to your store.
- Query workflow: runs on every user question. Embeds the question, retrieves the top matching chunks, and calls an LLM with those chunks as context.
Keeping them separate means you can re-index documents without touching the query path, and you can scale or rate-limit each independently.
Prerequisites
- n8n instance (self-hosted via Docker or n8n Cloud), version with the LangChain/AI nodes available.
- An LLM provider credential: OpenAI, Anthropic, or a local model through Ollama.
- A vector store. This guide uses Pinecone for the hosted path and Postgres with pgvector for the self-hosted path, but Qdrant, Supabase Vector, and Weaviate nodes work the same way conceptually.
- Source documents: PDFs, markdown, or a knowledge base export.
Spin up n8n quickly if you don't have it running:
docker run -it --rm \
--name n8n \
-p 5678:5678 \
-v n8n_data:/home/node/.n8n \
n8nio/n8nOpen http://localhost:5678 and create your owner account.
Step 1: Set up credentials
Before building nodes, add credentials under Settings > Credentials:
- OpenAI API (or Anthropic API) for embeddings and chat completions.
- Pinecone API (API key and environment/region), or a Postgres credential pointing at a database with the pgvector extension enabled.
If you're using Postgres with pgvector, enable the extension once:
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE IF NOT EXISTS document_chunks (
id SERIAL PRIMARY KEY,
content TEXT,
metadata JSONB,
embedding VECTOR(1536)
);
CREATE INDEX IF NOT EXISTS document_chunks_embedding_idx
ON document_chunks
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);Adjust the vector dimension (1536 above) to match your embedding model. text-embedding-3-small is 1536, some other models use different dimensions, so check your provider's docs before creating the table.
Step 2: Build the ingestion workflow
Create a new workflow named something like RAG - Ingest Documents.
Trigger node. Use one of:
- Manual Trigger while testing.
- Webhook if documents get pushed from another system.
- Google Drive Trigger or Local File Trigger for a "watch a folder" setup.
- Schedule Trigger for periodic re-crawls of a docs site.
Read/prepare the file. If you're pulling from Google Drive or S3, add the matching node (Google Drive > Download, S3 > Get) and connect it to a Read/Write Files from Disk or keep it in binary form for the next step.
Extract text. Add the Extract From File node. Set the operation to match your source type:
Extract from PDFfor PDFsExtract from HTMLfor scraped pagesMove Binary Datato JSON for plain text/markdown
This node outputs plain text in a field like data or text, which the splitter needs next.
Split into chunks. Add the Text Splitter node (Recursive Character Text Splitter is the default and works well for most prose). Configure:
- Chunk size: 500-1000 characters (or 300-500 tokens) is a reasonable starting point.
- Chunk overlap: 10-20% of chunk size, so 50-150 characters, to avoid losing context at chunk boundaries.
If your documents have clear structure (headings, code blocks), consider the Markdown-aware splitter instead so you don't cut code examples in half.
Generate embeddings and store vectors. Add the Vector Store node in "insert" mode, chained after an Embeddings node (OpenAI Embeddings, or your chosen provider). Configuration for the Pinecone version:
- Embeddings model:
text-embedding-3-small(cheap, strong baseline) ortext-embedding-3-largeif retrieval quality matters more than cost. - Pinecone index name: create the index ahead of time in the Pinecone console, matching the embedding dimension.
- Metadata fields to include: source filename, page number, ingestion date. This metadata is what lets you cite sources later and filter searches (for example, restrict retrieval to one customer's documents).
For the Postgres/pgvector version, use the Postgres Vector Store node pointed at the document_chunks table you created earlier, with content and metadata mapped from the splitter output.
Wire it as: Trigger -> Extract From File -> Text Splitter -> Embeddings -> Vector Store (insert). Run it once manually against a test document and check the vector store has rows/vectors afterward.
A gotcha worth flagging: if you re-run ingestion on an updated document without deleting the old chunks first, you'll get duplicate, stale context at query time. Add a Delete step keyed on the source filename (in metadata) before inserting fresh chunks, or use upsert semantics if your vector store supports it.
Step 3: Build the query workflow
Create a second workflow, RAG - Answer Question.
Trigger. A Webhook node is the most flexible: it lets you call this from a chat widget, Slack bot, or curl command. Set it to POST, expecting a JSON body like {"question": "How do I reset my password?"}.
Retrieve relevant chunks. Add the Vector Store node again, this time in "retrieve" mode (or use the dedicated Vector Store Retriever node if your n8n version separates them), pointed at the same Pinecone index or Postgres table. Set:
- Top K: 4-6 chunks is a good default. Too few and you miss context, too many and you dilute the prompt with irrelevant text and blow your token budget.
- Query text: map it to
{{ $json.body.question }}(or wherever your webhook puts the incoming question).
Assemble the prompt and call the LLM. This is the core of the n8n rag pattern, and there are two ways to do it:
*Option A: Basic LLM Chain node.* Simplest path. Connect the Vector Store Retriever as a sub-node input to a Basic LLM Chain node, and n8n handles injecting retrieved documents into the prompt template automatically. Set the prompt to something like:
You are a support assistant. Answer the question using ONLY the
context below. If the answer isn't in the context, say you don't
know and suggest contacting support.
Context:
{context}
Question: {question}
Answer:*Option B: Manual assembly with a Code node.* Gives you more control, useful if you want custom citation formatting. After retrieval, add a Code node:
const chunks = $input.all().map(item => item.json);
const context = chunks
.map((c, i) => `[${i + 1}] ${c.text}\nSource: ${c.metadata.source}`)
.join('\n\n');
const question = $('Webhook').item.json.body.question;
const prompt = `Answer using only the context. Cite sources as [1], [2] etc.
Context:
${context}
Question: ${question}
Answer:`;
return [{ json: { prompt, question, sources: chunks.map(c => c.metadata.source) } }];Then feed prompt into a Basic LLM Chain or OpenAI Chat Model node with the system message locked to "answer only from provided context."
Respond. Add a Respond to Webhook node returning the LLM's answer plus the source list, so callers can show citations:
{
"answer": "{{ $json.text }}",
"sources": {{ JSON.stringify($json.sources) }}
}Wire it as: Webhook -> Vector Store Retriever -> (Code node, optional) -> LLM Chain -> Respond to Webhook.
Grounding the answer and reducing hallucination
RAG reduces hallucination but doesn't eliminate it. A few things that noticeably help in practice:
- Instruct the model explicitly to refuse when context is insufficient. "If the answer is not in the context, say you don't know" in the system prompt cuts down on confident wrong answers.
- Keep chunk size tight enough that retrieved text is actually relevant, not a huge blob where the answer is buried in noise.
- Surface sources in the response so a human can verify. If your UI shows
[1],[2]citations linked to the retrieved metadata, users catch bad answers themselves. - Add a similarity score threshold. Most vector store retriever nodes return a score per match. If the top result's score is below a threshold (this varies by embedding model and metric, so tune it empirically), route to a fallback response like "I couldn't find relevant information" instead of forcing the LLM to answer from weak context.
You can implement the threshold check with an IF node right after retrieval, checking {{ $json.score }} < 0.75 (adjust based on testing) and branching to a static "no answer found" response.
Handling larger document sets
Once you're past a handful of PDFs, a few practical adjustments matter:
- Batch ingestion. Use the Split In Batches node before the Extract/Split/Embed chain when processing many files at once, so you don't hit provider rate limits on embeddings calls. A batch size of 5-10 documents per run is safe for most API tiers.
- Incremental indexing. Track a "last indexed at" timestamp (store it in a small Postgres table or n8n's static data) and filter your source query (Google Drive, S3, database) to only pull documents modified since that timestamp. Re-embedding everything on every run wastes money and time.
- Metadata-based filtering at query time. If you serve RAG for multiple teams or customers, add a metadata filter (
customer_id,department) to the Vector Store Retriever node so queries only search that tenant's documents. This is a query-time filter, not a separate index, and it's supported by Pinecone, Qdrant, and pgvector queries with aWHEREclause. - Monitor embedding costs. Embeddings are cheap per call but add up at scale. Log token counts from the Embeddings node output (most providers return usage data) into a Google Sheet or database so you can catch runaway ingestion loops before the bill does.
Testing the workflow
Before wiring this into a chat UI, test with curl:
curl -X POST http://localhost:5678/webhook/rag-answer \
-H "Content-Type: application/json" \
-d '{"question": "What is the refund policy for annual plans?"}'Check three things in the n8n execution log:
- The retrieved chunks actually contain text relevant to the question (open the Vector Store node's output in the execution view).
- The assembled prompt looks correct, no truncated context, no missing question text.
- The final answer references the retrieved content, not generic knowledge the model already had.
If retrieval quality is weak, the fix is almost always upstream: chunk size too large or too small, missing metadata, or an embedding model mismatch between ingestion and query (using different models to embed documents vs. questions will silently return garbage results, since the vectors live in different spaces).
Deploying and securing the webhook
For production, don't leave the query webhook open:
- Use n8n's built-in Header Auth or Basic Auth on the Webhook node, or validate a shared secret in an IF node before continuing.
- Rate-limit at the reverse proxy level (nginx, Cloudflare) since n8n itself doesn't throttle webhook calls.
- If exposing this to an external chat widget, put a thin backend or serverless function in front of the n8n webhook so you're not exposing your n8n instance URL directly to browser JavaScript.
FAQ
What's the difference between building RAG in n8n versus LangChain directly? n8n wraps LangChain's building blocks (text splitters, vector stores, retrievers, chains) in a visual, no-code-friendly interface with built-in credential management, scheduling, and execution logging. You trade some flexibility for faster iteration and easier handoff to non-developers. For most standard single-hop RAG use cases, the n8n nodes are functionally equivalent to hand-written LangChain code.
Which vector database works best with n8n? n8n ships native nodes for Pinecone, Qdrant, Supabase Vector, Postgres (pgvector), and a few others, plus a generic HTTP option for anything else. Pinecone is the fastest to set up since it's fully managed. Postgres with pgvector is a solid choice if you already run Postgres and want to avoid another managed service and its costs.
How many document chunks should I retrieve per query? Start with 4-6 chunks (top K) and adjust based on testing. Fewer chunks risk missing relevant context; more chunks dilute the prompt and increase token cost. If your documents are long-form and topics span multiple sections, lean toward the higher end.
Can I use a local LLM instead of OpenAI or Anthropic? Yes. n8n has an Ollama node for local model inference, and it plugs into the same Basic LLM Chain and Vector Store nodes as any other provider. Embeddings can also run locally through Ollama, though hosted embedding models are usually cheaper and faster unless you have strict data residency requirements.
Why does my RAG workflow return irrelevant answers even though the documents are indexed? The most common causes are a mismatch between the embedding model used at ingestion time and the one used at query time, chunks that are too large and mix unrelated topics, or a missing/incorrect metadata filter that's letting the retriever search the wrong document set. Check the raw retrieved chunks in the n8n execution log before assuming the LLM is at fault.
How do I keep the vector store in sync when source documents change? Delete existing chunks tied to that document's identifier (stored in metadata) before re-inserting updated chunks, or use your vector store's upsert capability if it supports stable IDs per chunk. Running ingestion repeatedly without cleanup silently accumulates stale duplicate context.
Is n8n rag suitable for production traffic, or just prototyping? It scales to production for many use cases, especially internal tools, support bots, and moderate-traffic customer-facing assistants. For very high query volumes, you'll want to self-host n8n with adequate worker capacity (n8n supports queue mode with Redis for horizontal scaling) rather than running everything on the default single instance.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.