◉GEMINI LABJP
●ARGON — gemini-4-argon is generally available as of 10/13, aimed at complex coding, agents, math and science●COMPUTER — Computer Use is now generally available in the Interactions and GenerateContent APIs (10/30)●LIVE — Live and TTS preview models shut down as early as 11/17 (16 days left). Move to gemini-3.8-live and the 3.8 TTS models●CLI — Gemini API CLI (gemini-api) is out in beta (10/27): a zero-dependency client for agents and CI●STORE — New paid projects now save Generate Content requests by default (store on) as of 10/26●NEW — Stop Re-Reading Every Crash Report: Gre…●ARGON — gemini-4-argon is generally available as of 10/13, aimed at complex coding, agents, math and science●COMPUTER — Computer Use is now generally available in the Interactions and GenerateContent APIs (10/30)●LIVE — Live and TTS preview models shut down as early as 11/17 (16 days left). Move to gemini-3.8-live and the 3.8 TTS models●CLI — Gemini API CLI (gemini-api) is out in beta (10/27): a zero-dependency client for agents and CI●STORE — New paid projects now save Generate Content requests by default (store on) as of 10/26●NEW — Stop Re-Reading Every Crash Report: Gre…
Articles/API / SDK
◈ API / SDK/2026-06-13Advanced

Rebuilding a Three-Layer RAG Cache After Migrating to Gemini 3.5 Flash

When Gemini 2.0 Flash was retired, I rebuilt my RAG caching stack around 3.5 Flash. Here are the working implementations for response, semantic, and embedding caches, measured hit rates from production, and how self-managed caching divides the work with the API's Context Caching.

gemini-3-5-flash3rag22cost-optimization30cachinggemini-embedding-27

✦ Premium Article

When Gemini 2.0 Flash was retired on June 1, my RAG pipeline — an indie developer project running on a solo budget — moved to 3.5 Flash. I would have loved for the migration to end with swapping a model ID and running the test suite. In practice, that was where the real work started: once pricing and token-handling assumptions change, a cache design that used to be optimal quietly stops being so.

So while I was in there, I peeled off every cache layer and rebuilt the stack from scratch. The punchline: I ended up with the same three-layer structure as before. What changed was the priority of each layer and how the work is divided with the API's own Context Caching. This article walks through the rebuilt design with working code and production numbers.

Cut your caches along the cost boundaries

A single RAG request spends money in exactly three places.

  1. Query embedding — the API call that vectorizes the question
  2. Vector search — the database query (billed per search on managed services)
  3. Answer generation — sending context plus question to the model

Items 2 and 3 dominate the invoice. That is why I find it cleanest to organize caches by which cost point each one eliminates:

  • L1 (response cache): stores answers to identical questions; a hit skips all three steps
  • L2 (semantic cache): reuses retrieval results for semantically similar past queries; skips step 2
  • L3 (embedding cache): never recomputes the embedding of an identical string; skips step 1

The higher the layer, the bigger the saving on a hit. That hierarchy survived the migration intact. What shifted was my judgment about how much to keep self-managed — more on that below.

L1: the response cache is still the first thing to install

User questions repeat far more than you expect. In FAQ-shaped workloads, exact matches alone catch around a third of traffic. In my production data for the past week, L1 hit 34% — one in three generation calls evaporates before it ever reaches the API.

import hashlib
import json
import redis
 
from google import genai
 
client = genai.Client()  # reads GEMINI_API_KEY from the environment
r = redis.Redis(decode_responses=True)
RESP_TTL = 60 * 60 * 24 * 7  # 7 days
 
def response_cache_key(tenant: str, query: str, filters: dict) -> str:
    """Build a deterministic key from tenant, normalized query, and filters."""
    payload = json.dumps(
        {"t": tenant, "q": " ".join(query.lower().split()), "f": filters},
        sort_keys=True,
    )
    return "rag:l1:" + hashlib.sha256(payload.encode()).hexdigest()
 
def answer_with_l1(tenant: str, query: str, filters: dict) -> dict:
    key = response_cache_key(tenant, query, filters)
    if (hit := r.get(key)) is not None:
        return json.loads(hit)
    result = run_rag_pipeline(tenant, query, filters)  # includes L2/L3
    r.setex(key, RESP_TTL, json.dumps(result))
    return result

Three deliberate choices here. First, normalization via lower().split() collapses case and whitespace variants into one key. Second, the tenant ID goes into the key, always — I once came within a code review of shipping a version without it, which would have served one tenant's answers to another. Permission boundaries belong in the cache key itself. Third, the TTL: keep serving yesterday's answer after a document update and user trust erodes quietly.

For invalidation I don't rely on TTL alone. Each cached answer carries the IDs of the documents it cites, and a document-update event purges every answer referencing it. At weekly update cadence, that mechanism alone has kept stale-answer incidents at zero.

✦

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
✦Working three-layer cache code (response, semantic, embedding) built on the google-genai SDK and Redis
✦The verification process behind a 0.92 similarity threshold, plus invalidation design against false hits, tenant bleed, and stale answers
✦How to divide responsibilities between the API's Context Caching and your own layers, with per-layer hit-rate instrumentation and an investment rule for L2
Secure payment via Stripe · Cancel anytime
✦

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

◈ API / SDK2026-04-19
Gemini API Caching in Production — Operational Notes from an Indie Mobile Developer
Field notes on running Gemini API's Context Caching and Implicit Caching together inside indie mobile apps. Includes working Python code, six months of measured costs from AdMob-funded apps, and seven non-obvious operational pitfalls.
◈ API / SDK2026-07-23
When to Hand Conversation State to the Server: previous_interaction_id vs. Pruning History Yourself
With the Interactions API, passing previous_interaction_id lets the server hold conversation state so you stop resending history. But a large tool output that lands mid-conversation can't be pruned afterward, and every later turn drags its weight. Here is a branching design that mixes server-side state with client-side pruning, plus a thin, working Python wrapper.
◈ API / SDK2026-05-14
Controlling thinking_budget in Gemini 2.5 Pro — Cut Costs by 70% Without Sacrificing Reasoning Quality
Leaving thinking_budget unset in Gemini 2.5 Pro leads to unexpected costs. This guide covers task-level budget design, dynamic control, and production monitoring with working Python code.