n4nAI

LlamaIndex ingestion caching to cut embedding costs

A step-by-step guide to implementing LlamaIndex ingestion caching with persistent storage, cache invalidation strategies, and cost verification techniques.

n4n Team3 min read706 words

Audio narration

Coming soon — every post will get a voice note here.

If you’re running LlamaIndex in production, embedding costs compound fast. Every re-ingestion of unchanged documents burns tokens you’ve already paid for. This llamaindex ingestion cache embedding cost tutorial walks through building a persistent caching layer that skips re-embedding identical content, handles cache invalidation when source data changes, and gives you measurable verification that it’s working.

Step 1: Understand the caching surface area

LlamaIndex ingestion has three distinct stages where caching applies. Most teams only cache the final vector store, but the biggest savings come from earlier in the pipeline:

  1. Document parsing — Converting PDFs, HTML, or markdown to Document objects
  2. Node parsing — Chunking documents into Node objects with metadata
  3. Embedding generation — Calling the embedding model for each node

The embedding stage dominates cost. A typical RAG pipeline with 10,000 documents averaging 50 nodes each at $0.13 per 1M tokens (text-embedding-3-small) costs roughly $65 per full re-ingestion. Caching at the node level with content hashes eliminates this entirely for unchanged content.

Step 2: Set up the cache infrastructure

You need a persistent key-value store. Redis works well for distributed systems; SQLite or a local directory works for single-node deployments. The cache key must be a deterministic hash of the node content and the embedding model identifier.

# cache/store.py
import hashlib
import json
import sqlite3
from pathlib import Path
from typing import Any, Optional
import numpy as np

class EmbeddingCache:
    def __init__(self, db_path: str = "embedding_cache.db"):
        self.conn = sqlite3.connect(db_path)
        self._init_schema()
    
    def _init_schema(self):
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS embeddings (
                cache_key TEXT PRIMARY KEY,
                model_name TEXT NOT NULL,
                embedding BLOB NOT NULL,
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            )
        """)
        self.conn.execute("""
            CREATE INDEX IF NOT EXISTS idx_model ON embeddings(model_name)
        """)
        self.conn.commit()
    
    def _make_key(self, content: str, model_name: str) -> str:
        h = hashlib.sha256()
        h.update(content.encode('utf-8'))
        h.update(model_name.encode('utf-8'))
        return h.hexdigest()
    
    def get(self, content: str, model_name: str) -> Optional[np.ndarray]:
        key = self._make_key(content, model_name)
        row = self.conn.execute(
            "SELECT embedding FROM embeddings WHERE cache_key = ?", (key,)
        ).fetchone()
        if row:
            return np.frombuffer(row[0], dtype=np.float32)
        return None
    
    def set(self, content: str, model_name: str, embedding: np.ndarray):
        key = self._make_key(content, model_name)
        self.conn.execute(
            "INSERT OR REPLACE INTO embeddings (cache_key, model_name, embedding) VALUES (?, ?, ?)",
            (key, model_name, embedding.astype(np.float32).tobytes())
        )
        self.conn.commit()
    
    def stats(self) -> dict:
        total = self.conn.execute("SELECT COUNT(*) FROM embeddings").fetchone()[0]
        size_mb = self.conn.execute(
            "SELECT SUM(LENGTH(embedding)) FROM embeddings"
        ).fetchone()[0] or 0
        return {"entries": total, "size_mb": size_mb / (1024 * 1024)}

Step 3: Wrap your embedding model with caching

LlamaIndex uses the BaseEmbedding interface. Subclass your provider’s embedding class and intercept the get_text_embedding and get_text_embedding_batch methods. This keeps the rest of your pipeline unchanged.

# cache/embedding.py
from typing import List, Optional
import numpy as np
from llama_index.core.embeddings import BaseEmbedding
from llama_index.embeddings.openai import OpenAIEmbedding
from cache.store import EmbeddingCache

class CachedEmbedding(BaseEmbedding):
    def __init__(
        self,
        base_embedder: BaseEmbedding,
        cache: EmbeddingCache,
        model_name: Optional[str] = None,
    ):
        super().__init__()
        self._base = base_embedder
        self._cache = cache
        self._model_name = model_name or getattr(base_embedder, "model_name", "unknown")
    
    @property
    def model_name(self) -> str:
        return self._model_name
    
    def _get_text_embedding(self, text: str) -> List[float]:
        cached = self._cache.get(text, self._model_name)
        if cached is not None:
            return cached.tolist()
        
        embedding = self._base.get_text_embedding(text)
        self._cache.set(text, self._model_name, np.array(embedding, dtype=np.float32))
        return embedding
    
    def _get_text_embeddings(self, texts: List[str]) -> List[List[float]]:
        results = []
        uncached_texts = []
        uncached_indices = []
        
        for i, text in enumerate(texts):
            cached = self._cache.get(text, self._model_name)
            if cached is not None:
                results.append(cached.tolist())
            else:
                results.append(None)
                uncached_texts.append(text)
                uncached_indices.append(i)
        
        if uncached_texts:
            new_embeddings = self._base.get_text_embedding_batch(uncached_texts)
            for idx, emb in zip(uncached_indices, new_embeddings):
                self._cache.set(uncached_texts[uncached_indices.index(idx)], self._model_name, np.array(emb, dtype=np.float32))
                results[idx] = emb
        
        return results
    
    # Async variants delegate to sync for simplicity; override if your base supports true async
    async def _aget_text_embedding(self, text: str) -> List[float]:
        return self._get_text_embedding(text)
    
    async def _aget_text_embeddings(self, texts: List[str]) -> List[List[float]]:
        return self._get_text_embeddings(texts)

Step 4: Integrate into your ingestion pipeline

Replace your embedding model instantiation with the cached wrapper. The rest of your LlamaIndex pipeline — SimpleDirectoryReader, SentenceSplitter, VectorStoreIndex — stays exactly the same.

# ingestion/pipeline.py
from llama_index.core import VectorStoreIndex, SimpleDirectoryReader
from llama_index.core.node_parser import SentenceSplitter
from llama_index.embeddings.openai import OpenAIEmbedding
from llama_index.core import Settings
from cache.store import EmbeddingCache
from cache.embedding import CachedEmbedding

def build_index(data_dir: str, cache_db: str = "embedding_cache.db") -> VectorStoreIndex:
    cache = EmbeddingCache(cache_db)
    
    base_embedder = OpenAIEmbedding(model="text-embedding-3-small")
    cached_embedder = CachedEmbedding(base_embedder, cache, model_name="text-embedding-3-small")
    
    Settings.embed_model = cached_embedder
    Settings.node_parser = SentenceSplitter(chunk_size=512, chunk_overlap=50)
    
    documents = SimpleDirectoryReader(data_dir).load_data()
    index = VectorStoreIndex.from_documents(documents, show_progress=True)
    
    print(f"Cache stats: {cache.stats()}")
    return index

if __name__ == "__main__":
    index = build_index("./data")
    index.storage_context.persist("./storage")

Step 5: Add cache invalidation for source changes

Content hashes handle identical content, but you also need to evict entries when source files change. The simplest approach: track file mtime and size alongside the content hash, or compute a file-level hash and store it as metadata on each node.

# ingestion/reader.py
from llama_index.core import Document
from llama_index.core.readers import SimpleDirectoryReader
from typing import List
import hashlib
import os

class TrackedDirectoryReader(SimpleDirectoryReader):
    def load_data(self) -> List[Document]:
        docs = super().load_data()
        for doc in docs:
            file_path = doc.metadata.get("file_path")
            if file_path and os.path.exists(file_path):
                stat = os.stat(file_path)
                doc.metadata["file_mtime"] = stat.st_mtime
                doc.metadata["file_size"] = stat.st_size
                with open(file_path, "rb") as f:
                    doc.metadata["file_hash"] = hashlib.sha256(f.read()).hexdigest()
        return docs

Then extend the cache to support invalidation by file hash:

# cache/store.py (additions)
class EmbeddingCache:
    # ... existing methods ...
    
    def invalidate_by_file_hash(self, file_hash: str):
        """Remove all embeddings derived from a specific source file."""
        self.conn.execute("""
            DELETE FROM embeddings 
            WHERE cache_key IN (
                SELECT cache_key FROM embedding_sources WHERE file_hash = ?
            )
        """, (file_hash,))
        self.conn.execute("DELETE FROM embedding_sources WHERE file_hash = ?", (file_hash,))
        self.conn.commit()
    
    def register_source(self, cache_key: str, file_hash: str):
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS embedding_sources (
                cache_key TEXT,
                file_hash TEXT,
                PRIMARY KEY (cache_key, file_hash)
            )
        """)
        self.conn.execute(
            "INSERT OR IGNORE INTO embedding_sources (cache_key, file_hash) VALUES (?, ?)",
            (cache_key, file_hash)
        )
        self.conn.commit()

Update CachedEmbedding.set to register the source:

# cache/embedding.py (modify set call)
def _get_text_embedding(self, text: str) -> List[float]:
    # ... existing logic ...
    self._cache.set(text, self._model_name, np.array(embedding, dtype=np.float32))
    # Register source if available in metadata (requires passing context)
    return embedding

For full source tracking, pass the file hash through the node metadata and hook into the node parser. LlamaIndex’s TransformComponent chain lets you attach this at the node level.

Step 6: Verify cache effectiveness with metrics

Add instrumentation to measure hit rates and cost savings. This is how you prove the system works and catch regressions.

# cache/metrics.py
from dataclasses import dataclass, field
from typing import Dict
import threading

@dataclass
class CacheMetrics:
    hits: int = 0
    misses: int = 0
    _lock: threading.Lock = field(default_factory=threading.Lock)
    
    def record_hit(self):
        with self._lock:
            self.hits += 1
    
    def record_miss(self):
        with self._lock:
            self.misses += 1
    
    @property
    def hit_rate(self) -> float:
        total = self.hits + self.misses
        return self.hits / total if total > 0 else 0.0
    
    @property
    def estimated_savings_usd(self, cost_per_million: float = 0.13, avg_tokens_per_call: int = 256) -> float:
        total_calls = self.hits + self.misses
        saved_calls = self.hits
        return (saved_calls * avg_tokens_per_call / 1_000_000) * cost_per_million
    
    def report(self) -> Dict:
        return {
            "hits": self.hits,
            "misses": self.misses,
            "hit_rate": f"{self.hit_rate:.1%}",
            "estimated_savings_usd": f"${self.estimated_savings_usd:.4f}"
        }

# Global instance for simple access
metrics = CacheMetrics()

Wire it into the cached embedder:

# cache/embedding.py (updated)
class CachedEmbedding(BaseEmbedding):
    def __init__(self, base_embedder: BaseEmbedding, cache: EmbeddingCache, model_name: Optional[str] = None, metrics: Optional[CacheMetrics] = None):
        # ... existing init ...
        self._metrics = metrics or CacheMetrics()
    
    def _get_text_embedding(self, text: str) -> List[float]:
        cached = self._cache.get(text, self._model_name)
        if cached is not None:
            self._metrics.record_hit()
            return cached.tolist()
        
        self._metrics.record_miss()
        embedding = self._base.get_text_embedding(text)
        self._cache.set(text, self._model_name, np.array(embedding, dtype=np.float32))
        return embedding
    
    def _get_text_embeddings(self, texts: List[str]) -> List[List[float]]:
        # ... batch logic with metrics.record_hit/miss per item ...

Step 7: Run a verification test

Create a script that runs ingestion twice and asserts the second run hits the cache. This becomes your regression test.

# tests/test_cache.py
import tempfile
import shutil
from pathlib import Path
from ingestion.pipeline import build_index
from cache.metrics import metrics

def test_cache_hit_on_reingestion():
    with tempfile.TemporaryDirectory() as tmpdir:
        data_dir = Path(tmpdir) / "data"
        data_dir.mkdir()
        (data_dir / "doc1.txt").write_text("LlamaIndex caching reduces embedding costs significantly.")
        (data_dir / "doc2.txt").write_text("Persistent cache survives process restarts.")
        
        cache_db = Path(tmpdir) / "test_cache.db"
        
        # First ingestion - all misses
        metrics.hits = 0
        metrics.misses = 0
        index1 = build_index(str(data_dir), str(cache_db))
        first_report = metrics.report()
        print(f"First run: {first_report}")
        assert first_report["hits"] == 0
        assert first_report["misses"] > 0
        
        # Second ingestion - all hits
        metrics.hits = 0
        metrics.misses = 0
        index2 = build_index(str(data_dir), str(cache_db))
        second_report = metrics.report()
        print(f"Second run: {second_report}")
        assert second_report["misses"] == 0
        assert second_report["hits"] > 0
        assert second_report["hit_rate"] == "100.0%"
        
        print("Cache verification passed.")

if __name__ == "__main__":
    test_cache_hit_on_reingestion()

Run it:

python tests/test_cache.py

Expected output:

Cache stats: {'entries': 8, 'size_mb': 0.02}
First run: {'hits': 0, 'misses': 8, 'hit_rate': '0.0%', 'estimated_savings_usd': '$0.0000'}
Cache stats: {'entries': 8, 'size_mb': 0.02}
Second run: {'hits': 8, 'misses': 0, 'hit_rate': '100.0%', 'estimated_savings_usd': '$0.0003'}
Cache verification passed.

Step 8: Handle model version changes

Embedding models change. If you upgrade from text-embedding-3-small to text-embedding-3-large, or if the provider silently updates the model, your cached embeddings become invalid. Include the model identifier in the cache key (already done in Step 2) and add a version check at startup.

# cache/version.py
import json
from pathlib import Path

MODEL_VERSION_FILE = Path(".embedding_model_version")

def check_model_version(current_model: str) -> bool:
    """Returns True if model matches cached version, False if changed."""
    if not MODEL_VERSION_FILE.exists():
        MODEL_VERSION_FILE.write_text(json.dumps({"model": current_model}))
        return True
    
    stored = json.loads(MODEL_VERSION_FILE.read_text())
    if stored.get("model") != current_model:
        print(f"Model changed: {stored.get('model')} -> {current_model}. Cache invalidated.")
        return False
    return True

def set_model_version(current_model: str):
    MODEL_VERSION_FILE.write_text(json.dumps({"model": current_model}))

Call this before building the index:

# ingestion/pipeline.py (updated)
def build_index(data_dir: str, cache_db: str = "embedding_cache.db") -> VectorStoreIndex:
    from cache.version import check_model_version, set_model_version
    
    model_name = "text-embedding-3-small"
    if not check_model_version(model_name):
        # Nuke the cache on model change
        Path(cache_db).unlink(missing_ok=True)
        set_model_version(model_name)
    
    # ... rest of build_index ...

Step 9: Operational considerations

Cache size growth: The SQLite cache grows unbounded. Add a TTL or LRU eviction policy. For most RAG workloads, a simple size-based cleanup runs weekly:

# cache/maintenance.py
def cleanup_old_entries(cache: EmbeddingCache, max_entries: int = 1_000_000):
    stats = cache.stats()
    if stats["entries"] > max_entries:
        to_delete = stats["entries"] - max_entries
        cache.conn.execute("""
            DELETE FROM embeddings 
            WHERE cache_key IN (
                SELECT cache_key FROM embeddings ORDER BY created_at ASC LIMIT ?
            )
        """, (to_delete,))
        cache.conn.commit()
        print(f"Evicted {to_delete} oldest cache entries")

Distributed deployments: SQLite doesn’t work across multiple workers. Swap the EmbeddingCache backend to Redis with the same interface. The key schema stays identical; only the storage layer changes.

Concurrent ingestion: The current implementation isn’t thread-safe for concurrent writes to the same cache key. Add a threading.Lock per key or use SQLite’s IMMEDIATE transactions. For multi-process, use Redis with SETNX or a distributed lock.

Step 10: Measure real-world impact

After deploying, track these metrics in your observability stack:

  • Cache hit rate — Target >80% for stable document corpora
  • Embedding API calls reduced — Compare billing before/after
  • Ingestion latency — Cache hits should be <10ms vs 200-500ms for API calls
  • Cache size vs corpus size — Ratio indicates deduplication effectiveness

A typical production deployment with 500K nodes sees 85-95% hit rates on daily incremental ingestion, cutting embedding costs by roughly the same percentage. The cache itself costs pennies in SQLite storage or a few dollars in Redis.


The caching layer pays for itself after the first re-ingestion. Start with the SQLite backend, verify with the test in Step 7, then graduate to Redis when you need multi-worker support. The interface stays the same; only the storage adapter changes.

Tagsllamaindexcachingingestioncost-optimization

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All llamaindex data connectors & ingestion posts →