How to Pass Large RAG Results Between n8n Nodes Without Burning Tokens

If you've spent any time building Retrieval-Augmented Generation (RAG) pipelines in n8n, you've likely hit the dreaded context window wall—or worse, watched your OpenAI or Anthropic API bill skyrocket because of silent data bloat.

You build a clean workflow. Your vector store node executes, retrieving ten dense, multi-paragraph document chunks to answer a user's query. Those chunks stream out of the node as pristine JSON arrays. Then, you pass that massive payload directly into an Advanced AI node, an LLM Chain, or an HTTP Request node.

Everything works on the test run. But in production, with real payloads, you notice two things: your execution history bloats to hundreds of megabytes, crashing the n8n database over time, and your LLM token consumption is burning through your budget because you are dumping unformatted, redundant retrieval artifacts directly into the prompt context.

Let's look at why standard n8n data handling breaks down under heavy RAG loads, and how to pass large RAG results between nodes efficiently without burning through your token budget.


📋 Table of Contents


The Root Cause: Why n8n's Data Model Fails at RAG Scale

To understand how to fix token waste, you first need to understand how n8n passes data between nodes. Every node in n8n outputs an array of items. Each item contains a json object and optionally a binary data reference.

When a Vector Store node (like Pinecone, Qdrant, or Milvus) or a Retriever node executes, it doesn't return a single string containing your search results. Instead, it typically returns 5 to 20 separate items, where each item represents a retrieved chunk. Each chunk carries:

  • The raw text content (pageContent or text)
  • A similarity score (score or distance)
  • A sprawling metadata object containing source URLs, file paths, author names, creation timestamps, and embedding configuration crumbs.

When you wire this node directly into an OpenAI Chat Model node or an Item List node, n8n serializes these items into memory. If your chunks average 500 tokens each, and you retrieve 15 chunks, you are pushing 7,500 tokens of raw text plus thousands of tokens of JSON structural overhead into the execution execution stream.

Worse, n8n saves the execution data of every single node to its database (PostgreSQL or SQLite) unless explicitly pruned. A few dozen concurrent RAG queries can bloat your database with gigabytes of redundant JSON stringified chunks, grinding your instance to a halt.

Let's examine the architectural patterns required to keep your n8n RAG workflows lean, fast, and token-efficient.


Pattern 1: The Code Node Truncation Filter

Scenario: Your vector search returns 20 rich chunks, but your downstream LLM only needs the top 5 most relevant sentences from each, not the entire three-page markdown document.

Why it matters: Passing full documents when only small segments are relevant causes "lost in the middle" hallucinations in LLMs and wastes thousands of tokens on boilerplate text, headers, and navigation menus captured during web scraping.

Solution: Insert a custom JavaScript or Python Code node immediately after your retriever to slice, sanitize, and truncate text payloads before they touch the AI node.

Code:

// Inside an n8n Code Node (JavaScript)
return items.map(item => {
  const rawText = item.json.pageContent || item.json.text || "";
  
  // Clean up excessive whitespace and newline characters
  const sanitized = rawText.replace(/\s+/g, ' ').trim();
  
  // Hard truncate to protect token limits (approx 300 words)
  const maxChars = 1500;
  const truncated = sanitized.length > maxChars 
    ? sanitized.substring(0, maxChars) + "..." 
    : sanitized;

  return {
    json: {
      content: truncated,
      source: item.json.metadata?.source || "unknown",
      score: item.json.score || 0.0
    }
  };
});

Why this works: By stripping unnecessary whitespace, removing bloated metadata fields that the LLM doesn't need to see, and enforcing a strict character ceiling per chunk, you reduce token consumption by 40% to 70% before the prompt even reaches the model.

💡 Practical note: Always keep the similarity score and a clean source identifier. Your LLM needs to cite its sources, but it doesn't need the database primary key or internal UUIDs.


Pattern 2: Dynamic Semantic Deduplication

Scenario: Your vector database returns three different chunks from the exact same PDF manual because the document was split into overlapping chunks during ingestion. Passing all three wastes context space on redundant phrasing.

Why it matters: Standard vector searches look for cosine similarity against a query embedding. If consecutive chunks cover the same topic, they will all score high and get pulled into your RAG context.

Solution: Use an n8n Code node to implement a sliding-window or text-overlap deduplication algorithm that scans incoming chunks and drops items with high string similarity or identical source lineages.

Code:

// Deduplicate chunks based on text prefix matching and source similarity
const seenContent = new Set();
const uniqueItems = [];

for (const item of items) {
  const text = item.json.content || "";
  // Create a fingerprint from the first 50 characters
  const fingerprint = text.substring(0, 50).toLowerCase().replace(/[^a-z0-9]/g, '');
  
  if (!fingerprint) continue;

  let isDuplicate = false;
  for (const seen of seenContent) {
    // Simple Jaccard-like or substring check for overlap
    if (seen.includes(fingerprint) || fingerprint.includes(seen)) {
      isDuplicate = true;
      break;
    }
  }

  if (!isDuplicate) {
    seenContent.add(fingerprint);
    uniqueItems.push(item);
  }
}

return uniqueItems;

Why this works: Instead of relying solely on the vector database's top-$k$ parameter—which often returns redundant clusters of information—this code enforces diversity in your retrieval set, ensuring every chunk passed to the LLM provides unique semantic value.


Pattern 3: Metadata Stripping and Reference Mapping

Scenario: Your vector store embeds documents that contain deep internal JSON schemas, database IDs, vector embedding arrays, and tracking tags in their metadata properties.

Why it matters: If you pass raw retriever outputs directly into an LLM node using n8n's expression syntax ({{ $json.metadata }}), n8n serializes the entire metadata object into the prompt template. If your metadata contains 2KB of system logs per chunk across 10 chunks, you are injecting 20KB of garbage data into your context window.

Solution: Map your retrieval output down to an explicit whitelist of required properties using an n8n Code node or the Set node, discarding everything else.

Code:

// Whitelist pattern: Extract only what is necessary for generation and citation
return items.map(item => {
  const meta = item.json.metadata || {};
  
  return {
    json: {
      // Keep only the text needed for generation
      text: item.json.pageContent,
      
      // Keep only user-facing metadata fields
      citation: {
        title: meta.document_title || "Untitled Document",
        page: meta.page_number || null,
        url: meta.external_url || null
      }
    }
  };
});

Why this works: It enforces strict schema boundaries between your backend data store and your frontend LLM prompt. Your prompt template becomes clean, readable, and entirely free of internal system pollution.


Pattern 4: The External Storage Pointer Pattern

Scenario: Your RAG pipeline retrieves massive enterprise reports, legal contracts, or financial statements (each 10,000+ words) that exceed practical prompt token limits, even when chunked.

Why it matters: Even advanced models with 1M+ token windows suffer from attention degradation ("needle in a haystack" failures) and high latency when fed gigabytes of raw reference material per request.

Solution: Instead of passing full document bodies through the n8n execution graph, store the large text payloads in an external caching layer (such as Redis, an S3-compatible bucket, or a PostgreSQL table) during ingestion, and pass only lightweight pointers (file keys or IDs) through the n8n nodes. When the LLM needs to inspect a specific section, use a sub-workflow or tool-calling pattern to fetch only the referenced text on demand.

Code:

// Code node simulating storage pointer injection
const crypto = require('crypto');

// Assume we have large text chunks that we want to offload
return items.map(item => {
  const largePayload = item.json.pageContent;
  const hashKey = crypto.createHash('sha256').update(largePayload).digest('hex');

  // In a real production workflow, you would write `largePayload` to Redis/S3 here
  // client.set(hashKey, largePayload, 'EX', 3600);

  return {
    json: {
      referenceId: hashKey,
      summary: item.json.summary || largePayload.substring(0, 200) + "...",
      source: item.json.source
    }
  };
});

Why this works: Your n8n execution history remains lightweight because nodes pass small 64-character hash strings instead of megabyte-sized string blocks. The database doesn't choke on execution logs, and your API calls remain fast and focused.

🚨 Production warning: If you use external caching like Redis for state management between nodes, ensure your keys have a short TTL (Time To Live), such as 15 to 60 minutes, to prevent orphaned state data from accumulating and consuming memory.


Pattern 5: Binary Compression for Large Payloads

Scenario: You are passing intermediate batch data or massive JSON document collections between custom n8n nodes, and n8n's internal JSON execution serialization is causing noticeable memory pressure and slow execution transitions.

Why it matters: n8n stores all node execution data as JSON strings in its database table (execution_entity). Passing thousands of large text objects as separate JSON items creates massive database write overhead.

Solution: Compress large text collections into a single gzipped binary file within an n8n Code node, pass that single binary item through the workflow, and decompress it only when you reach the LLM or processing node.

Code:

const zlib = require('zlib');

// Input: Multiple large items from retrieval
const combinedText = items.map(i => i.json.pageContent).join("\n\n---\n\n");

// Compress string to a gzipped buffer
const compressedBuffer = zlib.gzipSync(Buffer.from(combinedText, 'utf-8'));

return [{
  json: {
    totalChunks: items.length,
    originalSize: combinedText.length,
    compressedSize: compressedBuffer.length
  },
  binary: {
    data: {
      data: compressedBuffer.toString('base64'),
      mimeType: 'application/gzip',
      fileName: 'rag_context.gz'
    }
  }
}];

Why this works: By converting 20 distinct JSON items into a single compressed binary payload, you reduce database serialization overhead by up to 80% and optimize memory consumption during heavy data transit across complex sub-workflows.


Pattern 6: Structured Context Assembly via Markdown Templates

Scenario: You have sanitized and filtered your RAG chunks, but when you drop them into your LLM prompt using basic node expressions, they render as an unstructured wall of text, confusing the model about where one source ends and another begins.

Why it matters: LLMs rely heavily on clear structural delimiters (#, ---, XML tags) to parse context boundaries. Poorly formatted prompt injections lead to source blending and hallucinated citations.

Solution: Use an n8n Code node to assemble your retrieved items into a single, tightly formatted Markdown string structured explicitly for LLM consumption, injecting it as a single scalar variable into your prompt.

Code:

// Assemble filtered items into a clean XML/Markdown hybrid context block
let contextString = "";

items.forEach((item, index) => {
  const content = item.json.content;
  const source = item.json.source || "Unknown Source";
  const score = item.json.score ? item.json.score.toFixed(3) : "N/A";

  contextString += `<source id="${index + 1}" reference="${source}" relevance="${score}">\n`;
  contextString += `${content}\n`;
  contextString += `</source>\n\n`;
});

return [{
  json: {
    assembledContext: contextString.trim()
  }
}];

Why this works: Instead of letting n8n loop over 15 separate items inside a prompt template—which often duplicates prompt system instructions or creates malformed arrays—you consolidate the entire retrieval payload into a single, highly structured variable ({{ $json.assembledContext }}). This gives the LLM explicit hierarchical markers, improving citation accuracy and reducing reasoning errors.


Pattern 7: Node-Level Execution Data Pruning

Scenario: Your RAG workflow runs hundreds of times per day. Even with optimized payloads, your n8n PostgreSQL database size expands rapidly, causing backups to fail and UI performance to crawl.

Why it matters: By default, n8n saves every execution input and output for every node. For high-volume AI automation pipelines, this is unnecessary and destructive to long-term infrastructure health.

Solution: Configure environment-level retention policies and node-level settings to prune unnecessary execution data, and ensure that high-throughput data-gathering nodes do not persist verbose debugging payloads.

Configuration Strategy Implementation Method Benefit
Execution Data Saving Set EXECUTIONS_DATA_PRUNE=true in .env Automatically deletes old execution logs based on age/count.
Workflow-Level Pruning Enable "Save Execution Progress" only for error states Drastically cuts database writes in production workflows.
Node Output Disabling Use Code nodes to return minimal summary objects Prevents giant text blobs from being written to execution history.

Why this works: Pruning execution data ensures your n8n instance remains snappy, prevents disk-full alerts on your server, and complies with data minimization practices when handling sensitive RAG source material.


Architecture Decision Guide: Choosing the Right Strategy

Not every RAG workflow requires binary compression or external Redis pointers. Use this decision matrix to determine which optimization pattern to apply based on your workload characteristics:

Retrieval Volume Avg Chunk Size Recommended Strategy Primary Benefit
Low (1–5 chunks) < 500 chars Pattern 3 (Metadata Stripping) Removes prompt noise with zero infrastructure complexity.
Medium (5–15 chunks) 500–2,000 chars Pattern 1 + Pattern 6 (Truncation & Markdown) Enforces strict token ceilings and clean LLM parsing boundaries.
High (15–50 chunks) 2,000+ chars Pattern 2 + Pattern 4 (Deduplication & External Pointers) Prevents context window saturation and database bloat.
Massive (Batch Docs) > 10KB files Pattern 5 (Binary Compression) Minimizes JSON serialization overhead across complex sub-workflows.

By treating your RAG data pipeline as an intentional data-processing layer rather than a simple drag-and-drop wire connection, you protect your API budgets, accelerate execution speeds, and build production-grade AI automations that scale without friction.

Post a Comment

Previous Post Next Post